feat(ui): make ui able to load unprocessed session files (#652)

* feat(ui): make ui able to load unprocessed session files

* feat(ui): some lgos

* feat(ui): connect api, rewrite old code

* feat(ui): create testing ui functions

* feat(ui/player): add ability to jump back in time for assist

* feat(ui/player): rewrite for better readability

* fix(ui/player): small refactor for better readability

* fix(ui/player): fix private prop

* fix(ui/player): add tooltip

* feat(ui/player): create time calculating tooltip

* fix(player): fix message timestamp

* fix(ui/player): cleanup

* fix(ui/player): handle errors for unprocessed files as well

* fix(player): fix logged message

* fix(player): code review fixes

* fix(ui): fix circle color, fix button text

* fix(tracker): code review

* fix(player): small style fixes
This commit is contained in:
Delirium 2022-08-11 13:07:34 +03:00 committed by GitHub
parent 762d0fad53
commit 4ebcff74e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 671 additions and 268 deletions

View file

@ -25,6 +25,7 @@ const siteIdRequiredPaths = [
'/custom_metrics',
'/dashboards',
'/metrics',
'/unprocessed',
// '/custom_metrics/sessions',
];

View file

@ -57,7 +57,7 @@ function LivePlayer ({
return (
<PlayerProvider>
<InitLoader className="flex-1 p-3">
<PlayerBlockHeader activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} fullscreen={fullscreen}/>
<PlayerBlockHeader activeTab={activeTab} setActiveTab={setActiveTab} tabs={TABS} fullscreen={fullscreen}/>
<div className={ styles.session } data-fullscreen={fullscreen}>
<PlayerBlock />
</div>

View file

@ -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));
})(Session));

View file

