Ios player v2 (#1901)

* feat(ui): new ios player start

* fix(ui): image player

* fix(ui): fix autoplay

* fix(ui): fix copy paste code; error handler; default mode null

* fix(ui): fix loader animation

* fix(ui): memory cleanup

* fix(ui): memory cleanup
This commit is contained in:
Delirium 2024-02-22 12:50:19 +01:00 committed by GitHub
parent 3cea5d7d0b
commit e207d37e69
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 459 additions and 245 deletions

View file

@ -1,123 +1,177 @@
import React from 'react' import { PlayerMode } from 'Player';
import React from 'react';
import { MobilePlayerContext, IOSPlayerContext } from 'App/components/Session/playerContext'; import { MobilePlayerContext, IOSPlayerContext } from 'App/components/Session/playerContext';
import { observer } from 'mobx-react-lite'; import { observer } from 'mobx-react-lite';
import { mapIphoneModel } from "Player/mobile/utils"; import { mapIphoneModel } from 'Player/mobile/utils';
interface Props { interface Props {
videoURL: string; videoURL: string[];
userDevice: string; userDevice: string;
} }
const appleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="white" viewBox="0 0 16 16"> const appleIcon = `<svg xmlns="http://www.w3.org/2000/svg" width="72" height="72" fill="white" viewBox="0 0 16 16">
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/> <path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
<path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/> <path d="M11.182.008C11.148-.03 9.923.023 8.857 1.18c-1.066 1.156-.902 2.482-.878 2.516.024.034 1.52.087 2.475-1.258.955-1.345.762-2.391.728-2.43Zm3.314 11.733c-.048-.096-2.325-1.234-2.113-3.422.212-2.189 1.675-2.789 1.698-2.854.023-.065-.597-.79-1.254-1.157a3.692 3.692 0 0 0-1.563-.434c-.108-.003-.483-.095-1.254.116-.508.139-1.653.589-1.968.607-.316.018-1.256-.522-2.267-.665-.647-.125-1.333.131-1.824.328-.49.196-1.422.754-2.074 2.237-.652 1.482-.311 3.83-.067 4.56.244.729.625 1.924 1.273 2.796.576.984 1.34 1.667 1.659 1.899.319.232 1.219.386 1.843.067.502-.308 1.408-.485 1.766-.472.357.013 1.061.154 1.782.539.571.197 1.111.115 1.652-.105.541-.221 1.324-1.059 2.238-2.758.347-.79.505-1.217.473-1.282Z"/>
</svg>` </svg>`;
function ReplayWindow({ videoURL, userDevice }: Props) { function ReplayWindow({ videoURL, userDevice }: Props) {
const playerContext = React.useContext<IOSPlayerContext>(MobilePlayerContext); const playerContext = React.useContext<IOSPlayerContext>(MobilePlayerContext);
const videoRef = React.useRef<HTMLVideoElement>(); const videoRef = React.useRef<HTMLVideoElement>();
const imageRef = React.useRef<HTMLImageElement>();
const containerRef = React.useRef<HTMLDivElement>();
const time = playerContext.store.get().time const { time, currentSnapshot, mode } = playerContext.store.get();
React.useEffect(() => { React.useEffect(() => {
if (videoRef.current) { if (videoRef.current && mode === PlayerMode.VIDEO) {
const timeSecs = time / 1000 const timeSecs = time / 1000;
const delta = videoRef.current.currentTime - timeSecs const delta = videoRef.current.currentTime - timeSecs;
if (videoRef.current.duration >= timeSecs && Math.abs(delta) > 0.1) { if (videoRef.current.duration >= timeSecs && Math.abs(delta) > 0.1) {
videoRef.current.currentTime = timeSecs videoRef.current.currentTime = timeSecs;
} }
} }
}, [time]) }, [time, mode]);
React.useEffect(() => {
if (currentSnapshot && mode === PlayerMode.SNAPS) {
const blob = currentSnapshot.getBlobUrl();
if (imageRef.current) {
imageRef.current.src = blob;
}
}
return () => {
if (imageRef.current) {
URL.revokeObjectURL(imageRef.current.src)
}
}
}, [currentSnapshot, mode]);
React.useEffect(() => { React.useEffect(() => {
if (playerContext.player.screen.document && videoURL) { playerContext.player.pause()
playerContext.player.pause() const { svg, styles } = mapIphoneModel(userDevice);
const { svg, styles } = mapIphoneModel(userDevice) if (!containerRef.current && playerContext.player.screen.document) {
const host = document.createElement('div');
const shell = document.createElement('div');
const icon = document.createElement('div');
const videoContainer = document.createElement('div');
const host = document.createElement('div') videoContainer.style.borderRadius = '10px';
const videoEl = document.createElement('video') videoContainer.style.overflow = 'hidden';
const sourceEl = document.createElement('source') videoContainer.style.margin = styles.margin;
const shell = document.createElement('div') videoContainer.style.display = 'none';
const icon = document.createElement('div') videoContainer.style.width = styles.screen.width + 'px';
const videoContainer = document.createElement('div') videoContainer.style.height = styles.screen.height + 'px';
videoContainer.style.borderRadius = '10px' shell.innerHTML = svg;
videoContainer.style.overflow = 'hidden' Object.assign(icon.style, mobileIconStyle(styles));
videoContainer.style.margin = styles.margin const spacer = document.createElement('div');
videoContainer.style.display = 'none' spacer.style.width = '60px';
videoContainer.style.width = styles.screen.width + 'px' spacer.style.height = '60px';
videoContainer.style.height = styles.screen.height + 'px'
videoContainer.appendChild(videoEl) const loadingBar = document.createElement('div');
shell.innerHTML = svg Object.assign(loadingBar.style, mobileLoadingBarStyle(styles));
icon.innerHTML = appleIcon;
videoEl.width = styles.screen.width shell.style.position = 'absolute';
videoEl.height = styles.screen.height shell.style.top = '0';
videoEl.style.backgroundColor = '#333'
Object.assign(icon.style, { host.appendChild(videoContainer);
backgroundColor: '#333', host.appendChild(shell);
borderRadius: '10px',
width: styles.screen.width + 'px',
height: styles.screen.height + 'px',
margin: styles.margin,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
})
const spacer = document.createElement('div')
spacer.style.width = '60px'
spacer.style.height = '60px'
const loadingBar = document.createElement('div') icon.appendChild(spacer);
Object.assign(loadingBar.style, { icon.appendChild(loadingBar);
width: styles.screen.width/2 + 'px', host.appendChild(icon);
height: '6px',
borderRadius: '3px',
backgroundColor: 'white',
})
icon.innerHTML = appleIcon
icon.appendChild(spacer)
icon.appendChild(loadingBar)
shell.style.position = 'absolute' containerRef.current = host;
shell.style.top = '0' videoContainer.id = '___or_replay-video';
icon.id = '___or_ios-icon';
host.id = '___or_ios-player';
sourceEl.setAttribute('src', videoURL) playerContext.player.injectPlayer(host);
sourceEl.setAttribute('type', 'video/mp4') playerContext.player.customScale(styles.shell.width, styles.shell.height);
host.appendChild(videoContainer)
host.appendChild(shell)
host.appendChild(icon)
videoEl.appendChild(sourceEl)
videoEl.addEventListener("loadeddata", () => {
videoContainer.style.display = 'block'
icon.style.display = 'none'
host.removeChild(icon)
console.log('loaded')
playerContext.player.play()
})
videoRef.current = videoEl
playerContext.player.injectPlayer(host)
playerContext.player.customScale(styles.shell.width, styles.shell.height)
playerContext.player.updateDimensions({ playerContext.player.updateDimensions({
width: styles.screen.width, width: styles.screen.width,
height: styles.screen.height, height: styles.screen.height,
}) });
playerContext.player.updateOverlayStyle({ playerContext.player.updateOverlayStyle({
margin: styles.margin, margin: styles.margin,
width: styles.screen.width + 'px', width: styles.screen.width + 'px',
height: styles.screen.height + 'px', height: styles.screen.height + 'px',
}) });
} }
}, [videoURL, playerContext.player.screen.document]) }, [playerContext.player.screen.document]);
return (
<div /> React.useEffect(() => {
) const { styles } = mapIphoneModel(userDevice);
if (mode) {
const host = containerRef.current;
const videoContainer =
playerContext.player.screen.document?.getElementById('___or_replay-video');
const icon = playerContext.player.screen.document?.getElementById('___or_ios-icon');
if (host && videoContainer && icon) {
if (mode === PlayerMode.SNAPS) {
const imagePlayer = document.createElement('img');
imagePlayer.style.width = styles.screen.width + 'px';
imagePlayer.style.height = styles.screen.height + 'px';
imagePlayer.style.backgroundColor = '#333';
videoContainer.appendChild(imagePlayer);
const removeLoader = () => {
host.removeChild(icon);
videoContainer.style.display = 'block';
imagePlayer.removeEventListener('load', removeLoader);
};
imagePlayer.addEventListener('load', removeLoader);
imageRef.current = imagePlayer;
playerContext.player.play();
}
if (mode === PlayerMode.VIDEO) {
const mp4URL = videoURL.find((url) => url.includes('.mp4'));
if (mp4URL) {
const videoEl = document.createElement('video');
const sourceEl = document.createElement('source');
videoContainer.appendChild(videoEl);
videoEl.width = styles.screen.width;
videoEl.height = styles.screen.height;
videoEl.style.backgroundColor = '#333';
sourceEl.setAttribute('src', mp4URL);
sourceEl.setAttribute('type', 'video/mp4');
videoEl.appendChild(sourceEl);
videoEl.addEventListener('loadeddata', () => {
host.removeChild(icon);
videoContainer.style.display = 'block';
playerContext.player.play();
});
videoRef.current = videoEl;
}
}
}
}
}, [videoURL, playerContext.player.screen.document, mode]);
return <div />;
} }
export default observer(ReplayWindow); const mobileLoadingBarStyle = (styles: any) => ({
width: styles.screen.width / 2 + 'px',
height: '6px',
borderRadius: '3px',
backgroundColor: 'white',
});
const mobileIconStyle = (styles: any) => ({
backgroundColor: '#333',
borderRadius: '10px',
width: styles.screen.width + 'px',
height: styles.screen.height + 'px',
margin: styles.margin,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
});
export default observer(ReplayWindow);

11
frontend/app/player/common/common.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare module 'js-untar' {
export interface TarFile {
name: string
blob: Blob
buffer: ArrayBuffer
getBlobUrl: () => string
}
export default function untar(tarFile: ArrayBuffer): Promise<TarFile[]>
}

View file

@ -0,0 +1,25 @@
import untar, { TarFile } from 'js-untar';
const unpackTar = (data: Uint8Array): Promise<TarFile[]> => {
const isTar = true
// tarball ustar starts from 257, 75 73 74 61 72, but this is getting lost here for some reason
// so we rely on try catch
// data[257] === 0x75 &&
// data[258] === 0x73 &&
// data[259] === 0x74 &&
// data[260] === 0x61 &&
// data[261] === 0x72 &&
// data[262] === 0x00;
if (isTar) {
const now = performance.now();
return untar(data.buffer).then((files) => {
console.debug('Tar unpack time', Math.floor(performance.now() - now) + 'ms');
return files;
});
} else {
return Promise.reject('Not a tarball file');
}
};
export default unpackTar;

View file

@ -39,6 +39,7 @@ export interface SessionFilesInfo {
milliseconds: number milliseconds: number
valueOf: () => number valueOf: () => number
} }
videoURL: string[]
domURL: string[] domURL: string[]
devtoolsURL: string[] devtoolsURL: string[]
/** deprecated */ /** deprecated */

View file

@ -1,39 +1,40 @@
import * as fzstd from 'fzstd'; import * as fzstd from 'fzstd';
import { gunzipSync } from 'fflate' import { gunzipSync } from 'fflate';
const unpack = (b: Uint8Array): Uint8Array => { const unpack = (b: Uint8Array): Uint8Array => {
// zstd magical numbers 40 181 47 253 // zstd magical numbers 40 181 47 253
const isZstd = b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd const isZstd = b[0] === 0x28 && b[1] === 0xb5 && b[2] === 0x2f && b[3] === 0xfd;
const isGzip = b[0] === 0x1F && b[1] === 0x8B && b[2] === 0x08; const isGzip = b[0] === 0x1f && b[1] === 0x8b && b[2] === 0x08;
let data = b;
if (isGzip) { if (isGzip) {
const now = performance.now() const now = performance.now();
const data = gunzipSync(b) const uData = gunzipSync(b);
console.debug( console.debug(
"Gunzip time", 'Gunzip time',
Math.floor(performance.now() - now) + 'ms', Math.floor(performance.now() - now) + 'ms',
'size', 'size',
Math.floor(b.byteLength / 1024), Math.floor(b.byteLength / 1024),
'->', '->',
Math.floor(data.byteLength / 1024), Math.floor(uData.byteLength / 1024),
'kb' 'kb'
) );
return data data = uData;
} }
if (isZstd) { if (isZstd) {
const now = performance.now() const now = performance.now();
const data = fzstd.decompress(b) const uData = fzstd.decompress(b);
console.debug( console.debug(
"Zstd unpack time", 'Zstd unpack time',
Math.floor(performance.now() - now) + 'ms', Math.floor(performance.now() - now) + 'ms',
'size', 'size',
Math.floor(b.byteLength / 1024), Math.floor(b.byteLength / 1024),
'->', '->',
Math.floor(data.byteLength / 1024), Math.floor(uData.byteLength / 1024),
'kb' 'kb'
) );
return data data = uData;
} }
return b return data;
} };
export default unpack export default unpack;

View file

@ -1,5 +1,6 @@
import logger from 'App/logger'; import logger from 'App/logger';
import { getResourceFromNetworkRequest } from "Player"; import { TarFile } from "js-untar";
import { getResourceFromNetworkRequest } from 'Player';
import type { Store } from 'Player'; import type { Store } from 'Player';
import { IMessageManager } from 'Player/player/Animator'; import { IMessageManager } from 'Player/player/Animator';
@ -11,43 +12,52 @@ import Lists, {
INITIAL_STATE as LISTS_INITIAL_STATE, INITIAL_STATE as LISTS_INITIAL_STATE,
State as ListsState, State as ListsState,
} from './IOSLists'; } from './IOSLists';
import IOSPerformanceTrackManager, { PerformanceChartPoint } from "Player/mobile/managers/IOSPerformanceTrackManager"; import IOSPerformanceTrackManager, {
PerformanceChartPoint,
} from 'Player/mobile/managers/IOSPerformanceTrackManager';
import { MType } from '../web/messages'; import { MType } from '../web/messages';
import type { Message } from '../web/messages'; import type { Message } from '../web/messages';
import SnapshotManager from 'Player/mobile/managers/SnapshotManager';
import Screen, { import Screen, {
INITIAL_STATE as SCREEN_INITIAL_STATE, INITIAL_STATE as SCREEN_INITIAL_STATE,
State as ScreenState, State as ScreenState,
} from '../web/Screen/Screen'; } from '../web/Screen/Screen';
import { Log } from './types/log' import { Log } from './types/log';
import type { SkipInterval } from '../web/managers/ActivityManager'; import type { SkipInterval } from '../web/managers/ActivityManager';
export const performanceWarnings = ['thermalState', 'memoryWarning', 'lowDiskSpace', 'isLowPowerModeEnabled', 'batteryLevel'] export const performanceWarnings = [
'thermalState',
'memoryWarning',
'lowDiskSpace',
'isLowPowerModeEnabled',
'batteryLevel',
];
const perfWarningFrustrations = { const perfWarningFrustrations = {
thermalState: { thermalState: {
title: "Overheating", title: 'Overheating',
icon: "thermometer-sun", icon: 'thermometer-sun',
}, },
memoryWarning: { memoryWarning: {
title: "High Memory Usage", title: 'High Memory Usage',
icon: "memory-ios" icon: 'memory-ios',
}, },
lowDiskSpace: { lowDiskSpace: {
title: "Low Disk Space", title: 'Low Disk Space',
icon: "low-disc-space" icon: 'low-disc-space',
}, },
isLowPowerModeEnabled: { isLowPowerModeEnabled: {
title: "Low Power Mode", title: 'Low Power Mode',
icon: "battery-charging" icon: 'battery-charging',
}, },
batteryLevel: { batteryLevel: {
title: "Low Battery", title: 'Low Battery',
icon: "battery" icon: 'battery',
} },
} };
export interface State extends ScreenState, ListsState { export interface State extends ScreenState, ListsState {
skipIntervals: SkipInterval[]; skipIntervals: SkipInterval[];
@ -64,9 +74,15 @@ export interface State extends ScreenState, ListsState {
messagesProcessed: boolean; messagesProcessed: boolean;
eventCount: number; eventCount: number;
updateWarnings: number; updateWarnings: number;
currentSnapshot: TarFile | null;
} }
const userEvents = [MType.IosSwipeEvent, MType.IosClickEvent, MType.IosInputEvent, MType.IosScreenChanges]; const userEvents = [
MType.IosSwipeEvent,
MType.IosClickEvent,
MType.IosInputEvent,
MType.IosScreenChanges,
];
export default class IOSMessageManager implements IMessageManager { export default class IOSMessageManager implements IMessageManager {
static INITIAL_STATE: State = { static INITIAL_STATE: State = {
@ -83,6 +99,7 @@ export default class IOSMessageManager implements IMessageManager {
lastMessageTime: 0, lastMessageTime: 0,
messagesProcessed: false, messagesProcessed: false,
messagesLoading: false, messagesLoading: false,
currentSnapshot: null,
}; };
private activityManager: ActivityManager | null = null; private activityManager: ActivityManager | null = null;
@ -92,6 +109,7 @@ export default class IOSMessageManager implements IMessageManager {
private lastMessageTime: number = 0; private lastMessageTime: number = 0;
private touchManager: TouchManager; private touchManager: TouchManager;
private lists: Lists; private lists: Lists;
public snapshotManager: SnapshotManager;
constructor( constructor(
private readonly session: Record<string, any>, private readonly session: Record<string, any>,
@ -104,6 +122,7 @@ export default class IOSMessageManager implements IMessageManager {
this.lists = new Lists(initialLists); this.lists = new Lists(initialLists);
this.touchManager = new TouchManager(screen); this.touchManager = new TouchManager(screen);
this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live this.activityManager = new ActivityManager(this.session.duration.milliseconds); // only if not-live
this.snapshotManager = new SnapshotManager();
} }
public updateDimensions(dimensions: { width: number; height: number }) { public updateDimensions(dimensions: { width: number; height: number }) {
@ -111,16 +130,16 @@ export default class IOSMessageManager implements IMessageManager {
} }
public updateLists(lists: Partial<InitialLists>) { public updateLists(lists: Partial<InitialLists>) {
const exceptions = lists.exceptions const exceptions = lists.exceptions;
exceptions?.forEach(e => { exceptions?.forEach((e) => {
this.lists.lists.exceptions.insert(e); this.lists.lists.exceptions.insert(e);
this.lists.lists.log.insert(e) this.lists.lists.log.insert(e);
}) });
lists.frustrations?.forEach(f => { lists.frustrations?.forEach((f) => {
this.lists.lists.frustrations.insert(f); this.lists.lists.frustrations.insert(f);
}) });
const eventCount = this.lists.lists.event.count //lists?.event?.length || 0; const eventCount = this.lists.lists.event.count; //lists?.event?.length || 0;
const currentState = this.state.get(); const currentState = this.state.get();
this.state.update({ this.state.update({
eventCount: currentState.eventCount + eventCount, eventCount: currentState.eventCount + eventCount,
@ -134,8 +153,8 @@ export default class IOSMessageManager implements IMessageManager {
} }
public getListsFullState = () => { public getListsFullState = () => {
return this.lists.getFullListsState(); return this.lists.getFullListsState();
} };
private waitingForFiles: boolean = false; private waitingForFiles: boolean = false;
public onFileReadSuccess = () => { public onFileReadSuccess = () => {
@ -144,29 +163,29 @@ export default class IOSMessageManager implements IMessageManager {
eventCount: this.lists?.lists.event?.length || 0, eventCount: this.lists?.lists.event?.length || 0,
performanceChartData: this.performanceManager.chartData, performanceChartData: this.performanceManager.chartData,
...this.lists.getFullListsState(), ...this.lists.getFullListsState(),
} };
if (this.activityManager) { if (this.activityManager) {
this.activityManager.end(); this.activityManager.end();
newState['skipIntervals'] = this.activityManager.list newState['skipIntervals'] = this.activityManager.list;
} }
this.state.update(newState); this.state.update(newState);
}; };
public onFileReadFailed = (...e: any[]) => { public onFileReadFailed = (...e: any[]) => {
logger.error(e); logger.error(e);
this.state.update({error: true}); this.state.update({ error: true });
this.uiErrorHandler?.error('Error requesting a session file'); this.uiErrorHandler?.error('Error requesting a session file');
}; };
public onFileReadFinally = () => { public onFileReadFinally = () => {
this.waitingForFiles = false; this.waitingForFiles = false;
this.state.update({messagesProcessed: true}); this.state.update({ messagesProcessed: true });
}; };
public startLoading = () => { public startLoading = () => {
this.waitingForFiles = true; this.waitingForFiles = true;
this.state.update({messagesProcessed: false}); this.state.update({ messagesProcessed: false });
this.setMessagesLoading(true); this.setMessagesLoading(true);
}; };
@ -182,7 +201,7 @@ export default class IOSMessageManager implements IMessageManager {
if (lastPerformanceTrackMessage) { if (lastPerformanceTrackMessage) {
Object.assign(stateToUpdate, { Object.assign(stateToUpdate, {
performanceChartTime: lastPerformanceTrackMessage.time, performanceChartTime: lastPerformanceTrackMessage.time,
}) });
} }
this.touchManager.move(t); this.touchManager.move(t);
@ -194,61 +213,67 @@ export default class IOSMessageManager implements IMessageManager {
this.setMessagesLoading(true); this.setMessagesLoading(true);
} }
Object.assign(stateToUpdate, this.lists.moveGetState(t)) const snapshot = this.snapshotManager.moveReady(t);
Object.assign(stateToUpdate, { performanceListNow: this.lists.lists.performance.listNow }) if (snapshot) {
Object.assign(stateToUpdate, {
currentSnapshot: snapshot,
});
}
Object.assign(stateToUpdate, this.lists.moveGetState(t));
Object.assign(stateToUpdate, { performanceListNow: this.lists.lists.performance.listNow });
Object.keys(stateToUpdate).length > 0 && this.state.update(stateToUpdate); Object.keys(stateToUpdate).length > 0 && this.state.update(stateToUpdate);
} }
distributeMessage = (msg: Message & { tabId: string }): void => { distributeMessage = (msg: Message & { tabId: string }): void => {
const lastMessageTime = Math.max(msg.time, this.lastMessageTime); const lastMessageTime = Math.max(msg.time, this.lastMessageTime);
this.lastMessageTime = lastMessageTime; this.lastMessageTime = lastMessageTime;
this.state.update({lastMessageTime}); this.state.update({ lastMessageTime });
if (userEvents.includes(msg.tp)) { if (userEvents.includes(msg.tp)) {
this.activityManager?.updateAcctivity(msg.time); this.activityManager?.updateAcctivity(msg.time);
} }
switch (msg.tp) { switch (msg.tp) {
case MType.IosPerformanceEvent: case MType.IosPerformanceEvent:
const performanceStats = ['background', 'memoryUsage', 'mainThreadCPU'] const performanceStats = ['background', 'memoryUsage', 'mainThreadCPU'];
if (performanceStats.includes(msg.name)) { if (performanceStats.includes(msg.name)) {
this.performanceManager.append(msg); this.performanceManager.append(msg);
} }
if (performanceWarnings.includes(msg.name)) { if (performanceWarnings.includes(msg.name)) {
// @ts-ignore // @ts-ignore
const item = perfWarningFrustrations[msg.name] const item = perfWarningFrustrations[msg.name];
this.lists.lists.performance.append({ this.lists.lists.performance.append({
...msg, ...msg,
name: item.title, name: item.title,
techName: msg.name, techName: msg.name,
icon: item.icon, icon: item.icon,
type: 'ios_perf_event' type: 'ios_perf_event',
} as any) } as any);
} }
break; break;
// case MType.IosInputEvent: // case MType.IosInputEvent:
// console.log('input', msg) // console.log('input', msg)
// break; // break;
case MType.IosNetworkCall: case MType.IosNetworkCall:
this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart)) this.lists.lists.fetch.insert(getResourceFromNetworkRequest(msg, this.sessionStart));
break; break;
case MType.WsChannel: case MType.WsChannel:
this.lists.lists.websocket.insert(msg) this.lists.lists.websocket.insert(msg);
break; break;
case MType.IosEvent: case MType.IosEvent:
// @ts-ignore // @ts-ignore
this.lists.lists.event.insert({...msg, source: 'openreplay'}); this.lists.lists.event.insert({ ...msg, source: 'openreplay' });
break; break;
case MType.IosSwipeEvent: case MType.IosSwipeEvent:
case MType.IosClickEvent: case MType.IosClickEvent:
this.touchManager.append(msg); this.touchManager.append(msg);
break; break;
case MType.IosLog: case MType.IosLog:
const log = {...msg, level: msg.severity} const log = { ...msg, level: msg.severity };
// @ts-ignore // @ts-ignore
this.lists.lists.log.append(Log(log)); this.lists.lists.log.append(Log(log));
break; break;
default: default:
console.log(msg) console.log(msg);
// stuff // stuff
break; break;
} }
@ -257,16 +282,17 @@ export default class IOSMessageManager implements IMessageManager {
setMessagesLoading = (messagesLoading: boolean) => { setMessagesLoading = (messagesLoading: boolean) => {
this.screen.display(!messagesLoading); this.screen.display(!messagesLoading);
// @ts-ignore idk // @ts-ignore idk
this.state.update({messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading}); this.state.update({ messagesLoading, ready: !messagesLoading && !this.state.get().cssLoading });
}; };
private setSize({height, width}: { height: number; width: number }) { private setSize({ height, width }: { height: number; width: number }) {
this.screen.scale({height, width}); this.screen.scale({ height, width });
this.state.update({width, height}); this.state.update({ width, height });
} }
// TODO: clean managers? // TODO: clean managers?
clean() { clean() {
this.snapshotManager?.clean();
this.state.update(IOSMessageManager.INITIAL_STATE); this.state.update(IOSMessageManager.INITIAL_STATE);
} }
} }

View file

@ -1,10 +1,15 @@
import { Log, LogLevel, SessionFilesInfo } from 'Player' import { Log, LogLevel, SessionFilesInfo } from 'Player';
import type { Store } from 'Player' import type { Store } from 'Player';
import MessageLoader from "Player/web/MessageLoader"; import MessageLoader from 'Player/web/MessageLoader';
import Player from '../player/Player' import Player from '../player/Player';
import Screen, { ScaleMode } from '../web/Screen/Screen' import Screen, { ScaleMode } from '../web/Screen/Screen';
import IOSMessageManager from "Player/mobile/IOSMessageManager"; import IOSMessageManager from 'Player/mobile/IOSMessageManager';
export const PlayerMode = {
VIDEO: 'video',
SNAPS: 'snaps',
};
export default class IOSPlayer extends Player { export default class IOSPlayer extends Player {
static readonly INITIAL_STATE = { static readonly INITIAL_STATE = {
@ -12,120 +17,137 @@ export default class IOSPlayer extends Player {
...MessageLoader.INITIAL_STATE, ...MessageLoader.INITIAL_STATE,
...IOSMessageManager.INITIAL_STATE, ...IOSMessageManager.INITIAL_STATE,
scale: 1, scale: 1,
} mode: null,
public screen: Screen autoplay: false,
protected messageManager: IOSMessageManager };
protected readonly messageLoader: MessageLoader public screen: Screen;
protected messageManager: IOSMessageManager;
protected readonly messageLoader: MessageLoader;
constructor( constructor(
protected wpState: Store<any>, protected wpState: Store<any>,
session: SessionFilesInfo, session: SessionFilesInfo,
public readonly uiErrorHandler?: { error: (msg: string) => void } public readonly uiErrorHandler?: { error: (msg: string) => void }
) { ) {
const screen = new Screen(true, ScaleMode.Embed) const hasTar = session.videoURL.some((url) => url.includes('.tar.'));
const messageManager = new IOSMessageManager(session, wpState, screen, uiErrorHandler) const screen = new Screen(true, ScaleMode.Embed);
const messageManager = new IOSMessageManager(session, wpState, screen, uiErrorHandler);
const messageLoader = new MessageLoader( const messageLoader = new MessageLoader(
session, session,
wpState, wpState,
messageManager, messageManager,
false, false,
uiErrorHandler uiErrorHandler
) );
super(wpState, messageManager); super(wpState, messageManager);
this.screen = screen this.pause()
this.messageManager = messageManager this.screen = screen;
this.messageLoader = messageLoader this.messageManager = messageManager;
this.messageLoader = messageLoader;
void messageLoader.loadFiles() if (hasTar) {
const endTime = session.duration?.valueOf() || 0 messageLoader
.loadTarball(session.videoURL.find((url) => url.includes('.tar.'))!)
.then((files) => {
if (files) {
this.wpState.update({ mode: PlayerMode.SNAPS });
this.messageManager.snapshotManager.mapToSnapshots(files);
}
})
.catch((e) => {
this.wpState.update({ mode: PlayerMode.VIDEO });
});
}
void messageLoader.loadFiles();
const endTime = session.duration?.valueOf() || 0;
wpState.update({ wpState.update({
session, session,
endTime, endTime,
}) });
} }
attach = (parent: HTMLElement) => { attach = (parent: HTMLElement) => {
this.screen.attach(parent) this.screen.attach(parent);
} };
public updateDimensions(dimensions: { width: number; height: number }) { public updateDimensions(dimensions: { width: number; height: number }) {
return this.messageManager.updateDimensions(dimensions) return this.messageManager.updateDimensions(dimensions);
} }
public updateLists(session: any) { public updateLists(session: any) {
const exceptions = session.crashes.concat(session.errors || []) const exceptions = session.crashes.concat(session.errors || []);
const lists = { const lists = {
event: session.events.map((e: Record<string, any>) => { event:
if (e.name === 'Click') e.name = 'Touch' session.events.map((e: Record<string, any>) => {
return e if (e.name === 'Click') e.name = 'Touch';
}) || [], return e;
}) || [],
frustrations: session.frustrations || [], frustrations: session.frustrations || [],
stack: session.stackEvents || [], stack: session.stackEvents || [],
exceptions: exceptions.map(({ name, ...rest }: any) => exceptions:
Log({ exceptions.map(({ name, ...rest }: any) =>
level: LogLevel.ERROR, Log({
value: name, level: LogLevel.ERROR,
name, value: name,
message: rest.reason, name,
errorId: rest.crashId || rest.errorId, message: rest.reason,
...rest, errorId: rest.crashId || rest.errorId,
}) ...rest,
) || [], })
} ) || [],
};
return this.messageManager.updateLists(lists) return this.messageManager.updateLists(lists);
} }
public updateOverlayStyle(style: Partial<CSSStyleDeclaration>) { public updateOverlayStyle(style: Partial<CSSStyleDeclaration>) {
this.screen.updateOverlayStyle(style) this.screen.updateOverlayStyle(style);
} }
injectPlayer = (player: HTMLElement) => { injectPlayer = (player: HTMLElement) => {
this.screen.addToBody(player) this.screen.addToBody(player);
this.screen.addMobileStyles() this.screen.addMobileStyles();
window.addEventListener('resize', () => window.addEventListener('resize', () =>
this.customScale(this.customConstrains.width, this.customConstrains.height) this.customScale(this.customConstrains.width, this.customConstrains.height)
) );
} };
scale = () => { scale = () => {
// const { width, height } = this.wpState.get() // const { width, height } = this.wpState.get()
if (!this.screen) return; if (!this.screen) return;
console.debug("using customConstrains to scale player") console.debug('using customConstrains to scale player');
// sometimes happens in live assist sessions for some reason // sometimes happens in live assist sessions for some reason
this.screen?.scale?.(this.customConstrains) this.screen?.scale?.(this.customConstrains);
} };
customConstrains = { customConstrains = {
width: 0, width: 0,
height: 0, height: 0,
} };
customScale = (width: number, height: number) => { customScale = (width: number, height: number) => {
if (!this.screen) return; if (!this.screen) return;
this.screen?.scale?.({ width, height }) this.screen?.scale?.({ width, height });
this.customConstrains = { width, height } this.customConstrains = { width, height };
this.wpState.update({ scale: this.screen.getScale() }) this.wpState.update({ scale: this.screen.getScale() });
} };
addFullscreenBoundary = (isFullscreen?: boolean) => { addFullscreenBoundary = (isFullscreen?: boolean) => {
if (isFullscreen) { if (isFullscreen) {
this.screen?.addFullscreenBoundary() this.screen?.addFullscreenBoundary();
} else { } else {
this.screen?.addMobileStyles() this.screen?.addMobileStyles();
} }
} };
clean = () => { clean = () => {
super.clean() super.clean();
this.screen.clean() this.screen.clean();
// @ts-ignore // @ts-ignore
this.screen = undefined; this.screen = undefined;
this.messageLoader.clean() this.messageLoader.clean();
// @ts-ignore // @ts-ignore
this.messageManager = undefined; this.messageManager = undefined;
window.removeEventListener('resize', this.scale) window.removeEventListener('resize', this.scale);
} };
} }

View file

@ -0,0 +1,40 @@
import { TarFile } from "js-untar";
import ListWalker from 'Player/common/ListWalker';
interface Snapshots {
[timestamp: number]: TarFile
}
type Timestamp = { time: number }
export default class SnapshotManager extends ListWalker<Timestamp> {
private snapshots: Snapshots = {}
public mapToSnapshots(files: TarFile[]) {
const filenameRegexp = /(\d+)_1_(\d+)\.jpeg$/;
const firstPair = files[0].name.match(filenameRegexp)
const sessionStart = firstPair ? parseInt(firstPair[1], 10) : 0
files.forEach(file => {
const [_, _2, imageTimestamp] = file
.name
.match(filenameRegexp)
?.map(n => parseInt(n, 10)) ?? [0, 0, 0]
const messageTime = imageTimestamp - sessionStart
this.snapshots[messageTime] = file
this.append({ time: messageTime })
})
}
public moveReady(t: number) {
const msg = this.moveGetLast(t)
if (msg) {
return this.snapshots[msg.time]
}
}
public clean() {
this.snapshots = {}
this.reset()
}
}

View file

@ -1,11 +1,12 @@
import type { Store, SessionFilesInfo, PlayerMsg } from "Player"; import type { Store, SessionFilesInfo, PlayerMsg } from 'Player';
import { decryptSessionBytes } from './network/crypto'; import { decryptSessionBytes } from './network/crypto';
import MFileReader from './messages/MFileReader'; import MFileReader from './messages/MFileReader';
import { loadFiles, requestEFSDom, requestEFSDevtools } from './network/loadFiles'; import { loadFiles, requestEFSDom, requestEFSDevtools, requestTarball } from './network/loadFiles';
import logger from 'App/logger'; import logger from 'App/logger';
import unpack from 'Player/common/unpack'; import unpack from 'Player/common/unpack';
import MessageManager from 'Player/web/MessageManager'; import MessageManager from 'Player/web/MessageManager';
import IOSMessageManager from 'Player/mobile/IOSMessageManager'; import IOSMessageManager from 'Player/mobile/IOSMessageManager';
import unpackTar from 'Player/common/tarball';
interface State { interface State {
firstFileLoading: boolean; firstFileLoading: boolean;
@ -79,6 +80,18 @@ export default class MessageLoader {
this.messageManager.setMessagesLoading(false); this.messageManager.setMessagesLoading(false);
}; };
async loadTarball(url: string) {
try {
const tarBufferZstd = await requestTarball(url);
if (tarBufferZstd) {
const tar = unpack(tarBufferZstd);
return await unpackTar(tar);
}
} catch (e) {
throw e
}
}
async loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise<void>) { async loadDomFiles(urls: string[], parser: (b: Uint8Array) => Promise<void>) {
if (urls.length > 0) { if (urls.length > 0) {
this.store.update({ domLoading: true }); this.store.update({ domLoading: true });
@ -116,10 +129,10 @@ export default class MessageLoader {
this.messageManager.startLoading(); this.messageManager.startLoading();
try { try {
await this.loadMobs() await this.loadMobs();
} catch (sessionLoadError) { } catch (sessionLoadError) {
try { try {
await this.loadEFSMobs() await this.loadEFSMobs();
} catch (unprocessedLoadError) { } catch (unprocessedLoadError) {
this.messageManager.onFileReadFailed(sessionLoadError, unprocessedLoadError); this.messageManager.onFileReadFailed(sessionLoadError, unprocessedLoadError);
} }
@ -129,35 +142,35 @@ export default class MessageLoader {
} }
} }
loadMobs = async () => { loadMobs = async () => {
const loadMethod = const loadMethod =
this.session.domURL && this.session.domURL.length > 0 this.session.domURL && this.session.domURL.length > 0
? { ? {
mobUrls: this.session.domURL, mobUrls: this.session.domURL,
parser: () => this.createNewParser(true, this.processMessages, 'dom'), parser: () => this.createNewParser(true, this.processMessages, 'dom'),
} }
: { : {
mobUrls: this.session.mobsUrl, mobUrls: this.session.mobsUrl,
parser: () => this.createNewParser(false, this.processMessages, 'dom'), parser: () => this.createNewParser(false, this.processMessages, 'dom'),
}; };
const parser = loadMethod.parser(); const parser = loadMethod.parser();
const devtoolsParser = this.createNewParser(true, this.processMessages, 'devtools'); const devtoolsParser = this.createNewParser(true, this.processMessages, 'devtools');
/** /**
* to speed up time to replay * to speed up time to replay
* we load first dom mob file before the rest * we load first dom mob file before the rest
* (because parser can read them in parallel) * (because parser can read them in parallel)
* as a tradeoff we have some copy-paste code * as a tradeoff we have some copy-paste code
* for the devtools file * for the devtools file
* */ * */
await loadFiles([loadMethod.mobUrls[0]], parser); await loadFiles([loadMethod.mobUrls[0]], parser);
const restDomFilesPromise = this.loadDomFiles([...loadMethod.mobUrls.slice(1)], parser); const restDomFilesPromise = this.loadDomFiles([...loadMethod.mobUrls.slice(1)], parser);
const restDevtoolsFilesPromise = this.loadDevtools(devtoolsParser); const restDevtoolsFilesPromise = this.loadDevtools(devtoolsParser);
await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]); await Promise.allSettled([restDomFilesPromise, restDevtoolsFilesPromise]);
this.messageManager.onFileReadSuccess(); this.messageManager.onFileReadSuccess();
} };
loadEFSMobs = async () => { loadEFSMobs = async () => {
this.store.update({ domLoading: true, devtoolsLoading: true }); this.store.update({ domLoading: true, devtoolsLoading: true });
@ -172,16 +185,16 @@ export default class MessageLoader {
const devtoolsParser = this.createNewParser(false, this.processMessages, 'devtoolsEFS'); const devtoolsParser = this.createNewParser(false, this.processMessages, 'devtoolsEFS');
const parseDomPromise: Promise<any> = const parseDomPromise: Promise<any> =
domData.status === 'fulfilled' domData.status === 'fulfilled'
? domParser(domData.value) ? domParser(domData.value)
: Promise.reject('No dom file in EFS'); : Promise.reject('No dom file in EFS');
const parseDevtoolsPromise: Promise<any> = const parseDevtoolsPromise: Promise<any> =
devtoolsData.status === 'fulfilled' devtoolsData.status === 'fulfilled'
? devtoolsParser(devtoolsData.value) ? devtoolsParser(devtoolsData.value)
: Promise.reject('No devtools file in EFS'); : Promise.reject('No devtools file in EFS');
await Promise.all([parseDomPromise, parseDevtoolsPromise]); await Promise.all([parseDomPromise, parseDevtoolsPromise]);
this.messageManager.onFileReadSuccess(); this.messageManager.onFileReadSuccess();
} };
clean() { clean() {
this.store.update(MessageLoader.INITIAL_STATE); this.store.update(MessageLoader.INITIAL_STATE);

View file

@ -114,6 +114,8 @@ export default class Screen {
if (this.document) { if (this.document) {
this.document.body.style.margin = '0'; this.document.body.style.margin = '0';
this.document.body.appendChild(el); this.document.body.appendChild(el);
} else {
console.error('Attempt to add to player screen without document');
} }
} }

View file

@ -48,6 +48,16 @@ export async function requestEFSDevtools(sessionId: string) {
return await requestEFSMobFile(sessionId + "/devtools.mob") return await requestEFSMobFile(sessionId + "/devtools.mob")
} }
export async function requestTarball(url: string) {
const res = await window.fetch(url)
if (res.ok) {
const buf = await res.arrayBuffer()
return new Uint8Array(buf)
} else {
throw new Error(res.status.toString())
}
}
async function requestEFSMobFile(filename: string) { async function requestEFSMobFile(filename: string) {
const api = new APIClient() const api = new APIClient()
const res = await api.fetch('/unprocessed/' + filename) const res = await api.fetch('/unprocessed/' + filename)
@ -58,13 +68,13 @@ async function requestEFSMobFile(filename: string) {
} }
const processAPIStreamResponse = (response: Response, skippable: boolean) => { const processAPIStreamResponse = (response: Response, skippable: boolean) => {
return new Promise<Blob>((res, rej) => { return new Promise<ArrayBuffer>((res, rej) => {
if (response.status === 404 && skippable) { if (response.status === 404 && skippable) {
return rej(ALLOWED_404) return rej(ALLOWED_404)
} }
if (response.status >= 400) { if (response.status >= 400) {
return rej(`Bad file status code ${response.status}. Url: ${response.url}`) return rej(`Bad file status code ${response.status}. Url: ${response.url}`)
} }
res(response.blob()) res(response.arrayBuffer())
}).then(async blob => new Uint8Array(await blob.arrayBuffer())) }).then(async buf => new Uint8Array(buf))
} }

View file

@ -43,6 +43,7 @@
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"immutable": "^4.0.0-rc.12", "immutable": "^4.0.0-rc.12",
"jest-environment-jsdom": "^29.5.0", "jest-environment-jsdom": "^29.5.0",
"js-untar": "^2.0.0",
"jsbi": "^4.1.0", "jsbi": "^4.1.0",
"jshint": "^2.11.1", "jshint": "^2.11.1",
"jspdf": "^2.5.1", "jspdf": "^2.5.1",

View file

@ -15261,6 +15261,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"js-untar@npm:^2.0.0":
version: 2.0.0
resolution: "js-untar@npm:2.0.0"
checksum: d84138561b2ed12870ba07ff62c55dfb0f9884cd1eb32044ba6272eacbed4ec476e08cc62226f5824d8bda3de3597a506582ff9bdf0845f221d3e092f9d8b025
languageName: node
linkType: hard
"js-yaml@npm:^3.13.1": "js-yaml@npm:^3.13.1":
version: 3.14.1 version: 3.14.1
resolution: "js-yaml@npm:3.14.1" resolution: "js-yaml@npm:3.14.1"
@ -18152,6 +18159,7 @@ __metadata:
immutable: ^4.0.0-rc.12 immutable: ^4.0.0-rc.12
jest: ^29.5.0 jest: ^29.5.0
jest-environment-jsdom: ^29.5.0 jest-environment-jsdom: ^29.5.0
js-untar: ^2.0.0
jsbi: ^4.1.0 jsbi: ^4.1.0
jshint: ^2.11.1 jshint: ^2.11.1
jspdf: ^2.5.1 jspdf: ^2.5.1