change(tracker/ui): fix few bugs, save videos on aws after recording

This commit is contained in:
sylenien 2022-11-16 15:27:58 +01:00 committed by Delirium
parent 04c3df3dd3
commit 5127be6cfc
7 changed files with 178 additions and 79 deletions

View file

@ -104,12 +104,12 @@ export default class APIClient {
post(path, params, options) {
this.init.method = 'POST';
return this.fetch(path, params);
return this.fetch(path, params, undefined);
}
put(path, params, options) {
this.init.method = 'PUT';
return this.fetch(path, params);
return this.fetch(path, params, undefined);
}
delete(path, params, options) {

View file

@ -1,12 +1,13 @@
import React from 'react';
import { screenRecorder } from 'App/utils/screenRecorder';
import { Tooltip } from 'react-tippy'
import { Button } from 'UI'
import { requestRecording, stopRecording, connectPlayer } from 'Player'
import {
SessionRecordingStatus,
} from 'Player/MessageDistributor/managers/AssistManager';
let stopRecorderCb: () => void
import { Tooltip } from 'react-tippy';
import { connect } from 'react-redux';
import { Button } from 'UI';
import { requestRecording, stopRecording, connectPlayer } from 'Player';
import { SessionRecordingStatus } from 'Player/MessageDistributor/managers/AssistManager';
let stopRecorderCb: () => void;
import { recordingsService } from 'App/services';
import { toast } from 'react-toastify';
/**
* "edge" || "edg/" chromium based edge (dev or canary)
@ -17,24 +18,44 @@ let stopRecorderCb: () => void
* "safari" safari
*/
function isSupported() {
const agent = window.navigator.userAgent.toLowerCase()
const agent = window.navigator.userAgent.toLowerCase();
if (agent.includes("edge") || agent.includes("edg/")) return true
if (agent.includes('edge') || agent.includes('edg/')) return true;
// @ts-ignore
if (agent.includes("chrome") && !!window.chrome) return true
if (agent.includes('chrome') && !!window.chrome) return true;
return false
return false;
}
const supportedBrowsers = ["Chrome v91+", "Edge v90+"]
const supportedMessage = `Supported Browsers: ${supportedBrowsers.join(', ')}`
const supportedBrowsers = ['Chrome v91+', 'Edge v90+'];
const supportedMessage = `Supported Browsers: ${supportedBrowsers.join(', ')}`;
function ScreenRecorder({ recordingState }: { recordingState: SessionRecordingStatus }) {
function ScreenRecorder({
recordingState,
siteId,
sessionId,
}: {
recordingState: SessionRecordingStatus;
siteId: string;
sessionId: string;
}) {
const [isRecording, setRecording] = React.useState(false);
React.useEffect(() => {
return () => stopRecorderCb?.()
}, [])
return () => stopRecorderCb?.();
}, []);
const onSave = async (saveObj: { name: string; duration: number }, blob: Blob) => {
try {
const url = await recordingsService.reserveUrl(siteId, saveObj);
const status = recordingsService.saveFile(url, blob);
if (status) {
toast.success('Session recording saved');
}
} catch (e) {
console.error(e);
}
};
React.useEffect(() => {
if (!isRecording && recordingState === SessionRecordingStatus.Recording) {
@ -43,43 +64,51 @@ function ScreenRecorder({ recordingState }: { recordingState: SessionRecordingSt
if (isRecording && recordingState !== SessionRecordingStatus.Recording) {
stopRecordingHandler();
}
}, [recordingState, isRecording])
}, [recordingState, isRecording]);
const startRecording = async () => {
const stop = await screenRecorder();
stopRecorderCb = stop;
setRecording(true);
const stop = await screenRecorder('test rec_' + new Date().getTime(), sessionId, onSave);
stopRecorderCb = stop;
setRecording(true);
};
const stopRecordingHandler = () => {
stopRecording()
stopRecording();
stopRecorderCb?.();
setRecording(false);
}
};
const recordingRequest = () => {
requestRecording()
requestRecording();
// startRecording()
}
};
if (!isSupported()) return (
<div className="p-2">
{/* @ts-ignore */}
<Tooltip title={supportedMessage}>
<Button icon="record-circle" disabled variant={isRecording ? "text-red" : "text-primary"}>
Record Activity
</Button>
</Tooltip>
</div>
)
if (!isSupported())
return (
<div className="p-2">
{/* @ts-ignore */}
<Tooltip title={supportedMessage}>
<Button icon="record-circle" disabled variant={isRecording ? 'text-red' : 'text-primary'}>
Record Activity
</Button>
</Tooltip>
</div>
);
return (
<div onClick={!isRecording ? recordingRequest : stopRecordingHandler} className="p-2">
<Button icon={!isRecording ? 'stop-record-circle' : 'record-circle'} variant={isRecording ? "text-red" : "text-primary"}>
<Button
icon={!isRecording ? 'stop-record-circle' : 'record-circle'}
variant={isRecording ? 'text-red' : 'text-primary'}
>
{isRecording ? 'Stop Recording' : 'Record Activity'}
</Button>
</div>
);
}
// @ts-ignore
export default connectPlayer(state => ({ recordingState: state.recordingState}))(ScreenRecorder)
export default connectPlayer((state: any) => ({ recordingState: state.recordingState }))(
connect((state: any) => ({
siteId: state.getIn(['site', 'siteId']),
sessionId: state.getIn(['sessions', 'current', 'sessionId']),
}))(ScreenRecorder)
);

