diff --git a/frontend/app/api_client.js b/frontend/app/api_client.js
index 0e4699359..8b279b281 100644
--- a/frontend/app/api_client.js
+++ b/frontend/app/api_client.js
@@ -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) {
diff --git a/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx b/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx
index 00fdb9939..30d5ac08e 100644
--- a/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx
+++ b/frontend/app/components/Session_/ScreenRecorder/ScreenRecorder.tsx
@@ -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 (
-
- {/* @ts-ignore */}
-
-
-
-
- )
+ if (!isSupported())
+ return (
+
+ {/* @ts-ignore */}
+
+
+
+
+ );
return (
-
);
}
-// @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)
+);
diff --git a/frontend/app/services/RecordingsService.ts b/frontend/app/services/RecordingsService.ts
new file mode 100644
index 000000000..0b50fb943
--- /dev/null
+++ b/frontend/app/services/RecordingsService.ts
@@ -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 {
+ 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)
+ }
+ })
+ }
+
+}
diff --git a/frontend/app/services/index.ts b/frontend/app/services/index.ts
index 6de2d300b..2bcf5981e 100644
--- a/frontend/app/services/index.ts
+++ b/frontend/app/services/index.ts
@@ -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();
diff --git a/frontend/app/utils/screenRecorder.ts b/frontend/app/utils/screenRecorder.ts
index 47acf6bca..fe68df4a5 100644
--- a/frontend/app/utils/screenRecorder.ts
+++ b/frontend/app/utils/screenRecorder.ts
@@ -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);
}
}
diff --git a/tracker/tracker-assist/src/Assist.ts b/tracker/tracker-assist/src/Assist.ts
index c620a3af9..34d479f35 100644
--- a/tracker/tracker-assist/src/Assist.ts
+++ b/tracker/tracker-assist/src/Assist.ts
@@ -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)
}
})
diff --git a/tracker/tracker-assist/src/ScreenRecordingState.ts b/tracker/tracker-assist/src/ScreenRecordingState.ts
index 0ab99cc7a..521a80370 100644
--- a/tracker/tracker-assist/src/ScreenRecordingState.ts
+++ b/tracker/tracker-assist/src/ScreenRecordingState.ts
@@ -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) => {