develop #291

Merged
roman.rakov merged 7 commits from develop into master 2 years ago
  1. 17
      public/clients/india/terms-and-conditions.html
  2. 4
      public/images/matchTabs/likes.svg
  3. 17
      src/config/lexics/indexLexics.tsx
  4. 7
      src/config/routes.tsx
  5. 6
      src/features/AirPlay/index.tsx
  6. 4
      src/features/AuthStore/helpers.tsx
  7. 16
      src/features/ChromeCast/index.tsx
  8. 6
      src/features/HeaderFilters/store/hooks/index.tsx
  9. 2
      src/features/MatchPage/components/LiveMatch/hooks/index.tsx
  10. 58
      src/features/MatchPage/store/atoms.tsx
  11. 75
      src/features/MatchPage/store/hooks/useLikes.tsx
  12. 2
      src/features/MatchPage/store/index.tsx
  13. 126
      src/features/MatchSidePlaylists/components/LikeEvent/index.tsx
  14. 35
      src/features/MatchSidePlaylists/components/LikeEvent/styled.tsx
  15. 45
      src/features/MatchSidePlaylists/components/LikesList/index.tsx
  16. 2
      src/features/MatchSidePlaylists/components/TabEvents/styled.tsx
  17. 235
      src/features/MatchSidePlaylists/components/TabLikes/index.tsx
  18. 40
      src/features/MatchSidePlaylists/components/TabLikes/styled.tsx
  19. 1
      src/features/MatchSidePlaylists/config.tsx
  20. 4
      src/features/MatchSidePlaylists/hooks.tsx
  21. 142
      src/features/MatchSidePlaylists/index.tsx
  22. 27
      src/features/MatchSidePlaylists/styled.tsx
  23. 17
      src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx
  24. 1
      src/features/StreamPlayer/components/Controls/Components/ControlsMobile/styled.tsx
  25. 16
      src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx
  26. 3
      src/features/StreamPlayer/components/Controls/index.tsx
  27. 164
      src/features/StreamPlayer/components/LikeButton/index.tsx
  28. 78
      src/features/StreamPlayer/components/LikeButton/styled.tsx
  29. 7
      src/features/StreamPlayer/config.tsx
  30. 59
      src/features/StreamPlayer/hooks/index.tsx
  31. 138
      src/features/StreamPlayer/hooks/useParticles.tsx
  32. 57
      src/features/StreamPlayer/index.tsx
  33. 8
      src/features/StreamPlayer/styled.tsx
  34. 4
      src/features/UserAccount/components/PersonalInfoForm/hooks/useUserInfo.tsx
  35. 2
      src/hooks/index.tsx
  36. 19
      src/hooks/useVideoBounds.tsx
  37. 128
      src/hooks/useWebSocket.tsx
  38. 19
      src/requests/saveMatchStats.tsx

@ -665,6 +665,23 @@
Fee and any applicable Fees for the next renewal period.</span
>
</li>
<li
style="
color: black;
margin-bottom: 0in;
line-height: normal;
background: #f2f2f2;
"
>
<span
style="font-size: 10.5pt; font-family: 'Trebuchet MS', sans-serif"
>To ensure an optimal viewing experience for all users and to safeguard
against unauthorized account sharing be advised that only a single device
can access your inSports TV account at any given time. In the event
that multiple users intend to access the platform concurrently using
a shared account, an automatic logout mechanism will be triggered.</span
>
</li>
</ol>
<p
style="

@ -0,0 +1,4 @@
<svg width="25" height="25" viewBox="0 0 25 25" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.9261 5.8212C12.9307 5.87963 12.9358 5.93231 12.9406 5.97754C12.9332 5.99732 12.9251 6.019 12.9163 6.04251C12.8651 6.17851 12.7898 6.37523 12.6939 6.61585C12.502 7.09769 12.229 7.75251 11.9041 8.44659C11.5783 9.14247 11.2053 9.86744 10.8143 10.4938C10.4154 11.1329 10.0302 11.616 9.69372 11.8772C9.66718 11.8978 9.64228 11.9205 9.61923 11.9449C9.18111 12.41 8.91275 12.9173 8.74763 13.3195L8.7475 13.3194L8.74278 13.3316C8.71208 13.4109 8.6845 13.4866 8.66007 13.556C8.58397 13.7007 8.41439 13.9457 8.03684 14.1757C7.79824 14.321 7.67921 14.6026 7.74121 14.875L9.45857 22.4214C9.51892 22.6866 9.738 22.8862 10.0077 22.9216L10.0924 22.2772C10.0077 22.9216 10.0077 22.9216 10.0078 22.9216L10.008 22.9217L10.0085 22.9217L10.0104 22.922L10.0169 22.9228L10.0413 22.9259L10.1335 22.9373C10.2137 22.9471 10.3304 22.9608 10.4785 22.977C10.7745 23.0094 11.1963 23.052 11.7023 23.0927C12.704 23.1733 14.0447 23.2476 15.3961 23.2207C16.4734 23.3062 17.6651 23.3176 18.6492 23.1041C20.351 22.7378 21.1491 21.9031 21.424 21.0082C21.5524 20.5903 21.5506 20.2065 21.5195 19.9324C21.5184 19.923 21.5173 19.9137 21.5162 19.9045C22.2578 19.2016 22.4576 18.4095 22.4234 17.7496C22.407 17.4337 22.3384 17.1612 22.268 16.9564C22.532 16.603 22.6944 16.243 22.7698 15.8841C22.869 15.4117 22.8081 14.9852 22.6891 14.6367C22.5848 14.3315 22.4352 14.0826 22.3041 13.9021C22.3596 13.7543 22.414 13.5732 22.4473 13.3692C22.5426 12.7857 22.4608 11.9957 21.7482 11.3383C21.301 10.9249 20.7077 10.7201 20.1465 10.6173C19.5762 10.5129 18.9701 10.5014 18.4303 10.5271C17.8871 10.553 17.3897 10.6177 17.0293 10.6755C16.8485 10.7044 16.7006 10.7319 16.5969 10.7524C16.545 10.7626 16.504 10.7711 16.4753 10.7772L16.4465 10.7835C16.2767 10.8127 16.1004 10.8472 15.9166 10.8879C15.9176 10.8109 15.9236 10.7124 15.9386 10.5884C15.9877 10.1833 16.1238 9.57481 16.4163 8.67814C17.0456 6.75441 16.9047 5.43702 16.2643 4.58987C15.6381 3.76143 14.6875 3.5895 14.1627 3.5895C13.6555 3.5895 13.3235 3.89075 13.149 4.21465C12.9929 4.50432 12.9404 4.83899 12.9208 5.09751C12.9001 5.36964 12.9113 5.63254 12.9261 5.8212Z" stroke="white" stroke-width="1.3" stroke-linejoin="round"/>
<path d="M7.34175 23.2783C7.70504 23.2809 8.04815 23.132 8.27682 22.8488C8.50519 22.566 8.57817 22.2007 8.50503 21.8497C8.50503 21.8496 8.50503 21.8496 8.50503 21.8496L7.204 15.603L7.204 15.6029C7.06791 14.9497 6.44949 14.4357 5.77589 14.4357H3.29278C2.93392 14.4357 2.64296 14.7265 2.64278 15.0853L2.63906 22.6279C2.63898 22.8004 2.70742 22.9658 2.82933 23.0878C2.95124 23.2097 3.11662 23.2783 3.28906 23.2783H7.34175ZM7.34175 23.2783C7.34045 23.2783 7.33916 23.2782 7.33786 23.2782L7.34456 22.6283V23.2783H7.34175Z" stroke="white" stroke-width="1.3" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