View file

@ -0,0 +1,41 @@
import APIClient from 'App/api_client';
interface RecordingData {
name: string;
duration: number;
}
export default class RecordingsService {
private client: APIClient;
constructor(client?: APIClient) {
this.client = client ? client : new APIClient();
}
initClient(client?: APIClient) {
this.client = client || new APIClient();
}
reserveUrl(siteId: string, recordingData: RecordingData): Promise<string> {
return this.client.put(`/${siteId}/assist/save`, recordingData)
.then(r => {
if (r.ok) {
return r.json().then(j => j.data.URL)
} else {
throw new Error("Can't reserve space for recording: " + r.status);
}
})
}
saveFile(url: string, file: Blob) {
return fetch(url, { method: 'PUT', headers: { 'Content-Type': 'video/webm' }, body: file })
.then(r => {
if (r.ok) {
return true
} else {
throw new Error("Can't upload file: " + r.status)
}
})
}
}

View file

@ -6,6 +6,7 @@ import UserService from "./UserService";
import AuditService from './AuditService';
import ErrorService from "./ErrorService";
import NotesService from "./NotesService";
import RecordingsService from "./RecordingsService";
export const dashboardService = new DashboardService();
export const metricService = new MetricService();
@ -15,3 +16,4 @@ export const funnelService = new FunnelService();
export const auditService = new AuditService();
export const errorService = new ErrorService();
export const notesService = new NotesService();
export const recordingsService = new RecordingsService();

View file

