Merge pull request 'OTT-1701-part-7-copy' (#6) from OTT-1701-part-7-copy into develop

Reviewed-on: https://gitea.instat.tv/InStat/spa_instat_tv/pulls/6
keep-around/fdb88b04b32b9392e76795099e2ec47c9856b38b
andrey.dekterev 3 years ago
commit c7ad03f3bb
  1. 35
      package-lock.json
  2. 3
      package.json
  3. 3
      src/features/HeaderFilters/components/DateFilter/styled.tsx
  4. 2
      src/features/HeaderFilters/components/DatePicker/styled.tsx
  5. 11
      src/features/MatchCard/CardFrontside/index.tsx
  6. 27
      src/features/MatchCard/styled.tsx
  7. 2
      src/features/MatchPage/components/FinishedMatch/hooks/useEpisodes.tsx
  8. 4
      src/features/MatchPage/components/FinishedMatch/index.tsx
  9. 104
      src/features/MatchPage/components/LiveMatch/helpers.tsx
  10. 66
      src/features/MatchPage/components/LiveMatch/hooks/index.tsx
  11. 33
      src/features/MatchPage/components/LiveMatch/hooks/useChapters.tsx
  12. 72
      src/features/MatchPage/components/LiveMatch/hooks/usePlaylistLogger.tsx
  13. 23
      src/features/MatchPage/components/LiveMatch/hooks/useResumeUrlParam.tsx
  14. 42
      src/features/MatchPage/components/LiveMatch/index.tsx
  15. 25
      src/features/MatchPage/components/MatchDescription/index.tsx
  16. 10
      src/features/MatchPage/components/MatchDescription/styled.tsx
  17. 7
      src/features/MatchPage/components/SubscriptionGuard/index.tsx
  18. 7
      src/features/MatchPage/helpers/buildPlaylists.tsx
  19. 11
      src/features/MatchPage/helpers/fullMatchDuration.tsx
  20. 26
      src/features/MatchPage/index.tsx
  21. 92
      src/features/MatchPage/store/hooks/index.tsx
  22. 28
      src/features/MatchPage/store/hooks/useEvents.tsx
  23. 25
      src/features/MatchPage/store/hooks/useEventsLexics.tsx
  24. 82
      src/features/MatchPage/store/hooks/useMatchData.tsx
  25. 89
      src/features/MatchPage/store/hooks/useMatchPlaylists.tsx
  26. 22
      src/features/MatchPage/store/hooks/usePlaylistLexics.tsx
  27. 49
      src/features/MatchPage/store/hooks/useSelectedPlaylist.tsx
  28. 15
      src/features/MatchPage/store/index.tsx
  29. 6
      src/features/MatchPage/types.tsx
  30. 4
      src/features/MatchPopup/types.tsx
  31. 2
      src/features/MatchSidePlaylists/components/EventsList/index.tsx
  32. 4
      src/features/MatchSidePlaylists/components/MatchPlaylists/index.tsx
  33. 4
      src/features/MatchSidePlaylists/components/PlayButton/index.tsx
  34. 1
      src/features/MatchSidePlaylists/components/TabVideo/styled.tsx
  35. 1
      src/features/MatchSidePlaylists/components/TabWatch/index.tsx
  36. 1
      src/features/MatchSidePlaylists/styled.tsx
  37. 42
      src/features/MultiSourcePlayer/components/Chapters/index.tsx
  38. 2
      src/features/MultiSourcePlayer/components/ProgressBar/index.tsx
  39. 2
      src/features/MultiSourcePlayer/components/ProgressBar/styled.tsx
  40. 1
      src/features/MultiSourcePlayer/index.tsx
  41. 36
      src/features/StreamPlayer/components/Chapters/index.tsx
  42. 16
      src/features/StreamPlayer/components/Chapters/styled.tsx
  43. 25
      src/features/StreamPlayer/components/Controls/index.tsx
  44. 47
      src/features/StreamPlayer/components/ProgressBar/helpers/calculateChapterStyles/__tests__/index.tsx
  45. 62
      src/features/StreamPlayer/components/ProgressBar/helpers/calculateChapterStyles/index.tsx
  46. 52
      src/features/StreamPlayer/components/ProgressBar/hooks.tsx
  47. 47
      src/features/StreamPlayer/components/ProgressBar/index.tsx
  48. 44
      src/features/StreamPlayer/components/ProgressBar/stories.tsx
  49. 38
      src/features/StreamPlayer/components/ProgressBar/styled.tsx
  50. 27
      src/features/StreamPlayer/components/Settings/hooks.tsx
  51. 47
      src/features/StreamPlayer/components/Settings/index.tsx
  52. 78
      src/features/StreamPlayer/components/Settings/styled.tsx
  53. 8
      src/features/StreamPlayer/config.tsx
  54. 13
      src/features/StreamPlayer/helpers/index.tsx
  55. 326
      src/features/StreamPlayer/hooks/index.tsx
  56. 9
      src/features/StreamPlayer/hooks/useDuration.tsx
  57. 5
      src/features/StreamPlayer/hooks/useFullscreen.tsx
  58. 113
      src/features/StreamPlayer/hooks/usePlayingHandlers.tsx
  59. 48
      src/features/StreamPlayer/hooks/useProgressChangeHandler.tsx
  60. 6
      src/features/StreamPlayer/hooks/useVideoQuality.tsx
  61. 162
      src/features/StreamPlayer/index.tsx
  62. 105
      src/features/StreamPlayer/styled.tsx
  63. 33
      src/features/StreamPlayer/types.tsx
  64. 2
      src/features/TournamentsPopup/index.tsx
  65. 3
      src/features/VideoPlayer/hooks/index.tsx
  66. 16
      src/features/VideoPlayer/index.tsx
  67. 24
      src/requests/getFullMatchDuration.tsx
  68. 2
      src/requests/getMatchEvents.tsx
  69. 9
      src/requests/getMatchInfo.tsx
  70. 47
      src/requests/getMatchPlaylists.tsx
  71. 2
      src/requests/index.tsx

35
package-lock.json generated

@ -14,7 +14,7 @@
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"date-fns": "^2.14.0", "date-fns": "^2.14.0",
"history": "^4.10.1", "history": "^4.10.1",
"hls.js": "^0.14.15", "hls.js": "^1.1.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"m3u8-parser": "^4.7.0", "m3u8-parser": "^4.7.0",
"oidc-client": "^1.11.5", "oidc-client": "^1.11.5",
@ -46,7 +46,6 @@
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^7.1.2", "@testing-library/user-event": "^7.1.2",
"@types/history": "^4.7.6", "@types/history": "^4.7.6",
"@types/hls.js": "^0.13.2",
"@types/jest": "^26.0.15", "@types/jest": "^26.0.15",
"@types/lodash": "^4.14.154", "@types/lodash": "^4.14.154",
"@types/node": "^12.0.0", "@types/node": "^12.0.0",
@ -8659,12 +8658,6 @@
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==", "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==",
"dev": true "dev": true
}, },
"node_modules/@types/hls.js": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/@types/hls.js/-/hls.js-0.13.3.tgz",
"integrity": "sha512-Po8ZPCsAcPPuf5OODPEkb6cdWJ/w4BdX1veP7IIOc2WG0x1SW4GEQ1+FHKN1AMG2AePJfNUceJbh5PKtP92yRQ==",
"dev": true
},
"node_modules/@types/hoist-non-react-statics": { "node_modules/@types/hoist-non-react-statics": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -17659,13 +17652,9 @@
} }
}, },
"node_modules/hls.js": { "node_modules/hls.js": {
"version": "0.14.17", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.14.17.tgz", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.2.0.tgz",
"integrity": "sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==", "integrity": "sha512-QIEQIUpBRhcpBMq3NA+/qozG8lbNfVekuX7kCMUlhiVu4382xFWsnwcuBe/CA4Gp/wB/pf2aRBaGRFlxh/FN8g=="
"dependencies": {
"eventemitter3": "^4.0.3",
"url-toolkit": "^2.1.6"
}
}, },
"node_modules/hmac-drbg": { "node_modules/hmac-drbg": {
"version": "1.0.1", "version": "1.0.1",
@ -39870,12 +39859,6 @@
"integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==", "integrity": "sha512-MUc6zSmU3tEVnkQ78q0peeEjKWPUADMlC/t++2bI8WnAG2tvYRPIgHG8lWkXwqc8MsUF6Z2MOf+Mh5sazOmhiQ==",
"dev": true "dev": true
}, },
"@types/hls.js": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/@types/hls.js/-/hls.js-0.13.3.tgz",
"integrity": "sha512-Po8ZPCsAcPPuf5OODPEkb6cdWJ/w4BdX1veP7IIOc2WG0x1SW4GEQ1+FHKN1AMG2AePJfNUceJbh5PKtP92yRQ==",
"dev": true
},
"@types/hoist-non-react-statics": { "@types/hoist-non-react-statics": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz", "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.1.tgz",
@ -47032,13 +47015,9 @@
} }
}, },
"hls.js": { "hls.js": {
"version": "0.14.17", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.14.17.tgz", "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.2.0.tgz",
"integrity": "sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==", "integrity": "sha512-QIEQIUpBRhcpBMq3NA+/qozG8lbNfVekuX7kCMUlhiVu4382xFWsnwcuBe/CA4Gp/wB/pf2aRBaGRFlxh/FN8g=="
"requires": {
"eventemitter3": "^4.0.3",
"url-toolkit": "^2.1.6"
}
}, },
"hmac-drbg": { "hmac-drbg": {
"version": "1.0.1", "version": "1.0.1",

@ -24,7 +24,7 @@
"babel-polyfill": "^6.26.0", "babel-polyfill": "^6.26.0",
"date-fns": "^2.14.0", "date-fns": "^2.14.0",
"history": "^4.10.1", "history": "^4.10.1",
"hls.js": "^0.14.15", "hls.js": "^1.1.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"m3u8-parser": "^4.7.0", "m3u8-parser": "^4.7.0",
"oidc-client": "^1.11.5", "oidc-client": "^1.11.5",
@ -56,7 +56,6 @@
"@testing-library/react": "^12.1.2", "@testing-library/react": "^12.1.2",
"@testing-library/user-event": "^7.1.2", "@testing-library/user-event": "^7.1.2",
"@types/history": "^4.7.6", "@types/history": "^4.7.6",
"@types/hls.js": "^0.13.2",
"@types/jest": "^26.0.15", "@types/jest": "^26.0.15",
"@types/lodash": "^4.14.154", "@types/lodash": "^4.14.154",
"@types/node": "^12.0.0", "@types/node": "^12.0.0",

@ -73,11 +73,10 @@ type DateButtonProps = {
export const DateButton = styled(BaseButton)<DateButtonProps>` export const DateButton = styled(BaseButton)<DateButtonProps>`
position: absolute; position: absolute;
top: 0.2rem; top: 0.2rem;
right: 3.6rem; right: 4.6rem;
width: 1.3rem; width: 1.3rem;
height: 1.26rem; height: 1.26rem;
color: ${({ theme: { colors } }) => colors.dateButton}; color: ${({ theme: { colors } }) => colors.dateButton};
margin-left: 10px;
${isMobileDevice ${isMobileDevice
? css` ? css`

@ -6,7 +6,7 @@ import { BaseButton } from '../DateFilter/styled'
export const Wrapper = styled.div` export const Wrapper = styled.div`
position: absolute; position: absolute;
top: 3.5rem; top: 3.5rem;
right: 11rem; right: 12rem;
z-index: 10; z-index: 10;
${isMobileDevice ${isMobileDevice
? css` ? css`

@ -94,8 +94,9 @@ export const CardFrontside = ({
<CardWrapperOuter <CardWrapperOuter
onClick={onClick} onClick={onClick}
onKeyPress={onKeyPress} onKeyPress={onKeyPress}
isMatchPage={isMatchPage}
> >
<CardWrapper> <CardWrapper isMatchPage={isMatchPage}>
<HoverFrame /> <HoverFrame />
<PreviewWrapper> <PreviewWrapper>
{previewImage && ( {previewImage && (
@ -107,7 +108,7 @@ export const CardFrontside = ({
{access === MatchAccess.NoCountryAccess {access === MatchAccess.NoCountryAccess
? <NoAccessMessage /> ? <NoAccessMessage />
: ( : (
<TeamLogos> <TeamLogos isMatchPage={isMatchPage}>
<TeamLogo <TeamLogo
id={team1.id} id={team1.id}
nameAsTitle nameAsTitle
@ -137,8 +138,8 @@ export const CardFrontside = ({
)} )}
</MatchTimeInfo> </MatchTimeInfo>
</PreviewWrapper> </PreviewWrapper>
<Info> <Info isMatchPage={isMatchPage}>
<Teams> <Teams isMatchPage={isMatchPage}>
<Team> <Team>
<NameSignWrapper> <NameSignWrapper>
<TeamName nameObj={team1} /> <TeamName nameObj={team1} />
@ -154,11 +155,13 @@ export const CardFrontside = ({
{showScore && <Score>{team2.score}</Score>} {showScore && <Score>{team2.score}</Score>}
</Team> </Team>
</Teams> </Teams>
{!isMatchPage && (
<TournamentSubtitle <TournamentSubtitle
sportType={sportType} sportType={sportType}
countryId={countryId} countryId={countryId}
tournament={tournament} tournament={tournament}
/> />
)}
</Info> </Info>
</CardWrapper> </CardWrapper>
</CardWrapperOuter> </CardWrapperOuter>

@ -6,11 +6,16 @@ import { isMobileDevice } from 'config/userAgent'
import { Name } from 'features/Name' import { Name } from 'features/Name'
import { ProfileLogo } from 'features/ProfileLogo' import { ProfileLogo } from 'features/ProfileLogo'
type CardProps = {
isMatchPage?: boolean,
}
export const CardWrapperOuter = styled.li.attrs({ export const CardWrapperOuter = styled.li.attrs({
tabIndex: 0, tabIndex: 0,
})` })<CardProps>`
padding-top: 100%; padding-top: ${({ isMatchPage }) => (isMatchPage ? 0 : '100%')};
position: relative; position: relative;
${isMobileDevice ${isMobileDevice
? css` ? css`
width: 100%; width: 100%;
@ -28,13 +33,13 @@ export const CardWrapperOuter = styled.li.attrs({
: ''}; : ''};
` `
export const CardWrapper = styled.div` export const CardWrapper = styled.div<CardProps>`
position: absolute; position: ${({ isMatchPage }) => (isMatchPage ? 'relative' : 'absolute')};
top: 0; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
right: 0; right: 0;
padding-bottom: 0.75rem; padding-bottom: ${({ isMatchPage }) => (isMatchPage ? '0' : '0.75rem')};
border-radius: 2px; border-radius: 2px;
background-color: #3F3F3F; background-color: #3F3F3F;
box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.4); box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.4);
@ -133,10 +138,10 @@ export const Time = styled.span`
margin: 0 0.2rem; margin: 0 0.2rem;
` `
export const Info = styled.div` export const Info = styled.div<CardProps>`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0.85rem 0.472rem 0 0.519rem; padding: ${({ isMatchPage }) => (isMatchPage ? '1.2rem 0.472rem 1.2rem 0.519rem' : '0.85rem 0.472rem 0 0.519rem')};
color: #fff; color: #fff;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
@ -178,8 +183,8 @@ const nameStyles = css`
overflow: hidden; overflow: hidden;
` `
export const Teams = styled.div` export const Teams = styled.div<CardProps>`
margin-bottom: 0.567rem; margin-bottom: ${({ isMatchPage }) => (isMatchPage ? '0' : '0.567rem')};
${isMobileDevice ${isMobileDevice
? css` ? css`
margin-bottom: 15px; margin-bottom: 15px;
@ -215,11 +220,11 @@ export const TeamName = styled(Name)`
export const Score = styled.div`` export const Score = styled.div``
export const TeamLogos = styled.div` export const TeamLogos = styled.div<CardProps>`
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 0.71rem auto 0 auto; margin: ${({ isMatchPage }) => (isMatchPage ? '1.5rem auto 1rem auto' : '0.71rem auto 0 auto')};
z-index: 1; z-index: 1;
` `

@ -41,7 +41,7 @@ export const useEpisodes = () => {
}).then(setEpisodes) }).then(setEpisodes)
} else if (playlistOption.type === PlaylistTypes.MATCH } else if (playlistOption.type === PlaylistTypes.MATCH
|| playlistOption.type === PlaylistTypes.EVENT) { || playlistOption.type === PlaylistTypes.EVENT) {
setEpisodes(playlistOption.data) setEpisodes(playlistOption.episodes)
} }
}, [matchId, sportType]) }, [matchId, sportType])

@ -2,14 +2,14 @@ import { Fragment } from 'react'
import isEmpty from 'lodash/isEmpty' import isEmpty from 'lodash/isEmpty'
import { isMobileDevice } from 'config/userAgent'
import type { Events } from 'requests/getMatchEvents' import type { Events } from 'requests/getMatchEvents'
import type { MatchInfo } from 'requests/getMatchInfo' import type { MatchInfo } from 'requests/getMatchInfo'
import { MatchSidePlaylists } from 'features/MatchSidePlaylists' import { MatchSidePlaylists } from 'features/MatchSidePlaylists'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer' import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
import { isMobileDevice } from 'config/userAgent'
import { SettingsPopup } from '../SettingsPopup' import { SettingsPopup } from '../SettingsPopup'
import { useFinishedMatch } from './hooks' import { useFinishedMatch } from './hooks'

@ -0,0 +1,104 @@
import last from 'lodash/last'
import find from 'lodash/find'
import reduce from 'lodash/reduce'
import concat from 'lodash/concat'
import type { Episodes } from 'requests/getMatchPlaylists'
import type { MatchInfo } from 'requests/getMatchInfo'
import type { Chapters, Chapter } from 'features/StreamPlayer/types'
import type { MatchPlaylistOption, PlaylistOption } from '../../types'
import { FULL_GAME_KEY } from '../../helpers/buildPlaylists'
export const FULL_MATCH_BOUNDARY = '0'
/**
* Формирует эпизоды плейлиста Полный матч
* API не выдает полный матч как плейлист, формируем на фронте
* */
const getFullMatchChapters = (
profile: MatchInfo,
url: string,
playlist: MatchPlaylistOption,
) => {
const bound = find(profile?.video_bounds, { h: FULL_MATCH_BOUNDARY })
const durationMs = (playlist.duration ?? 0) * 1000
return [
{
duration: durationMs,
endMs: durationMs,
endOffsetMs: bound ? Number(bound.e) * 1000 : durationMs,
index: 0,
isFullMatchChapter: true,
startMs: 0,
startOffsetMs: bound ? Number(bound.s) * 1000 : 0,
url,
},
]
}
/**
* Формирует эпизоды плейлистов матча и игроков
* */
const getPlaylistChapters = (
profile: MatchInfo,
url: string,
episodes: Episodes,
) => reduce(
episodes,
(
acc: Chapters,
episode,
index,
) => {
if (episode.s >= episode.e) return acc
const bound = find(profile?.video_bounds, { h: String(episode.h) })
const boundStart = bound ? Number(bound.s) : 0
const episodeDuration = (episode.e - episode.s) * 1000
const prevVideoEndMs = last(acc)?.endMs ?? 0
const nextChapter: Chapter = {
duration: episodeDuration,
endMs: prevVideoEndMs + episodeDuration,
endOffsetMs: (boundStart + episode.e) * 1000,
index,
startMs: prevVideoEndMs,
startOffsetMs: (boundStart + episode.s) * 1000,
url,
}
return concat(acc, nextChapter)
},
[],
)
type Args = {
profile: MatchInfo,
selectedPlaylist?: PlaylistOption,
url: string,
}
/**
* Формирует список эпизодов из выбранного плейлиста для плеера
*/
export const buildChapters = ({
profile,
selectedPlaylist,
url,
}: Args): Chapters => {
if (!selectedPlaylist) return []
if (selectedPlaylist.id === FULL_GAME_KEY) {
return getFullMatchChapters(
profile,
url,
selectedPlaylist,
)
}
return getPlaylistChapters(
profile,
url,
selectedPlaylist.episodes,
)
}

@ -2,25 +2,24 @@ import { useMemo } from 'react'
import { API_ROOT } from 'config' import { API_ROOT } from 'config'
import type { MatchInfo } from 'requests/getMatchInfo'
import { usePageParams } from 'hooks/usePageParams' import { usePageParams } from 'hooks/usePageParams'
import { useMatchPopupStore } from 'features/MatchPopup' import { useMatchPageStore } from 'features/MatchPage/store'
import { usePlayerProgressReporter } from './usePlayerProgressReporter' import { usePlayerProgressReporter } from './usePlayerProgressReporter'
import { useLastPlayPosition } from './useLastPlayPosition' import { useResumeUrlParam } from './useResumeUrlParam'
import { useUrlParam } from './useUrlParam' import { useChapters } from './useChapters'
import { usePlaylistLogger } from './usePlaylistLogger'
export const useLiveMatch = (profile: MatchInfo) => { export const useLiveMatch = () => {
const { const {
handlePlaylistClick, handlePlaylistClick,
matchPlaylists, profile,
selectedPlaylist, selectedPlaylist,
} = useMatchPopupStore() setFullMatchPlaylistDuration,
} = useMatchPageStore()
const { profileId: matchId, sportType } = usePageParams() const { profileId: matchId, sportType } = usePageParams()
const resume = useUrlParam() const resume = useResumeUrlParam()
const fromStartIfStreamPaused = useMemo( const fromStartIfStreamPaused = useMemo(
() => (profile && !profile.live ? 0 : undefined), () => (profile && !profile.live ? 0 : undefined),
@ -30,13 +29,50 @@ export const useLiveMatch = (profile: MatchInfo) => {
[], [],
) )
const { chapters } = useChapters({
profile,
selectedPlaylist,
url: `${API_ROOT}/video/stream/${sportType}/${matchId}.m3u8`,
})
const {
logPlaylistChange,
onPlayingChange: notifyPlaylistLogger,
} = usePlaylistLogger()
const {
onPlayerProgressChange,
onPlayingChange: notifyProgressLogger,
} = usePlayerProgressReporter()
const onDurationChange = (duration: number) => {
if (profile?.live || profile?.video_bounds) return
setFullMatchPlaylistDuration(duration)
}
const onPlayingChange = (playing: boolean) => {
notifyPlaylistLogger(playing)
notifyProgressLogger(playing)
}
const onPlaylistSelect: typeof handlePlaylistClick = (playlist, e) => {
if (selectedPlaylist) {
logPlaylistChange(selectedPlaylist)
}
handlePlaylistClick(playlist, e)
}
return { return {
matchPlaylists, chapters,
onPlaylistSelect: handlePlaylistClick, onDurationChange,
onPlayerProgressChange,
onPlayingChange,
onPlaylistSelect,
resume: resume ?? fromStartIfStreamPaused, resume: resume ?? fromStartIfStreamPaused,
selectedPlaylist, selectedPlaylist,
streamUrl: `${API_ROOT}/video/stream/${sportType}/${matchId}.m3u8`, streamUrl: (
...usePlayerProgressReporter(), profile?.playbackUrl
...useLastPlayPosition(), || `${API_ROOT}/video/stream/${sportType}/${matchId}.m3u8`
),
} }
} }

@ -0,0 +1,33 @@
import { useMemo } from 'react'
import type { PlaylistOption } from 'features/MatchPage/types'
import type { MatchInfo } from 'requests/getMatchInfo'
import { buildChapters } from '../helpers'
type Args = {
profile: MatchInfo,
selectedPlaylist?: PlaylistOption,
url: string,
}
export const useChapters = ({
profile,
selectedPlaylist,
url,
}: Args) => {
const chapters = useMemo(
() => buildChapters({
profile,
selectedPlaylist,
url,
}),
[
profile,
selectedPlaylist,
url,
],
)
return { chapters }
}

@ -0,0 +1,72 @@
import {
useCallback,
useRef,
} from 'react'
import { useLocation } from 'react-router'
import { LogActions, logUserAction } from 'requests/logUserAction'
import { useInterval } from 'hooks/useInterval'
import { usePageParams } from 'hooks/usePageParams'
import { PlaylistOption, PlaylistTypes } from 'features/MatchPage/types'
const playlistTypeConfig = {
ball_in_play: 2,
full_game: 1,
goals: 4,
highlights: 3,
players: 5,
}
const getInitialData = () => ({ dateVisit: new Date().toISOString(), seconds: 0 })
export const usePlaylistLogger = () => {
const location = useLocation()
const { profileId, sportType } = usePageParams()
const data = useRef(getInitialData())
const incrementSeconds = () => data.current.seconds++
const resetData = () => {
data.current = getInitialData()
}
const { start, stop } = useInterval({
callback: incrementSeconds,
intervalDuration: 1000,
startImmediate: false,
})
const onPlayingChange = useCallback((playing: boolean) => {
if (playing) {
start()
} else {
stop()
}
}, [start, stop])
const logPlaylistChange = (prevPlaylist: PlaylistOption) => {
const args = prevPlaylist.type === PlaylistTypes.MATCH
? {
playlistType: playlistTypeConfig[prevPlaylist.id],
}
: {
playerId: prevPlaylist.id,
playlistType: playlistTypeConfig.players,
}
logUserAction({
actionType: LogActions.VideoChange,
dateVisit: data.current.dateVisit,
duration: data.current.seconds,
matchId: profileId,
sportType,
url: location.pathname,
...args,
})
resetData()
}
return { logPlaylistChange, onPlayingChange }
}

@ -0,0 +1,23 @@
import { useMemo } from 'react'
import { useLocation } from 'react-router'
import isNumber from 'lodash/isNumber'
export const RESUME_KEY = 'resume'
const readResumeParam = (search: string) => {
const params = new URLSearchParams(search)
const rawValue = params.get(RESUME_KEY)
if (!rawValue) return undefined
const value = JSON.parse(rawValue)
return isNumber(value) ? value : 0
}
export const useResumeUrlParam = () => {
const { search } = useLocation()
const resume = useMemo(() => readResumeParam(search), [search])
return resume
}

@ -1,8 +1,8 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import type { Events } from 'requests/getMatchEvents' import isEmpty from 'lodash/isEmpty'
import type { MatchInfo } from 'requests/getMatchInfo'
import { useMatchPageStore } from 'features/MatchPage/store'
import { StreamPlayer } from 'features/StreamPlayer' import { StreamPlayer } from 'features/StreamPlayer'
import { YoutubePlayer } from 'features/StreamPlayer/components/YoutubePlayer' import { YoutubePlayer } from 'features/StreamPlayer/components/YoutubePlayer'
import { MatchSidePlaylists } from 'features/MatchSidePlaylists' import { MatchSidePlaylists } from 'features/MatchSidePlaylists'
@ -13,42 +13,60 @@ import { Container } from '../../styled'
import { useLiveMatch } from './hooks' import { useLiveMatch } from './hooks'
import { TournamentData } from '../../types' import { TournamentData } from '../../types'
import { MatchDescription } from '../MatchDescription'
import { MatchProfileCardMobile } from '../MatchProfileCardMobile' import { MatchProfileCardMobile } from '../MatchProfileCardMobile'
type Props = { type Props = {
events: Events,
profile: MatchInfo,
tournamentData: TournamentData, tournamentData: TournamentData,
} }
export const LiveMatch = ({ export const LiveMatch = ({
events,
profile,
tournamentData, tournamentData,
}: Props) => { }: Props) => {
const { const {
events,
matchPlaylists, matchPlaylists,
profile,
selectedPlaylist,
} = useMatchPageStore()
const {
chapters,
onDurationChange,
onPlayerProgressChange, onPlayerProgressChange,
onPlayingChange, onPlayingChange,
onPlaylistSelect, onPlaylistSelect,
resume, resume,
selectedPlaylist,
streamUrl, streamUrl,
} = useLiveMatch(profile) } = useLiveMatch()
const Player = profile?.youtube_link ? YoutubePlayer : StreamPlayer
return ( return (
<Fragment> <Fragment>
<Container> <Container>
<Player {profile?.youtube_link ? (
<YoutubePlayer
chapters={chapters}
onPlayingChange={onPlayingChange} onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange} onProgressChange={onPlayerProgressChange}
profile={profile} profile={profile}
resumeFrom={resume} resumeFrom={resume}
url={streamUrl} url={streamUrl}
/> />
{isMobileDevice ? <MatchProfileCardMobile profile={profile} /> : null} ) : (
!isEmpty(chapters) && (
<StreamPlayer
onDurationChange={onDurationChange}
onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange}
isLive={profile?.live}
resumeFrom={resume}
chapters={chapters}
/>
)
)}
{isMobileDevice
? <MatchProfileCardMobile profile={profile} /> : (
<MatchDescription profile={profile} />)}
</Container> </Container>
<MatchSidePlaylists <MatchSidePlaylists

@ -6,7 +6,6 @@ import type { MatchInfo } from 'requests/getMatchInfo'
import { Name } from 'features/Name' import { Name } from 'features/Name'
import { SportIcon } from 'components/SportIcon/SportIcon' import { SportIcon } from 'components/SportIcon/SportIcon'
import { T9n } from 'features/T9n'
import { useAuthStore } from 'features/AuthStore' import { useAuthStore } from 'features/AuthStore'
import { useMatchSwitchesStore } from 'features/MatchSwitches' import { useMatchSwitchesStore } from 'features/MatchSwitches'
@ -21,7 +20,6 @@ import {
Description, Description,
DescriptionInnerBlock, DescriptionInnerBlock,
MatchDate, MatchDate,
StyledDash,
StyledLink, StyledLink,
Score, Score,
Title, Title,
@ -46,17 +44,15 @@ export const MatchDescription = ({
const { const {
country_id, country_id,
date, date,
live,
team1, team1,
team2, team2,
tournament, tournament,
} = profile } = profile
const isChangedTimeFormat = includes(['US', 'CA'], user?.profile.country_code) const isChangedTimeFormat = includes(['US', 'CA'], user?.profile.country_code)
const localDate = format(parseDate(date), const localDate = format(parseDate(date), 'MMMM d, y')
live ? 'HH:mm' : 'MMMM d, y')
const changedTimeFormat = format(parseDate(date), const changedTimeFormat = format(parseDate(date),
isChangedTimeFormat ? 'hh:mm aaa' : 'HH:mm') isChangedTimeFormat ? 'h:mm aaa' : 'HH:mm')
return ( return (
<Description> <Description>
@ -69,9 +65,13 @@ export const MatchDescription = ({
> >
<Name nameObj={team1} /> <Name nameObj={team1} />
</StyledLink> </StyledLink>
{!isScoreHidden && <Score>{team1.score}</Score>} <Score>
<StyledDash isScoreHidden={isScoreHidden}>-</StyledDash> {
{!isScoreHidden && <Score>{team2.score}</Score>} isScoreHidden || !team1.score || !team2.score
? '-'
: `${team1.score} - ${team2.score}`
}
</Score>
<StyledLink <StyledLink
id={team2.id} id={team2.id}
profileType={ProfileTypes.TEAMS} profileType={ProfileTypes.TEAMS}
@ -79,7 +79,6 @@ export const MatchDescription = ({
> >
<Name nameObj={team2} /> <Name nameObj={team2} />
</StyledLink> </StyledLink>
{live ? '\u00a0|\u00a0LIVE STREAM' : ''}
</Title> </Title>
<Tournament> <Tournament>
<SportIcon sport={sportType} /> <SportIcon sport={sportType} />
@ -96,11 +95,7 @@ export const MatchDescription = ({
</Tournament> </Tournament>
</DescriptionInnerBlock> </DescriptionInnerBlock>
<Views> <Views>
{ <Time>{changedTimeFormat}</Time>
live
? <T9n t='started_streaming_at' />
: <Time>{changedTimeFormat}</Time>
}
<MatchDate>{localDate}</MatchDate> <MatchDate>{localDate}</MatchDate>
</Views> </Views>
</Description> </Description>

@ -35,11 +35,6 @@ export const StyledLink = styled(ProfileLink)`
} }
` `
export const StyledDash = styled.span<{isScoreHidden?: boolean}>`
color: #fff;
margin: ${({ isScoreHidden }) => (isScoreHidden ? '0 10px' : '0')};
`
export const Title = styled.div` export const Title = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
@ -56,11 +51,6 @@ export const Title = styled.div`
opacity: 0.7; opacity: 0.7;
pointer-events: none; pointer-events: none;
} }
&:hover > ${StyledDash}:not(:hover){
opacity: 0.7;
pointer-events: none;
}
` `
export const Tournament = styled.span` export const Tournament = styled.span`

@ -1,20 +1,19 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { Fragment, useEffect } from 'react' import { Fragment, useEffect } from 'react'
import type { MatchInfo } from 'requests/getMatchInfo'
import { usePageParams } from 'hooks/usePageParams' import { usePageParams } from 'hooks/usePageParams'
import { useBuyMatchPopupStore } from 'features/BuyMatchPopup' import { useBuyMatchPopupStore } from 'features/BuyMatchPopup'
import { useMatchPageStore } from 'features/MatchPage/store'
import { prepareMatchProfile } from '../../helpers/prepareMatchProfile' import { prepareMatchProfile } from '../../helpers/prepareMatchProfile'
type Props = { type Props = {
children: ReactNode, children: ReactNode,
matchProfile: MatchInfo,
} }
export const SubscriptionGuard = ({ children, matchProfile }: Props) => { export const SubscriptionGuard = ({ children }: Props) => {
const { profile: matchProfile } = useMatchPageStore()
const { open: openBuyMatchPopup } = useBuyMatchPopupStore() const { open: openBuyMatchPopup } = useBuyMatchPopupStore()
const { profileId: matchId, sportType } = usePageParams() const { profileId: matchId, sportType } = usePageParams()

@ -1,7 +1,7 @@
import map from 'lodash/map' import map from 'lodash/map'
import sortBy from 'lodash/sortBy' import sortBy from 'lodash/sortBy'
import type { MatchPlaylists, Players } from 'requests' import type { MatchPlaylists, Players } from 'requests/getMatchPlaylists'
import type { import type {
Playlists, Playlists,
@ -25,10 +25,10 @@ const getMatchPlaylists = (matchPlaylists: MatchPlaylists | null): MatchPlaylist
return map(MATCH_KEYS, (key) => { return map(MATCH_KEYS, (key) => {
const playlist = matchPlaylists[key] const playlist = matchPlaylists[key]
const lexic = matchPlaylists.lexics[key] const lexic = matchPlaylists.lexics[key] ?? ''
return { return {
data: sortBy(playlist?.data, ['h', 's']),
duration: playlist?.dur, duration: playlist?.dur,
episodes: sortBy(playlist?.data, ['h', 's']),
id: key, id: key,
lexic, lexic,
type: PlaylistTypes.MATCH, type: PlaylistTypes.MATCH,
@ -39,6 +39,7 @@ const getMatchPlaylists = (matchPlaylists: MatchPlaylists | null): MatchPlaylist
const getPlayerPlaylists = (players?: Players): PlayerPlaylistOptions => ( const getPlayerPlaylists = (players?: Players): PlayerPlaylistOptions => (
map(players, (player) => ({ map(players, (player) => ({
...player, ...player,
episodes: [],
type: PlaylistTypes.PLAYER, type: PlaylistTypes.PLAYER,
})) }))
) )

@ -0,0 +1,11 @@
import find from 'lodash/find'
import type { MatchInfo } from 'requests/getMatchInfo'
import { FULL_MATCH_BOUNDARY } from 'features/MatchPage/components/LiveMatch/helpers'
export const calculateDuration = (profile: MatchInfo) => {
const bound = find(profile?.video_bounds, { h: FULL_MATCH_BOUNDARY })
if (!bound) return 0
return Number(bound.e) - Number(bound.s)
}

@ -10,21 +10,20 @@ import {
} from 'features/PageLayout' } from 'features/PageLayout'
import { FavoritesActions } from 'requests' import { FavoritesActions } from 'requests'
import { ProfileTypes } from 'config' import { ProfileTypes } from 'config'
import { isMobileDevice } from 'config/userAgent'
import { usePageLogger } from 'hooks/usePageLogger' import { usePageLogger } from 'hooks/usePageLogger'
import { usePageParams } from 'hooks/usePageParams' import { usePageParams } from 'hooks/usePageParams'
import { MatchPageStore } from './store'
import { SubscriptionGuard } from './components/SubscriptionGuard' import { SubscriptionGuard } from './components/SubscriptionGuard'
import { MatchProfileCard } from './components/MatchProfileCard'
import { FinishedMatch } from './components/FinishedMatch'
import { LiveMatch } from './components/LiveMatch' import { LiveMatch } from './components/LiveMatch'
import { useMatchProfile } from './hooks/useMatchProfile' import { useMatchProfile } from './hooks/useMatchProfile'
import { Wrapper } from './styled' import { Wrapper } from './styled'
import { MatchPageStore } from './store' import { FinishedMatch } from './components/FinishedMatch'
const MatchPage = () => { const MatchPageComponent = () => {
usePageLogger() usePageLogger()
const history = useHistory() const history = useHistory()
const { addRemoveFavorite, userFavorites } = useUserFavoritesStore() const { addRemoveFavorite, userFavorites } = useUserFavoritesStore()
@ -64,7 +63,6 @@ const MatchPage = () => {
const playFromScout = profile?.has_video && !profile?.live const playFromScout = profile?.has_video && !profile?.live
const playFromOTT = !profile?.has_video && (profile?.live || profile?.storage) const playFromOTT = !profile?.has_video && (profile?.live || profile?.storage)
// TODO Добавить попап 'Данный матч ещё не начался' // TODO Добавить попап 'Данный матч ещё не начался'
if (!isStarted && profile?.live === false) { if (!isStarted && profile?.live === false) {
const sportName = history.location.pathname.split('/')[1] const sportName = history.location.pathname.split('/')[1]
@ -72,19 +70,14 @@ const MatchPage = () => {
} }
return ( return (
<MatchPageStore>
<PageWrapper> <PageWrapper>
<ProfileHeader color='#2B2A28' height={4.5}> <ProfileHeader color='#2B2A28' height={4.5} />
{isMobileDevice ? null : <MatchProfileCard profile={profile} />}
</ProfileHeader>
<Main> <Main>
<UserFavorites /> <UserFavorites />
<SubscriptionGuard matchProfile={profile}> <SubscriptionGuard>
<Wrapper> <Wrapper>
{playFromOTT && ( {playFromOTT && (
<LiveMatch <LiveMatch
events={events}
profile={profile}
tournamentData={tournamentData} tournamentData={tournamentData}
/> />
)} )}
@ -99,8 +92,13 @@ const MatchPage = () => {
</SubscriptionGuard> </SubscriptionGuard>
</Main> </Main>
</PageWrapper> </PageWrapper>
</MatchPageStore>
) )
} }
const MatchPage = () => (
<MatchPageStore>
<MatchPageComponent />
</MatchPageStore>
)
export default MatchPage export default MatchPage

@ -0,0 +1,92 @@
import {
useEffect,
useState,
useMemo,
} from 'react'
import { useToggle } from 'hooks'
import type { MatchInfo } from 'requests/getMatchInfo'
import { getMatchInfo } from 'requests/getMatchInfo'
import { usePageParams } from 'hooks/usePageParams'
import { parseDate } from 'helpers/parseDate'
import { useMatchData } from './useMatchData'
import type { Playlists } from '../../types'
const addScoresFromPlaylists = (
profile: MatchInfo,
playlists: Playlists,
): MatchInfo => (
profile
? {
...profile,
team1: {
...profile?.team1,
score: playlists.score1,
},
team2: {
...profile?.team2,
score: playlists.score2,
},
}
: null
)
export const useMatchPage = () => {
const [matchProfile, setMatchProfile] = useState<MatchInfo>(null)
const { profileId: matchId, sportType } = usePageParams()
const {
close: hideProfileCard,
isOpen: profileCardShown,
open: showProfileCard,
} = useToggle(true)
useEffect(() => {
getMatchInfo(sportType, matchId).then(setMatchProfile)
}, [sportType, matchId])
useEffect(() => {
let getIntervalMatch: ReturnType<typeof setInterval>
if (matchProfile?.live && !matchProfile.youtube_link) {
getIntervalMatch = setInterval(
() => getMatchInfo(sportType, matchId).then(setMatchProfile), 1000 * 60 * 3,
)
}
return () => clearInterval(getIntervalMatch)
}, [matchProfile, sportType, matchId])
const {
events,
handlePlaylistClick,
matchPlaylists,
selectedPlaylist,
setFullMatchPlaylistDuration,
} = useMatchData(matchProfile)
const profile = useMemo(
() => addScoresFromPlaylists(matchProfile, matchPlaylists),
[matchProfile, matchPlaylists],
)
const isStarted = useMemo(() => (
profile?.date
? parseDate(profile.date) < new Date()
: true
), [profile?.date])
return {
events,
handlePlaylistClick,
hideProfileCard,
isStarted,
matchPlaylists,
profile,
profileCardShown,
selectedPlaylist,
setFullMatchPlaylistDuration,
showProfileCard,
}
}

@ -0,0 +1,28 @@
import { useCallback, useState } from 'react'
import type { Events } from 'requests'
import { getMatchEvents } from 'requests'
import { usePageParams } from 'hooks/usePageParams'
import { useEventsLexics } from './useEventsLexics'
export const useEvents = () => {
const [events, setEvents] = useState<Events>([])
const { fetchLexics } = useEventsLexics()
const { profileId: matchId, sportType } = usePageParams()
const fetchMatchEvents = useCallback(() => {
getMatchEvents({
matchId,
sportType,
}).then(fetchLexics)
.then(setEvents)
}, [
fetchLexics,
matchId,
sportType,
])
return { events, fetchMatchEvents }
}

@ -0,0 +1,25 @@
import { useCallback } from 'react'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
import uniq from 'lodash/uniq'
import type { Events } from 'requests'
import { useLexicsStore } from 'features/LexicsStore'
export const useEventsLexics = () => {
const { addLexicsConfig } = useLexicsStore()
const fetchLexics = useCallback((events: Events) => {
const lexics = uniq(map(events, ({ l }) => l))
if (!isEmpty(lexics)) {
addLexicsConfig(lexics)
}
return events
}, [addLexicsConfig])
return { fetchLexics }
}

@ -0,0 +1,82 @@
import { useEffect, useMemo } from 'react'
import debounce from 'lodash/debounce'
import { MatchInfo } from 'requests/getMatchInfo'
import { usePageParams } from 'hooks/usePageParams'
import { useInterval } from 'hooks/useInterval'
import { calculateDuration } from '../../helpers/fullMatchDuration'
import { useMatchPlaylists } from './useMatchPlaylists'
import { useEvents } from './useEvents'
const MATCH_DATA_POLL_INTERVAL = 60000
const MATCH_PLAYLISTS_DELAY = 5000
export const useMatchData = (profile: MatchInfo) => {
const { profileId: matchId, sportType } = usePageParams()
const {
fetchMatchPlaylists,
handlePlaylistClick,
matchPlaylists,
selectedPlaylist,
setFullMatchPlaylistDuration,
} = useMatchPlaylists()
const { events, fetchMatchEvents } = useEvents()
const fetchPlaylistsDebounced = useMemo(
() => debounce(fetchMatchPlaylists, MATCH_PLAYLISTS_DELAY),
[fetchMatchPlaylists],
)
const fullMatchDuration = useMemo(() => calculateDuration(profile), [profile])
useEffect(() => {
if (!profile) return
fetchMatchPlaylists({
fullMatchDuration,
id: matchId,
sportType,
})
fetchMatchEvents()
}, [
profile,
fullMatchDuration,
matchId,
sportType,
fetchMatchPlaylists,
fetchMatchEvents,
])
const intervalCallback = () => {
fetchPlaylistsDebounced({
fullMatchDuration,
id: matchId,
sportType,
})
fetchMatchEvents()
}
const { start, stop } = useInterval({
callback: intervalCallback,
intervalDuration: MATCH_DATA_POLL_INTERVAL,
startImmediate: false,
})
useEffect(() => {
if (profile?.live) {
start()
} else {
stop()
}
}, [profile?.live, start, stop])
return {
events,
handlePlaylistClick,
matchPlaylists,
selectedPlaylist,
setFullMatchPlaylistDuration,
}
}

@ -0,0 +1,89 @@
import {
useState,
useCallback,
} from 'react'
import isEmpty from 'lodash/isEmpty'
import type { SportTypes } from 'config/sportTypes'
import { getMatchPlaylists } from 'requests/getMatchPlaylists'
import type { Playlists } from 'features/MatchPage/types'
import { buildPlaylists } from 'features/MatchPage/helpers/buildPlaylists'
import { usePlaylistLexics } from './usePlaylistLexics'
import { useSelectedPlaylist } from './useSelectedPlaylist'
type ArgsFetchMatchPlaylists = {
fullMatchDuration: number,
id: number,
sportType: SportTypes,
}
const initialPlaylists = buildPlaylists(null)
export const useMatchPlaylists = () => {
const [matchPlaylists, setMatchPlaylists] = useState<Playlists>(initialPlaylists)
const { fetchLexics } = usePlaylistLexics()
const {
handlePlaylistClick,
selectedPlaylist,
setSelectedPlaylist,
} = useSelectedPlaylist()
const setInitialSeletedPlaylist = useCallback((playlists: Playlists) => {
setSelectedPlaylist((playlist) => {
if (!playlist && !isEmpty(playlists.match)) {
return playlists.match[0]
}
return playlist
})
return playlists
}, [setSelectedPlaylist])
const fetchMatchPlaylists = useCallback(({
fullMatchDuration,
id,
sportType,
}: ArgsFetchMatchPlaylists) => {
getMatchPlaylists({
fullMatchDuration,
matchId: id,
selectedActions: [],
sportType,
}).then(fetchLexics)
.then(buildPlaylists)
.then(setInitialSeletedPlaylist)
.then(setMatchPlaylists)
}, [fetchLexics, setInitialSeletedPlaylist])
/**
* API не выдает длительность Полного матча
* Здесь получаем его из самого видео
* и обновляем длительность плейлиста Полный матч
*/
const setFullMatchPlaylistDuration = (duration: number) => {
const playlists = [...matchPlaylists.match]
if (!playlists[0]) return
playlists[0].duration = duration
setMatchPlaylists({
...matchPlaylists,
match: playlists,
})
if (selectedPlaylist) {
setSelectedPlaylist({ ...selectedPlaylist })
}
}
return {
fetchMatchPlaylists,
handlePlaylistClick,
matchPlaylists,
selectedPlaylist,
setFullMatchPlaylistDuration,
}
}

@ -0,0 +1,22 @@
import { useCallback } from 'react'
import isEmpty from 'lodash/isEmpty'
import compact from 'lodash/compact'
import values from 'lodash/values'
import type { MatchPlaylists } from 'requests'
import { useLexicsStore } from 'features/LexicsStore'
export const usePlaylistLexics = () => {
const { addLexicsConfig } = useLexicsStore()
const fetchLexics = useCallback((playlist: MatchPlaylists) => {
const lexics = compact(values(playlist.lexics))
if (!isEmpty(lexics)) {
addLexicsConfig(lexics)
}
return playlist
}, [addLexicsConfig])
return { fetchLexics }
}

@ -0,0 +1,49 @@
import type { MouseEvent } from 'react'
import { useState, useCallback } from 'react'
import { getPlayerPlaylists } from 'requests/getPlayerPlaylists'
import { usePageParams } from 'hooks/usePageParams'
import {
PlayerPlaylistOption,
PlaylistOption,
PlaylistTypes,
} from 'features/MatchPage/types'
import { defaultSettings } from 'features/MatchPopup/types'
export const useSelectedPlaylist = () => {
const { profileId: matchId, sportType } = usePageParams()
const [selectedPlaylist, setSelectedPlaylist] = useState<PlaylistOption>()
const fetchPlayerEpisodes = useCallback((playlistOption: PlayerPlaylistOption) => (
getPlayerPlaylists({
matchId,
playerId: playlistOption.id,
settings: defaultSettings,
sportType,
})
), [matchId, sportType])
const handlePlaylistClick = useCallback((playlist: PlaylistOption, e?: MouseEvent) => {
e?.stopPropagation()
if (playlist === selectedPlaylist) return
if (playlist.type === PlaylistTypes.PLAYER) {
fetchPlayerEpisodes(playlist).then((episodes) => {
setSelectedPlaylist({
...playlist,
episodes,
})
})
} else {
setSelectedPlaylist(playlist)
}
}, [fetchPlayerEpisodes, selectedPlaylist])
return {
handlePlaylistClick,
selectedPlaylist,
setSelectedPlaylist,
}
}

@ -1,7 +1,10 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import { createContext, useContext } from 'react' import {
createContext,
useContext,
} from 'react'
import { useMatchPage } from '../hooks' import { useMatchPage } from './hooks'
type Context = ReturnType<typeof useMatchPage> type Context = ReturnType<typeof useMatchPage>
type Props = { children: ReactNode } type Props = { children: ReactNode }
@ -9,12 +12,8 @@ type Props = { children: ReactNode }
const MatchPageContext = createContext({} as Context) const MatchPageContext = createContext({} as Context)
export const MatchPageStore = ({ children }: Props) => { export const MatchPageStore = ({ children }: Props) => {
const value = useMatchPage() const values = useMatchPage()
return ( return <MatchPageContext.Provider value={values}>{children}</MatchPageContext.Provider>
<MatchPageContext.Provider value={value}>
{children}
</MatchPageContext.Provider>
)
} }
export const useMatchPageStore = () => useContext(MatchPageContext) export const useMatchPageStore = () => useContext(MatchPageContext)

@ -12,8 +12,8 @@ export enum PlaylistTypes {
} }
export type MatchPlaylistOption = { export type MatchPlaylistOption = {
data: Episodes,
duration?: number, duration?: number,
episodes: Episodes,
id: MatchPlaylistIds, id: MatchPlaylistIds,
lexic: number | string, lexic: number | string,
type: PlaylistTypes.MATCH, type: PlaylistTypes.MATCH,
@ -21,6 +21,7 @@ export type MatchPlaylistOption = {
export type PlayerPlaylistOption = { export type PlayerPlaylistOption = {
dur: number, dur: number,
episodes: Episodes,
gk?: boolean, gk?: boolean,
id: number, id: number,
name_eng: string, name_eng: string,
@ -31,6 +32,7 @@ export type PlayerPlaylistOption = {
} }
export type InterviewPlaylistOption = { export type InterviewPlaylistOption = {
episodes: Episodes,
id: number, id: number,
name_eng: string, name_eng: string,
name_rus: string, name_rus: string,
@ -38,7 +40,7 @@ export type InterviewPlaylistOption = {
} }
export type EventPlaylistOption = { export type EventPlaylistOption = {
data: Episodes, episodes: Episodes,
id: number, id: number,
type: PlaylistTypes.EVENT, type: PlaylistTypes.EVENT,
} }

@ -38,8 +38,8 @@ export type SettingsBySport = Partial<Record<SportTypes, Settings>>
export const defaultSettings: Settings = { export const defaultSettings: Settings = {
episodeDuration: { episodeDuration: {
after: 6, after: 30,
before: 6, before: 10,
}, },
selectedActions: [], selectedActions: [],
selectedFormat: PlayerPlaylistFormats.ALL_MATCH_TIME, selectedFormat: PlayerPlaylistFormats.ALL_MATCH_TIME,

@ -45,7 +45,7 @@ export const EventsList = ({
const repeatedEpisodes = event.rep || [] const repeatedEpisodes = event.rep || []
const eventPlaylist = { const eventPlaylist = {
data: [{ episodes: [{
e: event.e, e: event.e,
h: event.h, h: event.h,
s: event.s, s: event.s,

@ -16,6 +16,7 @@ import { T9n } from 'features/T9n'
import { PlayButton } from '../PlayButton' import { PlayButton } from '../PlayButton'
type Props = { type Props = {
live?: boolean,
onSelect?: (selectedMathPlaylist: PlaylistOption) => void, onSelect?: (selectedMathPlaylist: PlaylistOption) => void,
playlists: MatchPlaylistOptions, playlists: MatchPlaylistOptions,
selectedMathPlaylist?: PlaylistOption, selectedMathPlaylist?: PlaylistOption,
@ -36,6 +37,7 @@ const Item = styled.li`
` `
export const MatchPlaylists = ({ export const MatchPlaylists = ({
live,
onSelect, onSelect,
playlists, playlists,
selectedMathPlaylist, selectedMathPlaylist,
@ -52,7 +54,7 @@ export const MatchPlaylists = ({
<PlayButton <PlayButton
duration={playlist.duration} duration={playlist.duration}
active={isEqual(playlist, selectedMathPlaylist)} active={isEqual(playlist, selectedMathPlaylist)}
disabled={playlist.id !== FULL_GAME_KEY && isEmpty(playlist.data)} disabled={playlist.id !== FULL_GAME_KEY && isEmpty(playlist.episodes)}
onClick={() => onSelect?.(playlist)} onClick={() => onSelect?.(playlist)}
> >
<T9n t={playlist.lexic} /> <T9n t={playlist.lexic} />

@ -14,6 +14,7 @@ type Props = {
disabled?: boolean, disabled?: boolean,
duration?: number, duration?: number,
leftContent?: ReactNode, leftContent?: ReactNode,
live?: boolean,
onClick: () => void, onClick: () => void,
} }
@ -28,6 +29,7 @@ export const PlayButton = ({
disabled, disabled,
duration, duration,
leftContent, leftContent,
live,
onClick, onClick,
}: Props) => ( }: Props) => (
<Button <Button
@ -39,6 +41,6 @@ export const PlayButton = ({
<Title> <Title>
{children} {children}
</Title> </Title>
{!isUndefined(duration) && <Duration>{secondsToHms(duration)}</Duration>} {!isUndefined(duration) && !live && <Duration>{secondsToHms(duration)}</Duration>}
</Button> </Button>
) )

@ -9,5 +9,6 @@ export const MatchesWrapper = styled.div`
margin-bottom: 15px; margin-bottom: 15px;
} }
${customScrollbar} ${customScrollbar}
` `

@ -29,6 +29,7 @@ export const TabWatch = ({
playlists={playlists.match} playlists={playlists.match}
selectedMathPlaylist={selectedPlaylist} selectedMathPlaylist={selectedPlaylist}
onSelect={onSelect} onSelect={onSelect}
live={profile?.live}
/> />
<DropdownSection <DropdownSection
itemsCount={size(playlists.interview)} itemsCount={size(playlists.interview)}

@ -138,7 +138,6 @@ export const BlockTitle = styled.span`
color: rgba(255, 255, 255, 0.5); color: rgba(255, 255, 255, 0.5);
text-transform: uppercase; text-transform: uppercase;
` `
export const BackToTopBtn = styled.div` export const BackToTopBtn = styled.div`
position: absolute; position: absolute;
left: 50%; left: 50%;

@ -1,42 +0,0 @@
import map from 'lodash/map'
import {
LoadedProgress,
PlayedProgress,
} from 'features/StreamPlayer/components/ProgressBar/styled'
import type { Chapter } from '../../types'
import {
ChapterList,
ChapterContainer,
} from './styled'
type ChapterWithStyles = Chapter & {
loaded: number,
played: number,
width: number,
}
type Props = {
chapters: Array<ChapterWithStyles>,
}
export const Chapters = ({ chapters }: Props) => (
<ChapterList>
{
map(
chapters,
({
loaded,
played,
width,
}, index) => (
<ChapterContainer key={index} style={{ width: `${width}%` }}>
<LoadedProgress style={{ width: `${loaded}%` }} />
<PlayedProgress style={{ width: `${played}%` }} />
</ChapterContainer>
),
)
}
</ChapterList>
)

@ -2,7 +2,7 @@ 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'
import { Chapters } from '../Chapters' import { Chapters } from 'features/StreamPlayer/components/Chapters'
import type { Props } from './hooks' import type { Props } from './hooks'
import { useProgressBar } from './hooks' import { useProgressBar } from './hooks'
import { ProgressBarList } from './styled' import { ProgressBarList } from './styled'

@ -1,5 +1,5 @@
import styled, { css } from 'styled-components/macro' import styled, { css } from 'styled-components/macro'
import { isMobileDevice } from '../../../../config/userAgent' import { isMobileDevice } from 'config/userAgent'
export const ProgressBarList = styled.div` export const ProgressBarList = styled.div`
flex-grow: 1; flex-grow: 1;

@ -22,7 +22,6 @@ import { REWIND_SECONDS } from './config'
export const MultiSourcePlayer = (props: Props) => { export const MultiSourcePlayer = (props: Props) => {
const { profile } = props const { profile } = props
const { const {
activeChapterIndex, activeChapterIndex,
activePlayer, activePlayer,

@ -0,0 +1,36 @@
import map from 'lodash/map'
import type { Chapter } from '../../types'
import {
ChapterList,
ChapterContainer,
LoadedProgress,
PlayedProgress,
} from './styled'
type ChapterWithStyles = Chapter & {
loaded: number,
played: number,
width: number,
}
type Props = {
chapters: Array<ChapterWithStyles>,
}
export const Chapters = ({ chapters }: Props) => (
<ChapterList>
{map(chapters, ({
loaded,
played,
width,
}, index) => (
<ChapterContainer key={index} style={{ width: `${width}%` }}>
<LoadedProgress style={{ width: `${loaded}%` }} />
<PlayedProgress
style={{ width: `${played}%` }}
/>
</ChapterContainer>
))}
</ChapterList>
)

@ -16,3 +16,19 @@ export const ChapterContainer = styled.div`
margin-right: 3px; margin-right: 3px;
} }
` `
export const LoadedProgress = styled.div`
position: absolute;
z-index: 1;
background-color: rgba(255, 255, 255, 0.6);
height: 100%;
max-width: 100%;
`
export const PlayedProgress = styled.div`
position: absolute;
z-index: 2;
background-color: #CC0000;
height: 100%;
max-width: 100%;
`

@ -11,7 +11,7 @@ import { Chapters } from 'features/MultiSourcePlayer/types'
import { ControlsMobile } from './Components/ControlsMobile' import { ControlsMobile } from './Components/ControlsMobile'
import { ControlsWeb } from './Components/ControlsWeb' import { ControlsWeb } from './Components/ControlsWeb'
import { ProgressBar } from '../ProgressBar' // import { ProgressBar } from '../ProgressBar'
export type ControlsProps = { export type ControlsProps = {
activeChapterIndex?: number, activeChapterIndex?: number,
@ -65,7 +65,7 @@ export const Controls = (props: ControlsProps) => {
isStorage, isStorage,
loadedProgress, loadedProgress,
onProgressChange, onProgressChange,
onProgressChangeLive, // onProgressChangeLive,
onTouchEnd, onTouchEnd,
onTouchStart, onTouchStart,
playedProgress, playedProgress,
@ -84,18 +84,17 @@ export const Controls = (props: ControlsProps) => {
isLive, isLive,
isStorage, isStorage,
]) ])
const progressBarElement = useMemo(() => { const progressBarElement = useMemo(() => {
if (isLive || isStorage) { if (isLive || isStorage) {
return ( // return (
<ProgressBar // <ProgressBar
duration={duration} // duration={duration}
isScrubberVisible={controlsVisible} // isScrubberVisible={controlsVisible}
onPlayedProgressChange={onProgressChangeLive!} // onPlayedProgressChange={onProgressChangeLive!}
playedProgress={playedProgress} // playedProgress={playedProgress}
loadedProgress={loadedProgress} // loadedProgress={loadedProgress}
/> // />
) // )
} }
return ( return (
<ProgressBarMultiSource <ProgressBarMultiSource
@ -121,7 +120,7 @@ export const Controls = (props: ControlsProps) => {
isStorage, isStorage,
loadedProgress, loadedProgress,
onProgressChange, onProgressChange,
onProgressChangeLive, // onProgressChangeLive,
onTouchEnd, onTouchEnd,
onTouchStart, onTouchStart,
playedProgress, playedProgress,

@ -0,0 +1,47 @@
import { calculateChapterStyles } from '..'
const videoDuration = 60000
it('return correct progress and width lengthes', () => {
let chapter = {
duration: 15000,
endMs: 20000,
period: 0,
startMs: 5000,
urls: {},
}
let expected = {
...chapter,
loaded: 100,
played: 100,
width: 25,
}
expect(calculateChapterStyles({
activeChapterIndex: 0,
chapters: [chapter],
loadedProgress: 30000,
playedProgress: 30000,
videoDuration,
})).toEqual([expected])
chapter = {
duration: 30000,
endMs: 30000,
period: 0,
startMs: 0,
urls: {},
}
expected = {
...chapter,
loaded: 50,
played: 50,
width: 50,
}
expect(calculateChapterStyles({
activeChapterIndex: 0,
chapters: [chapter],
loadedProgress: 15000,
playedProgress: 15000,
videoDuration,
})).toEqual([expected])
})

@ -0,0 +1,62 @@
import map from 'lodash/fp/map'
import pipe from 'lodash/fp/pipe'
import size from 'lodash/fp/size'
import slice from 'lodash/fp/slice'
import type { Chapters, Chapter } from 'features/StreamPlayer/types'
const calculateChapterProgress = (progress: number, chapter: Chapter) => (
Math.min(progress * 100 / chapter.duration, 100)
)
type Args = {
activeChapterIndex: number,
chapters: Chapters,
loadedProgress: number,
playedProgress: number,
videoDuration: number,
}
export const calculateChapterStyles = ({
activeChapterIndex,
chapters,
loadedProgress,
playedProgress,
videoDuration,
}: Args) => {
const playedChapters = pipe(
slice(0, activeChapterIndex),
map((chapter: Chapter) => ({
...chapter,
loaded: 100,
played: 100,
width: chapter.duration * 100 / videoDuration,
})),
)(chapters)
const comingChapters = pipe(
slice(activeChapterIndex + 1, size(chapters)),
map((chapter: Chapter) => ({
...chapter,
loaded: 0,
played: 0,
width: chapter.duration * 100 / videoDuration,
})),
)(chapters)
const chapter = chapters[activeChapterIndex]
const activeChapter = {
...chapter,
loaded: calculateChapterProgress(loadedProgress, chapter),
played: playedProgress * 100 / videoDuration,
width: chapter.isFullMatchChapter
? 100
: chapter.duration * 100 / videoDuration,
}
return [
...playedChapters,
activeChapter,
...comingChapters,
]
}

@ -0,0 +1,52 @@
import { useMemo, RefObject } from 'react'
import { secondsToHms } from 'helpers'
import type { Chapters } from '../../../StreamPlayer/types'
import { calculateChapterStyles } from './helpers/calculateChapterStyles'
export type Props = {
activeChapterIndex: number,
allPlayedProgress: number,
chapters: Chapters,
duration: number,
isScrubberVisible?: boolean,
loadedProgress: number,
onPlayedProgressChange: (progress: number, seeking: boolean) => void,
onTouchEnd?: () => any,
onTouchStart?: () => any,
playedProgress: number,
videoRef?: RefObject<HTMLVideoElement>,
}
export const useProgressBar = ({
activeChapterIndex,
allPlayedProgress,
chapters = [],
duration,
loadedProgress,
playedProgress,
}: Props) => {
const calculatedChapters = useMemo(
() => calculateChapterStyles({
activeChapterIndex,
chapters,
loadedProgress,
playedProgress,
videoDuration: duration,
}),
[
activeChapterIndex,
loadedProgress,
playedProgress,
duration,
chapters,
],
)
return {
calculatedChapters,
playedProgressInPercent: Math.min(allPlayedProgress * 100 / duration, 100),
time: secondsToHms(allPlayedProgress / 1000),
}
}

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

@ -13,7 +13,7 @@ import { ProgressBar } from '.'
const Story = { const Story = {
component: ProgressBar, component: ProgressBar,
title: 'ProgressBar', title: 'ProgressBarWithChapters',
} }
export default Story export default Story
@ -47,9 +47,42 @@ const renderInControls = (progressBarElement: ReactElement) => (
const duration = 70000 const duration = 70000
const chapters = [
{
duration: 30000,
endMs: 30000,
endOffsetMs: 0,
period: 0,
startMs: 0,
startOffsetMs: 0,
url: '',
},
{
duration: 30000,
endMs: 60000,
endOffsetMs: 0,
period: 0,
startMs: 30000,
startOffsetMs: 0,
url: '',
},
{
duration: 10000,
endMs: 70000,
endOffsetMs: 0,
period: 0,
startMs: 60000,
startOffsetMs: 0,
url: '',
},
]
export const Empty = () => renderInControls( export const Empty = () => renderInControls(
<ProgressBar <ProgressBar
activeChapterIndex={0}
allPlayedProgress={0}
duration={duration} duration={duration}
chapters={chapters}
onPlayedProgressChange={callback} onPlayedProgressChange={callback}
playedProgress={0} playedProgress={0}
loadedProgress={0} loadedProgress={0}
@ -58,7 +91,10 @@ export const Empty = () => renderInControls(
export const HalfLoaded = () => renderInControls( export const HalfLoaded = () => renderInControls(
<ProgressBar <ProgressBar
activeChapterIndex={0}
allPlayedProgress={0}
duration={duration} duration={duration}
chapters={chapters}
onPlayedProgressChange={callback} onPlayedProgressChange={callback}
playedProgress={0} playedProgress={0}
loadedProgress={30000} loadedProgress={30000}
@ -67,7 +103,10 @@ export const HalfLoaded = () => renderInControls(
export const HalfPlayed = () => renderInControls( export const HalfPlayed = () => renderInControls(
<ProgressBar <ProgressBar
activeChapterIndex={1}
allPlayedProgress={1}
duration={duration} duration={duration}
chapters={chapters}
onPlayedProgressChange={callback} onPlayedProgressChange={callback}
playedProgress={30000} playedProgress={30000}
loadedProgress={0} loadedProgress={0}
@ -76,7 +115,10 @@ export const HalfPlayed = () => renderInControls(
export const Loaded40AndPlayed20 = () => renderInControls( export const Loaded40AndPlayed20 = () => renderInControls(
<ProgressBar <ProgressBar
activeChapterIndex={0}
allPlayedProgress={0}
duration={duration} duration={duration}
chapters={chapters}
onPlayedProgressChange={callback} onPlayedProgressChange={callback}
playedProgress={20000} playedProgress={20000}
loadedProgress={40000} loadedProgress={40000}

@ -1,40 +1,13 @@
import styled, { css } from 'styled-components/macro' import styled, { css } from 'styled-components/macro'
import { isMobileDevice } from 'config/userAgent' import { isMobileDevice } from 'config/userAgent'
import { Wrapper } from '../TimeTooltip/styled' import { Wrapper } from '../TimeTooltip/styled'
export const ProgressBarList = styled.div` export const ProgressBarList = styled.div`
flex-grow: 1; flex-grow: 1;
height: 4px; height: 4px;
position: relative; position: relative;
background-color: rgba(255, 255, 255, 0.3);
cursor: pointer;
${isMobileDevice
? css`
height: 1px;
`
: ''}
`
export const LoadedProgress = styled.div`
position: absolute;
z-index: 1;
background-color: rgba(255, 255, 255, 0.6);
height: 100%;
`
export const PlayedProgress = styled.div`
position: absolute;
z-index: 2;
background-color: #CC0000;
height: 100%;
${isMobileDevice
? css`
background-color: #FFFFFF;
`
: ''}
` `
export const Scrubber = styled.div` export const Scrubber = styled.div`
@ -42,7 +15,7 @@ export const Scrubber = styled.div`
outline: none; outline: none;
position: absolute; position: absolute;
top: 0; top: 0;
transform: translate(-50%, -38%); transform: translate(-50%, -43%);
z-index: 3; z-index: 3;
width: 18px; width: 18px;
height: 18px; height: 18px;
@ -56,9 +29,10 @@ export const Scrubber = styled.div`
${isMobileDevice ${isMobileDevice
? css` ? css`
width: 14px; width: 30px;
height: 14px; height: 30px;
background-color: #FFFFFF; background-clip: padding-box;
border: 10px solid transparent;
` `
: ''} : ''}
` `

@ -0,0 +1,27 @@
import { useToggle } from 'hooks'
export type Props = {
onSelect: (quality: string) => void,
selectedQuality: string,
videoQualities: Array<string>,
}
export const useSettings = ({ onSelect }: Props) => {
const {
close,
isOpen,
open,
} = useToggle()
const onItemClick = (quality: string) => {
onSelect(quality)
close()
}
return {
close,
isOpen,
onItemClick,
open,
}
}

@ -0,0 +1,47 @@
import { Fragment } from 'react'
import map from 'lodash/map'
import { OutsideClick } from 'features/OutsideClick'
import type { Props } from './hooks'
import { useSettings } from './hooks'
import {
SettingsButton,
QualitiesList,
QualityItem,
} from './styled'
export const Settings = (props: Props) => {
const { selectedQuality, videoQualities } = props
const {
close,
isOpen,
onItemClick,
open,
} = useSettings(props)
return (
<Fragment>
<SettingsButton onClick={open} />
{
isOpen && (
<OutsideClick onClick={close}>
<QualitiesList>
{
map(videoQualities, (quality) => (
<QualityItem
key={quality}
active={quality === selectedQuality}
onClick={() => onItemClick(quality)}
>
{quality}
</QualityItem>
))
}
</QualitiesList>
</OutsideClick>
)
}
</Fragment>
)
}

@ -0,0 +1,78 @@
import styled, { css } from 'styled-components/macro'
import { isMobileDevice } from 'config/userAgent'
import { ButtonBase } from 'features/StreamPlayer/styled'
export const SettingsButton = styled(ButtonBase)`
width: 22px;
height: 20px;
margin-left: 25px;
background-image: url(/images/settings.svg);
${isMobileDevice
? css`
width: 20px;
height: 18px;
margin-left: 10px;
cursor: pointer;
`
: ''};
`
export const QualitiesList = styled.ul`
position: absolute;
z-index: 1;
bottom: calc(100% + 14px);
right: 24px;
width: 52px;
list-style: none;
border-radius: 2px;
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
${isMobileDevice
? css`
right: 0;
bottom: 35px;
`
: ''};
`
type QualityItemProps = {
active: boolean,
}
const activeIcon = css`
:before {
position: absolute;
top: 45%;
transform: rotate(-45deg);
content: '';
left: 8px;
width: 5px;
height: 3px;
border-left: 1px solid #fff;
border-bottom: 1px solid #fff;
}
`
export const QualityItem = styled.li<QualityItemProps>`
width: 100%;
padding: 5px 8px;
text-align: right;
font-style: normal;
font-weight: normal;
/* stylelint-disable-next-line */
font-family: Montserrat;
font-size: 10px;
line-height: 12px;
letter-spacing: 0.01em;
color: #fff;
cursor: pointer;
position: relative;
:hover, :focus {
background-color: rgba(255, 255, 255, 0.1);
}
${({ active }) => (active ? activeIcon : '')}
`

@ -1,8 +1,8 @@
import Hls from 'hls.js' import type { HlsConfig } from 'hls.js'
import { readToken } from 'helpers/token' import { readToken } from 'helpers/token'
export const streamConfig: Partial<Hls.Config> = { export const streamConfig: Partial<HlsConfig> = {
liveSyncDuration: 30, liveSyncDuration: 30,
maxBufferLength: 30, maxBufferLength: 30,
xhrSetup: (xhr, urlString) => { xhrSetup: (xhr, urlString) => {
@ -11,3 +11,7 @@ export const streamConfig: Partial<Hls.Config> = {
xhr.open('GET', url.toString()) xhr.open('GET', url.toString())
}, },
} }
export const REWIND_SECONDS = 5
export const HOUR_IN_MILLISECONDS = 60 * 60 * 1000

@ -0,0 +1,13 @@
import findIndex from 'lodash/findIndex'
import size from 'lodash/size'
import type { Chapters } from '../types'
export const findChapterByProgress = (chapters: Chapters, progressMs: number) => {
if (size(chapters) === 1 && chapters[0].isFullMatchChapter) return 0
return (
findIndex(chapters, ({ endMs, startMs }) => (
startMs <= progressMs && progressMs <= endMs
))
)
}

@ -2,94 +2,156 @@ import type { MouseEvent } from 'react'
import { import {
useCallback, useCallback,
useEffect, useEffect,
useMemo,
useState, useState,
} from 'react' } from 'react'
import once from 'lodash/once' import size from 'lodash/size'
import isNumber from 'lodash/isNumber'
import isEmpty from 'lodash/isEmpty'
import { isIOS } from 'config/userAgent'
import { useObjectState } from 'hooks/useObjectState'
import { useEventListener } from 'hooks/useEventListener'
import { useVolume } from 'features/VideoPlayer/hooks/useVolume' import { useVolume } from 'features/VideoPlayer/hooks/useVolume'
import { REWIND_SECONDS } from 'features/MultiSourcePlayer/config'
import { useNoNetworkPopupStore } from 'features/NoNetworkPopup' import { useNoNetworkPopupStore } from 'features/NoNetworkPopup'
import { useLiveMatch } from 'features/MatchPage/components/LiveMatch/hooks'
import { useObjectState } from 'hooks' import type { Chapters } from 'features/StreamPlayer/types'
import type { MatchInfo } from 'requests/getMatchInfo' import { MatchInfo } from 'requests/getMatchInfo'
import { isIOS } from 'config/userAgent'
import { REWIND_SECONDS } from '../config'
import { useHlsPlayer } from './useHlsPlayer' import { useHlsPlayer } from './useHlsPlayer'
import { useFullscreen } from './useFullscreen' import { useFullscreen } from './useFullscreen'
import { useVideoQuality } from './useVideoQuality' import { useVideoQuality } from './useVideoQuality'
import { useControlsVisibility } from './useControlsVisibility' import { useControlsVisibility } from './useControlsVisibility'
import { useProgressChangeHandler } from './useProgressChangeHandler'
import { usePlayingHandlers } from './usePlayingHandlers'
import { useDuration } from './useDuration'
export type PlayerState = typeof initialState
const toMilliSeconds = (seconds: number) => seconds * 1000 const toMilliSeconds = (seconds: number) => seconds * 1000
const initialState = { const initialState = {
activeChapterIndex: 0,
buffering: true,
chapters: [] as Chapters,
duration: 0, duration: 0,
loadedProgress: 0, loadedProgress: 0,
playedProgress: 0, playedProgress: 0,
playing: false, playing: false,
ready: false, ready: false,
seek: 0, seek: 0,
seeking: false,
} }
export type Props = { export type Props = {
chapters: Chapters,
isLive?: boolean,
onDurationChange?: (duration: number) => void,
onPlayingChange: (playing: boolean) => void, onPlayingChange: (playing: boolean) => void,
onProgressChange: (seconds: number) => void, onProgressChange: (seconds: number) => void,
profile: MatchInfo, profile?: MatchInfo,
resumeFrom?: number, resumeFrom?: number,
url: string, url?: string,
} }
export const useVideoPlayer = ({ export const useVideoPlayer = ({
chapters: chaptersProps,
isLive,
onDurationChange,
onPlayingChange, onPlayingChange,
onProgressChange: progressChangeCallback, onProgressChange: progressChangeCallback,
resumeFrom, resumeFrom,
url,
}: Props) => { }: Props) => {
const { hls, videoRef } = useHlsPlayer(url, resumeFrom)
const [{ const [{
duration, activeChapterIndex,
buffering,
chapters,
duration: fullMatchDuration,
loadedProgress, loadedProgress,
playedProgress, playedProgress,
playing, playing,
ready, ready,
seek, seek,
}, setPlayerState] = useObjectState({ seeking,
...initialState, }, setPlayerState] = useObjectState({ ...initialState, chapters: chaptersProps })
playedProgress: toMilliSeconds(resumeFrom || 0),
seek: resumeFrom || 0,
})
const startPlaying = useMemo(() => once(() => { const { onPlaylistSelect, selectedPlaylist } = useLiveMatch()
setPlayerState({ playing: true, ready: true })
onPlayingChange(true) const { url } = chapters[0] ?? { url: '' }
}), [onPlayingChange, setPlayerState]) const numberOfChapters = size(chapters)
const { hls, videoRef } = useHlsPlayer(url, resumeFrom)
const [isLiveTime, setIsLiveTime] = useState(false)
const [isPausedTime, setIsPausedTime] = useState(false)
const [pausedProgress, setPausedProgress] = useState(0)
const chaptersDuration = useDuration(chapters)
const duration = (isLive && chapters[0]?.isFullMatchChapter)
? fullMatchDuration
: chaptersDuration
const {
onReady,
playNextChapter,
playPrevChapter,
stopPlaying,
togglePlaying,
} = usePlayingHandlers(setPlayerState, chapters)
const getActiveChapter = useCallback(
(index: number = activeChapterIndex) => chapters[index],
[chapters, activeChapterIndex],
)
const { const {
isFullscreen, isFullscreen,
onFullscreenClick, onFullscreenClick,
wrapperRef, wrapperRef,
} = useFullscreen() } = useFullscreen()
const [sizeOptions, setSizeOptions] = useState({ const [sizeOptions, setSizeOptions] = useState({
height: wrapperRef.current?.clientHeight, height: wrapperRef.current?.clientHeight,
width: wrapperRef.current?.clientWidth, width: wrapperRef.current?.clientWidth,
}) })
const togglePlaying = () => { const isFirstChapterPlaying = activeChapterIndex === 0
if (ready) { const isLastChapterPlaying = activeChapterIndex === numberOfChapters - 1
setPlayerState({ playing: !playing }) const seekTo = useCallback((progressMs: number) => {
onPlayingChange(!playing) if (!videoRef.current) return
videoRef.current.currentTime = progressMs / 1000
}, [videoRef])
const rewindForward = () => {
const chapter = getActiveChapter()
const newProgress = playedProgress + REWIND_SECONDS * 1000
if (newProgress <= chapter.duration || isLive) {
seekTo(chapter.startOffsetMs + newProgress)
} else if (isLastChapterPlaying) {
playNextChapter()
} else {
const nextChapter = getActiveChapter(activeChapterIndex + 1)
const fromMs = newProgress - chapter.duration
playNextChapter(fromMs, nextChapter.startOffsetMs)
} }
} }
const rewind = (seconds: number) => () => { const rewindBackward = () => {
if (!videoRef.current) return const chapter = getActiveChapter()
const { currentTime } = videoRef.current const newProgress = playedProgress - REWIND_SECONDS * 1000
const newProgress = currentTime + seconds if (newProgress >= 0) {
videoRef.current.currentTime = newProgress seekTo(chapter.startOffsetMs + newProgress)
} else if (isFirstChapterPlaying) {
seekTo(chapter.startOffsetMs)
} else {
const prevChapter = getActiveChapter(activeChapterIndex - 1)
const fromMs = prevChapter.duration + newProgress
playPrevChapter(fromMs, prevChapter.startOffsetMs)
}
} }
const onError = useCallback(() => { const onError = useCallback(() => {
@ -102,28 +164,177 @@ export const useVideoPlayer = ({
} }
} }
const onWaiting = () => {
setPlayerState({ buffering: true })
}
const onPlaying = () => {
setPlayerState({ buffering: false })
}
const onPause = () => {
setPlayerState({ playing: false })
}
const onPlay = () => {
setPlayerState({ playing: true })
}
const onDuration = (durationSeconds: number) => { const onDuration = (durationSeconds: number) => {
setPlayerState({ duration: toMilliSeconds(durationSeconds) }) setPlayerState({ duration: toMilliSeconds(durationSeconds) })
onDurationChange?.(durationSeconds)
} }
const onProgressChange = useCallback((progress: number) => { const onProgressChange = useProgressChangeHandler({
const progressMs = progress * duration chapters,
setPlayerState({ playedProgress: progressMs, seek: progressMs / 1000 }) duration,
}, [duration, setPlayerState]) setPlayerState,
})
const onLoadedProgress = (loadedMs: number) => { const onLoadedProgress = (loadedMs: number) => {
setPlayerState({ loadedProgress: loadedMs }) const chapter = getActiveChapter()
const value = loadedMs - chapter.startOffsetMs
setPlayerState({ loadedProgress: value })
} }
const onPlayedProgress = (playedMs: number) => { const onPlayedProgress = (playedMs: number) => {
setPlayerState({ playedProgress: playedMs }) const chapter = getActiveChapter()
progressChangeCallback(playedMs / 1000) const value = Math.max(playedMs - chapter.startOffsetMs, 0)
setPlayerState({ playedProgress: value })
progressChangeCallback(value / 1000)
} }
const backToLive = useCallback(() => { const backToLive = useCallback(() => {
const liveProgressMs = Math.max(duration - 10000, 0) if (!duration) return
if (selectedPlaylist?.id !== 'full_game') {
onPlaylistSelect({
duration: 0,
episodes: [],
id: 'full_game',
lexic: 13028,
type: 0,
})
setIsLiveTime(true)
}
const liveProgressMs = Math.max(duration - 30000, 0)
setPlayerState({ playedProgress: liveProgressMs, seek: liveProgressMs / 1000 }) setPlayerState({ playedProgress: liveProgressMs, seek: liveProgressMs / 1000 })
}, [duration, setPlayerState]) if (liveProgressMs > 0) setIsLiveTime(false)
}, [
duration,
onPlaylistSelect,
selectedPlaylist,
setPlayerState,
])
const backToPausedTime = useCallback(() => {
if (!duration) return
if (selectedPlaylist?.id !== 'full_game') {
onPlaylistSelect({
duration: 0,
episodes: [],
id: 'full_game',
lexic: 13028,
type: 0,
})
setIsPausedTime(true)
}
const liveProgressMs = Math.max(duration - 30000, 0)
setPlayerState({ playedProgress: pausedProgress, seek: pausedProgress / 1000 })
if (liveProgressMs > 0) setIsPausedTime(false)
// eslint-disable-next-line
}, [
duration,
onPlaylistSelect,
selectedPlaylist,
setPlayerState,
])
useEffect(() => {
if (chapters[0]?.isFullMatchChapter) {
setPausedProgress(playedProgress)
}
// eslint-disable-next-line
}, [selectedPlaylist])
useEffect(() => {
if (duration && isLiveTime && chapters[0]?.isFullMatchChapter) {
backToLive()
}
// eslint-disable-next-line
}, [duration, isLiveTime])
useEffect(() => {
if (duration && isPausedTime && chapters[0]?.isFullMatchChapter) {
backToPausedTime()
}
// eslint-disable-next-line
}, [duration, isPausedTime])
useEventListener({
callback: (e: KeyboardEvent) => {
if (e.code === 'ArrowLeft') rewindBackward()
else if (e.code === 'ArrowRight') rewindForward()
},
event: 'keydown',
})
useEffect(() => {
if (isNumber(seek)) {
setPlayerState({ seek: undefined })
}
}, [seek, setPlayerState])
useEffect(() => {
onPlayingChange(playing)
if (playing) {
setPlayerState({ buffering: false })
}
// eslint-disable-next-line
}, [playing, onPlayingChange])
useEffect(() => {
if ((isLive
&& chapters[0]?.endOffsetMs === chaptersProps[0]?.endOffsetMs)
|| isEmpty(chapters)) return
setPlayerState({
...initialState,
chapters: chaptersProps,
playing: true,
seek: chaptersProps[0].startOffsetMs / 1000,
})
}, [
chapters,
chaptersProps,
isLive,
setPlayerState,
])
useEffect(() => {
if ((isLive && chapters[0]?.isFullMatchChapter) || isEmpty(chapters)) return
const { duration: chapterDuration } = getActiveChapter()
if (playedProgress >= chapterDuration && !seeking) {
if (isLive) {
backToPausedTime()
} else {
playNextChapter()
}
}
// eslint-disable-next-line
}, [
isLive,
chapters,
getActiveChapter,
onPlaylistSelect,
playedProgress,
seeking,
playNextChapter,
])
const { isOnline } = useNoNetworkPopupStore() const { isOnline } = useNoNetworkPopupStore()
@ -138,13 +349,11 @@ export const useVideoPlayer = ({
useEffect(() => { useEffect(() => {
if (!isOnline) { if (!isOnline) {
setPlayerState({ playing: false }) stopPlaying()
onPlayingChange(false)
} }
}, [ }, [
isOnline, isOnline,
onPlayingChange, stopPlaying,
setPlayerState,
]) ])
useEffect(() => { useEffect(() => {
@ -159,27 +368,50 @@ export const useVideoPlayer = ({
} }
}, [setPlayerState]) }, [setPlayerState])
useEffect(() => {
if (ready && videoRef) {
videoRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'start',
})
}
}, [ready, videoRef])
return { return {
activeChapterIndex,
allPlayedProgress: playedProgress + getActiveChapter().startMs,
backToLive, backToLive,
buffering,
chapters,
duration, duration,
isFirstChapterPlaying,
isFullscreen, isFullscreen,
isLastChapterPlaying,
loadedProgress, loadedProgress,
numberOfChapters,
onDuration, onDuration,
onError, onError,
onFullscreenClick, onFullscreenClick,
onLoadedProgress, onLoadedProgress,
onPause,
onPlay,
onPlayedProgress, onPlayedProgress,
onPlayerClick, onPlayerClick,
onPlaying,
onProgressChange, onProgressChange,
onReady,
onWaiting,
playNextChapter,
playPrevChapter,
playedProgress, playedProgress,
playing, playing,
ready, ready,
rewindBackward: rewind(-REWIND_SECONDS), rewindBackward,
rewindForward: rewind(REWIND_SECONDS), rewindForward,
seek, seek,
sizeOptions, sizeOptions,
startPlaying,
togglePlaying, togglePlaying,
url,
videoRef, videoRef,
wrapperRef, wrapperRef,
...useControlsVisibility(isFullscreen), ...useControlsVisibility(isFullscreen),

@ -0,0 +1,9 @@
import { useMemo } from 'react'
import sumBy from 'lodash/sumBy'
import type { Chapters } from '../types'
export const useDuration = (chapters: Chapters) => (
useMemo(() => sumBy(chapters, 'duration'), [chapters])
)

@ -43,11 +43,8 @@ export const useFullscreen = () => {
} }
} }
/**
* В обертке могут быть 2 плеера, находим тот который играет сейчас, т.е. не скрыт
*/
const getPlayingVideoElement = () => ( const getPlayingVideoElement = () => (
wrapperRef.current?.querySelector('video:not([hidden])') as HTMLVideoElement | null wrapperRef.current?.querySelector('video') as HTMLVideoElement | null
) )
const toggleIOSFullscreen = () => { const toggleIOSFullscreen = () => {

@ -0,0 +1,113 @@
import { useCallback } from 'react'
import isUndefined from 'lodash/isUndefined'
import type { SetPartialState } from 'hooks'
import type { PlayerState } from '.'
import type { Chapters } from '../types'
export const usePlayingHandlers = (
setPlayerState: SetPartialState<PlayerState>,
chapters: Chapters,
) => {
const onReady = useCallback(() => {
setPlayerState((state) => (
state.ready
? state
: {
buffering: false,
playing: true,
ready: true,
}
))
}, [setPlayerState])
const togglePlaying = useCallback(() => {
setPlayerState((state) => (
state.ready
? { playing: !state.playing }
: state
))
}, [setPlayerState])
const stopPlaying = useCallback(() => {
setPlayerState({ playing: false })
}, [setPlayerState])
const startPlaying = useCallback(() => {
setPlayerState((state) => (
state.ready
? { playing: true }
: state
))
}, [setPlayerState])
const playNextChapter = useCallback((fromMs?: number, startOffsetMs?: number) => {
setPlayerState((state) => {
if (!state.ready) return state
const nextChapterIndex = state.activeChapterIndex + 1
const nextChapter = chapters[nextChapterIndex]
if (!nextChapter) {
return {
activeChapterIndex: 0,
loadedProgress: 0,
playedProgress: 0,
playing: false,
seek: chapters[0].startOffsetMs / 1000,
seeking: false,
}
}
if (isUndefined(fromMs) || isUndefined(startOffsetMs)) {
return {
activeChapterIndex: nextChapterIndex,
loadedProgress: 0,
playedProgress: 0,
seek: nextChapter.startOffsetMs / 1000,
}
}
return {
activeChapterIndex: nextChapterIndex,
loadedProgress: 0,
playedProgress: fromMs,
playing: state.playing,
seek: (startOffsetMs + fromMs) / 1000,
}
})
}, [chapters, setPlayerState])
const playPrevChapter = useCallback((fromMs?: number, startOffsetMs?: number) => {
setPlayerState((state) => {
if (!state.ready || state.activeChapterIndex === 0) return state
const prevChapterIndex = state.activeChapterIndex - 1
const prevChapter = chapters[prevChapterIndex]
if (isUndefined(fromMs) || isUndefined(startOffsetMs)) {
return {
activeChapterIndex: prevChapterIndex,
loadedProgress: 0,
playedProgress: 0,
seek: prevChapter.startOffsetMs / 1000,
}
}
return {
activeChapterIndex: prevChapterIndex,
loadedProgress: 0,
playedProgress: fromMs,
seek: (startOffsetMs + fromMs) / 1000,
}
})
}, [chapters, setPlayerState])
return {
onReady,
playNextChapter,
playPrevChapter,
startPlaying,
stopPlaying,
togglePlaying,
}
}

@ -0,0 +1,48 @@
import { useCallback } from 'react'
import type { SetPartialState } from 'hooks'
import type { Chapters } from '../types'
import type { PlayerState } from '.'
import { findChapterByProgress } from '../helpers'
type Args = {
chapters: Chapters,
duration: number,
setPlayerState: SetPartialState<PlayerState>,
}
export const useProgressChangeHandler = ({
chapters,
duration,
setPlayerState,
}: Args) => {
const onProgressChange = useCallback((progress: number, seeking: boolean) => {
setPlayerState((state) => {
// значение новой позиции ползунка в миллисекундах
const progressMs = progress * duration
const chapterIndex = findChapterByProgress(chapters, progressMs)
const chapter = chapters[chapterIndex]
const isProgressOnDifferentChapter = (
chapterIndex !== -1
&& chapterIndex !== state.activeChapterIndex
)
const nextChapter = isProgressOnDifferentChapter
? chapterIndex
: state.activeChapterIndex
// отнимаем начало эпизода на котором остановились от общего прогресса
// чтобы получить прогресс текущего эпизода
const chapterProgressMs = (progressMs - chapter.startMs)
const seekMs = chapterProgressMs + chapter.startOffsetMs
return {
activeChapterIndex: nextChapter,
playedProgress: chapterProgressMs,
seek: seekMs / 1000,
seeking,
}
})
}, [duration, chapters, setPlayerState])
return onProgressChange
}

@ -4,7 +4,7 @@ import {
useCallback, useCallback,
} from 'react' } from 'react'
import Hls from 'hls.js' import Hls, { Level } from 'hls.js'
import map from 'lodash/map' import map from 'lodash/map'
import find from 'lodash/find' import find from 'lodash/find'
@ -28,9 +28,9 @@ const autoQuality = {
* непонятное качество без свойств height, width и тд для определения * непонятное качество без свойств height, width и тд для определения
* какое это качество * какое это качество
*/ */
const filterOutUnknownQualities = filter(({ height }: Hls.Level) => Boolean(height)) const filterOutUnknownQualities = filter(({ height }: Level) => Boolean(height))
const getVideoQualities = (levels: Array<Hls.Level>) => { const getVideoQualities = (levels: Array<Level>) => {
if (isEmpty(levels)) return [] if (isEmpty(levels)) return []
const filteredQualities = filterOutUnknownQualities(levels) const filteredQualities = filterOutUnknownQualities(levels)

@ -1,34 +1,57 @@
import { Fragment } from 'react'
import { T9n } from 'features/T9n'
import { Loader } from 'features/Loader' import { Loader } from 'features/Loader'
import { REWIND_SECONDS } from 'features/MultiSourcePlayer/config'
import { VideoPlayer } from 'features/VideoPlayer' import { VideoPlayer } from 'features/VideoPlayer'
import { Name } from 'features/Name'
import { isMobileDevice } from 'config/userAgent' import { secondsToHms } from 'helpers'
import { HOUR_IN_MILLISECONDS, REWIND_SECONDS } from './config'
import { VolumeBar } from './components/VolumeBar'
import { Settings } from './components/Settings'
import { ProgressBar } from './components/ProgressBar'
import { import {
PlayerWrapper, PlayerWrapper,
LoaderWrapper, Controls,
ControlsGradient, ControlsRow,
ControlsGroup,
CenterControls, CenterControls,
Backward,
PlayStop, PlayStop,
Fullscreen,
LoaderWrapper,
Backward,
Forward, Forward,
TeamsDetailsWrapper, PlaybackTime,
ControlsGradient,
LiveBtn,
ChaptersText,
Next,
Prev,
} from './styled' } from './styled'
import type { Props } from './hooks' import type { Props } from './hooks'
import { useVideoPlayer } from './hooks' import { useVideoPlayer } from './hooks'
import { Controls } from './components/Controls'
/**
* HLS плеер, применяется на лайв и завершенных матчах
*/
export const StreamPlayer = (props: Props) => { export const StreamPlayer = (props: Props) => {
const { profile, url } = props const { isLive } = props
const { const {
activeChapterIndex,
allPlayedProgress,
backToLive, backToLive,
buffering,
chapters,
controlsVisible, controlsVisible,
duration, duration,
isFirstChapterPlaying,
isFullscreen, isFullscreen,
isLastChapterPlaying,
loadedProgress, loadedProgress,
muted, muted,
numberOfChapters,
onDuration, onDuration,
onError, onError,
onFullscreenClick, onFullscreenClick,
@ -36,23 +59,30 @@ export const StreamPlayer = (props: Props) => {
onMouseEnter, onMouseEnter,
onMouseLeave, onMouseLeave,
onMouseMove, onMouseMove,
onPause,
onPlay,
onPlayedProgress, onPlayedProgress,
onPlayerClick, onPlayerClick,
onPlaying,
onProgressChange, onProgressChange,
onQualitySelect, onQualitySelect,
onReady,
onTouchEnd, onTouchEnd,
onTouchStart, onTouchStart,
onVolumeChange, onVolumeChange,
onVolumeClick, onVolumeClick,
onWaiting,
playedProgress, playedProgress,
playing, playing,
playNextChapter,
playPrevChapter,
ready, ready,
rewindBackward, rewindBackward,
rewindForward, rewindForward,
seek, seek,
selectedQuality, selectedQuality,
startPlaying,
togglePlaying, togglePlaying,
url,
videoQualities, videoQualities,
videoRef, videoRef,
volume, volume,
@ -71,11 +101,9 @@ export const StreamPlayer = (props: Props) => {
onTouchStart={onTouchStart} onTouchStart={onTouchStart}
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
> >
{!ready && ( <LoaderWrapper buffering={buffering}>
<LoaderWrapper>
<Loader color='#515151' /> <Loader color='#515151' />
</LoaderWrapper> </LoaderWrapper>
)}
<VideoPlayer <VideoPlayer
width='100%' width='100%'
height='100%' height='100%'
@ -90,19 +118,14 @@ export const StreamPlayer = (props: Props) => {
onPlayedProgress={onPlayedProgress} onPlayedProgress={onPlayedProgress}
onDurationChange={onDuration} onDurationChange={onDuration}
onEnded={togglePlaying} onEnded={togglePlaying}
onReady={startPlaying} onReady={onReady}
onPause={onPause}
onPlay={onPlay}
onPlaying={onPlaying}
onWaiting={onWaiting}
onError={onError} onError={onError}
crossOrigin='use-credentials' crossOrigin='use-credentials'
/> />
{isMobileDevice && isFullscreen && controlsVisible && profile && (
<TeamsDetailsWrapper>
<Name nameObj={profile.team1} />
{` ${profile.team1.score}-${profile.team2.score} `}
<Name nameObj={profile.team2} />
</TeamsDetailsWrapper>
)}
{ready && ( {ready && (
<CenterControls playing={playing}> <CenterControls playing={playing}>
<Backward size='lg' onClick={rewindBackward}> <Backward size='lg' onClick={rewindBackward}>
@ -120,34 +143,83 @@ export const StreamPlayer = (props: Props) => {
</CenterControls> </CenterControls>
)} )}
<Controls <Controls visible={controlsVisible}>
allPlayedProgress={playedProgress} <ControlsRow>
backToLive={backToLive} <ProgressBar
controlsVisible={controlsVisible} activeChapterIndex={activeChapterIndex}
allPlayedProgress={allPlayedProgress}
duration={duration} duration={duration}
isFullscreen={isFullscreen} chapters={chapters}
isLive={profile?.live} onPlayedProgressChange={onProgressChange}
isStorage={profile?.storage} playedProgress={playedProgress}
loadedProgress={loadedProgress} loadedProgress={loadedProgress}
/>
</ControlsRow>
<ControlsRow>
<ControlsGroup>
<PlayStop onClickCapture={togglePlaying} playing={playing} />
{
numberOfChapters > 1 && (
<Fragment>
<Prev
disabled={isFirstChapterPlaying}
onClick={() => playPrevChapter()}
/>
<ChaptersText>
{activeChapterIndex + 1} / {numberOfChapters}
</ChaptersText>
<Next
disabled={isLastChapterPlaying}
onClick={() => playNextChapter()}
/>
</Fragment>
)
}
<VolumeBar
value={volumeInPercent}
muted={muted} muted={muted}
onFullscreenClick={onFullscreenClick} onChange={onVolumeChange}
onProgressChangeLive={onProgressChange} onClick={onVolumeClick}
onQualitySelect={onQualitySelect} />
onTouchEnd={onTouchEnd} {
onTouchStart={onTouchStart} isLive
onVolumeChange={onVolumeChange} ? (
onVolumeClick={onVolumeClick} <PlaybackTime>
playedProgress={playedProgress} {secondsToHms(allPlayedProgress / 1000)}
playing={playing} </PlaybackTime>
rewindBackward={rewindBackward} )
rewindForward={rewindForward} : (
<PlaybackTime width={duration > HOUR_IN_MILLISECONDS ? 150 : 130}>
{secondsToHms(allPlayedProgress / 1000)}
{' / '}
{secondsToHms(duration / 1000)}
</PlaybackTime>
)
}
<Backward onClick={rewindBackward}>{REWIND_SECONDS}</Backward>
<Forward onClick={rewindForward}>{REWIND_SECONDS}</Forward>
</ControlsGroup>
<ControlsGroup>
{
isLive && (
<LiveBtn onClick={backToLive}>
<T9n t='live' />
</LiveBtn>
)
}
<Settings
onSelect={onQualitySelect}
selectedQuality={selectedQuality} selectedQuality={selectedQuality}
togglePlaying={togglePlaying}
videoQualities={videoQualities} videoQualities={videoQualities}
volume={volume}
volumeInPercent={volumeInPercent}
/> />
<ControlsGradient isVisible={controlsVisible} /> <Fullscreen
onClick={onFullscreenClick}
isFullscreen={isFullscreen}
/>
</ControlsGroup>
</ControlsRow>
</Controls>
<ControlsGradient />
</PlayerWrapper> </PlayerWrapper>
) )
} }

@ -1,4 +1,4 @@
import styled, { css } from 'styled-components' import styled, { css } from 'styled-components/macro'
import { isMobileDevice } from 'config/userAgent' import { isMobileDevice } from 'config/userAgent'
@ -9,14 +9,6 @@ export const ControlsGradient = styled.div<{ isVisible?: boolean }>`
width: 100%; width: 100%;
pointer-events: none; pointer-events: none;
height: 145px; height: 145px;
${({ isVisible }) => (isMobileDevice
? css`
height: 100%;
background: ${(isVisible
? 'linear-gradient(0deg, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0) 25%, rgba(0,0,0,0) 75%, rgba(0,0,0,0.7) 100%)'
: 'initial')}
` : '')}
` `
type HoverStylesProps = { type HoverStylesProps = {
@ -108,11 +100,9 @@ export const PlayerWrapper = styled.div<PlayStopProps>`
position: relative; position: relative;
background-color: #000; background-color: #000;
min-height: 100%; min-height: 100%;
:fullscreen { :fullscreen {
padding-top: 0; padding-top: 0;
} }
${supportsAspectRatio ${supportsAspectRatio
? css`aspect-ratio: 16 / 9;` ? css`aspect-ratio: 16 / 9;`
: css` : css`
@ -128,15 +118,18 @@ export const PlayerWrapper = styled.div<PlayStopProps>`
: ''}; : ''};
` `
export const LoaderWrapper = styled.div` type LoaderWrapperProps = {
buffering?: boolean,
}
export const LoaderWrapper = styled.div<LoaderWrapperProps>`
position: absolute; position: absolute;
top: 0; top: 50%;
left: 0; left: 50%;
width: 100%; transform: translate(-50%, -50%);
height: 100%; z-index: 1;
display: flex; transition: opacity 0.3s ease-in-out;
align-items: center; opacity: ${({ buffering }) => (buffering ? 1 : 0)};
justify-content: center;
` `
export const ButtonBase = styled.button` export const ButtonBase = styled.button`
@ -153,7 +146,7 @@ export const ButtonBase = styled.button`
const sizes = { const sizes = {
lg: 92, lg: 92,
sm: 29, sm: 24,
} }
type PlayStopProps = { type PlayStopProps = {
@ -175,11 +168,10 @@ export const PlayStop = styled(ButtonBase)<PlayStopProps>`
height: ${sizes[size]}px; height: ${sizes[size]}px;
` `
)}; )};
${isMobileDevice ${isMobileDevice
? css` ? css`
width: ${sizes.sm}px; width: 20%;
height: ${sizes.sm}px; height: 60%;
margin-right: 0; margin-right: 0;
padding: 0; padding: 0;
` `
@ -206,7 +198,6 @@ export const Fullscreen = styled(ButtonBase)<FullscreenProps>`
? 'url(/images/player-fullscreen-off.svg)' ? 'url(/images/player-fullscreen-off.svg)'
: 'url(/images/player-fullscreen-on.svg)' : 'url(/images/player-fullscreen-on.svg)'
)}; )};
${isMobileDevice ${isMobileDevice
? css` ? css`
width: 20px; width: 20px;
@ -247,28 +238,28 @@ export const Backward = styled(ButtonBase)<ButtonProps>`
` `
)} )}
display: ${({ isHidden }) => (isHidden ? 'none' : 'block')}; display: ${({ isHidden }) => (isHidden ? 'none' : 'block')};
${isMobileDevice ${isMobileDevice
? css` ? css`
width: ${rewindButtonSizes.sides.sm}px; width: 15%;
height: ${rewindButtonSizes.sides.sm}px; font-size: 14px;
font-size: ${rewindButtonSizes.fontSizes.sm}px; margin-right: 6px;
` `
: ''}; : ''};
` `
export const Forward = styled(Backward)` export const Forward = styled(Backward)`
background-image: url(/images/player-forward.svg); background-image: url(/images/player-forward.svg);
margin-left: 10px;
margin-right: 0;
` `
export const PlaybackTime = styled.span` type PlaybackTimeProps = {
width?: number,
}
export const PlaybackTime = styled.span<PlaybackTimeProps>`
width: ${({ width = 130 }) => `${width}px`};
font-weight: 600; font-weight: 600;
font-size: 16px; font-size: 16px;
color: #fff; color: #fff;
margin-right: 10px;
${isMobileDevice ${isMobileDevice
? css` ? css`
margin-left: 5px; margin-left: 5px;
@ -294,12 +285,11 @@ export const CenterControls = styled.div<CenterControlsProps>`
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
transition: opacity 0.3s ease-in-out; transition: opacity 0.3s ease-in-out;
opacity: ${({ playing }) => (playing ? 0 : 1)}; opacity: ${({ playing }) => (playing ? 0 : 1)};
pointer-events: ${({ playing }) => (playing ? 'none' : 'auto')}; pointer-events: ${({ playing }) => (playing ? 'none' : 'auto')};
${isMobileDevice ${isMobileDevice
? css` ? css`
width: 135px; width: 70%;
` `
: ''}; : ''};
` `
@ -314,6 +304,51 @@ export const LiveBtn = styled(ButtonBase)`
padding: 4.5px 8px; padding: 4.5px 8px;
background-color: #CC0000; background-color: #CC0000;
border-radius: 1.3px; border-radius: 1.3px;
${isMobileDevice
? css`
`
: ''};
`
export const ChaptersText = styled.span`
margin: 0 14px;
font-weight: 500;
font-size: 16px;
color: #fff;
text-align: center;
${isMobileDevice
? css`
margin: 0 5px;
font-size: 12px;
width: 15%;
`
: ''};
`
type PrevProps = {
disabled?: boolean,
}
export const Prev = styled(ButtonBase)<PrevProps>`
width: 29px;
height: 28px;
background-image: url(/images/player-prev.svg);
${({ disabled }) => (
disabled
? 'opacity: 0.5;'
: ''
)}
${isMobileDevice
? css`
width: 20px;
height: 20px;
`
: ''};
`
export const Next = styled(Prev)`
margin-right: 10px;
transform: rotate(180deg);
` `
export const TeamsDetailsWrapper = styled.div` export const TeamsDetailsWrapper = styled.div`

@ -0,0 +1,33 @@
/**
* для примера матч с двумя эпизодами в плейлисте Голы, время в мс:
* [{start: 0, end: 20000}, {start: 60000, end: 80000}]
*/
export type Chapter = {
duration: number,
/**
* конец эпизода в плейлисте
* в первом эпизоде - 20000, во втором - 40000
*/
endMs: number,
/** конец эпизода как отмечено в матче */
endOffsetMs: number,
/** индекс эпизода для дебага */
index?: number,
isFullMatchChapter?: boolean,
/**
* начало эпизода в плейлисте
* в первом эпизоде - 0, во втором - 20000
*/
startMs: number,
/** начало эпизода как отмечено в матче */
startOffsetMs: number,
url: string,
}
export type Chapters = Array<Chapter>

@ -31,7 +31,7 @@ export const TournamentsPopup = () => {
<Wrapper> <Wrapper>
<ScBody>{ <ScBody>{
isFetching ? ( isFetching ? (
<LoaderWrapper> <LoaderWrapper buffering={false}>
<Loader color='#515151' /> <Loader color='#515151' />
</LoaderWrapper> </LoaderWrapper>
) : ( ) : (

@ -25,8 +25,11 @@ export type Props = {
onError?: (e?: SyntheticEvent<HTMLVideoElement>) => void, onError?: (e?: SyntheticEvent<HTMLVideoElement>) => void,
onLoadedProgress?: (loadedMs: number) => void, onLoadedProgress?: (loadedMs: number) => void,
onPause?: (e: SyntheticEvent<HTMLVideoElement>) => void, onPause?: (e: SyntheticEvent<HTMLVideoElement>) => void,
onPlay?: (e: SyntheticEvent<HTMLVideoElement>) => void,
onPlayedProgress?: (playedMs: number) => void, onPlayedProgress?: (playedMs: number) => void,
onPlaying?: () => void,
onReady?: () => void, onReady?: () => void,
onWaiting?: () => void,
playing?: boolean, playing?: boolean,
ref?: Ref, ref?: Ref,
seek?: number | null, seek?: number | null,

@ -4,6 +4,16 @@ import type { Props } from './hooks'
import { useVideoPlayer } from './hooks' import { useVideoPlayer } from './hooks'
import { Video } from './styled' import { Video } from './styled'
/**
* Низкоуровневый компонент для декларативной работы с HTMLVideoElement
* ```ts
* например старт и пауза плеера вместо
* video.play() | video.pause()
*
* контролируем через пропс playing
* <VideoPlayer playing={true | false} />
* ```
*/
export const VideoPlayer = forwardRef<HTMLVideoElement, Props>((props: Props, ref) => { export const VideoPlayer = forwardRef<HTMLVideoElement, Props>((props: Props, ref) => {
const { const {
className, className,
@ -15,6 +25,9 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, Props>((props: Props, re
onEnded, onEnded,
onError, onError,
onPause, onPause,
onPlay,
onPlaying,
onWaiting,
src, src,
width, width,
} = props } = props
@ -40,8 +53,11 @@ export const VideoPlayer = forwardRef<HTMLVideoElement, Props>((props: Props, re
onProgress={handleLoadedChange} onProgress={handleLoadedChange}
onEnded={onEnded} onEnded={onEnded}
onDurationChange={handleDurationChange} onDurationChange={handleDurationChange}
onPlay={onPlay}
onPause={onPause} onPause={onPause}
onError={onError} onError={onError}
onWaiting={onWaiting}
onPlaying={onPlaying}
crossOrigin={crossOrigin} crossOrigin={crossOrigin}
controls={controls} controls={controls}
/> />

@ -1,24 +0,0 @@
import pipe from 'lodash/fp/pipe'
import orderBy from 'lodash/fp/orderBy'
import sumBy from 'lodash/fp/sumBy'
import uniqBy from 'lodash/fp/uniqBy'
import type { Videos, Video } from './getVideos'
import { getVideos } from './getVideos'
const calculateDuration = (videos: Videos) => {
const durationMs = pipe(
orderBy(({ quality }: Video) => Number(quality), 'desc'),
uniqBy(({ period }: Video) => period),
sumBy(({ duration }: Video) => duration),
)(videos)
return durationMs / 1000
}
/**
* Временный способ получения длительности матча
*/
export const getFullMatchDuration = async (...args: Parameters<typeof getVideos>) => {
const videos = await getVideos(...args)
return calculateDuration(videos)
}

@ -76,5 +76,7 @@ export const getMatchEvents = async ({
url: `${DATA_URL}/${getSportLexic(sportType)}`, url: `${DATA_URL}/${getSportLexic(sportType)}`,
}) })
if (!response?.data) return Promise.reject(response)
return response?.data || [] return response?.data || []
} }

@ -16,6 +16,14 @@ export type Team = {
score: number, score: number,
} }
export type VideoBound = {
e: string,
h: string,
s: string,
}
type VideoBounds = Array<VideoBound>
export type MatchInfo = { export type MatchInfo = {
access?: boolean, access?: boolean,
calc: boolean, calc: boolean,
@ -33,6 +41,7 @@ export type MatchInfo = {
name_eng: string, name_eng: string,
name_rus: string, name_rus: string,
}, },
video_bounds?: VideoBounds,
youtube_link?: string, youtube_link?: string,
} | null } | null

@ -7,11 +7,10 @@ import {
} from 'config' } from 'config'
import { callApi, getSportLexic } from 'helpers' import { callApi, getSportLexic } from 'helpers'
import { getFullMatchDuration } from './getFullMatchDuration'
const proc = PROCEDURES.ott_match_popup const proc = PROCEDURES.ott_match_popup
type Args = { type Args = {
fullMatchDuration?: number,
matchId: number, matchId: number,
selectedActions: Array<number>, selectedActions: Array<number>,
sportType: SportTypes, sportType: SportTypes,
@ -49,12 +48,12 @@ type Player = {
export type Players = Array<Player> export type Players = Array<Player>
export type Lexics = { export type Lexics = {
ball_in_play: number, ball_in_play?: number,
full_game: number, full_game?: number,
goals: number, goals?: number,
highlights: number, highlights?: number,
interview: number, interview?: number,
players: number, players?: number,
} }
export type MatchPlaylists = { export type MatchPlaylists = {
@ -75,11 +74,11 @@ type Response = {
} }
export const getMatchPlaylists = async ({ export const getMatchPlaylists = async ({
fullMatchDuration,
matchId, matchId,
selectedActions, selectedActions,
sportType, sportType,
withFullMatchDuration, }: Args): Promise<MatchPlaylists> => {
}: Args) => {
const actions = isEmpty(selectedActions) ? null : selectedActions const actions = isEmpty(selectedActions) ? null : selectedActions
const config = { const config = {
@ -92,25 +91,29 @@ export const getMatchPlaylists = async ({
}, },
} }
const playlistPromise: Promise<Response> = callApi({ const playlist: Response = await callApi({
config, config,
url: `${DATA_URL}/${getSportLexic(sportType)}`, url: `${DATA_URL}/${getSportLexic(sportType)}`,
}) })
const matchDurationPromise = withFullMatchDuration
? getFullMatchDuration(sportType, matchId)
: Promise.resolve(undefined)
const [playlist, fullMatchDuration] = await Promise.all(
[playlistPromise, matchDurationPromise],
)
const full_game: PlaylistWithDuration = { const full_game: PlaylistWithDuration = {
data: [], data: [],
dur: fullMatchDuration, dur: fullMatchDuration,
} }
return playlist.data if (playlist.data) {
? { ...playlist.data, full_game } return { ...playlist.data, full_game }
: null }
return {
ball_in_play: {},
full_game,
goals: {},
highlights: {},
lexics: {},
players1: [],
players2: [],
score1: 0,
score2: 0,
}
} }

@ -11,9 +11,9 @@ export * from './getTournamentInfo'
export * from './getTeamInfo' export * from './getTeamInfo'
export * from './getUserInfo' export * from './getUserInfo'
export * from './getMatchInfo' export * from './getMatchInfo'
export * from './getVideos'
export * from './getUnauthenticatedMatch' export * from './getUnauthenticatedMatch'
export * from './reportPlayerProgress' export * from './reportPlayerProgress'
export * from './getVideos'
export * from './saveUserInfo' export * from './saveUserInfo'
export * from './getPlayerInfo' export * from './getPlayerInfo'
export * from './getMatchLastWatchSeconds' export * from './getMatchLastWatchSeconds'

Loading…
Cancel
Save