@ -155,6 +155,11 @@ const newDevicePopup = {
}
export const indexLexics = {
'1st_half': 1031,
'2nd_half': 1032,
'3rd_half': 20262,
'4th_half': 20263,
'5th_half': 20264,
add_to_favorites: 14967,
add_to_favorites_error: 12943,
all_competitions: 17926,
@ -170,7 +175,9 @@ export const indexLexics = {
cm: 817,
features: 13051,
football: 6958,
ft: 20261,
full_game: 13028,
full_time: 20258,
futsal: 17670,
game: 9680,
game_finished: 13026,
@ -186,9 +193,11 @@ export const indexLexics = {
hide_score: 12982,
highlights: 13033,
hockey: 6959,
ht: 20260,
interview: 13031,
kg: 652,
kickoff_in: 13027,
likes: 16628,
live: 13024,
loading: 3527,
logout: 4306,
@ -199,11 +208,14 @@ export const indexLexics = {
match_status_soon: 12986,
match_video: 13025,
month_title: 2202,
my_likes: 20254,
no_match_access_body: 13419,
no_match_access_title: 13418,
player: 14975,
players: 164,
players_video: 13032,
pm: 20259,
pre_match: 20257,
privacy_policy_and_statement: 15404,
round_highilights: 13050,
save: 828,
@ -213,14 +225,19 @@ export const indexLexics = {
team: 14973,
terms_and_conditions: 15738,
to_home: 13376,
total_likes: 20253,
tournament: 14974,
upcoming: 17925,
user_account: 12928,
user_liked_this: 20267,
users_liked_this: 20255,
volleyball: 9761,
watch_from_beginning: 13021,
watch_from_last_pause: 13022,
watch_now: 13020,
week_title: 6584,
you_and: 20256,
you_liked_this: 20265,
...filterPopup,
...confirmPopup,

@ -44,6 +44,12 @@ const PAYMENT_APIS = {
staging: 'https://pay.test.insports.tv',
}
const LIKES_API = {
preproduction: 'wss://ws.insports.tv/v1/events',
production: 'wss://ws.insports.tv/v1/events',
staging: 'wss://ws-test.insports.tv/v1/events',
}
const env = isProduction ? ENV : readSelectedApi() ?? ENV
export const VIEWS_API = VIEWS_APIS[env]
@ -55,3 +61,4 @@ export const URL_AWS = 'https://cf-aws.insports.tv'
export const STATS_API_URL = STATS_APIS[env]
export const ADS_API_URL = ADS_APIS[env]
export const PAYMENT_API_URL = PAYMENT_APIS[env]
export const LIKES_API_URL = LIKES_API[env]

@ -1,8 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { useEffect } from 'react'
import includes from 'lodash/includes'
import { usePageParams } from 'hooks/usePageParams'
import { API_ROOT } from 'config'
@ -19,9 +17,7 @@ export const AirPlay = ({ videoRef }: Props) => {
const { profileId: matchId, sportType } = usePageParams()
useEffect(() => {
const baseUrl = includes(videoRef?.current?.src, '.m3u8')
? `${API_ROOT}/video/chromecast/stream/${sportType}/${matchId}.m3u8?access_token=${readToken()}`
: videoRef?.current?.src!
const baseUrl = `${API_ROOT}/v1/broadcasts/${sportType}/${matchId}/master.m3u8?access_token=${readToken()}`
const video = videoRef.current!
const airPlayBtn = document.getElementById('airPlay')!

@ -76,4 +76,6 @@ export const getClientSettings = (): Settings => ({
userStore: new WebStorageStateStore({ store: window.localStorage }),
})
export const needCheckNewDeviсe = client.name === 'instat' || client.name === 'insports'
export const needCheckNewDeviсe = client.name === ClientNames.Instat
|| client.name === ClientNames.Insports
|| client.name === ClientNames.India

@ -1,7 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Fragment,
memo,
useEffect,
useRef,
useState,
@ -16,13 +15,9 @@ import { Container } from './styled'
import { CastPlayer } from './CastVideos'
import { useMatchPageStore } from '../MatchPage/store'
type Props = {
src?: string,
}
const NO_DEVICES_AVAILABLE = 'NO_DEVICES_AVAILABLE'
export const ChromeCast = memo(({ src } : Props) => {
export const ChromeCast = () => {
const [isCastAvailable, setIsCastAvailable] = useState(false)
const { profile } = useMatchPageStore()
@ -32,8 +27,7 @@ export const ChromeCast = memo(({ src } : Props) => {
const GoogleCastLauncher = (document as any).createElement('google-cast-launcher')
GoogleCastLauncher.setAttribute('id', 'castbutton')
const baseUrl = src ?? `${API_ROOT}/video/chromecast/stream/${sportType}/${matchId}.m3u8`
const urlWithToken = (/\d.m3u8/.test(baseUrl)) ? `${baseUrl}?access_token=${readToken()}` : baseUrl
const baseUrl = `${API_ROOT}/v1/broadcasts/${sportType}/${matchId}/master.m3u8?access_token=${readToken()}`
const teamsInfo = `${profile?.team1.name_eng} - ${profile?.team2.name_eng}`
@ -55,7 +49,7 @@ export const ChromeCast = memo(({ src } : Props) => {
document.body.appendChild(script)
const castPlayer = new CastPlayer(urlWithToken, teamsInfo);
const castPlayer = new CastPlayer(baseUrl, teamsInfo);
(window as any).__onGCastApiAvailable = (isAvailable: boolean) => {
if (isAvailable) {
castPlayer.initializeCastPlayer()
@ -71,11 +65,11 @@ export const ChromeCast = memo(({ src } : Props) => {
document.body.removeChild(script)
setIsCastAvailable(false)
}
}, [teamsInfo, urlWithToken])
}, [teamsInfo, baseUrl])
return (
<Fragment>
{isCastAvailable && <Container ref={containerRef} />}
</Fragment>
)
})
}

@ -14,7 +14,6 @@ import { useQueryParamStore } from 'hooks'
import { getSportLexic } from 'helpers'
import { isFacrClient } from 'config/clients'
import { querieKeys } from 'config'
import { getLocalStorageItem } from 'helpers/getLocalStorage'
@ -45,15 +44,14 @@ export const useFilters = () => {
const isTodaySelected = isToday(selectedDate)
useEffect(() => {
if (!isFacrClient) return
const dateMode = localStorage.getItem('dateMode')
const parseDateMode = dateMode && JSON.parse(dateMode)
if (!dateMode) return
const parseDateMode = JSON.parse(dateMode)
setSelectedMode(parseDateMode.selectedMode)
setSelectedMonthModeDate(new Date(parseDateMode.selectedMonthModeDate))
}, [])
useEffect(() => {
if (!isFacrClient) return
localStorage.setItem(
'dateMode',
JSON.stringify({

@ -39,7 +39,7 @@ export const useLiveMatch = () => {
const { chapters } = useChapters({
profile,
selectedPlaylist,
url: `${API_ROOT}/video/stream/${sportType}/${matchId}.m3u8?access_token=${readToken()}`,
url: `${API_ROOT}/v1/broadcasts/${sportType}/${matchId}/master.m3u8?access_token=${readToken()}`,
})
const {

@ -0,0 +1,58 @@
import { atom, selector } from 'recoil'
export interface RawLikeEvent {
e: number,
episode: number,
likes: number,
s: number,
}
export interface LikeEvent extends RawLikeEvent {
iLiked: boolean,
}
export const myLikesState = atom<Array<RawLikeEvent>>({
default: [],
key: 'myLikesState',
})
export const totalLikesState = atom<Array<RawLikeEvent>>({
default: [],
key: 'totalLikesState',
})
const transformedLikesState = selector({
get: ({ get }) => {
const totalLikes = get(totalLikesState)
const myLikes = get(myLikesState)
const likes: Array<LikeEvent> = totalLikes.map((like) => ({
...like,
iLiked: myLikes.findIndex(({ episode }) => episode === like.episode) !== -1,
}))
return likes
},
key: 'transformedLikesState',
})
export const sortTypeState = atom<'asc' | 'desc'>({
default: 'asc',
key: 'sortTypeState',
})
export const filterTypeState = atom<'myLikes' | 'totalLikes'>({
default: 'totalLikes',
key: 'filterTypeState',
})
export const filteredLikesState = selector<Array<LikeEvent>>({
get: ({ get }) => {
const likes = get(transformedLikesState)
const filterType = get(filterTypeState)
return filterType === 'totalLikes' ? likes : likes.filter(({ iLiked }) => iLiked)
},
key: 'filteredLikesState',
})

@ -0,0 +1,75 @@
import { useEffect } from 'react'
import { useSetRecoilState } from 'recoil'
import { LIKES_API_URL } from 'config'
import { useAuthStore } from 'features/AuthStore'
import { FULL_MATCH_BOUNDARY } from 'features/MatchPage/components/LiveMatch/helpers'
import {
usePageParams,
useVideoBounds,
useWebSocket,
} from 'hooks'
import {
myLikesState,
totalLikesState,
type RawLikeEvent,
} from '../atoms'
import { useMatchPageStore } from '..'
interface Data {
data: Array<RawLikeEvent>,
type: 'user_likes' | 'total_likes',
}
export const useLikes = () => {
const setTotalLikes = useSetRecoilState(totalLikesState)
const setMyLikes = useSetRecoilState(myLikesState)
const { playingProgress } = useMatchPageStore()
const videoBounds = useVideoBounds()
const { profileId, sportType } = usePageParams()
const { user } = useAuthStore()
useEffect(() => {
setTotalLikes([])
setMyLikes([])
}, [setMyLikes, setTotalLikes, profileId, sportType])
const url = `${LIKES_API_URL}?sport_id=${sportType}&match_id=${profileId}&access_token=${user?.access_token}`
const { sendMessage, webSocketState } = useWebSocket<Data>({
allowConnection: Boolean(user),
autoReconnect: true,
handlers: {
onMessage: ({ data = [], type }) => {
type === 'total_likes' && setTotalLikes(data)
type === 'user_likes' && setMyLikes(data)
},
},
maxReconnectAttempts: 10,
url,
})
const likeClick = () => {
const startSecond = Number(videoBounds?.find(({ h }) => h === FULL_MATCH_BOUNDARY)?.s || 0)
const message = {
data: {
second: playingProgress + startSecond,
},
event: 'like',
}
sendMessage(message)
}
return {
canLike: user && webSocketState === WebSocket.OPEN,
likeClick,
}
}

@ -6,6 +6,8 @@ import {
import { useMatchPage } from './hooks'
export * from './atoms'
type Context = ReturnType<typeof useMatchPage>
type Props = { children: ReactNode }

@ -0,0 +1,126 @@
import { Fragment } from 'react'
import { useVideoBounds } from 'hooks'
import type { LikeEvent as LikeEventType } from 'features/MatchPage/store/atoms'
import { T9n } from 'features/T9n'
import { useMatchPageStore } from 'features/MatchPage/store'
import { Tabs } from 'features/MatchSidePlaylists/config'
import { FULL_MATCH_BOUNDARY } from 'features/MatchPage/components/LiveMatch/helpers'
import { PlaylistTypes } from 'features/MatchPage/types'
import {
Title,
LikeIcon,
Time,
Button,
LikesCount,
} from './styled'
type Props = {
groupTitle: string,
likeEvent: LikeEventType,
}
const groupTitlesMap: Record<string, string> = {
full_time: 'ft',
half_time: 'ht',
pre_match: 'pm',
}
export const LikeEvent = ({
groupTitle,
likeEvent,
}: Props) => {
const videoBounds = useVideoBounds()
const { handlePlaylistClick, selectedPlaylist } = useMatchPageStore()
const firstBound = videoBounds?.find(({ h }) => h === FULL_MATCH_BOUNDARY)
const half = groupTitle.match(/\d/)
const startSecond = half
? likeEvent.s - Number(videoBounds?.find(({ h }) => h === half[0])?.s || 0)
: 0
const startMinute = Math.ceil(startSecond / 60)
const active = selectedPlaylist.id === likeEvent.episode && selectedPlaylist.tab === Tabs.LIKES
const handleClick = () => {
handlePlaylistClick({
playlist: {
episodes: [{
c: Math.ceil((likeEvent.e - likeEvent.s) / 12),
e: likeEvent.e - Number(firstBound?.s || 0),
h: 0,
s: likeEvent.s - Number(firstBound?.s || 0),
}],
id: likeEvent.episode,
type: PlaylistTypes.EVENT,
},
tab: Tabs.LIKES,
})
}
const getTitle = () => {
switch (true) {
case likeEvent.iLiked && likeEvent.likes === 1:
return (
<T9n t='you_liked_this' />
)
case likeEvent.iLiked && likeEvent.likes === 2:
return (
<Fragment>
<T9n t='you_and' /> <LikesCount>1</LikesCount> <T9n t='user_liked_this' />
</Fragment>
)
case likeEvent.iLiked && likeEvent.likes > 2:
return (
<Fragment>
<T9n t='you_and' /> <LikesCount>{likeEvent.likes - 1}</LikesCount> <T9n t='users_liked_this' />
</Fragment>
)
case !likeEvent.iLiked && likeEvent.likes === 1:
return (
<Fragment>
<LikesCount>1</LikesCount> <T9n t='user_liked_this' />
</Fragment>
)
case !likeEvent.iLiked && likeEvent.likes > 1:
return (
<Fragment>
<LikesCount>{likeEvent.likes}</LikesCount> <T9n t='users_liked_this' />
</Fragment>
)
default: return null
}
}
return (
<Button
onClick={handleClick}
active={active}
groupTitle={groupTitle}
>
{likeEvent.iLiked && (
<LikeIcon
alt='Like'
src='/images/like-active-icon.svg'
/>
)}
{groupTitle && (
<Fragment>
{groupTitle in groupTitlesMap
? <Time><T9n t={groupTitlesMap[groupTitle]} /></Time>
: <Time>{startMinute}&apos;</Time>}
</Fragment>
)}
<Title>{getTitle()}</Title>
</Button>
)
}

@ -0,0 +1,35 @@
import styled from 'styled-components/macro'
import { Button as ButtonBase } from 'features/MatchSidePlaylists/styled'
type ButtonProps = {
groupTitle: string,
}
export const Button = styled(ButtonBase)<ButtonProps>`
justify-content: initial;
padding-left: ${({ groupTitle }) => (groupTitle ? 77 : 42)}px;
`
export const Title = styled.div`
font-size: 12px;
color: ${({ theme }) => theme.colors.white};
`
export const LikeIcon = styled.img`
position: absolute;
left: 20px;
width: 11px;
height: 11px;
`
export const Time = styled.span`
position: absolute;
right: calc(100% - 67px);
font-size: 12px;
color: ${({ theme }) => theme.colors.white};
`
export const LikesCount = styled.span`
font-weight: 600;
`

@ -0,0 +1,45 @@
import { T9n } from 'features/T9n'
import type { LikeEvent as LikeEventType } from 'features/MatchPage/store'
import {
BlockTitle,
Event,
TextEvent,
List,
} from '../TabEvents/styled'
import { LikeEvent } from '../LikeEvent'
type Props = {
groupTitle: string,
likeEvents: Array<LikeEventType>,
}
export const LikesList = ({
groupTitle,
likeEvents,
}: Props) => {
if (!likeEvents.length) return null
const title = groupTitle.startsWith('half') ? 'half_time' : groupTitle
return (
<List>
{title && (
<TextEvent>
<BlockTitle>
<T9n t={title} />
</BlockTitle>
</TextEvent>
)}
{likeEvents.map((likeEvent) => (
<Event key={likeEvent.episode}>
<LikeEvent
likeEvent={likeEvent}
groupTitle={title}
/>
</Event>
))}
</List>
)
}

@ -157,7 +157,7 @@ export const Tabs = styled(TabsBase)`
color: #ffff;
flex: 1 1 auto;
${isMobileDevice ? 'padding-left: 33px;' : 'padding-left: 20px;'}
${isMobileDevice && 'padding-left: 33px;'}
`
type TTab = {

@ -0,0 +1,235 @@
/* eslint-disable sort-keys */
import { useEffect } from 'react'
import { useRecoilState, useRecoilValue } from 'recoil'
import findKey from 'lodash/findKey'
import orderBy from 'lodash/orderBy'
import isNumber from 'lodash/isNumber'
import { useVideoBounds } from 'hooks'
import type { MatchInfo } from 'requests'
import { T9n } from 'features/T9n'
import {
filterTypeState,
filteredLikesState,
sortTypeState,
type LikeEvent,
useMatchPageStore,
} from 'features/MatchPage/store'
import { Tabs } from 'features/MatchSidePlaylists/config'
import { LikesList } from '../LikesList'
import {
Wrapper,
Tabs as SortTabs,
Tab as SortTab,
ButtonsBlock,
HalfEvents,
HalfList,
} from '../TabEvents/styled'
import {
Tab,
TabList,
TabTitle,
} from './styled'
type Props = {
profile?: MatchInfo,
}
const groupTitles = [
'pre_match',
'1st_half',
'half1-2',
'half1-3',
'half1-4',
'half1-5',
'2nd_half',
'half2-3',
'half2-4',
'half2-5',
'3rd_half',
'half3-4',
'half3-5',
'4th_half',
'half4-5',
'5th_half',
'full_time',
]
export const TabLikes = ({ profile: matchProfile }:Props) => {
const [filterType, setFilterType] = useRecoilState(filterTypeState)
const [sortType, setSortType] = useRecoilState(sortTypeState)
const likeEvents = useRecoilValue(filteredLikesState)
const { selectedPlaylist, setEpisodeInfo } = useMatchPageStore()
const videoBounds = useVideoBounds()
const needUseGroups = videoBounds && videoBounds.findIndex(({ h }) => h === '1') !== -1
const getHalfInterval = (period: number) => {
const bound = videoBounds?.find(({ h }) => h === String(period))
return [bound?.s && Number(bound.s), bound?.e && Number(bound.e)]
}
const getTimeoutInterval = (firstPeriod: number, secondPeriod: number) => {
const firstBound = videoBounds?.find(({ h }) => h === String(firstPeriod))
const secondBound = videoBounds?.find(({ h }) => h === String(secondPeriod))
return [firstBound?.e && Number(firstBound.e) + 1, secondBound?.s && Number(secondBound.s) - 1]
}
const getGroupedLikes = () => {
if (!needUseGroups) return {}
const firstHalf = videoBounds?.find(({ h }) => h === '1')
const lastHalf = videoBounds?.[videoBounds.length - 1]
const groupedLikes: Record<string, Array<LikeEvent>> = {
pre_match: [],
'1st_half': [],
'half1-2': [],
'2nd_half': [],
'half2-3': [],
'3rd_half': [],
'half3-4': [],
'4th_half': [],
'half4-5': [],
'5th_half': [],
full_time: [],
'half1-3': [],
'half1-4': [],
'half1-5': [],
'half2-4': [],
'half2-5': [],
'half3-5': [],
}
const intervals: Record<string, Array<number | undefined | string>> = {
pre_match: [0, Number(firstHalf?.s || 0) - 1],
'1st_half': getHalfInterval(1),
'half1-2': getTimeoutInterval(1, 2),
'2nd_half': getHalfInterval(2),
'half2-3': getTimeoutInterval(2, 3),
'3rd_half': getHalfInterval(3),
'half3-4': getTimeoutInterval(3, 4),
'4th_half': getHalfInterval(4),
'half4-5': getTimeoutInterval(4, 5),
'5th_half': getHalfInterval(5),
full_time: [Number(lastHalf?.e || 0) + 1, 1e5],
'half3-5': getTimeoutInterval(3, 5),
'half2-4': getTimeoutInterval(2, 4),
'half2-5': getTimeoutInterval(2, 5),
'half1-3': getTimeoutInterval(1, 3),
'half1-4': getTimeoutInterval(1, 4),
'half1-5': getTimeoutInterval(1, 5),
}
likeEvents.forEach((likeEvent) => {
const half = findKey(intervals, (interval) => {
if (isNumber(interval[0]) && isNumber(interval[1])) {
return likeEvent.s >= interval[0] && likeEvent.s <= interval[1]
}
if (isNumber(interval[0])
&& !interval[1]
&& likeEvent.s >= interval[0]
&& matchProfile?.live
) return true
return false
})
half && groupedLikes[half].push(likeEvent)
})
Object.keys(groupedLikes).forEach((key) => {
groupedLikes[key] = orderBy(
groupedLikes[key],
's',
sortType,
)
})
return groupedLikes
}
const groupedLikes = getGroupedLikes()
const likesEntries = needUseGroups
? orderBy(
Object.entries(groupedLikes),
([title]) => groupTitles.findIndex((key) => key === title),
sortType,
)
: []
const sortedLikes = needUseGroups ? [] : orderBy(
likeEvents,
's',
sortType,
)
useEffect(() => {
selectedPlaylist.tab === Tabs.LIKES && setEpisodeInfo({})
}, [setEpisodeInfo, selectedPlaylist.tab])
return (
<Wrapper>
<ButtonsBlock>
<SortTabs>
<T9n t={sortType === 'asc' ? 'from_start_match' : 'from_end_match'} />
<SortTab
active={sortType === 'asc'}
onClick={() => setSortType('asc')}
id='match_likes_sort_start'
/>
<SortTab
active={sortType === 'desc'}
onClick={() => setSortType('desc')}
id='match_likes_sort_final'
/>
</SortTabs>
<TabList>
<Tab
aria-pressed={filterType === 'totalLikes'}
onClick={() => setFilterType('totalLikes')}
>
<TabTitle t='total_likes' />
</Tab>
<Tab
aria-pressed={filterType === 'myLikes'}
onClick={() => setFilterType('myLikes')}
>
<TabTitle t='my_likes' />
</Tab>
</TabList>
</ButtonsBlock>
{needUseGroups ? (
<HalfList>
{likesEntries.map(([groupTitle, likesInGroup]) => (
<HalfEvents key={groupTitle}>
<LikesList
groupTitle={groupTitle}
likeEvents={likesInGroup}
/>
</HalfEvents>
))}
</HalfList>
)
: (
<HalfList>
<HalfEvents>
<LikesList
groupTitle=''
likeEvents={sortedLikes}
/>
</HalfEvents>
</HalfList>
)}
</Wrapper>
)
}

@ -0,0 +1,40 @@
import styled, { css } from 'styled-components/macro'
import { isMobileDevice } from 'config'
import { T9n } from 'features/T9n'
export const TabList = styled.div.attrs({ role: 'tablist' })`
display: flex;
gap: 12px;
${isMobileDevice && css`
padding-right: 33px;
`}
`
export const TabTitle = styled(T9n)`
position: relative;
color: rgba(255, 255, 255, 0.5);
`
export const Tab = styled.button.attrs({ role: 'tab' })`
position: relative;
display: flex;
justify-content: center;
align-items: center;
padding: 0 0 5px;
font-size: 12px;
cursor: pointer;
border: none;
background: none;
border-bottom: 2px solid transparent;
&[aria-pressed="true"] {
border-color: ${({ theme }) => theme.colors.white};
${TabTitle} {
color: ${({ theme }) => theme.colors.white};
}
}
`

@ -3,4 +3,5 @@ export enum Tabs {
EVENTS,
STATS,
PLAYERS,
LIKES,
}

@ -27,7 +27,7 @@ export const useMatchSidePlaylists = () => {
const videoBounds = matchScore?.video_bounds || matchProfile?.video_bounds
const matchStatus = matchScore?.c_match_calc_status || matchProfile?.c_match_calc_status
const showTabs = Number(matchStatus) > MatchStatuses.Upcoming
const isMatchParsed = Number(matchStatus) > MatchStatuses.Upcoming
&& findIndex(videoBounds, ({ h, s }) => h === '1' && !isNil(s)) !== -1
useEffect(() => {
@ -35,8 +35,8 @@ export const useMatchSidePlaylists = () => {
}, [selectedTab, closePopup])
return {
isMatchParsed,
onTabClick: setSelectedTab,
selectedTab,
showTabs,
}
}

@ -4,11 +4,12 @@ import {
useState,
} from 'react'
import { createPortal } from 'react-dom'
import { useRecoilValue } from 'recoil'
import { useTour } from '@reactour/tour'
import type { PlaylistOption } from 'features/MatchPage/types'
import { useMatchPageStore } from 'features/MatchPage/store'
import { totalLikesState, useMatchPageStore } from 'features/MatchPage/store'
import {
Spotlight,
Steps,
@ -30,6 +31,7 @@ import { TabEvents } from './components/TabEvents'
import { TabWatch } from './components/TabWatch'
import { TabPlayers } from './components/TabPlayers'
import { TabStats } from './components/TabStats'
import { TabLikes } from './components/TabLikes'
import { useMatchSidePlaylists } from './hooks'
import {
@ -40,7 +42,6 @@ import {
TabIcon,
TabTitle,
Container,
TabButton,
EventsAdsWrapper,
} from './styled'
import { HeaderAds } from '../../components/Ads'
@ -50,6 +51,7 @@ const tabPanes = {
[Tabs.EVENTS]: TabEvents,
[Tabs.STATS]: TabStats,
[Tabs.PLAYERS]: TabPlayers,
[Tabs.LIKES]: TabLikes,
}
type Props = {
@ -61,6 +63,8 @@ export const MatchSidePlaylists = ({
onSelect,
selectedPlaylist,
}: Props) => {
const likeEvents = useRecoilValue(totalLikesState)
const {
ads,
hideProfileCard,
@ -73,8 +77,8 @@ export const MatchSidePlaylists = ({
} = useMatchPageStore()
const {
isMatchParsed,
onTabClick,
showTabs,
} = useMatchSidePlaylists()
const {
@ -89,6 +93,7 @@ export const MatchSidePlaylists = ({
const containerRef = useRef<HTMLDivElement | null>(null)
const tabPaneContainerRef = useRef<HTMLDivElement | null>(null)
const tabsGroupRef = useRef<HTMLDivElement | null>(null)
const [hasTabPaneScroll, setTabPaneScroll] = useState(false)
@ -113,14 +118,14 @@ export const MatchSidePlaylists = ({
if (
getLocalStorageItem(TOUR_COMPLETED_STORAGE_KEY) === 'true'
|| isOpen
|| !showTabs
|| !isMatchParsed
|| Number(profile?.c_match_calc_status) < 2
) return undefined
const timer = setTimeout(() => setIsOpen(true), 1500)
return () => clearTimeout(timer)
}, [showTabs, setIsOpen, profile?.c_match_calc_status, isOpen])
}, [isMatchParsed, setIsOpen, profile?.c_match_calc_status, isOpen])
useEventListener({
callback: () => {
@ -137,6 +142,19 @@ export const MatchSidePlaylists = ({
target: containerRef,
})
const getTabsSpace = () => {
const tabsGroup = tabsGroupRef.current
const tabsWidth = isMobileDevice && containerRef.current
? containerRef.current.clientWidth - 20
: 306
return tabsGroup?.children.length && tabsGroup.children.length > 1
? (tabsWidth - (Array.from(tabsGroup.children) as Array<HTMLButtonElement>)
.reduce((acc, elem) => acc + elem.clientWidth, 0)) / tabsGroup.children.length - 1
: 0
}
return (
<Wrapper
ref={containerRef}
@ -145,64 +163,74 @@ export const MatchSidePlaylists = ({
isTourOpen={Boolean(isOpen)}
isHidden={!profileCardShown}
>
{showTabs
&& (
<TabsWrapper>
{selectedTab === Tabs.EVENTS
<TabsWrapper>
{selectedTab === Tabs.EVENTS
&& ads
&& (
<EventsAdsWrapper hasScroll={hasTabPaneScroll}>
<HeaderAds ads={ads.filter(({ position }) => position.id === adsPositionId)} />
</EventsAdsWrapper>
)}
<TabsGroup>
<Tab
aria-pressed={selectedTab === Tabs.WATCH}
onClick={() => onTabClick(Tabs.WATCH)}
id='match_watch'
>
<TabButton>
<TabIcon icon='watch' />
<TabTitle t='watch' />
</TabButton>
</Tab>
<Tab
aria-pressed={selectedTab === Tabs.EVENTS}
onClick={() => onTabClick(Tabs.EVENTS)}
id='match_plays'
>
<TabButton>
<TabIcon icon='plays' />
<TabTitle t='actions' />
</TabButton>
</Tab>
<Tab
aria-pressed={selectedTab === Tabs.PLAYERS}
onClick={() => onTabClick(Tabs.PLAYERS)}
id='match_players'
>
<TabButton>
<TabIcon icon='players' />
<TabTitle t='players' />
</TabButton>
</Tab>
<Tab
aria-pressed={selectedTab === Tabs.STATS}
onClick={() => onTabClick(Tabs.STATS)}
data-step={Steps.Start}
id='match_stats'
>
{Boolean(currentStep === Steps.Start && isOpen) && (
<Spotlight />
)}
<TabButton>
<TabIcon icon='stats' />
<TabTitle t='stats' />
</TabButton>
</Tab>
</TabsGroup>
</TabsWrapper>
)}
<TabsGroup
ref={tabsGroupRef}
space={getTabsSpace()}
>
{(isMatchParsed || likeEvents.length > 0) && (
<Tab
aria-pressed={selectedTab === Tabs.WATCH}
onClick={() => onTabClick(Tabs.WATCH)}
id='match_watch'
>
<TabIcon icon='watch' />
<TabTitle t='watch' />
</Tab>
)}
{isMatchParsed && (
<Tab
aria-pressed={selectedTab === Tabs.EVENTS}
onClick={() => onTabClick(Tabs.EVENTS)}
id='match_plays'
>
<TabIcon icon='plays' />
<TabTitle t='actions' />
</Tab>
)}
{likeEvents.length > 0 && (
<Tab
aria-pressed={selectedTab === Tabs.LIKES}
onClick={() => onTabClick(Tabs.LIKES)}
id='match_likes'
>
<TabIcon icon='likes' />
<TabTitle t='likes' />
</Tab>
)}
{isMatchParsed && (
<Tab
aria-pressed={selectedTab === Tabs.PLAYERS}
onClick={() => onTabClick(Tabs.PLAYERS)}
id='match_players'
>
<TabIcon icon='players' />
<TabTitle t='players' />
</Tab>
)}
{isMatchParsed && (
<Tab
aria-pressed={selectedTab === Tabs.STATS}
onClick={() => onTabClick(Tabs.STATS)}
data-step={Steps.Start}
id='match_stats'
>
{Boolean(currentStep === Steps.Start && isOpen) && (
<Spotlight />
)}
<TabIcon icon='stats' />
<TabTitle t='stats' />
</Tab>
)}
</TabsGroup>
</TabsWrapper>
<Container
hasScroll={hasTabPaneScroll}
ref={tabPaneContainerRef}

@ -53,10 +53,14 @@ export const Wrapper = styled.div<WrapperProps>`
export const TabsWrapper = styled.div``
export const TabsGroup = styled.div.attrs({ role: 'tablist' })`
type TabsGroupProps = {
space: number,
}
export const TabsGroup = styled.div.attrs({ role: 'tablist' })<TabsGroupProps>`
display: flex;
justify-content: center;
gap: ${isMobileDevice ? 30 : 20}px;
gap: min(${({ space }) => space}px, ${isMobileDevice ? 19 : 16}px);
padding-top: 10px;
`
@ -67,7 +71,8 @@ export const TabTitle = styled(T9n)`
color: ${({ theme }) => theme.colors.white};
`
export const TabButton = styled.button`
export const Tab = styled.button.attrs({ role: 'tab' })`
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
@ -78,19 +83,9 @@ export const TabButton = styled.button`
cursor: pointer;
border: none;
background: none;
`
export const Tab = styled.div.attrs({ role: 'tab' })`
position: relative;
&[aria-pressed="true"], :hover {
${TabButton} {
opacity: 1;
${TabTitle} {
font-weight: 600;
}
}
opacity: 1;
}
:only-child {
@ -99,7 +94,7 @@ export const Tab = styled.div.attrs({ role: 'tab' })`
`
type TabIconProps = {
icon: 'watch' | 'plays' | 'players' | 'stats',
icon: 'watch' | 'plays' | 'players' | 'stats' | 'likes',
}
export const TabIcon = styled.div<TabIconProps>`
@ -112,7 +107,7 @@ export const TabIcon = styled.div<TabIconProps>`
background-position: center;
background-size: contain;
${({ icon }) => (icon === 'players'
${({ icon }) => (['likes', 'players'].includes(icon)
? css`
background-size: 25px;
`

@ -3,8 +3,10 @@ import { Settings } from 'features/StreamPlayer/components/Settings'
import { AirPlay } from 'features/AirPlay'
import { ChromeCast } from 'features/ChromeCast'
import { AudioTracks } from 'features/AudioTracks'
import { useLikes } from 'features/MatchPage/store/hooks/useLikes'
import { ControlsPropsExtended } from '../..'
import { LikeButton } from '../../../LikeButton'
import { LiveBtn } from '../../../../styled'
import {
Controls,
@ -22,19 +24,22 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) =>
backToLive,
changeAudioTrack,
controlsVisible,
isFullMatchChapter,
isFullscreen,
isLive,
isLiveTime,
likeButtonRef,
onFullscreenClick,
onQualitySelect,
playBackTime,
progressBarElement,
selectedAudioTrack,
selectedQuality,
src,
setGenerateParticles,
videoQualities,
videoRef,
} = props
const { canLike, likeClick } = useLikes()
return (
<Controls isFullscreen={isFullscreen}>
@ -43,6 +48,14 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) =>
{playBackTime}
</PlaybackTime>
{canLike && isFullMatchChapter && (
<LikeButton
ref={likeButtonRef}
setGenerateParticles={setGenerateParticles}
onClick={likeClick}
/>
)}
<ControlsGroup>
<AudioTracks
audioTracks={audioTracks!}
@ -60,7 +73,7 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) =>
</LiveBtn>
)}
<AirPlay videoRef={videoRef} />
<ChromeCast src={src} />
<ChromeCast />
<Settings
onSelect={onQualitySelect}
selectedQuality={selectedQuality}

@ -25,6 +25,7 @@ export const Controls = styled.div<FullscreenProps>`
`
export const ControlsRow = styled.div`
position: relative;
width: 100%;
display: flex;
justify-content: space-between;

@ -10,11 +10,13 @@ import { Settings } from 'features/StreamPlayer/components/Settings'
import { T9n } from 'features/T9n'
import { ChromeCast } from 'features/ChromeCast'
import { AudioTracks } from 'features/AudioTracks'
import { useLikes } from 'features/MatchPage/store/hooks/useLikes'
import { PiP } from 'components/PictureInPicture/PiP'
import { ControlsPropsExtended } from '../..'
import { VolumeBar } from '../../../VolumeBar'
import { LikeButton } from '../../../LikeButton'
import {
Backward,
Controls,
@ -36,11 +38,13 @@ export const ControlsWeb = (controlsProps: { props: ControlsPropsExtended }) =>
changeAudioTrack,
controlsVisible,
isFirstChapterPlaying,
isFullMatchChapter,
isFullscreen,
isLastChapterPlaying,
isLive,
isLiveTime,
isStorage,
likeButtonRef,
muted,
numberOfChapters = 0,
onFullscreenClick,
@ -56,17 +60,25 @@ export const ControlsWeb = (controlsProps: { props: ControlsPropsExtended }) =>
rewindForward,
selectedAudioTrack,
selectedQuality,
src,
setGenerateParticles,
togglePlaying,
videoQualities,
videoRef,
volumeInPercent,
} = props
const { canLike, likeClick } = useLikes()
return (
<Controls visible={controlsVisible}>
<ControlsRow>
{progressBarElement}
{canLike && isFullMatchChapter && (
<LikeButton
ref={likeButtonRef}
setGenerateParticles={setGenerateParticles}
onClick={likeClick}
/>
)}
</ControlsRow>
<ControlsRow>
<ControlsGroup>
@ -118,7 +130,7 @@ export const ControlsWeb = (controlsProps: { props: ControlsPropsExtended }) =>
<T9n t='live' />
</LiveBtn>
)}
<ChromeCast src={src} />
<ChromeCast />
{document.pictureInPictureEnabled && (
<PiP videoRef={videoRef} />
)}

@ -24,11 +24,13 @@ export type ControlsProps = {
controlsVisible: boolean,
duration: number,
isFirstChapterPlaying?: boolean,
isFullMatchChapter?: boolean,
isFullscreen: boolean,
isLastChapterPlaying?: boolean,
isLive?: boolean,
isLiveTime?: boolean,
isStorage?: boolean,
likeButtonRef: React.RefObject<HTMLButtonElement>,
liveChapters?: LiveChapters,
loadedProgress: number,
muted: boolean,
@ -48,6 +50,7 @@ export type ControlsProps = {
rewindForward: () => void,
selectedAudioTrack?: MediaPlaylist,
selectedQuality: string,
setGenerateParticles: (state: boolean) => void,
src?: string,
togglePlaying: () => void,
videoQualities: Array<string>,

@ -0,0 +1,164 @@
import {
forwardRef,
memo,
useEffect,
useRef,
useState,
} from 'react'
import { isMobileDevice } from 'config'
import { defaultTheme } from 'features/Theme/config'
import {
Button,
Svg,
Path,
} from './styled'
const COLOR_PAIRS = [
['#FF0000', '#FFDF00'],
['#70FF00', '#0001FF'],
['#CD00FF', defaultTheme.colors.white],
[defaultTheme.colors.white, defaultTheme.colors.white],
[defaultTheme.colors.white, defaultTheme.colors.white],
[defaultTheme.colors.white, defaultTheme.colors.white],
['#FF0000', '#FFDF00'],
['#70FF00', '#0001FF'],
['#CD00FF', defaultTheme.colors.white],
[],
]
const OFFSET_CHANGE_SPEED = isMobileDevice ? 20 : 5
const ANIMATION_INTERVAL = 5000
const FREEZE_TIME = 6000
const PARTICLES_GENERATION_TIME = 1500
type Props = {
onClick: () => void,
setGenerateParticles: (state: boolean) => void,
}
const LikeButtonFC = forwardRef<HTMLButtonElement, Props>((
{
onClick,
setGenerateParticles,
},
ref,
) => {
const stop1Ref = useRef<SVGStopElement>(null)
const stop2Ref = useRef<SVGStopElement>(null)
const [canAnimate, setCanAnimate] = useState(false)
const [isDisabled, setIsDisabled] = useState(false)
const freezeTimeoutIdRef = useRef<NodeJS.Timeout | null>(null)
const generateTimeoutIdRef = useRef<NodeJS.Timeout | null>(null)
const startGenerateParticles = () => setGenerateParticles(true)
const stopGenerateParticles = () => setGenerateParticles(false)
const handleClick = () => {
onClick()
setIsDisabled(true)
startGenerateParticles()
freezeTimeoutIdRef.current = setTimeout(() => setIsDisabled(false), FREEZE_TIME)
generateTimeoutIdRef.current = setTimeout(stopGenerateParticles, PARTICLES_GENERATION_TIME)
}
useEffect(() => {
let requestAnimationId: number
let timeoutId: NodeJS.Timeout
let index = 0
let offset = 100
const animate = () => {
if (!stop1Ref.current || !stop2Ref.current) return
stop1Ref.current.setAttribute('offset', `${offset}%`)
stop2Ref.current.setAttribute('offset', `${offset}%`)
stop1Ref.current.setAttribute('stop-color', `${COLOR_PAIRS[index][0]}`)
stop2Ref.current.setAttribute('stop-color', `${COLOR_PAIRS[index][1]}`)
offset = Math.max(offset - OFFSET_CHANGE_SPEED, 0)
if (offset === 0) {
offset = 100
index = index + 1 <= COLOR_PAIRS.length - 1
? index + 1
: 0
}
if (index === COLOR_PAIRS.length - 1 && offset === 100) {
requestAnimationId && cancelAnimationFrame(requestAnimationId)
setCanAnimate(false)
index = 0
timeoutId = setTimeout(() => {
requestAnimationId = requestAnimationFrame(animate)
}, ANIMATION_INTERVAL)
} else {
requestAnimationId = requestAnimationFrame(animate)
setCanAnimate(true)
}
}
timeoutId = setTimeout(animate, ANIMATION_INTERVAL)
return () => {
timeoutId && clearTimeout(timeoutId)
requestAnimationId && cancelAnimationFrame(requestAnimationId)
}
}, [])
useEffect(() => () => {
freezeTimeoutIdRef.current && clearTimeout(freezeTimeoutIdRef.current)
generateTimeoutIdRef.current && clearTimeout(generateTimeoutIdRef.current)
}, [])
return (
<Button
disabled={isDisabled}
canAnimate={canAnimate}
ref={ref}
aria-label='Like this moment'
onClick={handleClick}
>
<Svg
width='30'
height='30'
viewBox='0 0 30 30'
fill='none'
>
<defs>
<linearGradient id='gradient'>
<stop
ref={stop1Ref}
offset='50%'
stopColor={defaultTheme.colors.white}
/>
<stop
ref={stop2Ref}
offset='50%'
stopColor={defaultTheme.colors.white}
/>
</linearGradient>
</defs>
<g>
<Path
fill={canAnimate ? 'url(#gradient)' : defaultTheme.colors.white}
d='M17.0939 0.686035C15.6852 0.686035 16.1589 3.64594 16.1589 3.64594C16.1589 3.64594 13.1482 11.8087 10.2685 13.9842C9.65136 14.6215 9.26489 15.3262 9.02179 15.9023C8.96569 16.0432 8.91583 16.178 8.87219 16.3006C8.67896 16.6867 8.26757 17.2811 7.38867 17.802L10.2685 30.1135C10.2685 30.1135 14.7252 30.6834 19.1945 30.5915C20.9835 30.7324 22.8784 30.7447 24.3868 30.426C29.5106 29.3536 28.2265 25.8422 28.2265 25.8422C30.9879 23.8015 29.4171 21.2522 29.4171 21.2522C31.873 18.7335 29.4607 16.6193 29.4607 16.6193C29.4607 16.6193 30.7884 14.5847 29.0743 13.0466C26.9363 11.1223 21.1331 12.4031 21.1331 12.4031C20.7279 12.4705 20.2978 12.5563 19.8365 12.6666C19.8365 12.6666 17.8294 13.5858 19.8365 7.59861C21.8499 1.61139 18.5026 0.686035 17.0939 0.686035ZM8.19893 29.2249L6.01728 19.0338C5.89261 18.4516 5.29422 17.9736 4.68959 17.9736H0.525765L0.519531 30.279H7.32003C7.9309 30.2851 8.32359 29.8071 8.19893 29.2249Z'
/>
</g>
</Svg>
</Button>
)
})
export const LikeButton = memo(LikeButtonFC)

@ -0,0 +1,78 @@
import styled, { css, keyframes } from 'styled-components/macro'
import { isMobileDevice } from 'config'
const wiggle = keyframes`
0%, 7% {
transform: rotateZ(0);
}
15% {
transform: rotateZ(-15deg);
}
20% {
transform: rotateZ(10deg);
}
25% {
transform: rotateZ(-10deg);
}
30% {
transform: rotateZ(6deg);
}
35% {
transform: rotateZ(-4deg);
}
40%, 100% {
transform: rotateZ(0);
}
`
export const Svg = styled.svg``
export const Path = styled.path``
export const Button = styled.button<{ canAnimate?: boolean }>`
position: absolute;
bottom: 25px;
right: 22px;
width: 30px;
height: 30px;
padding: 0;
border: none;
background: none;
cursor: pointer;
${({ canAnimate }) => (canAnimate
? css`
animation: ${wiggle} 1s linear infinite;
`
: '')};
${isMobileDevice && css`
width: 25px;
height: 25px;
bottom: 40px;
${Svg} {
width: 25px;
height: 25px;
}
`}
:disabled {
opacity: 0.5;
cursor: initial;
animation: none;
${Path} {
fill: ${({ theme }) => theme.colors.white};
}
}
:hover {
animation: none;
${Path} {
fill: ${({ theme }) => theme.colors.white};
}
}
`

@ -1,17 +1,10 @@
import type { HlsConfig } from 'hls.js'
import { readToken } from 'helpers/token'
import { isMobileDevice } from 'config/userAgent'
export const streamConfig: Partial<HlsConfig> = {
liveSyncDuration: 30,
maxBufferLength: 30,
xhrSetup: (xhr, urlString) => {
const url = new URL(urlString)
url.searchParams.set('access_token', readToken() || '')
xhr.open('GET', url.toString())
},
}
export const REWIND_SECONDS = isMobileDevice ? 10 : 5

@ -51,6 +51,7 @@ import { useControlsVisibility } from './useControlsVisibility'
import { useProgressChangeHandler } from './useProgressChangeHandler'
import { usePlayingHandlers } from './usePlayingHandlers'
import { useAudioTrack } from './useAudioTrack'
import { useParticles } from './useParticles'
import { FULL_GAME_KEY } from '../../MatchPage/helpers/buildPlaylists'
export type PlayerState = typeof initialState
@ -116,8 +117,10 @@ export const useVideoPlayer = ({
playingOrder,
playingProgress,
playNextEpisode,
profile,
selectedPlaylist,
setCircleAnimation,
setEpisodeInfo,
setIsFullScreen,
setPlayingProgress,
} = useMatchPageStore()
@ -202,19 +205,22 @@ export const useVideoPlayer = ({
const chaptersDuration = useDuration(chapters)
const duration = useMemo(() => ((
isLive
&& chapters[0]?.isFullMatchChapter
&& Number.isFinite(videoRefDurationMs)
)
? videoRefDurationMs - getActiveChapter().startOffsetMs
: chaptersDuration
), [
const duration = useMemo(() => {
if (isLive && chapters[0]?.isFullMatchChapter) {
if (isIOS) {
return fullMatchDuration - getActiveChapter().startOffsetMs
} if (Number.isFinite(videoRefDurationMs)) {
return videoRefDurationMs - getActiveChapter().startOffsetMs
}
}
return chaptersDuration
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
chapters,
chaptersDuration,
fullMatchDuration,
getActiveChapter,
isLive,
videoRefDurationMs,
])
const {
@ -396,6 +402,7 @@ export const useVideoPlayer = ({
seek: pausedProgress.current / 1000,
})
}, 100)
setEpisodeInfo({})
}
useEffect(() => {
@ -648,15 +655,28 @@ export const useVideoPlayer = ({
// ведем статистику просмотра матча
const { start: startCollectingStats, stop: stopCollectingStats } = useInterval({
callback: useCallback(() => {
if (timeForStatistics.current !== 0) {
saveMatchStats({
matchId: profileId,
matchSecond: timeForStatistics.current,
sportType,
})
}
}, [profileId, sportType]),
callback: useCallback(
() => {
if (timeForStatistics.current !== 0) {
saveMatchStats({
matchDate: profile?.date,
matchId: profileId,
matchSecond: timeForStatistics.current,
seasonId: profile?.season.id,
sportType,
teamFirst: profile?.team1.id,
teamSecond: profile?.team2.id,
tournamentId: profile?.tournament.id,
})
}
},
[profile?.date,
profile?.season.id,
profile?.team1.id, profile?.team2.id,
profile?.tournament.id,
profileId, sportType,
],
),
intervalDuration: VIEW_INTERVAL_MS,
startImmediate: false,
})
@ -688,7 +708,7 @@ export const useVideoPlayer = ({
buffering,
chapters,
currentPlayingOrder,
duration: duration || videoRefDurationMs,
duration,
isFirstChapterPlaying,
isFullscreen,
isLastChapterPlaying,
@ -727,5 +747,6 @@ export const useVideoPlayer = ({
...useVolume(),
...useVideoQuality(hls),
...useAudioTrack(hls),
...useParticles(),
}
}

@ -0,0 +1,138 @@
import {
useEffect,
useRef,
useState,
} from 'react'
import { isMobileDevice } from 'config'
type Particle = {
height: number, // Высота частицы
image: HTMLImageElement, // Изображение частицы
life: number, // Время жизни частицы
opacity: number, // Непрозрачность частицы,
vx: number, // Горизонтальная скорость частицы
vy: number, // Вертикальная скорость частицы
width: number, // Ширина частицы
x: number, // Cмещение по горизонтали
y: number, // Смещение по вертикали
}
const BIRTH_RATE = 100
const PARTICLES_COUNT = isMobileDevice ? 1 : 2
export const useParticles = () => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const likeButtonRef = useRef<HTMLButtonElement>(null)
const requestAnimationIdRef = useRef<number | null>(null)
const [generateParticles, setGenerateParticles] = useState(false)
useEffect(() => {
const canvas = canvasRef.current
const likeButton = likeButtonRef.current
if (!canvas || !likeButton) return () => {}
let intervalId: NodeJS.Timeout
const particleImage = new Image()
particleImage.src = '/images/like-active-icon.svg'
const ctx = canvas.getContext('2d')
if (!ctx) return () => {}
const canvasRect = canvas.getBoundingClientRect()
const likeButtonRect = likeButton.getBoundingClientRect()
// Массив для хранения частиц
const particles: Array<Particle> = []
// Начальные координаты вылета частиц
const startX = (likeButtonRect.left - canvasRect.left) + likeButtonRect.width / 2
const startY = likeButtonRect.top - canvasRect.top
// Функция для создания частиц
const createParticles = (count: number) => {
for (let i = 0; i < count; i++) {
const particleSize = Math.random() * (isMobileDevice ? 30 : 40)
const particle: Particle = {
height: particleSize,
image: particleImage,
life: 500,
opacity: 0.5,
vx: 0,
vy: -(Math.random() * 2 + 2),
width: particleSize,
x: startX - particleSize / 2 + Math.random() * 40 - 20,
y: startY,
}
particles.push(particle)
}
}
// Функция для обновления частиц
const updateParticles = () => {
ctx.clearRect(
0,
0,
canvas.width,
canvas.height,
)
for (let i = 0; i < particles.length; i++) {
const particle = particles[i]
particle.life-- // уменьшаем время жизни частицы
// Удаляем частицу из массива, если её время жизни истекло
if (particle.life <= 0) {
particles.splice(i, 1)
i--
} else {
particle.x += particle.vx // Обновляем положение частицы по горизонтали
particle.y += particle.vy // Обновляем положение частицы по вертикали
ctx.globalAlpha = particle.opacity
ctx.drawImage(
particle.image,
particle.x,
particle.y,
particle.width,
particle.height,
)
}
}
if (particles.length === 0 && requestAnimationIdRef.current) {
cancelAnimationFrame(requestAnimationIdRef.current)
return
}
requestAnimationIdRef.current = requestAnimationFrame(updateParticles)
}
if (generateParticles) {
createParticles(PARTICLES_COUNT)
intervalId = setInterval(() => createParticles(PARTICLES_COUNT), BIRTH_RATE)
updateParticles()
}
return () => {
intervalId && clearInterval(intervalId)
}
}, [generateParticles])
useEffect(() => () => {
requestAnimationIdRef.current && cancelAnimationFrame(requestAnimationIdRef.current)
}, [])
return {
canvasRef,
likeButtonRef,
setGenerateParticles,
}
}

@ -1,6 +1,7 @@
import { Fragment } from 'react'
import { Fragment, memo } from 'react'
import includes from 'lodash/includes'
import isEmpty from 'lodash/isEmpty'
import { isMobileDevice } from 'config'
@ -37,8 +38,11 @@ import {
EpisodeInfoDivider,
CloseButton,
PlayerAdsWrapper,
Canvas,
} from './styled'
const CanvasComponent = memo(Canvas)
const tournamentsWithWatermark = {
316: 'Tunisia',
1136: 'Brasil',
@ -65,6 +69,7 @@ export const StreamPlayer = (props: Props) => {
audioTracks,
backToLive,
buffering,
canvasRef,
centerControlsVisible,
changeAudioTrack,
chapters,
@ -74,6 +79,7 @@ export const StreamPlayer = (props: Props) => {
isFullscreen,
isLive,
isLiveTime,
likeButtonRef,
loadedProgress,
mainControlsVisible,
muted,
@ -105,6 +111,7 @@ export const StreamPlayer = (props: Props) => {
seek,
selectedAudioTrack,
selectedQuality,
setGenerateParticles,
showCenterControls,
stopPlayingEpisodes,
togglePlaying,
@ -139,23 +146,27 @@ export const StreamPlayer = (props: Props) => {
)}
{isPlayingEpisode && (
<EpisodeInfo>
<EpisodeInfoName>
{isMobileDevice
? (
<Fragment>
{episodeInfo.playerOrTeamName}
{episodeInfo.playerOrTeamName && <br />}
{episodeInfo.paramName}
</Fragment>
)
: `${episodeInfo.playerOrTeamName || ''}${episodeInfo.paramName && episodeInfo.playerOrTeamName ? ' - ' : ''}${episodeInfo.paramName || ''}`}
</EpisodeInfoName>
{currentPlayingOrder > 0 && (
<EpisodeInfoOrder>
{currentPlayingOrder}
<EpisodeInfoDivider />
{episodeInfo.episodesCount}
</EpisodeInfoOrder>
{!isEmpty(episodeInfo) && (
<Fragment>
<EpisodeInfoName>
{isMobileDevice
? (
<Fragment>
{episodeInfo.playerOrTeamName}
{episodeInfo.playerOrTeamName && <br />}
{episodeInfo.paramName}
</Fragment>
)
: `${episodeInfo.playerOrTeamName || ''}${episodeInfo.paramName && episodeInfo.playerOrTeamName ? ' - ' : ''}${episodeInfo.paramName || ''}`}
</EpisodeInfoName>
{currentPlayingOrder > 0 && (
<EpisodeInfoOrder>
{currentPlayingOrder}
<EpisodeInfoDivider />
{episodeInfo.episodesCount}
</EpisodeInfoOrder>
)}
</Fragment>
)}
<CloseButton onClick={stopPlayingEpisodes} />
</EpisodeInfo>
@ -258,6 +269,9 @@ export const StreamPlayer = (props: Props) => {
activeChapterIndex={activeChapterIndex}
liveChapters={chapters}
selectedAudioTrack={selectedAudioTrack}
setGenerateParticles={setGenerateParticles}
likeButtonRef={likeButtonRef}
isFullMatchChapter={chapters[0].isFullMatchChapter}
/>
<ControlsGradient isVisible={mainControlsVisible} />
{!user && (
@ -269,6 +283,13 @@ export const StreamPlayer = (props: Props) => {
videoRef={videoRef}
/>
)}
{user && chapters[0].isFullMatchChapter && (
<CanvasComponent
ref={canvasRef}
width={wrapperRef.current?.clientWidth}
height={wrapperRef.current?.clientHeight}
/>
)}
</PlayerWrapper>
)
}

@ -55,6 +55,7 @@ export const Controls = styled.div`
`
export const ControlsRow = styled.div`
position: relative;
width: 100%;
display: flex;
justify-content: space-between;
@ -494,3 +495,10 @@ export const PlayerAdsWrapper = styled.div<PlayerAdsProps>`
opacity: ${({ isFullscreen }) => (isFullscreen ? 0 : 1)};
`
export const Canvas = styled.canvas`
position: absolute;
top: 0;
bottom: 0;
right: 0;
pointer-events: none;
`

@ -5,7 +5,7 @@ import find from 'lodash/find'
import map from 'lodash/map'
import { formIds } from 'config/form'
import { AUTH_SERVICE } from 'config/routes'
import { AUTH_SERVICE_OLD } from 'config/routes'
import { useForm } from 'features/FormStore'
import { useLexicsStore } from 'features/LexicsStore'
@ -112,7 +112,7 @@ export const useUserInfo = ({ loader, onSubmit }: Props) => {
}
const resetPassword = useCallback(() => {
window.location.href = `${AUTH_SERVICE}/change_password?client_id=${client.auth.clientId}&token=${token}`
window.location.href = `${AUTH_SERVICE_OLD}/change_password?client_id=${client.auth.clientId}&token=${token}`
}, [token])
return {

@ -10,3 +10,5 @@ export * from './useModalRoot'
export * from './usePageLogger'
export * from './useDuration'
export * from './useScreenOrientation'
export * from './useWebSocket'
export * from './useVideoBounds'

@ -0,0 +1,19 @@
import { useQueryClient } from 'react-query'
import { querieKeys } from 'config'
import type { MatchScore } from 'requests'
import { useMatchPageStore } from 'features/MatchPage/store'
export const useVideoBounds = () => {
const { profile: matchProfile } = useMatchPageStore()
const client = useQueryClient()
const matchScore = client.getQueryData<MatchScore>(querieKeys.matchScore)
const videoBounds = matchScore?.video_bounds || matchProfile?.video_bounds
return videoBounds
}

@ -0,0 +1,128 @@
import {
useEffect,
useRef,
useState,
} from 'react'
interface WebSocketHandlers<T> {
onClose?: (event: CloseEvent) => void,
onError?: (event: Event) => void,
onMessage?: (data: T) => void,
onOpen?: (event: Event) => void,
}
interface WebSocketOptions<T> {
allowConnection?: boolean, // Разрешено ли подключение
autoReconnect?: boolean, // Автоматическое переподключение при ошибке
handlers?: WebSocketHandlers<T>, // Обработчики событий
maxReconnectAttempts?: number, // Максимальное количество попыток переподключения
reconnectInterval?: number, // Интервал между попытками переподключения
url: string, // Url запроса
}
export const useWebSocket = <T, >({
allowConnection = true,
autoReconnect = false,
handlers = {},
maxReconnectAttempts = 5,
reconnectInterval = 5000,
url,
}: WebSocketOptions<T>) => {
const {
onClose,
onError,
onMessage,
onOpen,
} = handlers
const webSocketRef = useRef<WebSocket | null>(null)
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null)
const reconnectAttemptsRef = useRef<number>(0)
const onCloseRef = useRef(onClose)
const onErrorRef = useRef(onError)
const onMessageRef = useRef(onMessage)
const onOpenRef = useRef(onOpen)
const [webSocketState, setWebSocketState] = useState(WebSocket.CONNECTING)
const sendMessage = (message: Record<string, unknown>) => {
if (webSocketRef.current?.readyState !== WebSocket.OPEN) return
const body = JSON.stringify(message)
webSocketRef.current?.send(body)
}
const closeConnection = () => {
setWebSocketState(WebSocket.CLOSING)
webSocketRef.current?.close()
}
useEffect(() => {
onCloseRef.current = onClose
onErrorRef.current = onError
onMessageRef.current = onMessage
onOpenRef.current = onOpen
}, [onClose, onError, onMessage, onOpen])
useEffect(() => {
const connect = () => {
if (!allowConnection) return
const ws = new WebSocket(url)
ws.addEventListener('open', handleOpen)
ws.addEventListener('message', handleMessage)
ws.addEventListener('close', handleClose)
ws.addEventListener('error', handleError)
webSocketRef.current = ws
setWebSocketState(WebSocket.CONNECTING)
}
const handleOpen = (event: Event) => {
setWebSocketState(WebSocket.OPEN)
onOpenRef.current?.(event)
reconnectAttemptsRef.current = 0
}
const handleMessage = (event: MessageEvent) => {
const data = JSON.parse(event.data)
onMessageRef.current?.(data)
}
const handleClose = (event: CloseEvent) => {
setWebSocketState(WebSocket.CLOSED)
onCloseRef.current?.(event)
}
const handleError = (event: Event) => {
setWebSocketState(WebSocket.CLOSED)
onErrorRef.current?.(event)
if (autoReconnect && reconnectAttemptsRef.current < maxReconnectAttempts) {
timeoutIdRef.current = setTimeout(connect, reconnectInterval)
reconnectAttemptsRef.current++
}
}
connect()
return () => {
webSocketRef.current?.removeEventListener('open', handleOpen)
webSocketRef.current?.removeEventListener('message', handleMessage)
webSocketRef.current?.removeEventListener('close', handleClose)
webSocketRef.current?.removeEventListener('error', handleError)
webSocketRef.current?.close()
timeoutIdRef.current && clearTimeout(timeoutIdRef.current)
}
}, [url, autoReconnect, reconnectInterval, maxReconnectAttempts, allowConnection])
return {
closeConnection,
sendMessage,
webSocketState,
}
}

@ -3,26 +3,41 @@ import { VIEWS_API } from 'config'
import { callApi } from 'helpers'
type Props = {
matchDate?: string,
matchId: number,
matchSecond: number,
seasonId?: number,
sportType: number,
teamFirst?: number,
teamSecond?: number,
tournamentId?: number,
}
export const VIEW_INTERVAL_MS = 5000
export const saveMatchStats = ({
matchDate,
matchId,
matchSecond,
seasonId,
sportType,
teamFirst,
teamSecond,
tournamentId,
}: Props) => {
const url = `${VIEWS_API}/user/view`
const url = `${VIEWS_API}/v2/user/view`
const config = {
body: {
interval: VIEW_INTERVAL_MS / 1000,
duration: VIEW_INTERVAL_MS / 1000,
match_date: matchDate,
match_id: matchId,
season: seasonId,
second: matchSecond,
sport_id: sportType,
team1: teamFirst,
team2: teamSecond,
tournament: tournamentId,
},
}

Loading…
Cancel
Save