@ -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<Props> = memo(function Box({ preview }) {
export const Circle: FC<Props> = memo(function Box({ preview, isGreen }) {
return (
<div
className={ styles.positionTracker }
className={ cn(styles.positionTracker, { [styles.greenTracker]: isGreen }) }
role={preview ? 'BoxPreview' : 'Box'}
/>
)
})
export default Circle;
export default Circle;

View file

@ -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 (
<div className={ cn(styles.controls, {'px-5 pt-0' : live}) }>
{ !live && <Timeline jump={ this.props.jump } pause={this.props.pause} togglePlay={this.props.togglePlay} /> }
<div className={ styles.controls }>
{ !live || liveTimeTravel ? <Timeline jump={ this.props.jump } liveTimeTravel={liveTimeTravel} pause={this.props.pause} togglePlay={this.props.togglePlay} /> : null}
{ !fullscreen &&
<div className={ styles.buttons } data-is-live={ live }>
<div className={ cn(styles.buttons, {'!px-5 !pt-0' : live}) } data-is-live={ live }>
<div>
{ !live && (
<div className="flex items-center">
{ this.renderPlayBtn() }
{ !live && (
<div className="flex items-center font-semibold text-center" style={{ minWidth: 85 }}>
<ReduxTime isCustom name="time" format="mm:ss" />
<span className="px-1">/</span>
<ReduxTime isCustom name="endTime" format="mm:ss" />
</div>
)}
<div className="rounded ml-4 bg-active-blue border border-active-blue-border flex items-stretch">
<Tooltip
title='Rewind 10s'
delay={0}
position="top"
>
{this.controlIcon("skip-forward-fill", 18, this.backTenSeconds, true, 'hover:bg-active-blue-border color-main h-full flex items-center')}
</Tooltip>
<div className='p-1 border-l border-r bg-active-blue-border border-active-blue-border'>10s</div>
<Tooltip
title='Forward 10s'
delay={0}
position="top"
>
{this.controlIcon("skip-forward-fill", 18, this.forthTenSeconds, false, 'hover:bg-active-blue-border color-main h-full flex items-center')}
</Tooltip>
</div>
{!live &&
<div className='flex items-center mx-4'>
<Tooltip
title='Playback speed'
delay={0}
position="top"
>
<button
className={ styles.speedButton }
onClick={ this.props.toggleSpeed }
data-disabled={ disabled }
>
<div>{ speed + 'x' }</div>
</button>
</Tooltip>
<button
className={ cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip, [styles.active]: skip }, 'ml-4') }
onClick={ this.props.toggleSkip }
data-disabled={ disabled }
>
{skip && <Icon name="check" size="24" className="mr-1" />}
{ 'Skip Inactivity' }
</button>
</div>
}
</div>
{!live && (
<PlayerControls
live={live}
skip={skip}
speed={speed}
disabled={disabled}
backTenSeconds={this.backTenSeconds}
forthTenSeconds={this.forthTenSeconds}
toggleSpeed={toggleSpeed}
toggleSkip={toggleSkip}
playButton={this.renderPlayBtn()}
controlIcon={this.controlIcon}
/>
)}
{ live && !closedLive && (
<div className={ styles.buttonsLeft }>
<LiveTag isLive={livePlay} />
{'Elapsed'}
<ReduxTime name="time" />
<LiveTag isLive={livePlay} onClick={() => livePlay ? null : jumpToLive()} />
<div className="font-semibold px-2"><AssistDuration isLivePlay={livePlay} /></div>
{!liveTimeTravel && (
<div onClick={toggleTimetravel} className="p-2 ml-2 rounded hover:bg-teal-light bg-gray-lightest cursor-pointer">
See Past Activity
</div>
)}
</div>
)}
</div>

View file

@ -95,4 +95,4 @@ const CustomDragLayer: FC<Props> = memo(function CustomDragLayer(props) {
);
})
export default CustomDragLayer;
export default CustomDragLayer;

View file

@ -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<Props> = memo(function DraggableCircle(props) {
style={getStyles(left, isDragging)}
role="DraggableBox"
>
<Circle />
<Circle isGreen={left > 99} />
</div>
);
})
export default DraggableCircle
export default DraggableCircle

View file

@ -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', }) => (
<div className={ !isCustom ? styles.time : undefined }>
@ -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 };

View file

@ -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 (
<div
className={stl.timeTooltip}
style={{
top: -30,
left: offset - 20,
display: isVisible ? 'block' : 'none' }
}
>
{!time ? 'Loading' : duration}
</div>
);
}
export default connect((state) => {
const { time = 0, offset = 0, isVisible } = state.getIn(['sessions', 'timeLineTooltip']);
return { time, offset, isVisible };
})(TimeTooltip);

View file

