diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js index 1f85d5af9..33f7ffe66 100644 --- a/frontend/app/api_client.js +++ b/frontend/app/api_client.js @@ -25,6 +25,7 @@ const siteIdRequiredPaths = [ '/custom_metrics', '/dashboards', '/metrics', + '/unprocessed', // '/custom_metrics/sessions', ]; diff --git a/frontend/app/components/Session/LivePlayer.js b/frontend/app/components/Session/LivePlayer.js index b26e80923..c142167f0 100644 --- a/frontend/app/components/Session/LivePlayer.js +++ b/frontend/app/components/Session/LivePlayer.js @@ -57,7 +57,7 @@ function LivePlayer ({ return ( - +
diff --git a/frontend/app/components/Session/Session.js b/frontend/app/components/Session/Session.js index 2d9bfa882..d6bf31a53 100644 --- a/frontend/app/components/Session/Session.js +++ b/frontend/app/components/Session/Session.js @@ -15,10 +15,10 @@ const SESSIONS_ROUTE = sessionsRoute(); function Session({ sessionId, loading, - hasErrors, + hasErrors, session, fetchSession, - fetchSlackList, + fetchSlackList, }) { usePageTitle("OpenReplay Session Player"); const [ initializing, setInitializing ] = useState(true) @@ -63,4 +63,4 @@ export default withPermissions(['SESSION_REPLAY'], '', true)(connect((state, pro }, { fetchSession, fetchSlackList, -})(Session)); \ No newline at end of file +})(Session)); diff --git a/frontend/app/components/Session_/Player/Controls/Circle.tsx b/frontend/app/components/Session_/Player/Controls/Circle.tsx index 274b38f8a..73e1e1bb1 100644 --- a/frontend/app/components/Session_/Player/Controls/Circle.tsx +++ b/frontend/app/components/Session_/Player/Controls/Circle.tsx @@ -1,16 +1,18 @@ import React, { memo, FC } from 'react'; import styles from './timeline.module.css'; +import cn from 'classnames'; interface Props { preview?: boolean; + isGreen?: boolean; } -export const Circle: FC = memo(function Box({ preview }) { +export const Circle: FC = memo(function Box({ preview, isGreen }) { return (
) }) -export default Circle; \ No newline at end of file +export default Circle; diff --git a/frontend/app/components/Session_/Player/Controls/Controls.js b/frontend/app/components/Session_/Player/Controls/Controls.js index e92099393..73371c6e1 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.js +++ b/frontend/app/components/Session_/Player/Controls/Controls.js @@ -8,6 +8,11 @@ import { selectStorageListNow, } from 'Player/store'; import LiveTag from 'Shared/LiveTag'; +import { session as sessionRoute, withSiteId } from 'App/routes'; +import { + toggleTimetravel, + jumpToLive, +} from 'Player'; import { Icon } from 'UI'; import { toggleInspectorMode } from 'Player'; @@ -26,9 +31,10 @@ import { EXCEPTIONS, INSPECTOR, } from 'Duck/components/player'; -import { ReduxTime } from './Time'; +import { ReduxTime, AssistDuration } from './Time'; import Timeline from './Timeline'; import ControlButton from './ControlButton'; +import PlayerControls from './components/PlayerControls' import styles from './controls.module.css'; import { Tooltip } from 'react-tippy'; @@ -79,7 +85,6 @@ function getStorageName(type) { fullscreenDisabled: state.messagesLoading, logCount: state.logListNow.length, logRedCount: state.logRedCountNow, - // resourceCount: state.resourceCountNow, resourceRedCount: state.resourceRedCountNow, fetchRedCount: state.fetchRedCountNow, showStack: state.stackList.length > 0, @@ -97,6 +102,7 @@ function getStorageName(type) { exceptionsCount: state.exceptionsListNow.length, showExceptions: state.exceptionsList.length > 0, showLongtasks: state.longtasksList.length > 0, + liveTimeTravel: state.liveTimeTravel, })) @connect((state, props) => { const permissions = state.getIn([ 'user', 'account', 'permissions' ]) || []; @@ -129,7 +135,6 @@ export default class Controls extends React.Component { if ( nextProps.fullscreen !== this.props.fullscreen || nextProps.bottomBlock !== this.props.bottomBlock || - nextProps.endTime !== this.props.endTime || nextProps.live !== this.props.live || nextProps.livePlay !== this.props.livePlay || nextProps.playing !== this.props.playing || @@ -158,7 +163,8 @@ export default class Controls extends React.Component { nextProps.graphqlCount !== this.props.graphqlCount || nextProps.showExceptions !== this.props.showExceptions || nextProps.exceptionsCount !== this.props.exceptionsCount || - nextProps.showLongtasks !== this.props.showLongtasks + nextProps.showLongtasks !== this.props.showLongtasks || + nextProps.liveTimeTravel !== this.props.liveTimeTravel ) return true; return false; } @@ -206,7 +212,7 @@ export default class Controls extends React.Component { goLive =() => this.props.jump(this.props.endTime) renderPlayBtn = () => { - const { completed, playing, disabled } = this.props; + const { completed, playing } = this.props; let label; let icon; if (completed) { @@ -279,6 +285,9 @@ export default class Controls extends React.Component { fullscreen, inspectorMode, closedLive, + toggleSpeed, + toggleSkip, + liveTimeTravel, } = this.props; const toggleBottomTools = (blockName) => { @@ -290,75 +299,38 @@ export default class Controls extends React.Component { toggleBottomBlock(blockName); } } + return ( -
- { !live && } +
+ { !live || liveTimeTravel ? : null} { !fullscreen && -
+
- { !live && ( -
- { this.renderPlayBtn() } - { !live && ( -
- - / - -
- )} - -
- - {this.controlIcon("skip-forward-fill", 18, this.backTenSeconds, true, 'hover:bg-active-blue-border color-main h-full flex items-center')} - -
10s
- - {this.controlIcon("skip-forward-fill", 18, this.forthTenSeconds, false, 'hover:bg-active-blue-border color-main h-full flex items-center')} - -
- - {!live && -
- - - - - -
- } -
+ {!live && ( + )} { live && !closedLive && (
- - {'Elapsed'} - + livePlay ? null : jumpToLive()} /> +
+ + {!liveTimeTravel && ( +
+ See Past Activity +
+ )}
)}
diff --git a/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx b/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx index c72f03ce2..200c1c79f 100644 --- a/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx +++ b/frontend/app/components/Session_/Player/Controls/CustomDragLayer.tsx @@ -95,4 +95,4 @@ const CustomDragLayer: FC = memo(function CustomDragLayer(props) { ); }) -export default CustomDragLayer; \ No newline at end of file +export default CustomDragLayer; diff --git a/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx b/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx index 385707879..0e1ee85a7 100644 --- a/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx +++ b/frontend/app/components/Session_/Player/Controls/DraggableCircle.tsx @@ -9,10 +9,12 @@ function getStyles( isDragging: boolean, ): CSSProperties { // const transform = `translate3d(${(left * 1161) / 100}px, -8px, 0)` + const leftPosition = left > 100 ? 100 : left + return { position: 'absolute', top: '-3px', - left: `${left}%`, + left: `${leftPosition}%`, // transform, // WebkitTransform: transform, // IE fallback: hide the real node using CSS when dragging @@ -59,9 +61,9 @@ const DraggableCircle: FC = memo(function DraggableCircle(props) { style={getStyles(left, isDragging)} role="DraggableBox" > - + 99} />
); }) -export default DraggableCircle \ No newline at end of file +export default DraggableCircle diff --git a/frontend/app/components/Session_/Player/Controls/Time.js b/frontend/app/components/Session_/Player/Controls/Time.js index b0e95c6f0..ca3c6ce4c 100644 --- a/frontend/app/components/Session_/Player/Controls/Time.js +++ b/frontend/app/components/Session_/Player/Controls/Time.js @@ -2,6 +2,7 @@ import React from 'react'; import { Duration } from 'luxon'; import { connectPlayer } from 'Player'; import styles from './time.module.css'; +import { Tooltip } from 'react-tippy'; const Time = ({ time, isCustom, format = 'm:ss', }) => (
@@ -11,13 +12,37 @@ const Time = ({ time, isCustom, format = 'm:ss', }) => ( Time.displayName = "Time"; - const ReduxTime = connectPlayer((state, { name, format }) => ({ time: state[ name ], format, }))(Time); +const AssistDurationCont = connectPlayer( + state => { + const assistStart = state.assistStart; + return { + assistStart, + } + } +)(({ assistStart }) => { + const [assistDuration, setAssistDuration] = React.useState('00:00'); + React.useEffect(() => { + const interval = setInterval(() => { + setAssistDuration(Duration.fromMillis(+new Date() - assistStart).toFormat('mm:ss')); + } + , 1000); + return () => clearInterval(interval); + }, []) + return ( + <> + Elapsed {assistDuration} + + ) +}) + +const AssistDuration = React.memo(AssistDurationCont); + ReduxTime.displayName = "ReduxTime"; -export default Time; -export { ReduxTime }; +export default React.memo(Time); +export { ReduxTime, AssistDuration }; diff --git a/frontend/app/components/Session_/Player/Controls/TimeTooltip.tsx b/frontend/app/components/Session_/Player/Controls/TimeTooltip.tsx new file mode 100644 index 000000000..fe22c4ea9 --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/TimeTooltip.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +// @ts-ignore +import { Duration } from 'luxon'; +import { connect } from 'react-redux'; +// @ts-ignore +import stl from './timeline.module.css'; + +function TimeTooltip({ time, offset, isVisible, liveTimeTravel }: { time: number; offset: number; isVisible: boolean, liveTimeTravel: boolean }) { + const duration = Duration.fromMillis(time).toFormat(`${liveTimeTravel ? '-' : ''}mm:ss`); + return ( +
+ {!time ? 'Loading' : duration} +
+ ); +} + +export default connect((state) => { + const { time = 0, offset = 0, isVisible } = state.getIn(['sessions', 'timeLineTooltip']); + return { time, offset, isVisible }; +})(TimeTooltip); diff --git a/frontend/app/components/Session_/Player/Controls/Timeline.js b/frontend/app/components/Session_/Player/Controls/Timeline.js index 3acdb4c11..c177c14e0 100644 --- a/frontend/app/components/Session_/Player/Controls/Timeline.js +++ b/frontend/app/components/Session_/Player/Controls/Timeline.js @@ -6,11 +6,12 @@ import { TimelinePointer, Icon } from 'UI'; import TimeTracker from './TimeTracker'; import stl from './timeline.module.css'; import { TYPES } from 'Types/session/event'; -import { setTimelinePointer } from 'Duck/sessions'; +import { setTimelinePointer, setTimelineHoverTime } from 'Duck/sessions'; import DraggableCircle from './DraggableCircle'; import CustomDragLayer from './CustomDragLayer'; import { debounce } from 'App/utils'; import { Tooltip } from 'react-tippy'; +import TooltipContainer from './components/TooltipContainer'; const BOUNDRY = 15 @@ -63,6 +64,7 @@ const getPointerIcon = (type) => { let deboucneJump = () => null; +let debounceTooltipChange = () => null; @connectPlayer(state => ({ playing: state.playing, time: state.time, @@ -86,16 +88,25 @@ let deboucneJump = () => null; state.getIn([ 'sessions', 'current', 'clickRageTime' ]), returningLocationTime: state.getIn([ 'sessions', 'current', 'returningLocation' ]) && state.getIn([ 'sessions', 'current', 'returningLocationTime' ]), -}), { setTimelinePointer }) + tooltipVisible: state.getIn(['sessions', 'timeLineTooltip', 'isVisible']) +}), { setTimelinePointer, setTimelineHoverTime }) export default class Timeline extends React.PureComponent { progressRef = React.createRef() + timelineRef = React.createRef() wasPlaying = false seekProgress = (e) => { + const time = this.getTime(e) + this.props.jump(time); + this.hideTimeTooltip() + } + + getTime = (e) => { const { endTime } = this.props; const p = e.nativeEvent.offsetX / e.target.offsetWidth; const time = Math.max(Math.round(p * endTime), 0); - this.props.jump(time); + + return time } createEventClickHandler = pointer => (e) => { @@ -109,6 +120,7 @@ export default class Timeline extends React.PureComponent { const skipToIssue = Controls.updateSkipToIssue(); const firstIssue = issues.get(0); deboucneJump = debounce(this.props.jump, 500); + debounceTooltipChange = debounce(this.props.setTimelineHoverTime, 50); if (firstIssue && skipToIssue) { this.props.jump(firstIssue.time); @@ -127,12 +139,33 @@ export default class Timeline extends React.PureComponent { const p = (offset.x - BOUNDRY) / this.progressRef.current.offsetWidth; const time = Math.max(Math.round(p * endTime), 0); deboucneJump(time); + this.hideTimeTooltip(); if (this.props.playing) { this.wasPlaying = true; this.props.pause(); } } + showTimeTooltip = (e) => { + if (e.target !== this.progressRef.current && e.target !== this.timelineRef.current) { + return this.props.tooltipVisible && this.hideTimeTooltip() + } + const time = this.getTime(e); + const { endTime, liveTimeTravel } = this.props; + + const timeLineTooltip = { + time: liveTimeTravel ? endTime - time : time, + offset: e.nativeEvent.offsetX, + isVisible: true + } + debounceTooltipChange(timeLineTooltip) + } + + hideTimeTooltip = () => { + const timeLineTooltip = { isVisible: false } + debounceTooltipChange(timeLineTooltip) + } + render() { const { events, @@ -140,14 +173,13 @@ export default class Timeline extends React.PureComponent { skipIntervals, disabled, endTime, - live, - logList, exceptionsList, resourceList, clickRageTime, stackList, fetchList, issues, + liveTimeTravel, } = this.props; const scale = 100 / endTime; @@ -155,17 +187,23 @@ export default class Timeline extends React.PureComponent { return (
+ + {/* custo color is live */} + { skip && skipIntervals.map(interval => (
)) } -
+
+ { events.map(e => (
)) } -
+
); } diff --git a/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx new file mode 100644 index 000000000..be3ac24b3 --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/components/PlayerControls.tsx @@ -0,0 +1,111 @@ +import React from 'react' +import { Tooltip } from 'react-tippy'; +import { ReduxTime } from '../Time'; +import { Icon } from 'UI'; +import cn from 'classnames'; +// @ts-ignore +import styles from '../controls.module.css' + +interface Props { + live: boolean; + skip: boolean; + speed: number; + disabled: boolean; + playButton: JSX.Element; + backTenSeconds: () => void; + forthTenSeconds: () => void; + toggleSpeed: () => void; + toggleSkip: () => void; + controlIcon: (icon: string, size: number, action: () => void, isBackwards: boolean, additionalClasses: string) => JSX.Element; +} + +function PlayerControls(props: Props) { + const { + live, + skip, + speed, + disabled, + playButton, + backTenSeconds, + forthTenSeconds, + toggleSpeed, + toggleSkip, + controlIcon + } = props; + return ( +
+ {playButton} + {!live && ( +
+ {/* @ts-ignore */} + + / + {/* @ts-ignore */} + +
+ )} + +
+ {/* @ts-ignore */} + + {controlIcon( + "skip-forward-fill", + 18, + backTenSeconds, + true, + 'hover:bg-active-blue-border color-main h-full flex items-center' + )} + +
10s
+ {/* @ts-ignore */} + + {controlIcon( + "skip-forward-fill", + 18, + forthTenSeconds, + false, + 'hover:bg-active-blue-border color-main h-full flex items-center' + )} + +
+ + {!live && +
+ {/* @ts-ignore */} + + + + + +
+ } +
+ ) +} + +export default PlayerControls; diff --git a/frontend/app/components/Session_/Player/Controls/components/TooltipContainer.tsx b/frontend/app/components/Session_/Player/Controls/components/TooltipContainer.tsx new file mode 100644 index 000000000..2c90fcc1d --- /dev/null +++ b/frontend/app/components/Session_/Player/Controls/components/TooltipContainer.tsx @@ -0,0 +1,15 @@ +import React from 'react' +import TimeTooltip from '../TimeTooltip'; +import store from 'App/store'; +import { Provider } from 'react-redux'; + +function TooltipContainer({ liveTimeTravel }: { liveTimeTravel: boolean }) { + + return ( + + + + ) +} + +export default React.memo(TooltipContainer); diff --git a/frontend/app/components/Session_/Player/Controls/controls.module.css b/frontend/app/components/Session_/Player/Controls/controls.module.css index 0b377a594..ba04b3396 100644 --- a/frontend/app/components/Session_/Player/Controls/controls.module.css +++ b/frontend/app/components/Session_/Player/Controls/controls.module.css @@ -18,9 +18,6 @@ height: 65px; padding-left: 30px; padding-right: 0; - &[data-is-live=true] { - padding: 0; - } } .buttonsLeft { diff --git a/frontend/app/components/Session_/Player/Controls/timeline.module.css b/frontend/app/components/Session_/Player/Controls/timeline.module.css index a5676d6b1..48217119d 100644 --- a/frontend/app/components/Session_/Player/Controls/timeline.module.css +++ b/frontend/app/components/Session_/Player/Controls/timeline.module.css @@ -21,14 +21,21 @@ } +.greenTracker { + background-color: #42AE5E!important; + box-shadow: 0 0 0 1px #42AE5E; +} + .progress { height: 10px; padding: 8px 0; cursor: pointer; width: 100%; + max-width: 100%; position: relative; display: flex; align-items: center; + } @@ -163,3 +170,28 @@ } } } + +.timeTooltip { + position: absolute; + padding: 0.25rem; + transition-property: all; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; + background: black; + top: -35px; + color: white; + + &:after { + content:''; + position: absolute; + top: 100%; + left: 0; + right: 0; + margin: 0 auto; + width: 0; + height: 0; + border-top: solid 5px black; + border-left: solid 5px transparent; + border-right: solid 5px transparent; + } +} diff --git a/frontend/app/components/Session_/Player/Overlay.tsx b/frontend/app/components/Session_/Player/Overlay.tsx index 994608108..b067a3dd0 100644 --- a/frontend/app/components/Session_/Player/Overlay.tsx +++ b/frontend/app/components/Session_/Player/Overlay.tsx @@ -44,12 +44,6 @@ function Overlay({ togglePlay, closedLive }: Props) { - - // useEffect(() =>{ - // setTimeout(() => markTargets([{ selector: 'div', count:6}]), 5000) - // setTimeout(() => markTargets(null), 8000) - // },[]) - const showAutoplayTimer = !live && completed && autoplay && nextId const showPlayIconLayer = !live && !markedTargets && !inspectorMode && !loading && !showAutoplayTimer; const showLiveStatusText = live && liveStatusText && !loading; @@ -60,7 +54,7 @@ function Overlay({ { showLiveStatusText && } - { messagesLoading && } + { messagesLoading && } { showPlayIconLayer && } @@ -83,4 +77,4 @@ export default connectPlayer(state => ({ concetionStatus: state.peerConnectionStatus, markedTargets: state.markedTargets, activeTargetIndex: state.activeTargetIndex, -}))(Overlay); \ No newline at end of file +}))(Overlay); diff --git a/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx b/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx index ecf1cb7f0..a99633bb4 100644 --- a/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx +++ b/frontend/app/components/Session_/Player/Overlay/AutoplayTimer.tsx @@ -1,14 +1,19 @@ import React, { useEffect, useState } from 'react' import cn from 'classnames'; import { connect } from 'react-redux' -import { withRouter } from 'react-router-dom'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; import { Button, Link } from 'UI' import { session as sessionRoute, withSiteId } from 'App/routes' import stl from './AutoplayTimer.module.css'; import clsOv from './overlay.module.css'; -function AutoplayTimer({ nextId, siteId, history }) { - let timer +interface IProps extends RouteComponentProps { + nextId: number; + siteId: string; +} + +function AutoplayTimer({ nextId, siteId, history }: IProps) { + let timer: NodeJS.Timer const [cancelled, setCancelled] = useState(false); const [counter, setCounter] = useState(5); @@ -32,7 +37,7 @@ function AutoplayTimer({ nextId, siteId, history }) { } if (cancelled) - return '' + return null return (
@@ -50,7 +55,6 @@ function AutoplayTimer({ nextId, siteId, history }) { ) } - export default withRouter(connect(state => ({ siteId: state.getIn([ 'site', 'siteId' ]), nextId: parseInt(state.getIn([ 'sessions', 'nextId' ])), diff --git a/frontend/app/components/Session_/Subheader.js b/frontend/app/components/Session_/Subheader.js index 7dbde4535..c378386ee 100644 --- a/frontend/app/components/Session_/Subheader.js +++ b/frontend/app/components/Session_/Subheader.js @@ -12,7 +12,6 @@ function SubHeader(props) { const [isCopied, setCopied] = React.useState(false); const isAssist = window.location.pathname.includes('/assist/'); - if (isAssist) return null; const location = props.currentLocation && props.currentLocation.length > 60 ? `${props.currentLocation.slice(0, 60)}...` : props.currentLocation return ( @@ -39,37 +38,39 @@ function SubHeader(props) {
)} -
-
- {!isAssist && props.jiraConfig && props.jiraConfig.token && } + {!isAssist ? ( +
+
+ {props.jiraConfig && props.jiraConfig.token && } +
+
+ + + Share +
+ } + /> +
+
+ +
+
+ +
+
+
-
- - - Share -
- } - /> -
-
- -
-
- -
-
-
-
+ ) : null}
) } diff --git a/frontend/app/components/shared/LiveTag/LiveTag.module.css b/frontend/app/components/shared/LiveTag/LiveTag.module.css index cecf45bad..2914b0b76 100644 --- a/frontend/app/components/shared/LiveTag/LiveTag.module.css +++ b/frontend/app/components/shared/LiveTag/LiveTag.module.css @@ -8,26 +8,26 @@ cursor: pointer; user-select: none; height: 26px; - width: 56px; + padding: 4px 8px; border-radius: 3px; - background-color: $gray-light; + background-color: $main; display: flex; align-items: center; justify-content: center; - color: $gray-dark; + color: white; text-transform: uppercase; font-size: 10px; + font-weight: 600; letter-spacing: 1px; margin-right: 10px; & svg { - fill: $gray-dark; + fill: white; + opacity: .5; } &[data-is-live=true] { background-color: #42AE5E; - color: white; & svg { - fill: white; animation: fade 1s infinite; } } -} \ No newline at end of file +} diff --git a/frontend/app/components/shared/LiveTag/LiveTag.tsx b/frontend/app/components/shared/LiveTag/LiveTag.tsx index 36275783a..c29ae3d34 100644 --- a/frontend/app/components/shared/LiveTag/LiveTag.tsx +++ b/frontend/app/components/shared/LiveTag/LiveTag.tsx @@ -10,8 +10,8 @@ interface Props { function LiveTag({ isLive, onClick }: Props) { return ( ) } diff --git a/frontend/app/duck/sessions.js b/frontend/app/duck/sessions.js index e4a4ff7bd..d2556fce3 100644 --- a/frontend/app/duck/sessions.js +++ b/frontend/app/duck/sessions.js @@ -25,6 +25,8 @@ const SET_AUTOPLAY_VALUES = 'sessions/SET_AUTOPLAY_VALUES'; const TOGGLE_CHAT_WINDOW = 'sessions/TOGGLE_CHAT_WINDOW'; const SET_FUNNEL_PAGE_FLAG = 'sessions/SET_FUNNEL_PAGE_FLAG'; const SET_TIMELINE_POINTER = 'sessions/SET_TIMELINE_POINTER'; +const SET_TIMELINE_HOVER_POINTER = 'sessions/SET_TIMELINE_HOVER_POINTER'; + const SET_SESSION_PATH = 'sessions/SET_SESSION_PATH'; const LAST_PLAYED_SESSION_ID = `${name}/LAST_PLAYED_SESSION_ID`; const SET_ACTIVE_TAB = 'sessions/SET_ACTIVE_TAB'; @@ -61,6 +63,7 @@ const initialState = Map({ timelinePointer: null, sessionPath: {}, lastPlayedSessionId: null, + timeLineTooltip: { time: 0, offset: 0, isVisible: false } }); const reducer = (state = initialState, action = {}) => { @@ -187,6 +190,8 @@ const reducer = (state = initialState, action = {}) => { return state.set('funnelPage', action.funnelPage ? Map(action.funnelPage) : false); case SET_TIMELINE_POINTER: return state.set('timelinePointer', action.pointer); + case SET_TIMELINE_HOVER_POINTER: + return state.set('timeLineTooltip', action.timeLineTooltip); case SET_SESSION_PATH: return state.set('sessionPath', action.path); case LAST_PLAYED_SESSION_ID: @@ -350,6 +355,13 @@ export function setTimelinePointer(pointer) { }; } +export function setTimelineHoverTime(timeLineTooltip) { + return { + type: SET_TIMELINE_HOVER_POINTER, + timeLineTooltip + }; +} + export function setSessionPath(path) { return { type: SET_SESSION_PATH, diff --git a/frontend/app/player/MessageDistributor/MessageDistributor.ts b/frontend/app/player/MessageDistributor/MessageDistributor.ts index d9f2aac2b..cd48f54da 100644 --- a/frontend/app/player/MessageDistributor/MessageDistributor.ts +++ b/frontend/app/player/MessageDistributor/MessageDistributor.ts @@ -1,3 +1,4 @@ +// @ts-ignore import { Decoder } from "syncod"; import logger from 'App/logger'; @@ -5,7 +6,9 @@ import Resource, { TYPES } from 'Types/session/resource'; // MBTODO: player type import { TYPES as EVENT_TYPES } from 'Types/session/event'; import Log from 'Types/session/log'; -import { update } from '../store'; +import { update, getState } from '../store'; +import { toast } from 'react-toastify'; + import { init as initListsDepr, append as listAppend, @@ -24,7 +27,7 @@ import ActivityManager from './managers/ActivityManager'; import AssistManager from './managers/AssistManager'; import MFileReader from './messages/MFileReader'; -import loadFiles from './network/loadFiles'; +import { loadFiles, checkUnprocessedMobs } from './network/loadFiles'; import { INITIAL_STATE as SUPER_INITIAL_STATE, State as SuperState } from './StatedScreen/StatedScreen'; import { INITIAL_STATE as ASSIST_INITIAL_STATE, State as AssistState } from './managers/AssistManager'; @@ -70,29 +73,30 @@ import type { Timed } from './messages/timed'; export default class MessageDistributor extends StatedScreen { // TODO: consistent with the other data-lists - private readonly locationEventManager: ListWalker/**/ = new ListWalker(); - private readonly locationManager: ListWalker = new ListWalker(); - private readonly loadedLocationManager: ListWalker = new ListWalker(); - private readonly connectionInfoManger: ListWalker = new ListWalker(); - private readonly performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); - private readonly windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); - private readonly clickManager: ListWalker = new ListWalker(); + private locationEventManager: ListWalker/**/ = new ListWalker(); + private locationManager: ListWalker = new ListWalker(); + private loadedLocationManager: ListWalker = new ListWalker(); + private connectionInfoManger: ListWalker = new ListWalker(); + private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager(); + private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter(); + private clickManager: ListWalker = new ListWalker(); - private readonly resizeManager: ListWalker = new ListWalker([]); - private readonly pagesManager: PagesManager; - private readonly mouseMoveManager: MouseMoveManager; - private readonly assistManager: AssistManager; + private resizeManager: ListWalker = new ListWalker([]); + private pagesManager: PagesManager; + private mouseMoveManager: MouseMoveManager; + private assistManager: AssistManager; - private readonly scrollManager: ListWalker = new ListWalker(); + private scrollManager: ListWalker = new ListWalker(); private readonly decoder = new Decoder(); private readonly lists = initLists(); - private activirtManager: ActivityManager | null = null; + private activityManager: ActivityManager | null = null; - private readonly sessionStart: number; + private sessionStart: number; private navigationStartOffset: number = 0; private lastMessageTime: number = 0; + private lastRecordedMessageTime: number = 0; constructor(private readonly session: any /*Session*/, config: any, live: boolean) { super(); @@ -106,7 +110,7 @@ export default class MessageDistributor extends StatedScreen { initListsDepr({}) this.assistManager.connect(); } else { - this.activirtManager = new ActivityManager(this.session.duration.milliseconds); + this.activityManager = new ActivityManager(this.session.duration.milliseconds); /* == REFACTOR_ME == */ const eventList = this.session.events.toJSON(); initListsDepr({ @@ -115,12 +119,13 @@ export default class MessageDistributor extends StatedScreen { resource: this.session.resources.toJSON(), }); - eventList.forEach(e => { + // TODO: fix types for events, remove immutable js + eventList.forEach((e: Record) => { if (e.type === EVENT_TYPES.LOCATION) { //TODO type system this.locationEventManager.append(e); } }); - this.session.errors.forEach(e => { + this.session.errors.forEach((e: Record) => { this.lists.exceptions.append(e); }); /* === */ @@ -129,77 +134,160 @@ export default class MessageDistributor extends StatedScreen { } private waitingForFiles: boolean = false - private loadMessages(): void { + + private onFileSuccessRead() { + this.windowNodeCounter.reset() + + if (this.activityManager) { + this.activityManager.end() + update({ + skipIntervals: this.activityManager.list + }) + } + + this.waitingForFiles = false + this.setMessagesLoading(false) + } + + private readAndDistributeMessages(byteArray: Uint8Array, onReadCb?: (msg: Message) => void) { + const msgs: Array = [] + const reader = new MFileReader(new Uint8Array(), this.sessionStart) + + reader.append(byteArray) + let next: ReturnType + while (next = reader.next()) { + const [msg, index] = next + this.distributeMessage(msg, index) + msgs.push(msg) + onReadCb?.(msg) + } + + logger.info("Messages count: ", msgs.length, msgs) + + return msgs + } + + private processStateUpdates(msgs: Message[]) { + // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) + const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); + this.pagesManager.sortPages((m1, m2) => { + if (m1.time === m2.time) { + if (m1.tp === "remove_node" && m2.tp !== "remove_node") { + if (headChildrenIds.includes(m1.id)) { + return -1; + } + } else if (m2.tp === "remove_node" && m1.tp !== "remove_node") { + if (headChildrenIds.includes(m2.id)) { + return 1; + } + } else if (m2.tp === "remove_node" && m1.tp === "remove_node") { + const m1FromHead = headChildrenIds.includes(m1.id); + const m2FromHead = headChildrenIds.includes(m2.id); + if (m1FromHead && !m2FromHead) { + return -1; + } else if (m2FromHead && !m1FromHead) { + return 1; + } + } + } + return 0; + }) + + const stateToUpdate: {[key:string]: any} = { + performanceChartData: this.performanceTrackManager.chartData, + performanceAvaliability: this.performanceTrackManager.avaliability, + } + LIST_NAMES.forEach(key => { + stateToUpdate[ `${ key }List` ] = this.lists[ key ].list + }) + update(stateToUpdate) + this.setMessagesLoading(false) + } + + private loadMessages() { this.setMessagesLoading(true) this.waitingForFiles = true - const r = new MFileReader(new Uint8Array(), this.sessionStart) - const msgs: Array = [] + const onData = (byteArray: Uint8Array) => { + const msgs = this.readAndDistributeMessages(byteArray) + this.processStateUpdates(msgs) + } + loadFiles(this.session.mobsUrl, - b => { - r.append(b) - let next: ReturnType - while (next = r.next()) { - const [msg, index] = next - this.distributeMessage(msg, index) - msgs.push(msg) - } - - logger.info("Messages count: ", msgs.length, msgs) - - // @ts-ignore Hack for upet (TODO: fix ordering in one mutation in tracker(removes first)) - const headChildrenIds = msgs.filter(m => m.parentID === 1).map(m => m.id); - this.pagesManager.sortPages((m1, m2) => { - if (m1.time === m2.time) { - if (m1.tp === "remove_node" && m2.tp !== "remove_node") { - if (headChildrenIds.includes(m1.id)) { - return -1; - } - } else if (m2.tp === "remove_node" && m1.tp !== "remove_node") { - if (headChildrenIds.includes(m2.id)) { - return 1; - } - } else if (m2.tp === "remove_node" && m1.tp === "remove_node") { - const m1FromHead = headChildrenIds.includes(m1.id); - const m2FromHead = headChildrenIds.includes(m2.id); - if (m1FromHead && !m2FromHead) { - return -1; - } else if (m2FromHead && !m1FromHead) { - return 1; - } - } - } - return 0; - }) - - const stateToUpdate: {[key:string]: any} = { - performanceChartData: this.performanceTrackManager.chartData, - performanceAvaliability: this.performanceTrackManager.avaliability, - } - LIST_NAMES.forEach(key => { - stateToUpdate[ `${ key }List` ] = this.lists[ key ].list - }) - update(stateToUpdate) - this.setMessagesLoading(false) - } + onData ) - .then(() => { - this.windowNodeCounter.reset() - if (this.activirtManager) { - this.activirtManager.end() - update({ - skipIntervals: this.activirtManager.list + .then(() => this.onFileSuccessRead()) + .catch(async () => { + checkUnprocessedMobs(this.session.sessionId) + .then(file => file ? onData(file) : Promise.reject('No session file')) + .then(() => this.onFileSuccessRead()) + .catch((e) => { + logger.error(e) + update({ error: true }) + toast.error('Error getting a session replay file') + }) + .finally(() => { + this.waitingForFiles = false + this.setMessagesLoading(false) }) - } - this.waitingForFiles = false - this.setMessagesLoading(false) + }) - .catch(e => { - logger.error(e) - this.waitingForFiles = false - this.setMessagesLoading(false) + } + + public async reloadWithUnprocessedFile() { + // assist will pause and skip messages to prevent timestamp related errors + this.assistManager.toggleTimeTravelJump() + this.reloadMessageManagers() + + this.setMessagesLoading(true) + this.waitingForFiles = true + + const onData = (byteArray: Uint8Array) => { + const onReadCallback = () => this.setLastRecordedMessageTime(this.lastMessageTime) + const msgs = this.readAndDistributeMessages(byteArray, onReadCallback) + this.sessionStart = msgs[0].time + this.processStateUpdates(msgs) + } + + // unpausing assist + const unpauseAssist = () => { + this.assistManager.toggleTimeTravelJump() + update({ + liveTimeTravel: true, + }); + } + + try { + const unprocessedFile = await checkUnprocessedMobs(this.session.sessionId) + + Promise.resolve(onData(unprocessedFile)) + .then(() => this.onFileSuccessRead()) + .then(unpauseAssist) + } catch (unprocessedFilesError) { + logger.error(unprocessedFilesError) update({ error: true }) - }) + toast.error('Error getting a session replay file') + this.assistManager.toggleTimeTravelJump() + } finally { + this.waitingForFiles = false + this.setMessagesLoading(false) + } + } + + private reloadMessageManagers() { + this.locationEventManager = new ListWalker(); + this.locationManager = new ListWalker(); + this.loadedLocationManager = new ListWalker(); + this.connectionInfoManger = new ListWalker(); + this.clickManager = new ListWalker(); + this.scrollManager = new ListWalker(); + this.resizeManager = new ListWalker([]); + + this.performanceTrackManager = new PerformanceTrackManager() + this.windowNodeCounter = new WindowNodeCounter(); + this.pagesManager = new PagesManager(this, this.session.isMobile) + this.mouseMoveManager = new MouseMoveManager(this); + this.activityManager = new ActivityManager(this.session.duration.milliseconds); } move(t: number, index?: number): void { @@ -246,6 +334,7 @@ export default class MessageDistributor extends StatedScreen { LIST_NAMES.forEach(key => { const lastMsg = this.lists[key].moveGetLast(t, key === 'exceptions' ? undefined : index); if (lastMsg != null) { + // @ts-ignore TODO: fix types stateToUpdate[`${key}ListNow`] = this.lists[key].listNow; } }); @@ -279,10 +368,11 @@ export default class MessageDistributor extends StatedScreen { } } - private decodeMessage(msg, keys: Array) { + private decodeMessage(msg: any, keys: Array) { const decoded = {}; try { keys.forEach(key => { + // @ts-ignore TODO: types for decoder decoded[key] = this.decoder.decode(msg[key]); }); } catch (e) { @@ -294,7 +384,8 @@ export default class MessageDistributor extends StatedScreen { /* Binded */ distributeMessage(msg: Message, index: number): void { - this.lastMessageTime = Math.max(msg.time, this.lastMessageTime) + const lastMessageTime = Math.max(msg.time, this.lastMessageTime) + this.lastMessageTime = lastMessageTime if ([ "mouse_move", "mouse_click", @@ -304,7 +395,7 @@ export default class MessageDistributor extends StatedScreen { "set_viewport_size", "set_viewport_scroll", ].includes(msg.tp)) { - this.activirtManager?.updateAcctivity(msg.time); + this.activityManager?.updateAcctivity(msg.time); } //const index = i + index; //? let decoded; @@ -444,4 +535,12 @@ export default class MessageDistributor extends StatedScreen { update(INITIAL_STATE); this.assistManager.clear(); } + + public setLastRecordedMessageTime(time: number) { + this.lastRecordedMessageTime = time; + } + + public getLastRecordedMessageTime(): number { + return this.lastRecordedMessageTime; + } } diff --git a/frontend/app/player/MessageDistributor/managers/AssistManager.ts b/frontend/app/player/MessageDistributor/managers/AssistManager.ts index d56bf3a39..50a203e5f 100644 --- a/frontend/app/player/MessageDistributor/managers/AssistManager.ts +++ b/frontend/app/player/MessageDistributor/managers/AssistManager.ts @@ -56,10 +56,11 @@ export function getStatusText(status: ConnectionStatus): string { } export interface State { - calling: CallingState, - peerConnectionStatus: ConnectionStatus, - remoteControl: RemoteControlStatus, - annotating: boolean, + calling: CallingState; + peerConnectionStatus: ConnectionStatus; + remoteControl: RemoteControlStatus; + annotating: boolean; + assistStart: number; } export const INITIAL_STATE: State = { @@ -67,12 +68,16 @@ export const INITIAL_STATE: State = { peerConnectionStatus: ConnectionStatus.Connecting, remoteControl: RemoteControlStatus.Disabled, annotating: false, + assistStart: 0, } const MAX_RECONNECTION_COUNT = 4; export default class AssistManager { + private timeTravelJump = false; + private jumped = false; + constructor(private session: any, private md: MessageDistributor, private config: any) {} private setStatus(status: ConnectionStatus) { @@ -121,6 +126,10 @@ export default class AssistManager { let waitingForMessages = true let showDisconnectTimeout: ReturnType | undefined let inactiveTimeout: ReturnType | undefined + + const now = +new Date() + update({ assistStart: now }) + import('socket.io-client').then(({ default: io }) => { if (this.cleaned) { return } if (this.socket) { this.socket.close() } // TODO: single socket connection @@ -145,7 +154,7 @@ export default class AssistManager { update({ calling: CallingState.NoCall }) }) socket.on('messages', messages => { - jmr.append(messages) // as RawMessage[] + !this.timeTravelJump && jmr.append(messages) // as RawMessage[] if (waitingForMessages) { waitingForMessages = false // TODO: more explicit @@ -153,12 +162,21 @@ export default class AssistManager { // Call State if (getState().calling === CallingState.Reconnecting) { - this._call() // reconnecting call (todo improve code separation) + this._callSessionPeer() // reconnecting call (todo improve code separation) } } + if (this.timeTravelJump) { + return; + } + for (let msg = reader.readNext();msg !== null;msg = reader.readNext()) { //@ts-ignore + if (this.jumped) { + // @ts-ignore + msg.time = this.md.getLastRecordedMessageTime() + msg.time + } + // @ts-ignore TODO: fix msg types in generator this.md.distributeMessage(msg, msg._index) } }) @@ -521,6 +539,11 @@ export default class AssistManager { private annot: AnnotationCanvas | null = null + toggleTimeTravelJump() { + this.jumped = true; + this.timeTravelJump = !this.timeTravelJump; + } + /* ==== Cleaning ==== */ private cleaned: boolean = false clear() { diff --git a/frontend/app/player/MessageDistributor/managers/ListWalker.ts b/frontend/app/player/MessageDistributor/managers/ListWalker.ts index 9bae8203e..acf7b70aa 100644 --- a/frontend/app/player/MessageDistributor/managers/ListWalker.ts +++ b/frontend/app/player/MessageDistributor/managers/ListWalker.ts @@ -118,4 +118,4 @@ export default class ListWalker { } } -} \ No newline at end of file +} diff --git a/frontend/app/player/MessageDistributor/messages/MFileReader.ts b/frontend/app/player/MessageDistributor/messages/MFileReader.ts index 82d505716..9db9c2cff 100644 --- a/frontend/app/player/MessageDistributor/messages/MFileReader.ts +++ b/frontend/app/player/MessageDistributor/messages/MFileReader.ts @@ -8,9 +8,9 @@ import RawMessageReader from './RawMessageReader'; // which should be probably somehow incapsulated export default class MFileReader extends RawMessageReader { private pLastMessageID: number = 0 - private currentTime: number = 0 + private currentTime: number public error: boolean = false - constructor(data: Uint8Array, private readonly startTime: number) { + constructor(data: Uint8Array, private startTime?: number) { super(data) } @@ -60,6 +60,9 @@ export default class MFileReader extends RawMessageReader { } if (rMsg.tp === "timestamp") { + if (!this.startTime) { + this.startTime = rMsg.timestamp + } this.currentTime = rMsg.timestamp - this.startTime return this.next() } @@ -68,6 +71,7 @@ export default class MFileReader extends RawMessageReader { time: this.currentTime, _index: this.pLastMessageID, }) + return [msg, this.pLastMessageID] } } diff --git a/frontend/app/player/MessageDistributor/messages/MStreamReader.ts b/frontend/app/player/MessageDistributor/messages/MStreamReader.ts index 02e765a67..87ed89cf6 100644 --- a/frontend/app/player/MessageDistributor/messages/MStreamReader.ts +++ b/frontend/app/player/MessageDistributor/messages/MStreamReader.ts @@ -68,4 +68,4 @@ export default class MStreamReader { _index: this.idx++, }) } -} \ No newline at end of file +} diff --git a/frontend/app/player/MessageDistributor/messages/timed.ts b/frontend/app/player/MessageDistributor/messages/timed.ts index 2dd4cc707..143f6baec 100644 --- a/frontend/app/player/MessageDistributor/messages/timed.ts +++ b/frontend/app/player/MessageDistributor/messages/timed.ts @@ -1 +1 @@ -export interface Timed { readonly time: number }; +export interface Timed { time: number }; diff --git a/frontend/app/player/MessageDistributor/network/loadFiles.ts b/frontend/app/player/MessageDistributor/network/loadFiles.ts index 5bc8c580d..ff9e62de0 100644 --- a/frontend/app/player/MessageDistributor/network/loadFiles.ts +++ b/frontend/app/player/MessageDistributor/network/loadFiles.ts @@ -1,9 +1,16 @@ -const NO_NTH_FILE = "nnf" +import APIClient from 'App/api_client'; -export default function load( +const NO_NTH_FILE = "nnf" +const NO_UNPROCESSED_FILES = "nuf" + +const getUnprocessedFileLink = (sessionId: string) => '/unprocessed/' + sessionId + +type onDataCb = (data: Uint8Array) => void + +export const loadFiles = ( urls: string[], - onData: (ba: Uint8Array) => void, -): Promise { + onData: onDataCb, +): Promise => { const firstFileURL = urls[0] urls = urls.slice(1) if (!firstFileURL) { @@ -11,31 +18,16 @@ export default function load( } return window.fetch(firstFileURL) .then(r => { - if (r.status >= 400) { - throw new Error(`no start file. status code ${ r.status }`) - } - return r.arrayBuffer() + return processAPIStreamResponse(r, true) }) - .then(b => new Uint8Array(b)) .then(onData) .then(() => urls.reduce((p, url) => p.then(() => window.fetch(url) .then(r => { - return new Promise((res, rej) => { - if (r.status == 404) { - rej(NO_NTH_FILE) - return - } - if (r.status >= 400) { - rej(`Bad endfile status code ${r.status}`) - return - } - res(r.arrayBuffer()) - }) + return processAPIStreamResponse(r, false) }) - .then(b => new Uint8Array(b)) .then(onData) ), Promise.resolve(), @@ -48,3 +40,32 @@ export default function load( throw e }) } + +export const checkUnprocessedMobs = async (sessionId: string) => { + try { + const api = new APIClient() + const res = await api.fetch(getUnprocessedFileLink(sessionId)) + if (res.status >= 400) { + throw NO_UNPROCESSED_FILES + } + const byteArray = await processAPIStreamResponse(res, false) + return byteArray + } catch (e) { + throw e + } +} + +const processAPIStreamResponse = (response: Response, isFirstFile: boolean) => { + return new Promise((res, rej) => { + if (response.status === 404 && !isFirstFile) { + return rej(NO_NTH_FILE) + } + if (response.status >= 400) { + return rej( + isFirstFile ? `no start file. status code ${ response.status }` + : `Bad endfile status code ${response.status}` + ) + } + res(response.arrayBuffer()) + }).then(buffer => new Uint8Array(buffer)) +} diff --git a/frontend/app/player/Player.ts b/frontend/app/player/Player.ts index c7f37c0ca..4d4f40ed4 100644 --- a/frontend/app/player/Player.ts +++ b/frontend/app/player/Player.ts @@ -45,6 +45,7 @@ export const INITIAL_STATE = { inspectorMode: false, live: false, livePlay: false, + liveTimeTravel: false, } as const; @@ -53,7 +54,7 @@ export const INITIAL_NON_RESETABLE_STATE = { skipToIssue: initialSkipToIssue, autoplay: initialAutoplay, speed: initialSpeed, - showEvents: initialShowEvents + showEvents: initialShowEvents, } export default class Player extends MessageDistributor { @@ -118,9 +119,12 @@ export default class Player extends MessageDistributor { }); } - if (live && time > endTime) { + // throttle store updates + // TODO: make it possible to change frame rate + if (live && time - endTime > 100) { update({ endTime: time, + livePlay: endTime - time < 900 }); } this._setTime(time); @@ -153,20 +157,22 @@ export default class Player extends MessageDistributor { } jump(time = getState().time, index: number) { - const { live } = getState(); - if (live) return; + const { live, liveTimeTravel, endTime } = getState(); + if (live && !liveTimeTravel) return; if (getState().playing) { cancelAnimationFrame(this._animationFrameRequestId); // this._animationFrameRequestId = requestAnimationFrame(() => { this._setTime(time, index); this._startAnimation(); - update({ livePlay: time === getState().endTime }); + // throttilg the redux state update from each frame to nearly half a second + // which is better for performance and component rerenders + update({ livePlay: Math.abs(time - endTime) < 500 }); //}); } else { //this._animationFrameRequestId = requestAnimationFrame(() => { this._setTime(time, index); - update({ livePlay: time === getState().endTime }); + update({ livePlay: Math.abs(time - endTime) < 500 }); //}); } } @@ -246,6 +252,20 @@ export default class Player extends MessageDistributor { this._updateSpeed(Math.max(1, speed/2)); } + toggleTimetravel() { + if (!getState().liveTimeTravel) { + this.reloadWithUnprocessedFile() + this.play() + } + } + + jumpToLive() { + cancelAnimationFrame(this._animationFrameRequestId); + this._setTime(getState().endTime); + this._startAnimation(); + update({ livePlay: true }); +} + clean() { this.pause(); super.clean(); diff --git a/frontend/app/player/singletone.js b/frontend/app/player/singletone.js index 808605793..81d6a6138 100644 --- a/frontend/app/player/singletone.js +++ b/frontend/app/player/singletone.js @@ -77,6 +77,8 @@ export const requestReleaseRemoteControl = initCheck((...args) => instance.assis export const markTargets = initCheck((...args) => instance.markTargets(...args)) export const activeTarget = initCheck((...args) => instance.activeTarget(...args)) export const toggleAnnotation = initCheck((...args) => instance.assistManager.toggleAnnotation(...args)) +export const toggleTimetravel = initCheck((...args) => instance.toggleTimetravel(...args)) +export const jumpToLive = initCheck((...args) => instance.jumpToLive(...args)) export const Controls = { jump,