diff --git a/public/images/matchTabs/likes.svg b/public/images/matchTabs/likes.svg
new file mode 100644
index 00000000..71f9cc82
--- /dev/null
+++ b/public/images/matchTabs/likes.svg
@@ -0,0 +1,4 @@
+
diff --git a/src/config/lexics/indexLexics.tsx b/src/config/lexics/indexLexics.tsx
index 0c3b5835..03a8224b 100644
--- a/src/config/lexics/indexLexics.tsx
+++ b/src/config/lexics/indexLexics.tsx
@@ -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,
diff --git a/src/config/routes.tsx b/src/config/routes.tsx
index 30964d0c..a9e4a8f1 100644
--- a/src/config/routes.tsx
+++ b/src/config/routes.tsx
@@ -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]
diff --git a/src/features/MatchPage/store/atoms.tsx b/src/features/MatchPage/store/atoms.tsx
new file mode 100644
index 00000000..758b610b
--- /dev/null
+++ b/src/features/MatchPage/store/atoms.tsx
@@ -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>({
+ default: [],
+ key: 'myLikesState',
+})
+
+export const totalLikesState = atom>({
+ default: [],
+ key: 'totalLikesState',
+})
+
+const transformedLikesState = selector({
+ get: ({ get }) => {
+ const totalLikes = get(totalLikesState)
+ const myLikes = get(myLikesState)
+
+ const likes: Array = 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>({
+ get: ({ get }) => {
+ const likes = get(transformedLikesState)
+ const filterType = get(filterTypeState)
+
+ return filterType === 'totalLikes' ? likes : likes.filter(({ iLiked }) => iLiked)
+ },
+ key: 'filteredLikesState',
+})
+
diff --git a/src/features/MatchPage/store/hooks/useLikes.tsx b/src/features/MatchPage/store/hooks/useLikes.tsx
new file mode 100644
index 00000000..0af1fd78
--- /dev/null
+++ b/src/features/MatchPage/store/hooks/useLikes.tsx
@@ -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,
+ 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({
+ 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,
+ }
+}
diff --git a/src/features/MatchPage/store/index.tsx b/src/features/MatchPage/store/index.tsx
index 778a5853..f6f76098 100644
--- a/src/features/MatchPage/store/index.tsx
+++ b/src/features/MatchPage/store/index.tsx
@@ -6,6 +6,8 @@ import {
import { useMatchPage } from './hooks'
+export * from './atoms'
+
type Context = ReturnType
type Props = { children: ReactNode }
diff --git a/src/features/MatchSidePlaylists/components/LikeEvent/index.tsx b/src/features/MatchSidePlaylists/components/LikeEvent/index.tsx
new file mode 100644
index 00000000..1147707d
--- /dev/null
+++ b/src/features/MatchSidePlaylists/components/LikeEvent/index.tsx
@@ -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 = {
+ 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 (
+
+ )
+
+ case likeEvent.iLiked && likeEvent.likes === 2:
+ return (
+
+ 1
+
+ )
+
+ case likeEvent.iLiked && likeEvent.likes > 2:
+ return (
+
+ {likeEvent.likes - 1}
+
+ )
+
+ case !likeEvent.iLiked && likeEvent.likes === 1:
+ return (
+
+ 1
+
+ )
+
+ case !likeEvent.iLiked && likeEvent.likes > 1:
+ return (
+
+ {likeEvent.likes}
+
+ )
+
+ default: return null
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/src/features/MatchSidePlaylists/components/LikeEvent/styled.tsx b/src/features/MatchSidePlaylists/components/LikeEvent/styled.tsx
new file mode 100644
index 00000000..2112c9f1
--- /dev/null
+++ b/src/features/MatchSidePlaylists/components/LikeEvent/styled.tsx
@@ -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)`
+ 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;
+`
diff --git a/src/features/MatchSidePlaylists/components/LikesList/index.tsx b/src/features/MatchSidePlaylists/components/LikesList/index.tsx
new file mode 100644
index 00000000..852fea5b
--- /dev/null
+++ b/src/features/MatchSidePlaylists/components/LikesList/index.tsx
@@ -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,
+}
+
+export const LikesList = ({
+ groupTitle,
+ likeEvents,
+}: Props) => {
+ if (!likeEvents.length) return null
+
+ const title = groupTitle.startsWith('half') ? 'half_time' : groupTitle
+
+ return (
+
+ {title && (
+
+
+
+
+
+ )}
+
+ {likeEvents.map((likeEvent) => (
+
+
+
+ ))}
+
+ )
+}
diff --git a/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx b/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx
index 311908b8..4984f5c1 100644
--- a/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx
+++ b/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx
@@ -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 = {
diff --git a/src/features/MatchSidePlaylists/components/TabLikes/index.tsx b/src/features/MatchSidePlaylists/components/TabLikes/index.tsx
new file mode 100644
index 00000000..75cdbb66
--- /dev/null
+++ b/src/features/MatchSidePlaylists/components/TabLikes/index.tsx
@@ -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> = {
+ 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> = {
+ 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 (
+
+
+
+
+ setSortType('asc')}
+ id='match_likes_sort_start'
+ />
+ setSortType('desc')}
+ id='match_likes_sort_final'
+ />
+
+
+ setFilterType('totalLikes')}
+ >
+
+
+ setFilterType('myLikes')}
+ >
+
+
+
+
+ {needUseGroups ? (
+
+ {likesEntries.map(([groupTitle, likesInGroup]) => (
+
+
+
+ ))}
+
+ )
+ : (
+
+
+
+
+
+ )}
+
+ )
+}
diff --git a/src/features/MatchSidePlaylists/components/TabLikes/styled.tsx b/src/features/MatchSidePlaylists/components/TabLikes/styled.tsx
new file mode 100644
index 00000000..dde40327
--- /dev/null
+++ b/src/features/MatchSidePlaylists/components/TabLikes/styled.tsx
@@ -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};
+ }
+ }
+`
diff --git a/src/features/MatchSidePlaylists/config.tsx b/src/features/MatchSidePlaylists/config.tsx
index fc7219fd..eaba0098 100644
--- a/src/features/MatchSidePlaylists/config.tsx
+++ b/src/features/MatchSidePlaylists/config.tsx
@@ -3,4 +3,5 @@ export enum Tabs {
EVENTS,
STATS,
PLAYERS,
+ LIKES,
}
diff --git a/src/features/MatchSidePlaylists/hooks.tsx b/src/features/MatchSidePlaylists/hooks.tsx
index 8d75181a..5527e240 100644
--- a/src/features/MatchSidePlaylists/hooks.tsx
+++ b/src/features/MatchSidePlaylists/hooks.tsx
@@ -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,
}
}
diff --git a/src/features/MatchSidePlaylists/index.tsx b/src/features/MatchSidePlaylists/index.tsx
index 8c1122c3..790e2ca1 100644
--- a/src/features/MatchSidePlaylists/index.tsx
+++ b/src/features/MatchSidePlaylists/index.tsx
@@ -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(null)
const tabPaneContainerRef = useRef(null)
+ const tabsGroupRef = useRef(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)
+ .reduce((acc, elem) => acc + elem.clientWidth, 0)) / tabsGroup.children.length - 1
+ : 0
+ }
+
return (
- {showTabs
- && (
-
- {selectedTab === Tabs.EVENTS
+
+ {selectedTab === Tabs.EVENTS
&& ads
&& (
position.id === adsPositionId)} />
)}
-
- onTabClick(Tabs.WATCH)}
- id='match_watch'
- >
-
-
-
-
-
- onTabClick(Tabs.EVENTS)}
- id='match_plays'
- >
-
-
-
-
-
- onTabClick(Tabs.PLAYERS)}
- id='match_players'
- >
-
-
-
-
-
- onTabClick(Tabs.STATS)}
- data-step={Steps.Start}
- id='match_stats'
- >
- {Boolean(currentStep === Steps.Start && isOpen) && (
-
- )}
-
-
-
-
-
-
-
- )}
+
+ {(isMatchParsed || likeEvents.length > 0) && (
+ onTabClick(Tabs.WATCH)}
+ id='match_watch'
+ >
+
+
+
+ )}
+ {isMatchParsed && (
+ onTabClick(Tabs.EVENTS)}
+ id='match_plays'
+ >
+
+
+
+ )}
+ {likeEvents.length > 0 && (
+ onTabClick(Tabs.LIKES)}
+ id='match_likes'
+ >
+
+
+
+ )}
+ {isMatchParsed && (
+ onTabClick(Tabs.PLAYERS)}
+ id='match_players'
+ >
+
+
+
+ )}
+ {isMatchParsed && (
+ onTabClick(Tabs.STATS)}
+ data-step={Steps.Start}
+ id='match_stats'
+ >
+ {Boolean(currentStep === Steps.Start && isOpen) && (
+
+ )}
+
+
+
+ )}
+
+
`
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' })`
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`
@@ -112,7 +107,7 @@ export const TabIcon = styled.div`
background-position: center;
background-size: contain;
- ${({ icon }) => (icon === 'players'
+ ${({ icon }) => (['likes', 'players'].includes(icon)
? css`
background-size: 25px;
`
diff --git a/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx b/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx
index ea87d84d..053dc409 100644
--- a/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx
+++ b/src/features/StreamPlayer/components/Controls/Components/ControlsMobile/index.tsx
@@ -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,18 +24,22 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) =>
backToLive,
changeAudioTrack,
controlsVisible,
+ isFullMatchChapter,
isFullscreen,
isLive,
isLiveTime,
+ likeButtonRef,
onFullscreenClick,
onQualitySelect,
playBackTime,
progressBarElement,
selectedAudioTrack,
selectedQuality,
+ setGenerateParticles,
videoQualities,
videoRef,
} = props
+ const { canLike, likeClick } = useLikes()
return (
@@ -42,6 +48,14 @@ export const ControlsMobile = (controlsProps: {props: ControlsPropsExtended}) =>
{playBackTime}
+ {canLike && isFullMatchChapter && (
+
+ )}
+
`
`
export const ControlsRow = styled.div`
+ position: relative;
width: 100%;
display: flex;
justify-content: space-between;
diff --git a/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx b/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx
index e95b577e..d5bd2f27 100644
--- a/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx
+++ b/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx
@@ -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,16 +60,25 @@ export const ControlsWeb = (controlsProps: { props: ControlsPropsExtended }) =>
rewindForward,
selectedAudioTrack,
selectedQuality,
+ setGenerateParticles,
togglePlaying,
videoQualities,
videoRef,
volumeInPercent,
} = props
+ const { canLike, likeClick } = useLikes()
return (
{progressBarElement}
+ {canLike && isFullMatchChapter && (
+
+ )}
diff --git a/src/features/StreamPlayer/components/Controls/index.tsx b/src/features/StreamPlayer/components/Controls/index.tsx
index 0e52bbf2..d0f226fe 100644
--- a/src/features/StreamPlayer/components/Controls/index.tsx
+++ b/src/features/StreamPlayer/components/Controls/index.tsx
@@ -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,
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,
diff --git a/src/features/StreamPlayer/components/LikeButton/index.tsx b/src/features/StreamPlayer/components/LikeButton/index.tsx
new file mode 100644
index 00000000..bedbeb84
--- /dev/null
+++ b/src/features/StreamPlayer/components/LikeButton/index.tsx
@@ -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((
+ {
+ onClick,
+ setGenerateParticles,
+ },
+ ref,
+) => {
+ const stop1Ref = useRef(null)
+ const stop2Ref = useRef(null)
+
+ const [canAnimate, setCanAnimate] = useState(false)
+ const [isDisabled, setIsDisabled] = useState(false)
+ const freezeTimeoutIdRef = useRef(null)
+ const generateTimeoutIdRef = useRef(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 (
+
+ )
+})
+
+export const LikeButton = memo(LikeButtonFC)
+
diff --git a/src/features/StreamPlayer/components/LikeButton/styled.tsx b/src/features/StreamPlayer/components/LikeButton/styled.tsx
new file mode 100644
index 00000000..54db74b5
--- /dev/null
+++ b/src/features/StreamPlayer/components/LikeButton/styled.tsx
@@ -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};
+ }
+ }
+`
diff --git a/src/features/StreamPlayer/hooks/index.tsx b/src/features/StreamPlayer/hooks/index.tsx
index 7daced4b..fc07d7a7 100644
--- a/src/features/StreamPlayer/hooks/index.tsx
+++ b/src/features/StreamPlayer/hooks/index.tsx
@@ -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
@@ -119,6 +120,7 @@ export const useVideoPlayer = ({
profile,
selectedPlaylist,
setCircleAnimation,
+ setEpisodeInfo,
setIsFullScreen,
setPlayingProgress,
} = useMatchPageStore()
@@ -400,6 +402,7 @@ export const useVideoPlayer = ({
seek: pausedProgress.current / 1000,
})
}, 100)
+ setEpisodeInfo({})
}
useEffect(() => {
@@ -744,5 +747,6 @@ export const useVideoPlayer = ({
...useVolume(),
...useVideoQuality(hls),
...useAudioTrack(hls),
+ ...useParticles(),
}
}
diff --git a/src/features/StreamPlayer/hooks/useParticles.tsx b/src/features/StreamPlayer/hooks/useParticles.tsx
new file mode 100644
index 00000000..0f2bacd5
--- /dev/null
+++ b/src/features/StreamPlayer/hooks/useParticles.tsx
@@ -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(null)
+ const likeButtonRef = useRef(null)
+ const requestAnimationIdRef = useRef(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 = []
+
+ // Начальные координаты вылета частиц
+ 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,
+ }
+}
diff --git a/src/features/StreamPlayer/index.tsx b/src/features/StreamPlayer/index.tsx
index 72f67257..d833d63b 100644
--- a/src/features/StreamPlayer/index.tsx
+++ b/src/features/StreamPlayer/index.tsx
@@ -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 && (
-
- {isMobileDevice
- ? (
-
- {episodeInfo.playerOrTeamName}
- {episodeInfo.playerOrTeamName &&
}
- {episodeInfo.paramName}
-
- )
- : `${episodeInfo.playerOrTeamName || ''}${episodeInfo.paramName && episodeInfo.playerOrTeamName ? ' - ' : ''}${episodeInfo.paramName || ''}`}
-
- {currentPlayingOrder > 0 && (
-
- {currentPlayingOrder}
-
- {episodeInfo.episodesCount}
-
+ {!isEmpty(episodeInfo) && (
+
+
+ {isMobileDevice
+ ? (
+
+ {episodeInfo.playerOrTeamName}
+ {episodeInfo.playerOrTeamName &&
}
+ {episodeInfo.paramName}
+
+ )
+ : `${episodeInfo.playerOrTeamName || ''}${episodeInfo.paramName && episodeInfo.playerOrTeamName ? ' - ' : ''}${episodeInfo.paramName || ''}`}
+
+ {currentPlayingOrder > 0 && (
+
+ {currentPlayingOrder}
+
+ {episodeInfo.episodesCount}
+
+ )}
+
)}
@@ -258,6 +269,9 @@ export const StreamPlayer = (props: Props) => {
activeChapterIndex={activeChapterIndex}
liveChapters={chapters}
selectedAudioTrack={selectedAudioTrack}
+ setGenerateParticles={setGenerateParticles}
+ likeButtonRef={likeButtonRef}
+ isFullMatchChapter={chapters[0].isFullMatchChapter}
/>
{!user && (
@@ -269,6 +283,13 @@ export const StreamPlayer = (props: Props) => {
videoRef={videoRef}
/>
)}
+ {user && chapters[0].isFullMatchChapter && (
+
+ )}
)
}
diff --git a/src/features/StreamPlayer/styled.tsx b/src/features/StreamPlayer/styled.tsx
index 1cab2be7..4879ea4f 100644
--- a/src/features/StreamPlayer/styled.tsx
+++ b/src/features/StreamPlayer/styled.tsx
@@ -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`
opacity: ${({ isFullscreen }) => (isFullscreen ? 0 : 1)};
`
+export const Canvas = styled.canvas`
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 0;
+ pointer-events: none;
+`
diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx
index 46ea864e..54db60f2 100644
--- a/src/hooks/index.tsx
+++ b/src/hooks/index.tsx
@@ -10,3 +10,5 @@ export * from './useModalRoot'
export * from './usePageLogger'
export * from './useDuration'
export * from './useScreenOrientation'
+export * from './useWebSocket'
+export * from './useVideoBounds'
diff --git a/src/hooks/useVideoBounds.tsx b/src/hooks/useVideoBounds.tsx
new file mode 100644
index 00000000..35ff1a07
--- /dev/null
+++ b/src/hooks/useVideoBounds.tsx
@@ -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(querieKeys.matchScore)
+
+ const videoBounds = matchScore?.video_bounds || matchProfile?.video_bounds
+
+ return videoBounds
+}
diff --git a/src/hooks/useWebSocket.tsx b/src/hooks/useWebSocket.tsx
new file mode 100644
index 00000000..fb391946
--- /dev/null
+++ b/src/hooks/useWebSocket.tsx
@@ -0,0 +1,128 @@
+import {
+ useEffect,
+ useRef,
+ useState,
+} from 'react'
+
+interface WebSocketHandlers {
+ onClose?: (event: CloseEvent) => void,
+ onError?: (event: Event) => void,
+ onMessage?: (data: T) => void,
+ onOpen?: (event: Event) => void,
+}
+
+interface WebSocketOptions {
+ allowConnection?: boolean, // Разрешено ли подключение
+ autoReconnect?: boolean, // Автоматическое переподключение при ошибке
+ handlers?: WebSocketHandlers, // Обработчики событий
+ maxReconnectAttempts?: number, // Максимальное количество попыток переподключения
+ reconnectInterval?: number, // Интервал между попытками переподключения
+ url: string, // Url запроса
+}
+
+export const useWebSocket = ({
+ allowConnection = true,
+ autoReconnect = false,
+ handlers = {},
+ maxReconnectAttempts = 5,
+ reconnectInterval = 5000,
+ url,
+}: WebSocketOptions) => {
+ const {
+ onClose,
+ onError,
+ onMessage,
+ onOpen,
+ } = handlers
+
+ const webSocketRef = useRef(null)
+ const timeoutIdRef = useRef(null)
+ const reconnectAttemptsRef = useRef(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) => {
+ 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,
+ }
+}
+