change(tracker/ui): fix few bugs, save videos on aws after recording
This commit is contained in:
parent
04c3df3dd3
commit
5127be6cfc
7 changed files with 178 additions and 79 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
41
frontend/app/services/RecordingsService.ts
Normal file
41
frontend/app/services/RecordingsService.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue