diff --git a/.github/workflows/frontend.yaml b/.github/workflows/frontend.yaml index b13f88f37..836e63391 100644 --- a/.github/workflows/frontend.yaml +++ b/.github/workflows/frontend.yaml @@ -19,10 +19,12 @@ jobs: uses: actions/checkout@v2 - name: Cache node modules - uses: actions/cache@v1 + uses: actions/cache@v4 with: - path: node_modules - key: ${{ runner.OS }}-build-${{ hashFiles('**/package-lock.json') }} + path: | + /home/runner/work/openreplay/openreplay/frontend/node_modules + /home/runner/work/openreplay/openreplay/frontend/.yarn + key: ${{ runner.OS }}-build-${{ hashFiles('frontend/yarn.lock') }} restore-keys: | ${{ runner.OS }}-build- ${{ runner.OS }}- diff --git a/frontend/app/PrivateRoutes.tsx b/frontend/app/PrivateRoutes.tsx index 68793ec69..09729bd4d 100644 --- a/frontend/app/PrivateRoutes.tsx +++ b/frontend/app/PrivateRoutes.tsx @@ -5,6 +5,7 @@ import { Loader } from 'UI'; import withSiteIdUpdater from 'HOCs/withSiteIdUpdater'; import APIClient from './api_client'; +import { getScope } from "./duck/user"; import * as routes from './routes'; import { OB_DEFAULT_TAB } from 'App/routes'; import { GLOBAL_HAS_NO_RECORDINGS } from 'App/constants/storageKeys'; @@ -28,6 +29,7 @@ const components: any = { UsabilityTestOverviewPure: lazy(() => import('Components/UsabilityTesting/TestOverview')), SpotsListPure: lazy(() => import('Components/Spots/SpotsList')), SpotPure: lazy(() => import('Components/Spots/SpotPlayer')), + ScopeSetup: lazy(() => import('Components/ScopeForm')), }; const enhancedComponents: any = { @@ -47,6 +49,7 @@ const enhancedComponents: any = { UsabilityTestOverview: withSiteIdUpdater(components.UsabilityTestOverviewPure), SpotsList: withSiteIdUpdater(components.SpotsListPure), Spot: components.SpotPure, + ScopeSetup: components.ScopeSetup }; const withSiteId = routes.withSiteId; @@ -92,6 +95,7 @@ const USABILITY_TESTING_VIEW_PATH = routes.usabilityTestingView(); const SPOTS_LIST_PATH = routes.spotsList(); const SPOT_PATH = routes.spot(); +const SCOPE_SETUP = routes.scopeSetup(); interface Props { isEnterprise: boolean; @@ -100,6 +104,7 @@ interface Props { jwt: string; sites: Map; onboarding: boolean; + spotOnly?: boolean; } function PrivateRoutes(props: Props) { @@ -107,6 +112,7 @@ function PrivateRoutes(props: Props) { const redirectToOnboarding = !onboarding && localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true'; const siteIdList: any = sites.map(({ id }) => id).toJS(); + return ( }> @@ -115,149 +121,157 @@ function PrivateRoutes(props: Props) { path={withSiteId(ONBOARDING_PATH, siteIdList)} component={enhancedComponents.Onboarding} /> - { - const client = new APIClient(); - switch (location.pathname) { - case '/integrations/slack': - client.post('integrations/slack/add', { - code: location.search.split('=')[1], - state: props.tenantId, - }); - break; - case '/integrations/msteams': - client.post('integrations/msteams/add', { - code: location.search.split('=')[1], - state: props.tenantId, - }); - break; - } - return ; - }} - /> - {redirectToOnboarding && } - - {/* DASHBOARD and Metrics */} - - - - - - - - - - - - - - - - + + {props.spotOnly ? null : <> + { + const client = new APIClient(); + switch (location.pathname) { + case "/integrations/slack": + client.post("integrations/slack/add", { + code: location.search.split("=")[1], + state: props.tenantId + }); + break; + case "/integrations/msteams": + client.post("integrations/msteams/add", { + code: location.search.split("=")[1], + state: props.tenantId + }); + break; + } + return ; + }} + /> + {redirectToOnboarding && } - {Object.entries(routes.redirects).map(([fr, to]) => ( - - ))} - + {/* DASHBOARD and Metrics */} + + + + + + + + + + + + + + + + + {Object.entries(routes.redirects).map(([fr, to]) => ( + + ))} + + } + {props.spotOnly ? : null} ); @@ -269,6 +283,7 @@ export default connect((state: any) => ({ sites: state.getIn(['site', 'list']), siteId: state.getIn(['site', 'siteId']), jwt: state.getIn(['user', 'jwt']), + spotOnly: getScope(state) === 'spot', tenantId: state.getIn(['user', 'account', 'tenantId']), isEnterprise: state.getIn(['user', 'account', 'edition']) === 'ee' || diff --git a/frontend/app/PublicRoutes.tsx b/frontend/app/PublicRoutes.tsx index 5099363f1..a1f8e72ed 100644 --- a/frontend/app/PublicRoutes.tsx +++ b/frontend/app/PublicRoutes.tsx @@ -29,7 +29,7 @@ function PublicRoutes(props: Props) { - + diff --git a/frontend/app/Router.tsx b/frontend/app/Router.tsx index 82d0c7c2b..b728dbc2b 100644 --- a/frontend/app/Router.tsx +++ b/frontend/app/Router.tsx @@ -9,8 +9,8 @@ import PublicRoutes from 'App/PublicRoutes'; import { GLOBAL_DESTINATION_PATH, IFRAME, - JWT_PARAM, -} from 'App/constants/storageKeys'; + JWT_PARAM, SPOT_ONBOARDING +} from "App/constants/storageKeys"; import Layout from 'App/layout/Layout'; import { withStore } from "App/mstore"; import { checkParam } from 'App/utils'; @@ -23,7 +23,9 @@ import { init as initSite } from 'Duck/site'; import { fetchUserInfo, setJwt } from 'Duck/user'; import { fetchTenants } from 'Duck/user'; import { Loader } from 'UI'; +import { spotsList } from "./routes"; import * as routes from './routes'; +import { toast } from 'react-toastify' interface RouterProps extends RouteComponentProps, @@ -43,9 +45,11 @@ interface RouterProps }; }; mstore: any; - setJwt: (jwt: string) => any; + setJwt: (params: { jwt: string, spotJwt: string | null }) => any; fetchMetadata: (siteId: string) => void; initSite: (site: any) => void; + scopeSetup: boolean; + localSpotJwt: string | null; } const Router: React.FC = (props) => { @@ -58,18 +62,62 @@ const Router: React.FC = (props) => { fetchUserInfo, fetchSiteList, history, - match: { - params: { siteId: siteIdFromPath }, - }, setSessionPath, + scopeSetup, + localSpotJwt, } = props; + const params = new URLSearchParams(location.search) + const spotCb = params.get('spotCallback'); + const spotReqSent = React.useRef(false) const [isIframe, setIsIframe] = React.useState(false); const [isJwt, setIsJwt] = React.useState(false); const handleJwtFromUrl = () => { - const urlJWT = new URLSearchParams(location.search).get('jwt'); - if (urlJWT) { - props.setJwt(urlJWT); + const params = new URLSearchParams(location.search) + const urlJWT = params.get('jwt'); + const spotJwt = params.get('spotJwt'); + if (spotJwt) { + handleSpotLogin(spotJwt); } + if (urlJWT) { + props.setJwt({ jwt: urlJWT, spotJwt: spotJwt ?? null }); + } + }; + + const handleSpotLogin = (jwt: string) => { + if (spotReqSent.current) { + return; + } else { + spotReqSent.current = true; + } + let tries = 0; + if (!jwt) { + return; + } + let int: ReturnType; + + const onSpotMsg = (event: any) => { + if (event.data.type === 'orspot:logged') { + clearInterval(int); + window.removeEventListener('message', onSpotMsg); + } + }; + window.addEventListener('message', onSpotMsg); + + int = setInterval(() => { + if (tries > 20) { + clearInterval(int); + window.removeEventListener('message', onSpotMsg); + return; + } + window.postMessage( + { + type: 'orspot:token', + token: jwt, + }, + '*' + ); + tries += 1; + }, 250); }; const handleDestinationPath = () => { @@ -123,8 +171,20 @@ const Router: React.FC = (props) => { useEffect(() => { if (prevIsLoggedIn !== isLoggedIn && isLoggedIn) { handleUserLogin(); + if (scopeSetup) { + history.push(routes.scopeSetup()) + } else if (spotCb) { + history.push(spotsList()) + localStorage.setItem(SPOT_ONBOARDING, 'true') + } } - }, [isLoggedIn]); + }, [isLoggedIn, scopeSetup]); + + useEffect(() => { + if (isLoggedIn && location.pathname.includes('login') && localSpotJwt) { + handleSpotLogin(localSpotJwt); + } + }, [location, isLoggedIn, localSpotJwt]) useEffect(() => { if (siteId && siteId !== lastFetchedSiteIdRef.current) { @@ -153,7 +213,9 @@ const Router: React.FC = (props) => { location.pathname.includes('/assist/') || location.pathname.includes('multiview') || location.pathname.includes('/view-spot/') || - location.pathname.includes('/spots/'); + location.pathname.includes('/spots/') || + location.pathname.includes('/scope-setup') + if (isIframe) { return ( @@ -164,7 +226,7 @@ const Router: React.FC = (props) => { return isLoggedIn ? ( - + @@ -186,14 +248,17 @@ const mapStateToProps = (state: Map) => { 'loading', ]); const sitesLoading = state.getIn(['site', 'fetchListRequest', 'loading']); - + const scopeSetup = state.getIn(['user', 'scopeSetup']) + const loading = Boolean(userInfoLoading) || Boolean(sitesLoading) return { siteId, changePassword, sites: state.getIn(['site', 'list']), jwt, + localSpotJwt: state.getIn(['user', 'spotJwt']), isLoggedIn: jwt !== null && !changePassword, - loading: siteId === null || userInfoLoading || sitesLoading, + scopeSetup, + loading, email: state.getIn(['user', 'account', 'email']), account: state.getIn(['user', 'account']), organisation: state.getIn(['user', 'account', 'name']), diff --git a/frontend/app/api_client.ts b/frontend/app/api_client.ts index 7c3e65152..f6bb4dc49 100644 --- a/frontend/app/api_client.ts +++ b/frontend/app/api_client.ts @@ -201,11 +201,11 @@ export default class APIClient { const data = await response.json(); const refreshedJwt = data.jwt; - store.dispatch(setJwt(refreshedJwt)); + store.dispatch(setJwt({ jwt: refreshedJwt, })); return refreshedJwt; } catch (error) { console.error('Error refreshing token:', error); - store.dispatch(setJwt(null)); + store.dispatch(setJwt({ jwt: null })); throw error; } } diff --git a/frontend/app/assets/img/chrome.svg b/frontend/app/assets/img/chrome.svg new file mode 100644 index 000000000..45c46e8f3 --- /dev/null +++ b/frontend/app/assets/img/chrome.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/app/assets/img/chromeStore.svg b/frontend/app/assets/img/chromeStore.svg new file mode 100644 index 000000000..ee9b6df42 --- /dev/null +++ b/frontend/app/assets/img/chromeStore.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/frontend/app/assets/img/init-or.png b/frontend/app/assets/img/init-or.png new file mode 100644 index 000000000..a2b14bb9f Binary files /dev/null and b/frontend/app/assets/img/init-or.png differ diff --git a/frontend/app/assets/img/spot1.svg b/frontend/app/assets/img/spot1.svg new file mode 100644 index 000000000..244e64a9f --- /dev/null +++ b/frontend/app/assets/img/spot1.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/assets/img/spot2.svg b/frontend/app/assets/img/spot2.svg new file mode 100644 index 000000000..2fa9a1b29 --- /dev/null +++ b/frontend/app/assets/img/spot2.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/assets/img/spotThumbBg.svg b/frontend/app/assets/img/spotThumbBg.svg new file mode 100644 index 000000000..b347b90c0 --- /dev/null +++ b/frontend/app/assets/img/spotThumbBg.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/assets/img/videoProcessing.svg b/frontend/app/assets/img/videoProcessing.svg new file mode 100644 index 000000000..86557a928 --- /dev/null +++ b/frontend/app/assets/img/videoProcessing.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/app/components/Login/Login.tsx b/frontend/app/components/Login/Login.tsx index a9362e533..7a9a1ee36 100644 --- a/frontend/app/components/Login/Login.tsx +++ b/frontend/app/components/Login/Login.tsx @@ -9,8 +9,14 @@ import { toast } from 'react-toastify'; import { ENTERPRISE_REQUEIRED } from 'App/constants'; import { useStore } from 'App/mstore'; -import { forgotPassword, signup } from 'App/routes'; -import { fetchTenants, loginSuccess, setJwt } from 'Duck/user'; +import { forgotPassword, signup, spotsList } from 'App/routes'; +import { + fetchTenants, + loadingLogin, + loginFailure, + loginSuccess, + setJwt, +} from 'Duck/user'; import { Button, Form, Icon, Input, Link, Loader, Tooltip } from 'UI'; import Copyright from 'Shared/Copyright'; @@ -27,6 +33,8 @@ interface LoginProps { loginSuccess: typeof loginSuccess; setJwt: typeof setJwt; fetchTenants: typeof fetchTenants; + loadingLogin: typeof loadingLogin; + loginFailure: typeof loginFailure; location: Location; } @@ -38,6 +46,8 @@ const Login: React.FC = ({ setJwt, fetchTenants, location, + loadingLogin, + loginFailure, }) => { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); @@ -60,8 +70,12 @@ const Login: React.FC = ({ useEffect(() => { fetchTenants(); const jwt = params.get('jwt'); + const spotJwt = params.get('spotJwt'); + if (spotJwt) { + handleSpotLogin(spotJwt); + } if (jwt) { - setJwt(jwt); + setJwt({ jwt, spotJwt }); } }, []); @@ -99,18 +113,27 @@ const Login: React.FC = ({ }; const handleSubmit = (token?: string) => { + if (!email || !password) { + return; + } + loadingLogin(); loginStore.setEmail(email.trim()); loginStore.setPassword(password); if (token) { loginStore.setCaptchaResponse(token); } - loginStore.generateJWT().then((resp) => { - if (resp) { - handleSpotLogin(resp.spotJwt); - } - loginSuccess(resp) - setJwt(resp.jwt) - }) + loginStore + .generateJWT() + .then((resp) => { + if (resp) { + loginSuccess({ ...resp, spotJwt: resp.spotJwt ?? null }); + setJwt({ jwt: resp.jwt, spotJwt: resp.spotJwt ?? null }); + handleSpotLogin(resp.spotJwt); + } + }) + .catch((e) => { + loginFailure(e); + }); }; const onSubmit = (e: React.FormEvent) => { @@ -122,14 +145,10 @@ const Login: React.FC = ({ } }; - const onSSOClick = () => { - if (window !== window.top) { - // if in iframe - window.parent.location.href = `${window.location.origin}/api/sso/saml2?iFrame=true`; - } else { - window.location.href = `${window.location.origin}/api/sso/saml2`; - } - }; + const ssoLink = + window !== window.top + ? `${window.location.origin}/api/sso/saml2?iFrame=true&spot=true` + : `${window.location.origin}/api/sso/saml2?spot=true`; return (
@@ -223,7 +242,7 @@ const Login: React.FC = ({
{authDetails.sso ? ( - + @@ -294,6 +313,8 @@ const mapDispatchToProps = { loginSuccess, setJwt, fetchTenants, + loadingLogin, + loginFailure, }; export default withPageTitle('Login - OpenReplay')( diff --git a/frontend/app/components/ScopeForm/ScopeForm.tsx b/frontend/app/components/ScopeForm/ScopeForm.tsx new file mode 100644 index 000000000..6e762ea94 --- /dev/null +++ b/frontend/app/components/ScopeForm/ScopeForm.tsx @@ -0,0 +1,85 @@ +import { ArrowRightOutlined } from '@ant-design/icons'; +import { Button, Card, Radio } from 'antd'; +import React from 'react'; +import { connect } from 'react-redux'; +import { upgradeScope, downgradeScope } from "App/duck/user"; +import { useHistory } from 'react-router-dom'; +import * as routes from 'App/routes' +import { SPOT_ONBOARDING } from "../../constants/storageKeys"; + +const Scope = { + FULL: 'full', + SPOT: 'spot', +}; + +function ScopeForm({ + upgradeScope, + downgradeScope, +}: any) { + const [scope, setScope] = React.useState(Scope.FULL); + React.useEffect(() => { + const isSpotSetup = localStorage.getItem(SPOT_ONBOARDING) + if (isSpotSetup) { + setScope(Scope.SPOT) + localStorage.removeItem(SPOT_ONBOARDING) + } + }, []) + const history = useHistory(); + const onContinue = () => { + if (scope === Scope.FULL) { + upgradeScope(); + history.replace(routes.onboarding()) + } else { + downgradeScope(); + history.replace(routes.spotsList()) + } + }; + return ( +
+ +
+ How will you primarily use OpenReplay?{' '} +
+
+
+ You will have access to all OpenReplay features regardless of your + choice. +
+
+ Your preference will simply help us tailor your onboarding experience. +
+
+ setScope(e.target.value)} + className={'flex flex-col gap-2 mt-4 '} + > + + Session Replay & Debugging, Customer Support and more + + Report bugs via Spot + + +
+ +
+
+
+ ); +} + +export default connect(null, { upgradeScope, downgradeScope })(ScopeForm); diff --git a/frontend/app/components/ScopeForm/index.ts b/frontend/app/components/ScopeForm/index.ts new file mode 100644 index 000000000..89021071e --- /dev/null +++ b/frontend/app/components/ScopeForm/index.ts @@ -0,0 +1 @@ +export { default } from './ScopeForm'; \ No newline at end of file diff --git a/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx b/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx index 8f3b73157..837dd94ee 100644 --- a/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx +++ b/frontend/app/components/Session/Player/LivePlayer/LivePlayerSubHeader.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Icon, Tooltip } from 'UI'; +import {Link2} from 'lucide-react'; import { PlayerContext } from 'App/components/Session/playerContext'; import { observer } from 'mobx-react-lite'; import SessionTabs from 'Components/Session/Player/SharedComponents/SessionTabs'; @@ -19,8 +20,8 @@ function SubHeader() { {location && (
- - + + {location} diff --git a/frontend/app/components/Session_/BottomBlock/Header.js b/frontend/app/components/Session_/BottomBlock/Header.js index 4812305b7..05b361bee 100644 --- a/frontend/app/components/Session_/BottomBlock/Header.js +++ b/frontend/app/components/Session_/BottomBlock/Header.js @@ -12,12 +12,13 @@ const Header = ({ onFilterChange, showClose = true, customStyle, + customClose, ...props }) => (
{ children }
- { showClose && } + { showClose && }
); diff --git a/frontend/app/components/Session_/Issues/IssueCommentForm.js b/frontend/app/components/Session_/Issues/IssueCommentForm.js index ad11efd1a..ac688205b 100644 --- a/frontend/app/components/Session_/Issues/IssueCommentForm.js +++ b/frontend/app/components/Session_/Issues/IssueCommentForm.js @@ -6,7 +6,11 @@ import { addMessage } from 'Duck/assignments'; class IssueCommentForm extends React.PureComponent { state = { comment: '' } - write = ({ target: { name, value } }) => this.setState({ comment: value }); + write = (e) => { + e.stopPropagation(); + const { target: { name, value } } = e + this.setState({ comment: value }); + } addComment = () => { const { comment } = this.state; diff --git a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx index 42aa27542..d3fade552 100644 --- a/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx +++ b/frontend/app/components/Session_/OverviewPanel/OverviewPanel.tsx @@ -23,7 +23,7 @@ import FeatureSelection, { import OverviewPanelContainer from './components/OverviewPanelContainer'; import TimelinePointer from './components/TimelinePointer'; import TimelineScale from './components/TimelineScale'; -import VerticalPointerLine from './components/VerticalPointerLine'; +import VerticalPointerLine, { VerticalPointerLineComp } from './components/VerticalPointerLine'; function MobileOverviewPanelCont({ issuesList, @@ -210,6 +210,35 @@ function WebOverviewPanelCont({ ); } +export function SpotOverviewPanelCont({ + resourceList, + exceptionsList, + spotTime, + spotEndTime, + onClose, +}: any) { + const selectedFeatures = ['ERRORS', 'NETWORK']; + const fetchPresented = false; // TODO + const endTime = 0; // TODO + const resources = { + NETWORK: resourceList, + ERRORS: exceptionsList, + }; + + return ( + + ); +} + function PanelComponent({ selectedFeatures, endTime, @@ -224,11 +253,15 @@ function PanelComponent({ sessionId, zoomTab, setZoomTab, + isSpot, + spotTime, + spotEndTime, + onClose, }: any) { return ( - +
X-Ray {showSummary ? ( @@ -265,13 +298,15 @@ function PanelComponent({ ) : null}
-
- - -
+ {isSpot ? null : ( +
+ + +
+ )}
{summaryChecked ? : null} @@ -291,7 +326,7 @@ function PanelComponent({
} > - + {isSpot ? : } {selectedFeatures.map((feature: any, index: number) => (
)} - endTime={endTime} + endTime={isSpot ? spotEndTime : endTime} message={HELP_MESSAGE[feature]} /> {isMobile && feature === 'PERFORMANCE' ? ( diff --git a/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx index 5f815efa6..f0dec1d1c 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx +++ b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/VerticalPointerLine.tsx @@ -7,9 +7,13 @@ function VerticalPointerLine() { const { store } = React.useContext(PlayerContext) const { time, endTime } = store.get(); - const scale = 100 / endTime; + return +} +export function VerticalPointerLineComp ({ time, endTime }: { time: number, endTime: number }) { + const scale = 100 / endTime; const left = time * scale; + return ; } diff --git a/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts index 4a75fc048..51c155c2f 100644 --- a/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts +++ b/frontend/app/components/Session_/OverviewPanel/components/VerticalPointerLine/index.ts @@ -1 +1 @@ -export { default } from './VerticalPointerLine' \ No newline at end of file +export { default, VerticalPointerLineComp } from './VerticalPointerLine' \ No newline at end of file diff --git a/frontend/app/components/Session_/Subheader.js b/frontend/app/components/Session_/Subheader.js index 7fdb149be..a7fa9a45f 100644 --- a/frontend/app/components/Session_/Subheader.js +++ b/frontend/app/components/Session_/Subheader.js @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { useStore } from 'App/mstore'; import KeyboardHelp from 'Components/Session_/Player/Controls/components/KeyboardHelp'; import { Icon } from 'UI'; +import {Link2} from 'lucide-react'; import QueueControls from './QueueControls'; import Bookmark from 'Shared/Bookmark'; import SharePopup from '../shared/SharePopup/SharePopup'; @@ -141,8 +142,8 @@ function SubHeader(props) { {locationTruncated && (
- - + + {locationTruncated} diff --git a/frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx b/frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx index d9698a631..11b445333 100644 --- a/frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx +++ b/frontend/app/components/Spots/SpotPlayer/SpotPlayer.tsx @@ -1,3 +1,4 @@ +import { Button, Card } from 'antd'; import cn from 'classnames'; import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -5,12 +6,15 @@ import { connect } from 'react-redux'; import { useHistory, useParams } from 'react-router-dom'; import { useStore } from 'App/mstore'; -import { EscapeButton, Loader } from 'UI'; +import { EscapeButton, Icon, Loader } from 'UI'; + +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; import { debounceUpdate, getDefaultPanelHeight, } from '../../Session/Player/ReplayPlayer/PlayerInst'; +import { SpotOverviewPanelCont } from '../../Session_/OverviewPanel/OverviewPanel'; import withPermissions from '../../hocs/withPermissions'; import SpotConsole from './components/Panels/SpotConsole'; import SpotNetwork from './components/Panels/SpotNetwork'; @@ -32,6 +36,11 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { const { spotId } = useParams<{ spotId: string }>(); const [activeTab, setActiveTab] = React.useState(null); + React.useEffect(() => { + if (spotStore.currentSpot) { + document.title = spotStore.currentSpot.title + ' - OpenReplay' + } + }, [spotStore.currentSpot]) React.useEffect(() => { if (!loggedIn) { const query = new URLSearchParams(window.location.search); @@ -98,6 +107,12 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { }); const ev = (e: KeyboardEvent) => { + if ( + e.target instanceof HTMLInputElement || + e.target instanceof HTMLTextAreaElement + ) { + return false; + } if (e.key === 'Escape') { spotPlayerStore.setIsFullScreen(false); } @@ -116,6 +131,19 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { const highest = 16; spotPlayerStore.setPlaybackRate(Math.min(highest, current * 2)); } + if (e.key === 'ArrowRight') { + spotPlayerStore.setTime( + Math.min( + spotPlayerStore.duration, + spotPlayerStore.time + spotPlayerStore.skipInterval + ) + ); + } + if (e.key === 'ArrowLeft') { + spotPlayerStore.setTime( + Math.max(0, spotPlayerStore.time - spotPlayerStore.skipInterval) + ); + } }; document.addEventListener('keydown', ev); @@ -127,8 +155,47 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { }, []); if (!spotStore.currentSpot) { return ( -
- +
+ {spotStore.accessError ? ( + <> +
+ + +
+ The Spot link has expired. +
+

+ Contact the person who shared it to re-spot. +

+
+
+ +
+
+ + ) : ( + + )}
); } @@ -166,7 +233,6 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { // }] // }; - console.log(spotStore.currentSpot) return (
spotStore.checkIsProcessed(spotId)} />
{!isFullScreen && spotPlayerStore.activePanel ? ( @@ -227,6 +294,9 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { panelHeight={panelHeight} /> ) : null} + {spotPlayerStore.activePanel === PANELS.OVERVIEW ? ( + + ) : null}
) : null}
@@ -244,6 +314,30 @@ function SpotPlayer({ loggedIn }: { loggedIn: boolean }) { ); } +const SpotOverviewConnector = observer(() => { + const endTime = spotPlayerStore.duration * 1000; + const time = spotPlayerStore.time * 1000; + const resourceList = spotPlayerStore.network + .filter((r: any) => r.isRed || r.isYellow || (r.status && r.status >= 400)) + .filter((i: any) => i.type === 'xhr'); + const exceptionsList = spotPlayerStore.logs.filter( + (l) => l.level === 'error' + ); + + const onClose = () => { + spotPlayerStore.setActivePanel(null); + } + return ( + + ); +}); + function mapStateToProps(state: any) { const userEmail = state.getIn(['user', 'account', 'name']); const loggedIn = !!userEmail; diff --git a/frontend/app/components/Spots/SpotPlayer/components/AccessModal.tsx b/frontend/app/components/Spots/SpotPlayer/components/AccessModal.tsx index af32ce6ed..551c2848c 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/AccessModal.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/AccessModal.tsx @@ -1,15 +1,10 @@ -import { DownOutlined, LinkOutlined, StopOutlined } from '@ant-design/icons'; -import { Button, Dropdown, Segmented } from 'antd'; +import { DownOutlined, CopyOutlined, StopOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Menu, Segmented, Modal } from 'antd'; import copy from 'copy-to-clipboard'; -import React from 'react'; - +import React, { useState } from 'react'; +import { observer } from 'mobx-react-lite'; import { useStore } from 'App/mstore'; -import { confirm } from 'UI'; -import { durationFormatted } from "../../../../date"; - -const HOUR_SECS = 60 * 60; -const DAY_SECS = 24 * HOUR_SECS; -const WEEK_SECS = 7 * DAY_SECS; +import { formatExpirationTime, HOUR_SECS, DAY_SECS, WEEK_SECS } from 'App/utils/index'; enum Intervals { hour, @@ -20,9 +15,11 @@ enum Intervals { function AccessModal() { const { spotStore } = useStore(); - const [isCopied, setIsCopied] = React.useState(false); - const [isPublic, setIsPublic] = React.useState(!!spotStore.pubKey); - const [generated, setGenerated] = React.useState(!!spotStore.pubKey); + const [isCopied, setIsCopied] = useState(false); + const [isPublic, setIsPublic] = useState(!!spotStore.pubKey); + const [generated, setGenerated] = useState(!!spotStore.pubKey); + const [selectedInterval, setSelectedInterval] = useState(Intervals.hour); + const [loadingKey, setLoadingKey] = useState(false); const expirationValues = { [Intervals.hour]: HOUR_SECS, @@ -37,68 +34,52 @@ function AccessModal() { const menuItems = [ { - key: Intervals.hour, - label:
One Hour
, + key: Intervals.hour.toString(), + label:
1 Hour
, }, { - key: Intervals.threeHours, - label:
Three Hours
, + key: Intervals.threeHours.toString(), + label:
3 Hours
, }, { - key: Intervals.day, - label:
One Day
, + key: Intervals.day.toString(), + label:
1 Day
, }, { - key: Intervals.week, - label:
One Week
, + key: Intervals.week.toString(), + label:
1 Week
, }, ]; - const onMenuClick = ({ key }: { key: Intervals }) => { - const val = expirationValues[key]; - if ( - spotStore.pubKey?.expiration && - Math.abs(spotStore.pubKey?.expiration - val) / val < 0.1 - ) { - return; - } - void spotStore.generateKey(spotId, val); + + const onMenuClick = async (info: { key: string }) => { + const val = expirationValues[Number(info.key) as Intervals]; + setSelectedInterval(Number(info.key) as Intervals); + await spotStore.generateKey(spotId, val); }; const changeAccess = async (toPublic: boolean) => { if (isPublic && !toPublic && spotStore.pubKey) { - if ( - await confirm({ - header: 'Confirm', - confirmButton: 'Disable', - confirmation: - 'Are you sure you want to disable public sharing for this spot?', - }) - ) { - void spotStore.generateKey(spotId, 0); - } + await spotStore.generateKey(spotId, 0); + setIsPublic(toPublic); + } else { + setIsPublic(toPublic); } - setIsPublic(toPublic); }; + const revokeKey = async () => { - if ( - await confirm({ - header: 'Confirm', - confirmButton: 'Disable', - confirmation: - 'Are you sure you want to disable public sharing for this spot?', - }) - ) { - void spotStore.generateKey(spotId, 0); - setGenerated(false); - setIsPublic(false); - } + await spotStore.generateKey(spotId, 0); + setGenerated(false); + setIsPublic(false); }; + const generateInitial = async () => { + setLoadingKey(true); const k = await spotStore.generateKey( spotId, expirationValues[Intervals.hour] ); setGenerated(!!k); + setLoadingKey(false); }; const onCopy = () => { @@ -108,12 +89,8 @@ function AccessModal() { }; return ( -
+
-
Who can access this Spot
-
- All team members in your project will able to view this Spot +
+ Link for internal team members
-
+
{spotLink}
@@ -143,56 +120,60 @@ function AccessModal() {
) : !generated ? ( -
+
) : ( <> -
-
Anyone with following link will be able to view this spot
-
- {spotLink} -
-
-
-
Link expires in
- -
- {spotStore.isLoading ? 'Loading' : durationFormatted(spotStore.pubKey!.expiration * 1000)} - +
+
+
Anyone with the following link can access this Spot
+
+ {spotLink}
- -
-
-
-
+ +
+
Link expires in
+ }> +
+ {loadingKey ? 'Loading' : formatExpirationTime(expirationValues[selectedInterval])} + +
+
+
+
+
+ +
+
-
)} @@ -200,4 +181,4 @@ function AccessModal() { ); } -export default AccessModal; +export default observer(AccessModal); diff --git a/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx b/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx index 5dc336251..1cc924407 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/CommentsSection.tsx @@ -1,109 +1,143 @@ +import { CloseOutlined } from '@ant-design/icons'; +import { SendOutlined } from '@ant-design/icons'; import { Button, Input, Tooltip } from 'antd'; import cn from 'classnames'; -import { X } from 'lucide-react'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { connect } from 'react-redux'; +import { toast } from 'react-toastify'; + import { resentOrDate } from 'App/date'; import { useStore } from 'App/mstore'; -import { observer } from 'mobx-react-lite'; -import { SendOutlined } from '@ant-design/icons'; -function CommentsSection({ - onClose, -}: { - onClose?: () => void; -}) { +function CommentsSection({ onClose }: { onClose?: () => void }) { const { spotStore } = useStore(); const comments = spotStore.currentSpot?.comments ?? []; return (
-
-
Comments
-
- -
+
+
Comments
+
{comments.map((comment) => ( -
+
{comment.user[0]}
-
{comment.user}
+
+ {comment.user} +
+ {resentOrDate(new Date(comment.createdAt).getTime())} +
+
{comment.text}
-
- {resentOrDate(new Date(comment.createdAt).getTime())} -
))} - 5} /> + 5} + loggedLimit={comments.length > 25} + />
); } -function BottomSection({ loggedIn, userEmail, disableComments }: { disableComments: boolean, loggedIn?: boolean, userEmail?: string }) { +function BottomSection({ + loggedIn, + userEmail, + unloggedLimit, + loggedLimit, +}: { + loggedLimit: boolean; + unloggedLimit: boolean; + loggedIn?: boolean; + userEmail?: string; +}) { const [commentText, setCommentText] = React.useState(''); const [userName, setUserName] = React.useState(userEmail ?? ''); const { spotStore } = useStore(); const addComment = async () => { - await spotStore.addComment( - spotStore.currentSpot!.spotId, - commentText, - userName - ); - setCommentText(''); + try { + await spotStore.addComment( + spotStore.currentSpot!.spotId, + commentText, + userName + ); + setCommentText(''); + } catch (e) { + toast.error('Failed to add comment; Try again later'); + } }; - const disableSubmit = commentText.trim().length === 0 || userName.trim().length === 0 || disableComments + const unlogged = userName.trim().length === 0 && unloggedLimit + const disableSubmit = + commentText.trim().length === 0 || + unlogged || loggedLimit; return (
-
- setUserName(e.target.value)} - /> - setCommentText(e.target.value)} - /> -
- +
+ setUserName(e.target.value)} + /> + { + e.preventDefault(); + e.stopPropagation(); + setCommentText(e.target.value); + }} + placeholder="Add a comment..." + /> +
+
diff --git a/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx index 2bbf6aa49..b5cec283c 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotConsole.tsx @@ -16,8 +16,10 @@ import spotPlayerStore from '../../spotPlayerStore'; function SpotConsole({ onClose }: { onClose: () => void }) { const [activeTab, setActiveTab] = React.useState(TABS[0]); const _list = React.useRef(null); - const onTabClick = (tab: any) => { - setActiveTab(tab); + + const onTabClick = (tab: string) => { + const newTab = TABS.find((t) => t.text === tab); + setActiveTab(newTab); }; const logs = spotPlayerStore.logs; const filteredList = React.useMemo(() => { @@ -29,7 +31,7 @@ function SpotConsole({ onClose }: { onClose: () => void }) { }, [activeTab]); const jump = (t: number) => { - spotPlayerStore.setTime(t); + spotPlayerStore.setTime(t / 1000); }; return ( @@ -48,7 +50,7 @@ function SpotConsole({ onClose }: { onClose: () => void }) { +
No Data
diff --git a/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx index 0a5bf49f5..0f346999c 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/Panels/SpotNetwork.tsx @@ -26,7 +26,7 @@ function SpotNetwork({ panelHeight, onClose }: { panelHeight: number, onClose: ( websocketListNow={[]} /* @ts-ignore */ player={{ jump: (t) => spotPlayerStore.setTime(t) }} - activeIndex={index} + activeOutsideIndex={index} onClose={onClose} /> ); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx index 3f1f4689b..bd61eee10 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotActivity.tsx @@ -1,5 +1,6 @@ +import { CloseOutlined } from '@ant-design/icons'; import { TYPES } from 'Types/session/event'; -import { X } from 'lucide-react'; +import { Button } from 'antd'; import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -28,14 +29,14 @@ function SpotActivity({ onClose }: { onClose: () => void }) { }; return (
-
Activity
-
- -
+
Activity
+
170 ? `${currUrl.slice(0, 170)}...` : currUrl; return (
diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx index 55fab6655..771678aae 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerControls.tsx @@ -94,6 +94,11 @@ function SpotPlayerControls() {
+ togglePanel(PANELS.OVERVIEW)} + active={spotPlayerStore.activePanel === PANELS.OVERVIEW} + /> togglePanel(PANELS.CONSOLE)} diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx index 3c6537760..47c5e49e6 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotPlayerHeader.tsx @@ -1,23 +1,24 @@ -import { - ArrowLeftOutlined, - CommentOutlined, - LinkOutlined, - SettingOutlined, - UserSwitchOutlined, -} from '@ant-design/icons'; -import { Button, Popover } from 'antd'; +import { ArrowLeftOutlined, CommentOutlined, CopyOutlined, DeleteOutlined, DownloadOutlined, MoreOutlined, SettingOutlined, UserSwitchOutlined } from '@ant-design/icons'; +import { Badge, Button, Dropdown, MenuProps, Popover, Tooltip, message } from 'antd'; import copy from 'copy-to-clipboard'; -import React from 'react'; +import { observer } from 'mobx-react-lite'; +import React, { useState } from 'react'; import { connect } from 'react-redux'; -import { Link } from 'react-router-dom'; +import { useHistory } from 'react-router-dom'; + + +import { useStore } from 'App/mstore'; import { spotsList } from 'App/routes'; import { hashString } from 'App/types/session/session'; import { Avatar, Icon } from 'UI'; + + import { TABS, Tab } from '../consts'; import AccessModal from './AccessModal'; + const spotLink = spotsList(); function SpotPlayerHeader({ @@ -43,53 +44,96 @@ function SpotPlayerHeader({ platform: string | null; hasShareAccess: boolean; }) { - const [isCopied, setIsCopied] = React.useState(false); - const [dropdownOpen, setDropdownOpen] = React.useState(false); + const { spotStore } = useStore(); + const comments = spotStore.currentSpot?.comments ?? []; + + const [dropdownOpen, setDropdownOpen] = useState(false); + const history = useHistory(); + const onCopy = () => { - setIsCopied(true); copy(window.location.href); - setTimeout(() => setIsCopied(false), 2000); + message.success('Internal sharing link copied to clipboard'); }; + + const navigateToSpotsList = () => { + history.push(spotLink); + }; + + const items: MenuProps['items'] = [ + { + key: '1', + icon: , + label: 'Download Video', + }, + { + key: '2', + icon: , + label: 'Delete', + }, + ]; + + const onMenuClick = async ({ key }: { key: string }) => { + if (key === '1') { + const { url } = await spotStore.getVideo(spotStore.currentSpot!.spotId); + await downloadFile(url, `${spotStore.currentSpot!.title}.webm`) + } else if (key === '2') { + spotStore.deleteSpot([spotStore.currentSpot!.spotId]).then(() => { + history.push(spotsList()); + message.success('Spot deleted successfully'); + }); + } + }; + return (
{isLoggedIn ? ( - -
- -
All Spots
-
- + ) : ( <> -
- -
Spot
-
-
by OpenReplay
+ + + )}
-
+
-
{title}
-
+ +
+ {title} +
+
+
{user}
·
-
{date}
+
{date}
{browserVersion && ( <>
·
-
Chrome v{browserVersion}
+
Chrome v{browserVersion}
)} {resolution && ( @@ -101,7 +145,7 @@ function SpotPlayerHeader({ {platform && ( <>
·
-
{platform}
+
{platform}
)}
@@ -113,24 +157,30 @@ function SpotPlayerHeader({ {hasShareAccess ? ( - }> + }> ) : null} + + +
@@ -143,18 +193,44 @@ function SpotPlayerHeader({ > Activity +
); } +async function downloadFile(url: string, fileName: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + + const blob = await response.blob(); + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = fileName; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(blobUrl); + } catch (error) { + console.error('Error downloading file:', error); + } +} + export default connect((state: any) => { const jwt = state.getIn(['user', 'jwt']); const isEE = state.getIn(['user', 'account', 'edition']) === 'ee'; @@ -163,4 +239,4 @@ export default connect((state: any) => { const hasShareAccess = isEE ? permissions.includes('SPOT_PUBLIC') : true; return { isLoggedIn: !!jwt, hasShareAccess }; -})(SpotPlayerHeader); +})(observer(SpotPlayerHeader)); diff --git a/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx b/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx index 2b44f4eb4..2d03c204c 100644 --- a/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx +++ b/frontend/app/components/Spots/SpotPlayer/components/SpotVideoContainer.tsx @@ -1,3 +1,4 @@ +import Hls from 'hls.js'; import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -19,84 +20,109 @@ function SpotVideoContainer({ videoURL, streamFile, thumbnail, + checkReady, }: { videoURL: string; streamFile?: string; thumbnail?: string; + checkReady: () => Promise; }) { - const [videoLink, setVideoLink] = React.useState(videoURL); - - const { spotStore } = useStore(); + const [prevIsProcessing, setPrevIsProcessing] = React.useState(false); + const [isProcessing, setIsProcessing] = React.useState(false); const [isLoaded, setLoaded] = React.useState(false); const videoRef = React.useRef(null); const playbackTime = React.useRef(0); const hlsRef = React.useRef(null); React.useEffect(() => { - import('hls.js').then(({ default: Hls }) => { - if (Hls.isSupported() && videoRef.current) { - videoRef.current.addEventListener('loadeddata', () => { - setLoaded(true); - }); - if (streamFile) { - const hls = new Hls({ - enableWorker: false, - // workerPath: '/hls-worker.js', - // 1MB buffer -- we have small videos anyways - maxBufferSize: 1000 * 1000, + const startPlaying = () => { + if (spotPlayerStore.isPlaying && videoRef.current) { + videoRef.current + .play() + .then(() => { + console.debug('playing'); + }) + .catch((e) => { + console.error(e); + spotPlayerStore.setIsPlaying(false); + const onClick = () => { + spotPlayerStore.setIsPlaying(true); + document.removeEventListener('click', onClick); + }; + document.addEventListener('click', onClick); }); - const url = URL.createObjectURL(base64toblob(streamFile)); - if (url && videoRef.current) { - hls.loadSource(url); - hls.attachMedia(videoRef.current); - if (spotPlayerStore.isPlaying) { - void videoRef.current.play(); + } + }; + checkReady().then((isReady) => { + if (!isReady) { + setIsProcessing(true); + setPrevIsProcessing(true); + const int = setInterval(() => { + checkReady().then((r) => { + if (r) { + setIsProcessing(false); + clearInterval(int); } - hlsRef.current = hls; - } else { - if (videoRef.current) { - videoRef.current.src = videoURL; - if (spotPlayerStore.isPlaying) { - void videoRef.current.play(); - } - } - } - } else { - const check = () => { - fetch(videoLink).then((r) => { - if (r.ok && r.status === 200) { - if (videoRef.current) { - videoRef.current.src = ''; - setTimeout(() => { - videoRef.current!.src = videoURL; - }, 0); - } - - return true; - } else { - setTimeout(() => { - check(); - }, 1000); - } - }); - }; - check(); - videoRef.current.src = videoURL; - if (spotPlayerStore.isPlaying) { - void videoRef.current.play(); - } - } - } else { - if (videoRef.current) { + }); + }, 5000) + } + import('hls.js').then(({ default: Hls }) => { + if (Hls.isSupported() && videoRef.current) { videoRef.current.addEventListener('loadeddata', () => { setLoaded(true); }); - videoRef.current.src = videoURL; - if (spotPlayerStore.isPlaying) { - void videoRef.current.play(); + if (streamFile) { + const hls = new Hls({ + // not needed for small videos (we have 3 min limit and 720 quality with half kbps) + enableWorker: false, + // = 1MB, should be enough + maxBufferSize: 1000 * 1000, + }); + const url = URL.createObjectURL(base64toblob(streamFile)); + if (url && videoRef.current) { + hls.loadSource(url); + hls.attachMedia(videoRef.current); + startPlaying(); + hlsRef.current = hls; + } else { + if (videoRef.current) { + videoRef.current.src = videoURL; + startPlaying(); + } + } + } else { + const check = () => { + fetch(videoURL).then((r) => { + if (r.ok && r.status === 200) { + if (videoRef.current) { + videoRef.current.src = ''; + setTimeout(() => { + videoRef.current!.src = videoURL; + }, 0); + } + + return true; + } else { + setTimeout(() => { + check(); + }, 1000); + } + }); + }; + check(); + videoRef.current.src = videoURL; + startPlaying(); + } + } else { + if (videoRef.current) { + videoRef.current.addEventListener('loadeddata', () => { + setLoaded(true); + }); + videoRef.current.src = videoURL; + startPlaying(); } } - } + }); }); return () => { hlsRef.current?.destroy(); @@ -143,29 +169,46 @@ function SpotVideoContainer({ videoRef.current.playbackRate = spotPlayerStore.playbackRate; } }, [spotPlayerStore.playbackRate]); + + const warnText = isProcessing ? 'You’re viewing the entire recording. The trimmed Spot is on its way.' : 'Your trimmed Spot is ready! Please reload the page.' return ( <> + {isProcessing || prevIsProcessing + ?
+ {warnText} +
+ : null} + {!isLoaded && ( +
+ {'Processing +
Loading Spot Recording
+
+ )}