@ -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 (
<div
className="flex items-center absolute w-full"
style={{ top: '-4px', zIndex: 100, padding: `0 ${BOUNDRY}px`}}
style={{ top: '-4px', zIndex: 100, padding: `0 ${BOUNDRY}px`, maxWidth: '100%' }}
>
<div
className={ stl.progress }
onClick={ disabled ? null : this.seekProgress }
ref={ this.progressRef }
role="button"
onMouseMoveCapture={this.showTimeTooltip}
onMouseEnter={ this.showTimeTooltip}
onMouseLeave={this.hideTimeTooltip}
>
<TooltipContainer liveTimeTravel={liveTimeTravel} />
{/* custo color is live */}
<DraggableCircle left={this.props.time * scale} onDrop={this.onDragEnd} />
<CustomDragLayer onDrag={this.onDrag} minX={BOUNDRY} maxX={this.progressRef.current && this.progressRef.current.offsetWidth + BOUNDRY} />
<TimeTracker scale={ scale } />
{ skip && skipIntervals.map(interval =>
(<div
key={ interval.start }
@ -176,7 +214,8 @@ export default class Timeline extends React.PureComponent {
} }
/>))
}
<div className={ stl.timeline }/>
<div className={ stl.timeline } ref={this.timelineRef} />
{ events.map(e => (
<div
key={ e.key }
@ -359,7 +398,7 @@ export default class Timeline extends React.PureComponent {
</div>
))
}
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="flex items-center">
{playButton}
{!live && (
<div className="flex items-center font-semibold text-center" style={{ minWidth: 85 }}>
{/* @ts-ignore */}
<ReduxTime isCustom name="time" format="mm:ss" />
<span className="px-1">/</span>
{/* @ts-ignore */}
<ReduxTime isCustom name="endTime" format="mm:ss" />
</div>
)}
<div className="rounded ml-4 bg-active-blue border border-active-blue-border flex items-stretch">
{/* @ts-ignore */}
<Tooltip
title='Rewind 10s'
delay={0}
position="top"
>
{controlIcon(
"skip-forward-fill",
18,
backTenSeconds,
true,
'hover:bg-active-blue-border color-main h-full flex items-center'
)}
</Tooltip>
<div className='p-1 border-l border-r bg-active-blue-border border-active-blue-border'>10s</div>
{/* @ts-ignore */}
<Tooltip
title='Forward 10s'
delay={0}
position="top"
>
{controlIcon(
"skip-forward-fill",
18,
forthTenSeconds,
false,
'hover:bg-active-blue-border color-main h-full flex items-center'
)}
</Tooltip>
</div>
{!live &&
<div className='flex items-center mx-4'>
{/* @ts-ignore */}
<Tooltip
title='Playback speed'
delay={0}
position="top"
>
<button
className={styles.speedButton}
onClick={toggleSpeed}
data-disabled={disabled}
>
<div>{speed + 'x'}</div>
</button>
</Tooltip>
<button
className={cn(styles.skipIntervalButton, { [styles.withCheckIcon]: skip, [styles.active]: skip }, 'ml-4')}
onClick={toggleSkip}
data-disabled={disabled}
>
{skip && <Icon name="check" size="24" className="mr-1" />}
{'Skip Inactivity'}
</button>
</div>
}
</div>
)
}
export default PlayerControls;

View file

@ -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 (
<Provider store={store}>
<TimeTooltip liveTimeTravel={liveTimeTravel} />
</Provider>
)
}
export default React.memo(TooltipContainer);

View file

@ -18,9 +18,6 @@
height: 65px;
padding-left: 30px;
padding-right: 0;
&[data-is-live=true] {
padding: 0;
}
}
.buttonsLeft {

View file

@ -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;
}
}

View file

@ -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 &&
<LiveStatusText text={liveStatusText} concetionStatus={closedLive ? ConnectionStatus.Closed : concetionStatus} />
}
{ messagesLoading && <Loader/> }
{ messagesLoading && <Loader /> }
{ showPlayIconLayer &&
<PlayIconLayer playing={playing} togglePlay={togglePlay} />
}
@ -83,4 +77,4 @@ export default connectPlayer(state => ({
concetionStatus: state.peerConnectionStatus,
markedTargets: state.markedTargets,
activeTargetIndex: state.activeTargetIndex,
}))(Overlay);
}))(Overlay);

View file

@ -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 (
<div className={ cn(clsOv.overlay, stl.overlayBg) } >
@ -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' ])),

View file

@ -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) {
</Tooltip>
</div>
)}
<div className="ml-auto text-sm flex items-center color-gray-medium" style={{ width: 'max-content' }}>
<div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1">
{!isAssist && props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />}
{!isAssist ? (
<div className="ml-auto text-sm flex items-center color-gray-medium" style={{ width: 'max-content' }}>
<div className="cursor-pointer mr-4 hover:bg-gray-light-shade rounded-md p-1">
{props.jiraConfig && props.jiraConfig.token && <Issues sessionId={props.sessionId} />}
</div>
<div className="cursor-pointer">
<SharePopup
entity="sessions"
id={ props.sessionId }
showCopyLink={true}
trigger={
<div className="flex items-center hover:bg-gray-light-shade rounded-md p-1">
<Icon
className="mr-2"
disabled={ props.disabled }
name="share-alt"
size="16"
/>
<span>Share</span>
</div>
}
/>
</div>
<div className="mx-4 hover:bg-gray-light-shade rounded-md p-1">
<Bookmark noMargin sessionId={props.sessionId} />
</div>
<div>
<Autoplay />
</div>
<div>
</div>
</div>
<div className="cursor-pointer">
<SharePopup
entity="sessions"
id={ props.sessionId }
showCopyLink={true}
trigger={
<div className="flex items-center hover:bg-gray-light-shade rounded-md p-1">
<Icon
className="mr-2"
disabled={ props.disabled }
name="share-alt"
size="16"
/>
<span>Share</span>
</div>
}
/>
</div>
<div className="mx-4 hover:bg-gray-light-shade rounded-md p-1">
<Bookmark noMargin sessionId={props.sessionId} />
</div>
<div>
<Autoplay />
</div>
<div>
</div>
</div>
) : null}
</div>
)
}

