From d06425df9415598d1513ab8a32b0f7215387ca69 Mon Sep 17 00:00:00 2001 From: Ruslan Khayrullin Date: Fri, 21 Jul 2023 00:55:16 +0500 Subject: [PATCH] feat(in-645): likes --- public/images/matchTabs/likes.svg | 4 + src/config/lexics/indexLexics.tsx | 17 ++ src/config/routes.tsx | 7 + src/features/MatchPage/store/atoms.tsx | 58 +++++ .../MatchPage/store/hooks/useLikes.tsx | 75 ++++++ src/features/MatchPage/store/index.tsx | 2 + .../components/LikeEvent/index.tsx | 126 ++++++++++ .../components/LikeEvent/styled.tsx | 35 +++ .../components/LikesList/index.tsx | 45 ++++ .../components/TabEvents/styled.tsx | 2 +- .../components/TabLikes/index.tsx | 235 ++++++++++++++++++ .../components/TabLikes/styled.tsx | 40 +++ src/features/MatchSidePlaylists/config.tsx | 1 + src/features/MatchSidePlaylists/hooks.tsx | 4 +- src/features/MatchSidePlaylists/index.tsx | 142 ++++++----- src/features/MatchSidePlaylists/styled.tsx | 27 +- .../Components/ControlsMobile/index.tsx | 14 ++ .../Components/ControlsMobile/styled.tsx | 1 + .../Controls/Components/ControlsWeb/index.tsx | 13 + .../components/Controls/index.tsx | 3 + .../components/LikeButton/index.tsx | 164 ++++++++++++ .../components/LikeButton/styled.tsx | 78 ++++++ src/features/StreamPlayer/hooks/index.tsx | 4 + .../StreamPlayer/hooks/useParticles.tsx | 138 ++++++++++ src/features/StreamPlayer/index.tsx | 57 +++-- src/features/StreamPlayer/styled.tsx | 8 + src/hooks/index.tsx | 2 + src/hooks/useVideoBounds.tsx | 19 ++ src/hooks/useWebSocket.tsx | 128 ++++++++++ 29 files changed, 1355 insertions(+), 94 deletions(-) create mode 100644 public/images/matchTabs/likes.svg create mode 100644 src/features/MatchPage/store/atoms.tsx create mode 100644 src/features/MatchPage/store/hooks/useLikes.tsx create mode 100644 src/features/MatchSidePlaylists/components/LikeEvent/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/LikeEvent/styled.tsx create mode 100644 src/features/MatchSidePlaylists/components/LikesList/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabLikes/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabLikes/styled.tsx create mode 100644 src/features/StreamPlayer/components/LikeButton/index.tsx create mode 100644 src/features/StreamPlayer/components/LikeButton/styled.tsx create mode 100644 src/features/StreamPlayer/hooks/useParticles.tsx create mode 100644 src/hooks/useVideoBounds.tsx create mode 100644 src/hooks/useWebSocket.tsx 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, + } +} +