@ -1,44 +1,63 @@
const FILE_TYPE = 'video/webm';
const FRAME_RATE = 30;
function createFileRecorder (stream: MediaStream, mimeType: string) {
function createFileRecorder(
stream: MediaStream,
mimeType: string,
recName: string,
sessionId: string,
saveCb: Function
) {
let ended = false;
const start = new Date().getTime();
let recordedChunks: BlobPart[] = [];
const SAVE_INTERVAL_MS = 200;
const mediaRecorder = new MediaRecorder(stream);
mediaRecorder.ondataavailable = function (e) {
if (e.data.size > 0) {
if (e.data.size > 0) {
recordedChunks.push(e.data);
}
}
};
function onEnd() {
if (ended) return;
if (ended) return;
ended = true;
saveFile(recordedChunks, mimeType);
recordedChunks = [];
};
ended = true;
saveFile(recordedChunks, mimeType, start, recName, sessionId, saveCb);
recordedChunks = [];
}
// sometimes we get race condition or the onstop won't trigger at all,
// this is why we have to make it twice to make sure that stream is saved
// plus we want to be able to handle both, native and custom button clicks
mediaRecorder.stream.getTracks().forEach(track => track.onended = onEnd);
mediaRecorder.stream.getTracks().forEach((track) => (track.onended = onEnd));
mediaRecorder.onstop = () => {
onEnd();
mediaRecorder.stream.getTracks().forEach(track => track.stop());
}
onEnd();
mediaRecorder.stream.getTracks().forEach((track) => track.stop());
};
mediaRecorder.start(SAVE_INTERVAL_MS);
return mediaRecorder;
}
function saveFile(recordedChunks: BlobPart[], mimeType: string) {
function saveFile(
recordedChunks: BlobPart[],
mimeType: string,
startDate: number,
recName: string,
sessionId: string,
saveCb: Function
) {
const saveObject = { name: recName, duration: new Date().getTime() - startDate, sessionId };
const blob = new Blob(recordedChunks, {
type: mimeType,
type: mimeType,
});
const filename = +new Date() + '.' + mimeType.split('/')[1];
saveCb(saveObject, blob);
const filename = recName + '.' + mimeType.split('/')[1];
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = filename;
@ -52,33 +71,33 @@ function saveFile(recordedChunks: BlobPart[], mimeType: string) {
async function recordScreen() {
return await navigator.mediaDevices.getDisplayMedia({
audio: true,
video: { frameRate: FRAME_RATE },
// potential chrome hack
// @ts-ignore
preferCurrentTab: true,
audio: true,
video: { frameRate: FRAME_RATE },
// potential chrome hack
// @ts-ignore
preferCurrentTab: true,
});
};
}
/**
* Creates a screen recorder that sends the media stream to MediaRecorder
* which then saves the stream to a file.
*
* Supported Browsers:
*
* Windows: Chrome v91+, Edge v90+ - FULL SUPPORT;
* *Nix: Chrome v91+, Edge v90+ - LIMITED SUPPORT - (audio only captured from current tab)
*
* @returns a promise that resolves to a function that stops the recording
*/
export async function screenRecorder() {
* Creates a screen recorder that sends the media stream to MediaRecorder
* which then saves the stream to a file.
*
* Supported Browsers:
*
* Windows: Chrome v91+, Edge v90+ - FULL SUPPORT;
* *Nix: Chrome v91+, Edge v90+ - LIMITED SUPPORT - (audio only captured from current tab)
*
* @returns a promise that resolves to a function that stops the recording
*/
export async function screenRecorder(recName: string, sessionId: string, saveCb: Function) {
try {
const stream = await recordScreen();
const mediaRecorder = createFileRecorder(stream, FILE_TYPE);
const stream = await recordScreen();
const mediaRecorder = createFileRecorder(stream, FILE_TYPE, recName, sessionId, saveCb);
return () => mediaRecorder.stop();
return () => mediaRecorder.stop();
} catch (e) {
console.log(e)
console.log(e);
}
}

View file

@ -12,7 +12,7 @@ import AnnotationCanvas from './AnnotationCanvas.js'
import ConfirmWindow from './ConfirmWindow/ConfirmWindow.js'
import { callConfirmDefault, } from './ConfirmWindow/defaults.js'
import type { Options as ConfirmOptions, } from './ConfirmWindow/defaults.js'
import ScreenRecordingState, { RecordingState, } from './ScreenRecordingState'
import ScreenRecordingState from './ScreenRecordingState'
// TODO: fully specified strict check with no-any (everywhere)
@ -208,7 +208,7 @@ export default class Assist {
const onDenyRecording = () => {
socket.emit('recording_denied')
}
const recordingState = new ScreenRecordingState(onAcceptRecording, onDenyRecording, this.options)
const recordingState = new ScreenRecordingState(onAcceptRecording, onDenyRecording, this.options)
// TODO: check incoming args
socket.on('request_control', this.remoteControl.requestControl)
@ -259,11 +259,15 @@ export default class Assist {
this.agents[id]?.onDisconnect?.()
delete this.agents[id]
if (Object.keys(this.agents).length === 0 && recordingState.isActive) {
recordingState.denyRecording()
}
endAgentCall(id)
})
socket.on('NO_AGENT', () => {
Object.values(this.agents).forEach(a => a.onDisconnect?.())
this.agents = {}
if (recordingState.isActive) recordingState.denyRecording()
})
socket.on('call_end', (id) => {
if (!callingAgents.has(id)) {
@ -281,13 +285,13 @@ export default class Assist {
callUI?.toggleVideoStream(feedState)
})
socket.on('request_recording', (id, agentData) => {
if (recordingState.status === RecordingState.Off) {
if (!recordingState.isActive) {
this.options.onRecordingRequest?.(JSON.parse(agentData))
recordingState.requestRecording(id)
}
})
socket.on('stop_recording', (id) => {
if (recordingState.status !== RecordingState.Off) {
if (recordingState.isActive) {
recordingState.denyRecording(id)
}
})

View file

@ -50,6 +50,10 @@ export default class ScreenRecordingState {
private readonly options: AssistOptions
) {}
public get isActive() {
return this.status !== RecordingState.Off
}
private confirm: ConfirmWindow | null = null
public requestRecording = (id: string) => {