View file

@ -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;
}
}
}
}

View file

@ -10,8 +10,8 @@ interface Props {
function LiveTag({ isLive, onClick }: Props) {
return (
<button onClick={ onClick } className={ stl.liveTag } data-is-live={ isLive }>
<Icon name="circle" size="8" marginRight="5" color="white" />
<div>{'Live'}</div>
<Icon name="circle" size="8" marginRight={5} color="white" />
<div>{isLive ? 'Live' : 'Go live'}</div>
</button>
)
}

View file

@ -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,

View file

@ -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<any>/*<LocationEvent>*/ = new ListWalker();
private readonly locationManager: ListWalker<SetPageLocation> = new ListWalker();
private readonly loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker();
private readonly connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker();
private readonly performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager();
private readonly windowNodeCounter: WindowNodeCounter = new WindowNodeCounter();
private readonly clickManager: ListWalker<MouseClick> = new ListWalker();
private locationEventManager: ListWalker<any>/*<LocationEvent>*/ = new ListWalker();
private locationManager: ListWalker<SetPageLocation> = new ListWalker();
private loadedLocationManager: ListWalker<SetPageLocation> = new ListWalker();
private connectionInfoManger: ListWalker<ConnectionInformation> = new ListWalker();
private performanceTrackManager: PerformanceTrackManager = new PerformanceTrackManager();
private windowNodeCounter: WindowNodeCounter = new WindowNodeCounter();
private clickManager: ListWalker<MouseClick> = new ListWalker();
private readonly resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
private readonly pagesManager: PagesManager;
private readonly mouseMoveManager: MouseMoveManager;
private readonly assistManager: AssistManager;
private resizeManager: ListWalker<SetViewportSize> = new ListWalker([]);
private pagesManager: PagesManager;
private mouseMoveManager: MouseMoveManager;
private assistManager: AssistManager;
private readonly scrollManager: ListWalker<SetViewportScroll> = new ListWalker();
private scrollManager: ListWalker<SetViewportScroll> = 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<string, string>) => {
if (e.type === EVENT_TYPES.LOCATION) { //TODO type system
this.locationEventManager.append(e);
}
});
this.session.errors.forEach(e => {
this.session.errors.forEach((e: Record<string, string>) => {
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<Message> = []
const reader = new MFileReader(new Uint8Array(), this.sessionStart)
reader.append(byteArray)
let next: ReturnType<MFileReader['next']>
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<Message> = []
const onData = (byteArray: Uint8Array) => {
const msgs = this.readAndDistributeMessages(byteArray)
this.processStateUpdates(msgs)
}
loadFiles(this.session.mobsUrl,
b => {
r.append(b)
let next: ReturnType<MFileReader['next']>
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<string>) {
private decodeMessage(msg: any, keys: Array<string>) {
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;
}
}

View file

@ -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<typeof setTimeout> | undefined
let inactiveTimeout: ReturnType<typeof setTimeout> | 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() {

View file

@ -118,4 +118,4 @@ export default class ListWalker<T extends Timed> {
}
}
}
}

View file

@ -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]
}
}

View file

@ -68,4 +68,4 @@ export default class MStreamReader {
_index: this.idx++,
})
}
}
}

View file

@ -1 +1 @@
export interface Timed { readonly time: number };
export interface Timed { time: number };

View file

@ -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<void> {
onData: onDataCb,
): Promise<void> => {
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<ArrayBuffer>((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<ArrayBuffer>((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))
}

View file

@ -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();

View file

@ -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,