From 56cf0ba0d4e1d0ecb9a1cd2ea0982bef26ffb5b0 Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Wed, 18 Mar 2026 22:56:03 -0600
Subject: [PATCH 01/12] Add webcam recording support
---
public/libraries/screen-recording/config.json | 2 +-
public/libraries/webcam-recording/config.json | 21 +
.../assets/screen-recording-page.md | 3 +-
.../assets/screen-recording.md | 2 +-
public/library-screen-recording/config.json | 7 +-
.../assets/webcam-recording-page.md | 5 +
.../assets/webcam-recording.md | 13 +
public/library-webcam-recording/config.json | 46 ++
.../individualStudy/StudyAnalysisTabs.tsx | 3 +-
.../thinkAloud/ThinkAloudFooter.tsx | 53 +-
.../tests/ThinkAloudAnalysis.spec.tsx | 3 +-
src/components/ConfigSwitcher.tsx | 9 +-
src/components/StepRenderer.tsx | 3 +-
.../audioAnalysis/AudioProvenanceVis.tsx | 8 +-
src/components/downloader/DownloadButtons.tsx | 29 +-
.../downloader/tests/DownloadButtons.spec.tsx | 26 +-
src/components/interface/AppAside.tsx | 9 +-
.../interface/tests/AppAside.spec.tsx | 1 +
.../screenRecording/ScreenRecordingReplay.tsx | 154 ++++--
.../tests/ScreenRecordingReplay.spec.tsx | 43 +-
src/components/tests/ConfigSwitcher.spec.tsx | 1 +
src/controllers/ComponentController.tsx | 4 +-
.../tests/ComponentController.spec.tsx | 4 +
src/parser/LibraryConfigSchema.json | 40 ++
src/parser/StudyConfigSchema.json | 44 ++
src/parser/types.ts | 4 +
.../assets/ScreenRecording.tsx | 221 ++++----
.../assets/WebcamRecording.tsx | 167 ++++++
src/storage/engines/FirebaseStorageEngine.ts | 15 +
src/storage/engines/LocalStorageEngine.ts | 12 +
src/storage/engines/SupabaseStorageEngine.ts | 11 +
src/storage/engines/types.ts | 35 ++
src/storage/tests/edgeCases.spec.ts | 8 +
src/storage/tests/highLevel.spec.ts | 2 +-
src/storage/tests/highLevelFirebase.spec.ts | 14 +
src/store/hooks/tests/useRecording.spec.ts | 79 ++-
.../hooks/tests/useRecordingConfig.spec.ts | 23 +-
src/store/hooks/tests/useReplay.spec.tsx | 31 ++
src/store/hooks/useRecording.ts | 477 +++++++++++-------
src/store/hooks/useRecordingConfig.ts | 16 +-
src/store/hooks/useReplay.ts | 101 ++--
src/store/store.tsx | 4 +
src/store/types.ts | 1 +
src/tests/utils.ts | 4 +
src/utils/handleDownloadFiles.ts | 122 ++++-
src/utils/tests/handleDownloadFiles.spec.ts | 77 ++-
src/utils/tests/useStudyRecordings.spec.ts | 16 +
src/utils/useStudyRecordings.ts | 39 +-
48 files changed, 1483 insertions(+), 529 deletions(-)
create mode 100644 public/libraries/webcam-recording/config.json
create mode 100644 public/library-webcam-recording/assets/webcam-recording-page.md
create mode 100644 public/library-webcam-recording/assets/webcam-recording.md
create mode 100644 public/library-webcam-recording/config.json
create mode 100644 src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
diff --git a/public/libraries/screen-recording/config.json b/public/libraries/screen-recording/config.json
index c755ef381c..b2f9c922c3 100644
--- a/public/libraries/screen-recording/config.json
+++ b/public/libraries/screen-recording/config.json
@@ -1,6 +1,6 @@
{
"$schema": "https://raw.githubusercontent.com/revisit-studies/study/v2.4.3/src/parser/LibraryConfigSchema.json",
- "description": "This is a required library for screen recording. It provides a component that requests user permission for screen and microphone access. Then, it starts screen capture until the end of the study.",
+ "description": "This library requests permission for screen recording and any configured webcam or microphone recording, then keeps those capture streams active until the end of the study.",
"components": {
"screenRecordingPermission": {
"description": "Get permission to start Screen recording",
diff --git a/public/libraries/webcam-recording/config.json b/public/libraries/webcam-recording/config.json
new file mode 100644
index 0000000000..9d331cdebd
--- /dev/null
+++ b/public/libraries/webcam-recording/config.json
@@ -0,0 +1,21 @@
+{
+ "$schema": "https://raw.githubusercontent.com/revisit-studies/study/dev/src/parser/LibraryConfigSchema.json",
+ "description": "This library requests webcam and optional microphone permission, then keeps those capture streams active until the end of the study.",
+ "components": {
+ "webcamRecordingPermission": {
+ "description": "Get permission to start webcam recording",
+ "type": "react-component",
+ "path": "libraries/webcam-recording/assets/WebcamRecording.tsx",
+ "nextButtonLocation": "belowStimulus",
+ "nextButtonText": "Continue",
+ "recordAudio": false,
+ "response": [{
+ "hidden": true,
+ "type": "reactive",
+ "id": "webcamRecordingPermission",
+ "prompt": "Webcam recording enabled"
+ }]
+ }
+ },
+ "sequences": {}
+}
diff --git a/public/library-screen-recording/assets/screen-recording-page.md b/public/library-screen-recording/assets/screen-recording-page.md
index 6b429fc838..da5d66bdc9 100644
--- a/public/library-screen-recording/assets/screen-recording-page.md
+++ b/public/library-screen-recording/assets/screen-recording-page.md
@@ -1,9 +1,8 @@
# Demo Page
-Any stimulus that follows the `screenRecordingPermission` page can be recorded.
+Any stimulus that follows the `screenRecordingPermission` page can capture both screen and webcam video.
Explore the webpage in the iframe below, then proceed to the next page.
- Click the button below
- {' '}
- to enable screen recording.
-
- {screenCapturing ? 'Stop Recording' : 'Start Recording'}
-
-
- Please make sure you are recording the correct tab or window. Otherwise, stop and re-share the correct one.
-
- Note:
-
- Please do not close the window or screen recording until the entire study is completed.
-
- >
- )}
+ into your microphone to check if audio is working.
+ {audioCapturing ? : }
+
+ )}
+
+ Note:
+
+ {studyHasAudioRecording && After we hear you say something, the Continue button will be enabled. }
+ Please do not stop the recording streams until the entire study is completed.
+
);
}
diff --git a/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx b/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
new file mode 100644
index 0000000000..b1fb2cdf3a
--- /dev/null
+++ b/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
@@ -0,0 +1,167 @@
+import {
+ Box, Button, Title,
+} from '@mantine/core';
+import { useEffect, useMemo, useState } from 'react';
+import { useRecordingContext } from '../../../../store/hooks/useRecording';
+import { StimulusParams } from '../../../../store/types';
+import { RecordingAudioWaveform } from '../../../../components/interface/RecordingAudioWaveform';
+
+function WebcamRecordingPermission({ setAnswer }: StimulusParams) {
+ const {
+ studyHasAudioRecording,
+ webcamVideoRef,
+ startWebcamCapture,
+ stopScreenCapture,
+ isWebcamCapturing,
+ isAudioCapturing,
+ screenRecordingError: error,
+ audioRecordingError,
+ audioMediaStream,
+ } = useRecordingContext();
+
+ const [audioCapturingSuccess, setAudioCapturingSuccess] = useState(false);
+
+ const setupComplete = useMemo(
+ () => isWebcamCapturing && (!studyHasAudioRecording || audioCapturingSuccess),
+ [audioCapturingSuccess, isWebcamCapturing, studyHasAudioRecording],
+ );
+
+ useEffect(() => {
+ setAnswer({
+ status: setupComplete,
+ provenanceGraph: undefined,
+ answers: {
+ webcamRecordingPermission: isWebcamCapturing,
+ },
+ });
+ }, [isWebcamCapturing, setAnswer, setupComplete]);
+
+ useEffect(() => {
+ if (!isWebcamCapturing || !studyHasAudioRecording) {
+ return undefined;
+ }
+
+ const stream = audioMediaStream.current;
+ if (!stream) {
+ return undefined;
+ }
+
+ const audioContext = new AudioContext();
+ const audioStreamSource = audioContext.createMediaStreamSource(stream);
+ const analyser = audioContext.createAnalyser();
+
+ analyser.minDecibels = -45;
+ audioStreamSource.connect(analyser);
+
+ const bufferLength = analyser.frequencyBinCount;
+ const domainData = new Uint8Array(bufferLength);
+ let soundDetected = false;
+ let animationFrame = 0;
+
+ const detectSound = () => {
+ if (soundDetected) {
+ return;
+ }
+
+ analyser.getByteFrequencyData(domainData);
+
+ for (let i = 0; i < bufferLength; i += 1) {
+ if (domainData[i] > 0) {
+ soundDetected = true;
+ setAudioCapturingSuccess(true);
+ return;
+ }
+ }
+
+ animationFrame = window.requestAnimationFrame(detectSound);
+ };
+
+ animationFrame = window.requestAnimationFrame(detectSound);
+
+ return () => {
+ window.cancelAnimationFrame(animationFrame);
+ audioStreamSource.disconnect();
+ analyser.disconnect();
+ audioContext.close().catch(() => undefined);
+ };
+ }, [audioMediaStream, isWebcamCapturing, studyHasAudioRecording]);
+
+ return (
+
+
+ Webcam
+ {studyHasAudioRecording && ' and Audio'}
+ {' '}
+ Recording Permission
+
+
+
+ This study requires recording of your
+ {' '}
+
+ webcam
+ {studyHasAudioRecording ? ' and audio' : ''}
+
+ . If you're not comfortable, you may exit and return the study.
+
+ Follow the steps below to grant the required permissions.
+
+
+
+ Click the button below
+ {' '}
+ to enable webcam recording.
+
+ {isWebcamCapturing ? 'Stop Recording' : 'Start Recording'}
+
+ {error && {error}
}
+ {audioRecordingError && {audioRecordingError}
}
+
+
+ Confirm your webcam preview
+ {' '}
+ before continuing.
+
+
+ {studyHasAudioRecording && (
+
+ Speak
+ {' '}
+ into your microphone to check if audio is working.
+ {isAudioCapturing ? (
+
+
+
+ ) : }
+
+ )}
+
+ Note:
+
+ {studyHasAudioRecording && (
+
+ After we hear you say something, the
+ {' '}
+ Continue
+ {' '}
+ button will be enabled.
+
+ )}
+ Please do not stop the webcam recording stream until the entire study is completed.
+
+
+ );
+}
+
+export default WebcamRecordingPermission;
diff --git a/src/storage/engines/FirebaseStorageEngine.ts b/src/storage/engines/FirebaseStorageEngine.ts
index ac7c8af4be..734a8b4b83 100644
--- a/src/storage/engines/FirebaseStorageEngine.ts
+++ b/src/storage/engines/FirebaseStorageEngine.ts
@@ -587,6 +587,21 @@ export class FirebaseStorageEngine extends CloudStorageEngine {
}
}
+ protected async _getWebcamRecordingUrl(
+ task: string,
+ participantId: string,
+ ): Promise {
+ const storage = getStorage();
+ const webcamRecordingRef = ref(storage, `${this.collectionPrefix}${this.studyId}/webcamRecording/${participantId}_${task}`);
+
+ try {
+ return await getDownloadURL(webcamRecordingRef);
+ } catch {
+ console.warn(`Webcam recording for task ${task} and participant ${participantId} not found.`);
+ return null;
+ }
+ }
+
protected async _getTranscriptUrl(
task: string,
participantId: string,
diff --git a/src/storage/engines/LocalStorageEngine.ts b/src/storage/engines/LocalStorageEngine.ts
index 48695b5752..2e80b08800 100644
--- a/src/storage/engines/LocalStorageEngine.ts
+++ b/src/storage/engines/LocalStorageEngine.ts
@@ -274,6 +274,18 @@ export class LocalStorageEngine extends StorageEngine {
return URL.createObjectURL(screenRecordingBlob);
}
+ protected async _getWebcamRecordingUrl(task: string, participantId?: string) {
+ await this.verifyStudyDatabase();
+ if (this.studyId === undefined) {
+ throw new Error('Study ID is not set');
+ }
+ const webcamRecordingBlob = await this._getFromStorage(`webcamRecording/${participantId || this.currentParticipantId}`, task);
+ if (!webcamRecordingBlob) {
+ throw new Error(`WebcamRecording for task ${task} and participant ${participantId || this.currentParticipantId} not found`);
+ }
+ return URL.createObjectURL(webcamRecordingBlob);
+ }
+
protected async _testingReset(studyId: string) {
if (!studyId) {
throw new Error('Study ID is required for reset');
diff --git a/src/storage/engines/SupabaseStorageEngine.ts b/src/storage/engines/SupabaseStorageEngine.ts
index 8d1e023871..a99d374927 100644
--- a/src/storage/engines/SupabaseStorageEngine.ts
+++ b/src/storage/engines/SupabaseStorageEngine.ts
@@ -543,6 +543,17 @@ export class SupabaseStorageEngine extends CloudStorageEngine {
return screenRecording ? URL.createObjectURL(screenRecording) : null;
}
+ protected async _getWebcamRecordingUrl(task: string, participantId?: string) {
+ await this.verifyStudyDatabase();
+ const id = participantId || this.currentParticipantId;
+ if (!id) {
+ throw new Error('Participant not initialized');
+ }
+
+ const webcamRecording = await this._getFromStorage(`/webcamRecording/${id}`, task);
+ return webcamRecording ? URL.createObjectURL(webcamRecording) : null;
+ }
+
protected async _testingReset(studyId: string) {
// Delete all rows with studyId matching the studyId
const { error } = await this.supabase
diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts
index aad3b747a0..713adf4f28 100644
--- a/src/storage/engines/types.ts
+++ b/src/storage/engines/types.ts
@@ -310,6 +310,9 @@ export abstract class StorageEngine {
// Gets the screen recording URL for the given task and participantId. This method is used to fetch the screen recording video file from the storage engine.
protected abstract _getScreenRecordingUrl(task: string, participantId?: string): Promise;
+ // Gets the webcam recording URL for the given task and participantId.
+ protected abstract _getWebcamRecordingUrl(task: string, participantId?: string): Promise;
+
// Gets the transcript URL for the given task and participantId. (Optional - not all storage engines need to implement this, only if they generate transcripts).
protected _getTranscriptUrl?(task: string, participantId?: string): Promise;
@@ -1764,6 +1767,32 @@ export abstract class StorageEngine {
});
}
+ // Gets the webcam recording for a specific task and participantId.
+ async getWebcamRecording(
+ task: string,
+ participantId: string,
+ ) {
+ const url = await this._getWebcamRecordingUrl(task, participantId);
+ return this.getAsset(url);
+ }
+
+ // Saves the webcam video stream as a separate per-task asset.
+ async saveWebcamRecording(
+ blob: Blob,
+ taskName: string,
+ ) {
+ return this.trackAssetOperation(`webcamRecording/${taskName}`, async () => {
+ if (this.studyId === undefined) {
+ throw new Error('Study ID is not set');
+ }
+ const modes = await this.getModes(this.studyId);
+ if (!modes.dataCollectionEnabled) {
+ throw new Error('Data collection is disabled for this study');
+ }
+ return this.saveAsset('webcamRecording', blob, taskName);
+ });
+ }
+
// Gets the sequence array from the storage engine.
async getSequenceArray() {
await this.verifyStudyDatabase();
@@ -1846,6 +1875,7 @@ export abstract class StorageEngine {
await this._copyDirectory(`${sourceName}/participants`, `${targetName}/participants`);
await this._copyDirectory(`${sourceName}/audio`, `${targetName}/audio`);
await this._copyDirectory(`${sourceName}/screenRecording`, `${targetName}/screenRecording`);
+ await this._copyDirectory(`${sourceName}/webcamRecording`, `${targetName}/webcamRecording`);
await this._copyDirectory(`${sourceName}/provenance`, `${targetName}/provenance`);
await this._copyDirectory(sourceName, targetName);
await this._copyRealtimeData(sourceName, targetName);
@@ -1894,6 +1924,7 @@ export abstract class StorageEngine {
await this._deleteDirectory(`${deletionTarget}/participants`);
await this._deleteDirectory(`${deletionTarget}/audio`);
await this._deleteDirectory(`${deletionTarget}/screenRecording`);
+ await this._deleteDirectory(`${deletionTarget}/webcamRecording`);
await this._deleteDirectory(`${deletionTarget}/provenance`);
await this._deleteDirectory(deletionTarget);
await this._deleteRealtimeData(deletionTarget);
@@ -1964,6 +1995,10 @@ export abstract class StorageEngine {
`${snapshotName}/screenRecording`,
`${originalName}/screenRecording`,
);
+ await this._copyDirectory(
+ `${snapshotName}/webcamRecording`,
+ `${originalName}/webcamRecording`,
+ );
await this._copyDirectory(
`${snapshotName}/provenance`,
`${originalName}/provenance`,
diff --git a/src/storage/tests/edgeCases.spec.ts b/src/storage/tests/edgeCases.spec.ts
index 8f578061c8..5283607c18 100644
--- a/src/storage/tests/edgeCases.spec.ts
+++ b/src/storage/tests/edgeCases.spec.ts
@@ -287,6 +287,14 @@ describe('StorageEngine edge cases', () => {
await expect(storageEngine.saveScreenRecording(blob, 'task1')).rejects.toThrow('Data collection is disabled');
});
+ test('saveWebcamRecording throws when data collection is disabled', async () => {
+ await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata);
+ await storageEngine.setMode(studyId, 'dataCollectionEnabled', false);
+
+ const blob = new Blob(['video'], { type: 'video/webm' });
+ await expect(storageEngine.saveWebcamRecording(blob, 'task1')).rejects.toThrow('Data collection is disabled');
+ });
+
test('multiple concurrent saveAsset calls are tracked independently', async () => {
await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata);
diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts
index d0d20f7523..7020f9b0bd 100644
--- a/src/storage/tests/highLevel.spec.ts
+++ b/src/storage/tests/highLevel.spec.ts
@@ -180,7 +180,7 @@ class DelayedLocalStorageEngine extends LocalStorageEngine {
) {
const isParticipantDataWrite = type === 'participantData' && prefix.startsWith('participants/');
const isAssetUpload = objectToUpload instanceof Blob
- && (prefix.startsWith('audio/') || prefix.startsWith('screenRecording/'));
+ && (prefix.startsWith('audio/') || prefix.startsWith('screenRecording/') || prefix.startsWith('webcamRecording/'));
if (isParticipantDataWrite && this.holdParticipantDataWrite) {
this.holdParticipantDataWrite = false;
diff --git a/src/storage/tests/highLevelFirebase.spec.ts b/src/storage/tests/highLevelFirebase.spec.ts
index a8087875a8..810067231a 100644
--- a/src/storage/tests/highLevelFirebase.spec.ts
+++ b/src/storage/tests/highLevelFirebase.spec.ts
@@ -1094,6 +1094,20 @@ describe.each([
expect(await storageEngine._getScreenRecordingUrl('missing', 'p1')).toBeNull();
});
+ test('_getWebcamRecordingUrl returns URL when recording exists', async () => {
+ // @ts-expect-error protected
+ const prefix = storageEngine.collectionPrefix;
+ const path = `${prefix}${studyId}/webcamRecording/p1_task2`;
+ storageObjects[path] = 'data';
+ // @ts-expect-error protected
+ expect(await storageEngine._getWebcamRecordingUrl('task2', 'p1')).toContain(path);
+ });
+
+ test('_getWebcamRecordingUrl returns null when recording does not exist', async () => {
+ // @ts-expect-error protected
+ expect(await storageEngine._getWebcamRecordingUrl('missing', 'p1')).toBeNull();
+ });
+
test('_getTranscriptUrl returns URL when transcript exists', async () => {
// @ts-expect-error protected
const prefix = storageEngine.collectionPrefix;
diff --git a/src/store/hooks/tests/useRecording.spec.ts b/src/store/hooks/tests/useRecording.spec.ts
index 5036497436..8abeaa655a 100644
--- a/src/store/hooks/tests/useRecording.spec.ts
+++ b/src/store/hooks/tests/useRecording.spec.ts
@@ -16,8 +16,10 @@ import ScreenRecordingPermission from '../../../public/libraries/screen-recordin
let mockRecordingConfig = {
studyHasScreenRecording: false,
studyHasAudioRecording: false,
+ studyHasWebcamRecording: false,
currentComponentHasAudioRecording: false,
currentComponentHasScreenRecording: false,
+ currentComponentHasWebcamRecording: false,
currentComponentHasClickToRecord: false,
};
let mockCurrentComponent = 'intro';
@@ -60,9 +62,15 @@ class MockMediaRecorder {
constructor(s: MockMediaStream) { this.stream = s; }
- start = vi.fn();
+ start = vi.fn(() => {
+ this.state = 'recording';
+ this._listeners.start?.({});
+ });
- stop = vi.fn();
+ stop = vi.fn(() => {
+ this.state = 'inactive';
+ this._listeners.stop?.({});
+ });
addEventListener = vi.fn((event: string, handler: (event: Partial<{ data: Blob }>) => void) => {
this._listeners[event] = handler;
@@ -97,7 +105,7 @@ function renderWithMantine(ui: React.ReactElement) {
vi.mock('../useStudyConfig', () => ({
useStudyConfig: () => ({
- uiConfig: { recordScreenFPS: undefined, recordAudio: false },
+ uiConfig: { recordScreenFPS: undefined, recordAudio: false, recordWebcam: false },
sequence: {
id: 'root', order: 'fixed', components: ['intro', 'end'], skip: [],
},
@@ -137,8 +145,10 @@ beforeEach(() => {
mockRecordingConfig = {
studyHasScreenRecording: false,
studyHasAudioRecording: false,
+ studyHasWebcamRecording: false,
currentComponentHasAudioRecording: false,
currentComponentHasScreenRecording: false,
+ currentComponentHasWebcamRecording: false,
currentComponentHasClickToRecord: false,
};
mockCurrentComponent = 'intro';
@@ -178,7 +188,9 @@ describe('useRecording', () => {
const { result } = renderHook(() => useRecording());
expect(result.current.isScreenRecording).toBe(false);
expect(result.current.isAudioRecording).toBe(false);
+ expect(result.current.isWebcamRecording).toBe(false);
expect(result.current.isScreenCapturing).toBe(false);
+ expect(result.current.isWebcamCapturing).toBe(false);
});
test('initial screenRecordingError is null', () => {
@@ -281,6 +293,22 @@ describe('useRecording startScreenCapture', () => {
expect(result.current.screenRecordingError).toBe('Recording permission denied');
});
});
+
+ test('webcam-only capture does not request display media', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasWebcamRecording: true,
+ currentComponentHasWebcamRecording: true,
+ };
+ mockStorageEngine = { saveWebcamRecording: vi.fn(async () => {}) };
+ const { result } = renderHook(() => useRecording());
+
+ act(() => { result.current.startWebcamCapture(); });
+
+ await waitFor(() => expect(result.current.isWebcamCapturing).toBe(true));
+ expect(result.current.isScreenCapturing).toBe(false);
+ expect(navigator.mediaDevices.getDisplayMedia).not.toHaveBeenCalled();
+ });
});
// ── startScreenRecording tests ─────────────────────────────────────────────────
@@ -327,13 +355,56 @@ describe('useRecording startScreenRecording after startScreenCapture', () => {
currentComponentHasScreenRecording: true,
currentComponentHasAudioRecording: false,
};
- mockStorageEngine = { saveAudioRecording: vi.fn(async () => {}) };
+ mockStorageEngine = { saveScreenRecording: vi.fn(async () => {}) };
const { result } = renderHook(() => useRecording());
act(() => { result.current.startScreenCapture(); });
await waitFor(() => { expect(result.current.isMediaCapturing).toBe(true); });
act(() => { result.current.startScreenRecording('trial_0'); });
expect(result.current.isScreenRecording).toBe(true);
});
+
+ test('starts a separate webcam recorder for webcam-only trials', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasWebcamRecording: true,
+ currentComponentHasWebcamRecording: true,
+ };
+ mockStorageEngine = { saveWebcamRecording: vi.fn(async () => {}) };
+ const { result } = renderHook(() => useRecording());
+
+ act(() => { result.current.startWebcamCapture(); });
+ await waitFor(() => expect(result.current.isMediaCapturing).toBe(true));
+ await waitFor(() => expect(result.current.isWebcamRecording).toBe(true));
+
+ act(() => { result.current.stopScreenRecording(); });
+ expect(result.current.isWebcamRecording).toBe(false);
+ });
+
+ test('starts screen, webcam, and audio recording together', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasAudioRecording: true,
+ studyHasWebcamRecording: true,
+ currentComponentHasScreenRecording: true,
+ currentComponentHasAudioRecording: true,
+ currentComponentHasWebcamRecording: true,
+ };
+ mockStorageEngine = {
+ saveScreenRecording: vi.fn(async () => {}),
+ saveAudioRecording: vi.fn(async () => {}),
+ saveWebcamRecording: vi.fn(async () => {}),
+ };
+ const { result } = renderHook(() => useRecording());
+
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.isMediaCapturing).toBe(true));
+ await waitFor(() => {
+ expect(result.current.isScreenRecording).toBe(true);
+ expect(result.current.isWebcamRecording).toBe(true);
+ expect(result.current.isAudioRecording).toBe(true);
+ });
+ });
});
// ── stopScreenCapture with populated refs ──────────────────────────────────────
diff --git a/src/store/hooks/tests/useRecordingConfig.spec.ts b/src/store/hooks/tests/useRecordingConfig.spec.ts
index 6adba5f7ee..df5f75b680 100644
--- a/src/store/hooks/tests/useRecordingConfig.spec.ts
+++ b/src/store/hooks/tests/useRecordingConfig.spec.ts
@@ -10,7 +10,9 @@ import { makeStudyConfig } from '../../../tests/utils';
vi.mock('../useStudyConfig', () => ({
useStudyConfig: vi.fn(() => ({
- uiConfig: { recordScreen: false, recordAudio: false, clickToRecord: false },
+ uiConfig: {
+ recordScreen: false, recordAudio: false, recordWebcam: false, clickToRecord: false,
+ },
components: {},
})),
}));
@@ -30,8 +32,10 @@ describe('useRecordingConfig', () => {
const { result } = renderHook(() => useRecordingConfig());
expect(result.current.studyHasScreenRecording).toBe(false);
expect(result.current.studyHasAudioRecording).toBe(false);
+ expect(result.current.studyHasWebcamRecording).toBe(false);
expect(result.current.currentComponentHasScreenRecording).toBe(false);
expect(result.current.currentComponentHasAudioRecording).toBe(false);
+ expect(result.current.currentComponentHasWebcamRecording).toBe(false);
expect(result.current.currentComponentHasClickToRecord).toBe(false);
});
@@ -55,4 +59,21 @@ describe('useRecordingConfig', () => {
const { result } = renderHook(() => useRecordingConfig());
expect(result.current.studyHasAudioRecording).toBe(true);
});
+
+ test('webcam recording can be enabled globally', () => {
+ vi.mocked(useStudyConfig).mockReturnValueOnce(makeStudyConfig({ uiConfig: { recordWebcam: true } }));
+ const { result } = renderHook(() => useRecordingConfig());
+ expect(result.current.studyHasWebcamRecording).toBe(true);
+ expect(result.current.currentComponentHasWebcamRecording).toBe(true);
+ });
+
+ test('webcam recording can be enabled for one sequence component', () => {
+ vi.mocked(useStudyConfig).mockReturnValueOnce(
+ makeStudyConfig({ components: { trial1: { recordWebcam: true } } }),
+ );
+ vi.mocked(useFlatSequence).mockReturnValueOnce(['trial1']);
+ const { result } = renderHook(() => useRecordingConfig());
+ expect(result.current.studyHasWebcamRecording).toBe(true);
+ expect(result.current.currentComponentHasWebcamRecording).toBe(true);
+ });
});
diff --git a/src/store/hooks/tests/useReplay.spec.tsx b/src/store/hooks/tests/useReplay.spec.tsx
index 7a107f51cf..1f3e69a938 100644
--- a/src/store/hooks/tests/useReplay.spec.tsx
+++ b/src/store/hooks/tests/useReplay.spec.tsx
@@ -289,6 +289,37 @@ describe('useReplay — handlePlay/Seeked/Pause via video element events', () =>
expect(result.current.replayRef.current).toBe(audio);
});
+ test('updateReplayRef uses webcam video when no screen recording exists', () => {
+ const { result } = renderHook(() => useReplay());
+ const webcam = makeVideoWithSrc();
+ act(() => {
+ result.current.webcamVideoRef.current = webcam;
+ result.current.updateReplayRef();
+ });
+ expect(result.current.replayRef.current).toBe(webcam);
+ expect(webcam.muted).toBe(true);
+ });
+
+ test('screen playback starts and seeks the webcam recording', () => {
+ const { result } = renderHook(() => useReplay());
+ const screenVideo = makeVideoWithSrc();
+ const webcamVideo = makeVideoWithSrc();
+ webcamVideo.play = vi.fn(async () => {});
+ webcamVideo.pause = vi.fn();
+
+ act(() => {
+ result.current.videoRef.current = screenVideo;
+ result.current.webcamVideoRef.current = webcamVideo;
+ result.current.updateReplayRef();
+ result.current.setSeekTime(4);
+ screenVideo.dispatchEvent(new Event('play'));
+ });
+
+ expect(webcamVideo.currentTime).toBe(4);
+ expect(webcamVideo.play).toHaveBeenCalled();
+ expect(webcamVideo.muted).toBe(true);
+ });
+
test('updateReplayRef detaches listeners from the previous media element', () => {
const { result } = renderHook(() => useReplay());
const originalVideo = makeVideoWithSrc();
diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts
index 4f534916e5..3592472cf3 100644
--- a/src/store/hooks/useRecording.ts
+++ b/src/store/hooks/useRecording.ts
@@ -15,9 +15,25 @@ import {
} from '../../utils/recordingWarnings';
import { useStoreSelector } from '../store';
+const SCREEN_PERMISSION_COMPONENT = '$screen-recording.components.screenRecordingPermission';
+const WEBCAM_PERMISSION_COMPONENT = '$webcam-recording.components.webcamRecordingPermission';
+
+function stopMediaTracks(stream: MediaStream | null) {
+ stream?.getTracks().forEach((track) => {
+ track.stop();
+ stream.removeTrack(track);
+ });
+}
+
+function stopRecorder(recorder: MediaRecorder | null) {
+ if (recorder && recorder.state !== 'inactive') {
+ recorder.stop();
+ }
+}
+
/**
- * Captures and records the screen and audio.
- * When screen recording is enabled in atleast a stimulus, screen capture should be called before recording is initiated.
+ * Captures and records the screen, webcam, and audio.
+ * When screen or webcam recording is enabled in a stimulus, capture should be called before recording is initiated.
* When just audio recording is enabled throughout the study, recording is initiated on each screen separately.
*/
export function useRecording() {
@@ -26,14 +42,17 @@ export function useRecording() {
const { recordScreenFPS, recordAudio } = studyConfig.uiConfig;
const recordVideoRef = useRef(null);
+ const webcamVideoRef = useRef(null);
const [screenRecordingError, setRecordingError] = useState(null);
const [audioRecordingError, setAudioRecordingError] = useState(null);
const [isScreenRecording, setIsScreenRecording] = useState(false);
const [isAudioRecording, setIsAudioRecording] = useState(false);
+ const [isWebcamRecording, setIsWebcamRecording] = useState(false);
const [screenWithAudioRecording, setScreenWithAudioRecording] = useState(false);
- const [screenCaptureStarted, setScreenCaptureStarted] = useState(false);
+ const [mediaCaptureStarted, setMediaCaptureStarted] = useState(false);
const [isScreenCapturing, setIsScreenCapturing] = useState(false);
const [isAudioCapturing, setIsAudioCapturing] = useState(false);
+ const [isWebcamCapturing, setIsWebcamCapturing] = useState(false);
const [isMediaCapturing, setIsMediaCapturing] = useState(false);
const [isRejected, setIsRejected] = useState(false);
const [isMuted, setIsMuted] = useState(false);
@@ -41,7 +60,6 @@ export function useRecording() {
const [isSpeakingWhileMuted, setIsSpeakingWhileMuted] = useState(false);
const [analysisStreamReady, setAnalysisStreamReady] = useState(false);
const [showMutedWarning, setShowMutedWarning] = useState(false);
- const modes = useStoreSelector((state) => state.modes);
// currentMediaStream and recorder can be just screen, just audio, or screen and audio combined.
const currentMediaStream = useRef(null);
@@ -49,6 +67,9 @@ export function useRecording() {
const audioMediaStream = useRef(null);
const audioMediaRecorder = useRef(null); // recorder for audio. Necessary to save audio file to get transcription.
const screenMediaStream = useRef(null);
+ const webcamMediaStream = useRef(null);
+ const webcamMediaRecorder = useRef(null);
+ const isStoppingCapture = useRef(false);
const currentTrialName = useRef(null);
const identifier = useCurrentIdentifier();
@@ -56,8 +77,7 @@ export function useRecording() {
const isAnalysis = useIsAnalysis();
const { storageEngine } = useStorageEngine();
-
- const { dataCollectionEnabled } = modes;
+ const { dataCollectionEnabled } = useStoreSelector((state) => state.modes);
const currentComponent = useCurrentComponent();
@@ -66,8 +86,10 @@ export function useRecording() {
const {
studyHasScreenRecording,
studyHasAudioRecording,
+ studyHasWebcamRecording,
currentComponentHasAudioRecording,
currentComponentHasScreenRecording,
+ currentComponentHasWebcamRecording,
currentComponentHasClickToRecord,
} = useRecordingConfig();
@@ -79,156 +101,163 @@ export function useRecording() {
// At the beginning of each stimulus, recording starts by calling `startScreenRecording`.
// At the end of each stimulus, recording stops by calling `stopScreenRecording`.
- // Stop the screen capture.
+ // Stop all persistent media capture streams.
const stopScreenCapture = useCallback(() => {
+ if (isStoppingCapture.current) {
+ return;
+ }
+ isStoppingCapture.current = true;
+
if (recordVideoRef.current) {
recordVideoRef.current.srcObject = null;
}
+ if (webcamVideoRef.current) {
+ webcamVideoRef.current.srcObject = null;
+ }
setIsScreenCapturing(false);
setIsAudioCapturing(false);
+ setIsWebcamCapturing(false);
setIsMediaCapturing(false);
setIsScreenRecording(false);
setIsAudioRecording(false);
+ setIsWebcamRecording(false);
setScreenWithAudioRecording(false);
- if (audioMediaStream.current) {
- audioMediaStream.current.getTracks().forEach((track) => {
- track.stop();
- audioMediaStream.current?.removeTrack(track);
- });
- audioMediaStream.current = null;
- }
+ stopRecorder(currentMediaRecorder.current);
+ stopRecorder(webcamMediaRecorder.current);
+ stopRecorder(audioMediaRecorder.current);
+
+ stopMediaTracks(audioMediaStream.current);
+ stopMediaTracks(screenMediaStream.current);
+ stopMediaTracks(webcamMediaStream.current);
+ stopMediaTracks(analysisAudioStream.current);
- analysisAudioStream.current?.getTracks().forEach((t) => t.stop());
+ audioMediaStream.current = null;
+ screenMediaStream.current = null;
+ webcamMediaStream.current = null;
analysisAudioStream.current = null;
setAnalysisStreamReady(false);
+ currentMediaRecorder.current = null;
+ webcamMediaRecorder.current = null;
+ audioMediaRecorder.current = null;
- if (screenMediaStream.current) {
- screenMediaStream.current.getTracks().forEach((track) => {
- track.stop();
- screenMediaStream.current?.removeTrack(track);
- });
- screenMediaStream.current = null;
- }
-
- if (currentMediaRecorder.current) {
- currentMediaRecorder.current.stream.getTracks().forEach((track) => { track.stop(); currentMediaRecorder.current?.stream.removeTrack(track); });
- currentMediaRecorder.current.stream.getVideoTracks().forEach((track) => { track.stop(); currentMediaRecorder.current?.stream.removeTrack(track); });
- currentMediaRecorder.current.stream.getAudioTracks().forEach((track) => { track.stop(); currentMediaRecorder.current?.stream.removeTrack(track); });
- currentMediaRecorder.current.stop();
- currentMediaRecorder.current = null;
- }
-
- if (audioMediaRecorder.current) {
- audioMediaRecorder.current.stream.getTracks().forEach((track) => { track.stop(); audioMediaRecorder.current?.stream.removeTrack(track); });
- audioMediaRecorder.current.stream.getAudioTracks().forEach((track) => { track.stop(); audioMediaRecorder.current?.stream.removeTrack(track); });
- audioMediaRecorder.current.stop();
- audioMediaRecorder.current = null;
- }
+ window.setTimeout(() => {
+ isStoppingCapture.current = false;
+ }, 0);
}, []);
- // Start screen recording
- const startScreenRecording = useCallback((trialName: string) => {
- if (!dataCollectionEnabled) {
- return;
- }
-
- // check if the current stimulus needs combined or just screen
- if (!(currentComponentHasAudioRecording || currentComponentHasScreenRecording)) {
- return;
- }
-
- if (!screenMediaStream.current) {
- return;
- }
-
- currentMediaStream.current = new MediaStream([
- ...((currentComponentHasScreenRecording ? screenMediaStream.current?.getVideoTracks() : []) ?? []),
- ...((currentComponentHasAudioRecording ? audioMediaStream.current?.getAudioTracks() : []) ?? []),
- ]);
-
- const stream = currentMediaStream.current;
+ const attachSaveHandler = useCallback((
+ recorder: MediaRecorder,
+ saveBlob: ((blob: Blob, trialName: string) => Promise) | undefined,
+ trialName: string,
+ errorLabel: string,
+ ) => {
+ let chunks: Blob[] = [];
- stream.getAudioTracks().forEach((track) => {
- track.enabled = !isMuted;
+ recorder.addEventListener('start', () => {
+ chunks = [];
});
-
- const mediaRecorder = new MediaRecorder(stream);
-
- currentMediaRecorder.current = mediaRecorder;
-
- const audioRecorder = (currentComponentHasAudioRecording && audioMediaStream.current) ? new MediaRecorder(audioMediaStream.current) : null;
- audioMediaRecorder.current = audioRecorder;
-
- let chunks: Blob[] = [];
- mediaRecorder.addEventListener('dataavailable', (event: BlobEvent) => {
+ recorder.addEventListener('dataavailable', (event: BlobEvent) => {
if (event.data && event.data.size > 0) {
chunks.push(event.data);
}
});
-
- let audioChunks: Blob[] = [];
- audioRecorder?.addEventListener('dataavailable', (event: BlobEvent) => {
- if (event.data && event.data.size > 0) {
- audioChunks.push(event.data);
+ recorder.addEventListener('stop', () => {
+ if (!saveBlob) {
+ return;
}
+ saveBlob(new Blob(chunks, { type: recorder.mimeType }), trialName).catch((error) => {
+ console.error(`Error saving ${errorLabel}:`, error);
+ });
});
+ }, []);
- mediaRecorder.addEventListener('start', () => {
- chunks = [];
- });
-
- audioRecorder?.addEventListener('start', () => {
- audioChunks = [];
- });
-
- if (currentComponentHasScreenRecording) {
- mediaRecorder.addEventListener('stop', () => {
- const { mimeType } = mediaRecorder;
+ // Start separate per-trial screen, webcam, and audio recordings from persistent capture streams.
+ const startScreenRecording = useCallback((trialName: string) => {
+ const wantsScreen = currentComponentHasScreenRecording;
+ const wantsAudio = currentComponentHasAudioRecording;
+ const wantsWebcam = currentComponentHasWebcamRecording;
- const blob = new Blob(chunks, { type: mimeType });
- storageEngine?.saveScreenRecording(blob, trialName).catch((error) => {
- console.error('Error saving screen recording:', error);
- });
- });
+ if (!dataCollectionEnabled || !(wantsScreen || wantsAudio || wantsWebcam)) {
+ return;
+ }
+ if ((wantsScreen && !screenMediaStream.current)
+ || (wantsWebcam && !webcamMediaStream.current)
+ || (wantsAudio && !audioMediaStream.current)) {
+ return;
+ }
- audioRecorder?.addEventListener('stop', () => {
- const { mimeType } = audioRecorder;
+ if (wantsScreen && screenMediaStream.current) {
+ const screenStream = new MediaStream([
+ ...screenMediaStream.current.getVideoTracks(),
+ ...(wantsAudio ? audioMediaStream.current?.getAudioTracks() ?? [] : []),
+ ]);
+ currentMediaStream.current = screenStream;
+ const recorder = new MediaRecorder(screenStream);
+ currentMediaRecorder.current = recorder;
+ attachSaveHandler(
+ recorder,
+ storageEngine ? storageEngine.saveScreenRecording.bind(storageEngine) : undefined,
+ trialName,
+ 'screen recording',
+ );
+ recorder.start(1000);
+ }
- const blob = new Blob(audioChunks, { type: mimeType });
- storageEngine?.saveAudioRecording(blob, trialName).catch((error) => {
- console.error('Error saving audio recording:', error);
- });
- });
- } else {
- mediaRecorder.addEventListener('stop', () => {
- const { mimeType } = mediaRecorder;
+ if (wantsWebcam && webcamMediaStream.current) {
+ const webcamStream = new MediaStream(webcamMediaStream.current.getVideoTracks());
+ const recorder = new MediaRecorder(webcamStream);
+ webcamMediaRecorder.current = recorder;
+ currentMediaRecorder.current ??= recorder;
+ attachSaveHandler(
+ recorder,
+ storageEngine ? storageEngine.saveWebcamRecording.bind(storageEngine) : undefined,
+ trialName,
+ 'webcam recording',
+ );
+ recorder.start(1000);
+ }
- const blob = new Blob(chunks, { type: mimeType });
- storageEngine?.saveAudioRecording(blob, trialName).catch((error) => {
- console.error('Error saving audio recording:', error);
- });
- });
+ if (wantsAudio && audioMediaStream.current) {
+ const audioStream = new MediaStream(audioMediaStream.current.getAudioTracks());
+ const recorder = new MediaRecorder(audioStream);
+ audioMediaRecorder.current = recorder;
+ currentMediaRecorder.current ??= recorder;
+ attachSaveHandler(
+ recorder,
+ storageEngine ? storageEngine.saveAudioRecording.bind(storageEngine) : undefined,
+ trialName,
+ 'audio recording',
+ );
+ recorder.start(1000);
}
- setIsScreenCapturing(true);
- setScreenCaptureStarted(true);
- setScreenWithAudioRecording(currentComponentHasAudioRecording);
+ setScreenWithAudioRecording(wantsScreen && wantsAudio);
setRecordingError(null);
-
- setIsAudioRecording(currentComponentHasAudioRecording);
- setIsScreenRecording(currentComponentHasScreenRecording);
-
- mediaRecorder.start(1000); // 1s chunks
- audioRecorder?.start(1000);
- }, [currentComponentHasAudioRecording, currentComponentHasScreenRecording, storageEngine, isMuted]);
+ setIsAudioRecording(wantsAudio);
+ setIsScreenRecording(wantsScreen);
+ setIsWebcamRecording(wantsWebcam);
+ }, [
+ attachSaveHandler,
+ currentComponentHasAudioRecording,
+ currentComponentHasScreenRecording,
+ currentComponentHasWebcamRecording,
+ dataCollectionEnabled,
+ storageEngine,
+ ]);
// Stop screen recording. This does not stop screen capture.
const stopScreenRecording = useCallback(() => {
setIsScreenRecording(false);
+ setIsAudioRecording(false);
+ setIsWebcamRecording(false);
setScreenWithAudioRecording(false);
- currentMediaRecorder.current?.stop();
- audioMediaRecorder.current?.stop();
+ stopRecorder(currentMediaRecorder.current);
+ stopRecorder(webcamMediaRecorder.current);
+ stopRecorder(audioMediaRecorder.current);
+ currentMediaRecorder.current = null;
+ webcamMediaRecorder.current = null;
+ audioMediaRecorder.current = null;
}, []);
const stopAudioRecording = useCallback(() => {
@@ -236,23 +265,38 @@ export function useRecording() {
setScreenWithAudioRecording(false);
setIsAudioRecording(false);
- currentMediaRecorder.current?.stop();
+ stopRecorder(currentMediaRecorder.current);
if (audioMediaRecorder.current) {
audioMediaRecorder.current.stream.getTracks().forEach((track) => { track.stop(); audioMediaRecorder.current?.stream.removeTrack(track); });
audioMediaRecorder.current.stream.getAudioTracks().forEach((track) => { track.stop(); audioMediaRecorder.current?.stream.removeTrack(track); });
- audioMediaRecorder.current.stop();
+ stopRecorder(audioMediaRecorder.current);
audioMediaRecorder.current = null;
}
- analysisAudioStream.current?.getTracks().forEach((t) => t.stop());
+ stopMediaTracks(analysisAudioStream.current);
analysisAudioStream.current = null;
setAnalysisStreamReady(false);
}, []);
useEffect(() => {
- if (currentComponent !== '$screen-recording.components.screenRecordingPermission' && currentComponent !== 'end' && screenCaptureStarted && !isScreenCapturing) {
+ const isPermissionComponent = currentComponent === SCREEN_PERMISSION_COMPONENT
+ || currentComponent === WEBCAM_PERMISSION_COMPONENT;
+ const missingRequiredCapture = (studyHasScreenRecording && !isScreenCapturing)
+ || (studyHasWebcamRecording && !isWebcamCapturing)
+ || ((studyHasScreenRecording || studyHasWebcamRecording) && studyHasAudioRecording && !isAudioCapturing);
+
+ if (!isPermissionComponent && currentComponent !== 'end' && mediaCaptureStarted && missingRequiredCapture) {
setIsRejected(true);
}
- }, [currentComponent, isScreenCapturing, screenCaptureStarted]);
+ }, [
+ currentComponent,
+ isAudioCapturing,
+ isScreenCapturing,
+ isWebcamCapturing,
+ mediaCaptureStarted,
+ studyHasAudioRecording,
+ studyHasScreenRecording,
+ studyHasWebcamRecording,
+ ]);
const startAudioRecording = useCallback((trialName: string) => {
if (!dataCollectionEnabled) {
@@ -306,7 +350,7 @@ export function useRecording() {
setAudioRecordingError('Microphone permission denied');
setIsAudioRecording(false);
});
- }, [dataCollectionEnabled, isMuted, storageEngine]);
+ }, [dataCollectionEnabled, storageEngine, isMuted]);
// For study with just audio recording
useEffect(() => {
@@ -317,7 +361,7 @@ export function useRecording() {
return;
}
- if (!studyConfig || studyHasScreenRecording || !studyHasAudioRecording || !storageEngine || (status && status.endTime > 0) || isAnalysis) {
+ if (!studyConfig || studyHasScreenRecording || studyHasWebcamRecording || !studyHasAudioRecording || !storageEngine || (status && status.endTime > 0) || isAnalysis) {
return;
}
@@ -334,18 +378,18 @@ export function useRecording() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentComponent, identifier, currentComponentHasAudioRecording]);
- // For study with screen recording
+ // For studies with screen or webcam recording.
useEffect(() => {
- if (!studyConfig || !(studyHasScreenRecording) || !storageEngine || (status && status.endTime > 0) || isAnalysis) {
+ if (!studyConfig || !(studyHasScreenRecording || studyHasWebcamRecording) || !storageEngine || (status && status.endTime > 0) || isAnalysis) {
return;
}
- if (currentMediaRecorder.current) {
+ if (currentMediaRecorder.current || webcamMediaRecorder.current || audioMediaRecorder.current) {
stopScreenRecording();
currentTrialName.current = null;
}
- if (currentComponent !== 'end' && isMediaCapturing && currentTrialName.current !== identifier && (currentComponentHasAudioRecording || currentComponentHasScreenRecording)) {
+ if (currentComponent !== 'end' && isMediaCapturing && currentTrialName.current !== identifier && (currentComponentHasAudioRecording || currentComponentHasScreenRecording || currentComponentHasWebcamRecording)) {
currentTrialName.current = identifier;
startScreenRecording(identifier);
}
@@ -355,83 +399,118 @@ export function useRecording() {
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [currentComponent, identifier, currentComponentHasAudioRecording, currentComponentHasScreenRecording, isMediaCapturing]);
+ }, [currentComponent, identifier, currentComponentHasAudioRecording, currentComponentHasScreenRecording, currentComponentHasWebcamRecording, isMediaCapturing]);
+
+ // Start persistent capture. Per-trial recording begins in the navigation effect above.
+ const startMediaCapture = useCallback(async ({
+ includeScreen,
+ includeAudio,
+ includeWebcam,
+ }: {
+ includeScreen: boolean;
+ includeAudio: boolean;
+ includeWebcam: boolean;
+ }) => {
+ if (!dataCollectionEnabled) {
+ return;
+ }
- // Start screen capture. This does not begin recording.
- const startScreenCapture = useCallback(() => {
- if (!dataCollectionEnabled) return;
-
- const captureFn = async () => {
- document.title = `RECORD THIS TAB: ${pageTitle}`;
-
- try {
- setRecordingError(null);
- setAudioRecordingError(null);
-
- const screenStream = (studyHasScreenRecording) ? await navigator.mediaDevices.getDisplayMedia({
- video: { displaySurface: 'browser', ...(recordScreenFPS ? { frameRate: { ideal: recordScreenFPS } } : {}) },
- audio: false,
- // @ts-expect-error: experimental (selfBrowserSurface and preferCurrentTab are not yet standardized)
- // https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#selfbrowsersurface
- selfBrowserSurface: 'include',
- preferCurrentTab: true,
- }) : null;
-
- screenMediaStream.current = screenStream;
-
- let micStream: MediaStream | null = null;
- if (studyHasAudioRecording && dataCollectionEnabled) {
- try {
- micStream = await navigator.mediaDevices.getUserMedia({
- audio: true,
- video: false,
- });
- } catch (err) {
- console.error('Error accessing microphone:', err);
- setAudioRecordingError('Microphone permission denied');
- }
- }
+ document.title = includeScreen ? `RECORD THIS TAB: ${pageTitle}` : pageTitle;
- audioMediaStream.current = micStream;
+ try {
+ setRecordingError(null);
+ setAudioRecordingError(null);
- const analysisTrack = micStream?.getAudioTracks()[0]?.clone();
- if (analysisTrack) {
- analysisAudioStream.current = new MediaStream([analysisTrack]);
- setAnalysisStreamReady(true);
+ const screenStream = includeScreen ? await navigator.mediaDevices.getDisplayMedia({
+ video: { displaySurface: 'browser', ...(recordScreenFPS ? { frameRate: { ideal: recordScreenFPS } } : {}) },
+ audio: false,
+ // @ts-expect-error: experimental (selfBrowserSurface and preferCurrentTab are not yet standardized)
+ // https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getDisplayMedia#selfbrowsersurface
+ selfBrowserSurface: 'include',
+ preferCurrentTab: true,
+ }) : null;
+
+ const webcamStream = includeWebcam ? await navigator.mediaDevices.getUserMedia({
+ video: true,
+ audio: false,
+ }) : null;
+
+ let micStream: MediaStream | null = null;
+ if (includeAudio) {
+ try {
+ micStream = await navigator.mediaDevices.getUserMedia({
+ audio: true,
+ video: false,
+ });
+ } catch (err) {
+ console.error('Error accessing microphone:', err);
+ setAudioRecordingError('Microphone permission denied');
}
+ }
- const combinedStream = new MediaStream([
- ...screenStream?.getVideoTracks() || [],
- ...(micStream?.getAudioTracks() ?? []),
- ]);
+ screenMediaStream.current = screenStream;
+ webcamMediaStream.current = webcamStream;
+ audioMediaStream.current = micStream;
- currentMediaStream.current = combinedStream;
+ const analysisTrack = micStream?.getAudioTracks()[0]?.clone();
+ if (analysisTrack) {
+ analysisAudioStream.current = new MediaStream([analysisTrack]);
+ setAnalysisStreamReady(true);
+ }
- if (recordVideoRef.current) {
- recordVideoRef.current.srcObject = combinedStream;
- recordVideoRef.current.play();
+ if (recordVideoRef.current) {
+ recordVideoRef.current.srcObject = screenStream;
+ if (screenStream) {
+ recordVideoRef.current.play().catch(() => undefined);
}
+ }
+ if (webcamVideoRef.current) {
+ webcamVideoRef.current.srcObject = webcamStream;
+ if (webcamStream) {
+ webcamVideoRef.current.play().catch(() => undefined);
+ }
+ }
- combinedStream.getTracks().forEach((track) => {
- track.addEventListener('ended', () => {
- stopScreenCapture();
- });
- });
+ const stopOnEnded = () => {
+ if (!isStoppingCapture.current) {
+ stopScreenCapture();
+ }
+ };
+ [screenStream, webcamStream, micStream].forEach((stream) => {
+ stream?.getTracks().forEach((track) => track.addEventListener('ended', stopOnEnded));
+ });
- setIsScreenCapturing(screenStream !== null);
- setIsAudioCapturing(micStream !== null);
- setIsMediaCapturing(screenStream !== null || micStream !== null);
- setScreenCaptureStarted(true);
- setScreenWithAudioRecording(micStream !== null && !!recordAudio);
- } catch (err) {
- console.error('Error accessing screen:', err);
- setRecordingError('Recording permission denied');
- } finally {
- document.title = pageTitle;
- }
- };
- captureFn();
- }, [pageTitle, recordAudio, recordScreenFPS, stopScreenCapture, studyHasAudioRecording, studyHasScreenRecording, dataCollectionEnabled]);
+ setIsScreenCapturing(!!screenStream);
+ setIsWebcamCapturing(!!webcamStream);
+ setIsAudioCapturing(!!micStream);
+ setIsMediaCapturing(!!(screenStream || webcamStream || micStream));
+ setMediaCaptureStarted(true);
+ setScreenWithAudioRecording(!!(screenStream && micStream && recordAudio));
+ setIsRejected(false);
+ } catch (err) {
+ console.error('Error accessing recording media:', err);
+ setRecordingError('Recording permission denied');
+ stopScreenCapture();
+ } finally {
+ document.title = pageTitle;
+ }
+ }, [dataCollectionEnabled, pageTitle, recordAudio, recordScreenFPS, stopScreenCapture]);
+
+ const startScreenCapture = useCallback(() => {
+ startMediaCapture({
+ includeScreen: studyHasScreenRecording,
+ includeAudio: studyHasAudioRecording,
+ includeWebcam: studyHasWebcamRecording,
+ });
+ }, [startMediaCapture, studyHasAudioRecording, studyHasScreenRecording, studyHasWebcamRecording]);
+
+ const startWebcamCapture = useCallback(() => {
+ startMediaCapture({
+ includeScreen: false,
+ includeAudio: studyHasAudioRecording,
+ includeWebcam: true,
+ });
+ }, [startMediaCapture, studyHasAudioRecording]);
useEffect(() => {
audioMediaStream.current?.getAudioTracks().forEach((track) => {
@@ -510,13 +589,17 @@ export function useRecording() {
return {
recordVideoRef,
+ webcamVideoRef,
studyHasScreenRecording,
studyHasAudioRecording,
+ studyHasWebcamRecording,
currentComponentHasAudioRecording,
+ currentComponentHasWebcamRecording,
isMuted,
setIsMuted,
recordAudio,
startScreenCapture,
+ startWebcamCapture,
stopScreenCapture,
startScreenRecording,
stopScreenRecording,
@@ -524,8 +607,10 @@ export function useRecording() {
audioRecordingError,
isScreenRecording,
isAudioRecording,
+ isWebcamRecording,
isScreenCapturing,
isAudioCapturing,
+ isWebcamCapturing,
isMediaCapturing,
combinedMediaRecorder: currentMediaRecorder,
audioMediaStream,
diff --git a/src/store/hooks/useRecordingConfig.ts b/src/store/hooks/useRecordingConfig.ts
index 77d4cff7e0..097e1e3c65 100644
--- a/src/store/hooks/useRecordingConfig.ts
+++ b/src/store/hooks/useRecordingConfig.ts
@@ -9,12 +9,19 @@ export function useRecordingConfig() {
const currentComponent = useCurrentComponent();
const stepConfig = studyConfig.components[currentComponent];
- const { recordScreen, recordAudio, clickToRecord } = studyConfig.uiConfig;
+ const {
+ recordScreen,
+ recordAudio,
+ recordWebcam,
+ clickToRecord,
+ } = studyConfig.uiConfig;
const studyHasScreenRecording = useMemo(() => (recordScreen || participantSequence.some((comp) => studyConfig.components[comp]?.recordScreen)), [participantSequence, studyConfig, recordScreen]);
const studyHasAudioRecording = useMemo(() => (recordAudio || participantSequence.some((comp) => studyConfig.components[comp]?.recordAudio)), [participantSequence, studyConfig, recordAudio]);
+ const studyHasWebcamRecording = useMemo(() => (recordWebcam || participantSequence.some((comp) => studyConfig.components[comp]?.recordWebcam)), [participantSequence, studyConfig, recordWebcam]);
+
const currentComponentHasScreenRecording = useMemo(
() => stepConfig?.recordScreen ?? !!recordScreen,
[recordScreen, stepConfig],
@@ -25,6 +32,11 @@ export function useRecordingConfig() {
[recordAudio, stepConfig],
);
+ const currentComponentHasWebcamRecording = useMemo(
+ () => stepConfig?.recordWebcam ?? !!recordWebcam,
+ [recordWebcam, stepConfig],
+ );
+
const currentComponentHasClickToRecord = useMemo(
() => stepConfig?.clickToRecord ?? !!clickToRecord,
[clickToRecord, stepConfig],
@@ -33,8 +45,10 @@ export function useRecordingConfig() {
return {
studyHasAudioRecording,
studyHasScreenRecording,
+ studyHasWebcamRecording,
currentComponentHasAudioRecording,
currentComponentHasScreenRecording,
+ currentComponentHasWebcamRecording,
currentComponentHasClickToRecord,
};
}
diff --git a/src/store/hooks/useReplay.ts b/src/store/hooks/useReplay.ts
index f723844028..ce6c6eda11 100644
--- a/src/store/hooks/useReplay.ts
+++ b/src/store/hooks/useReplay.ts
@@ -22,6 +22,7 @@ function mediaIncludesTime(media: HTMLMediaElement, time: number) {
*/
export function useReplay() {
const videoRef = useRef(null);
+ const webcamVideoRef = useRef(null);
const audioRef = useRef(null);
const isMountedRef = useRef(true);
@@ -45,6 +46,31 @@ export function useReplay() {
const internalIsPlaying = useRef(false);
const timerValue = useRef(0);
+ const getMediaElements = useCallback(() => (
+ [videoRef.current, webcamVideoRef.current, audioRef.current]
+ .filter((media): media is HTMLMediaElement => !!media)
+ ), []);
+
+ const getActiveMediaElements = useCallback(() => (
+ getMediaElements().filter((media) => !!media.src)
+ ), [getMediaElements]);
+
+ const getSecondaryMediaElements = useCallback(() => (
+ getActiveMediaElements().filter((media) => media !== replayRef.current)
+ ), [getActiveMediaElements]);
+
+ const updateMutedState = useCallback(() => {
+ if (videoRef.current) {
+ videoRef.current.muted = !isMasterPlayer || replayRef.current !== videoRef.current;
+ }
+ if (webcamVideoRef.current) {
+ webcamVideoRef.current.muted = true;
+ }
+ if (audioRef.current) {
+ audioRef.current.muted = !isMasterPlayer || replayRef.current === videoRef.current;
+ }
+ }, [isMasterPlayer]);
+
const updateIsPlaying = useCallback((playing: boolean) => {
internalIsPlaying.current = playing;
_setIsPlaying(playing);
@@ -116,19 +142,14 @@ export function useReplay() {
}, [seekTime, isPlaying, speed, isMasterPlayer]);
useEffect(() => {
- const muted = !isMasterPlayer;
- if (videoRef.current) videoRef.current.muted = muted;
- if (audioRef.current) audioRef.current.muted = muted;
- }, [isMasterPlayer]);
+ updateMutedState();
+ }, [updateMutedState]);
useEffect(() => {
- if (videoRef.current) {
- videoRef.current.playbackRate = speed;
- }
- if (audioRef.current) {
- audioRef.current.playbackRate = speed;
- }
- }, [speed]);
+ getMediaElements().forEach((media) => {
+ media.playbackRate = speed;
+ });
+ }, [getMediaElements, speed]);
const handlePlay = useCallback(() => {
if (!isMountedRef.current) {
@@ -140,15 +161,11 @@ export function useReplay() {
const t = replayRef.current?.currentTime || 0;
emitterRef.current.emit('play', t);
- if (videoRef.current === replayRef.current) {
- if (audioRef.current) {
- audioRef.current.muted = true;
- audioRef.current.play();
- }
- } else {
- videoRef.current?.play();
- }
- }, [updateIsPlaying]);
+ updateMutedState();
+ getSecondaryMediaElements().forEach((media) => {
+ media.play().catch(() => undefined);
+ });
+ }, [getSecondaryMediaElements, updateIsPlaying, updateMutedState]);
const handleSeeked = useCallback(() => {
// Media may clamp a task-level seek to its shorter duration. Keep the task
@@ -160,12 +177,7 @@ export function useReplay() {
setIsMasterPlayer(!isRemoteTriggered);
_setSeekTime(time);
timerValue.current = time;
- if (videoRef.current) {
- seekMedia(videoRef.current, time);
- }
- if (audioRef.current) {
- seekMedia(audioRef.current, time);
- }
+ getMediaElements().forEach((media) => seekMedia(media, time));
if (
internalIsPlaying.current
&& replayRef.current?.paused
@@ -175,7 +187,7 @@ export function useReplay() {
}
emitterRef.current.emit('timeupdate', time);
setHasEnded(internalDuration.current > 0 && time >= internalDuration.current);
- }, [requestReplayPlayback]);
+ }, [getMediaElements, requestReplayPlayback]);
const handlePause = useCallback(() => {
if (!isMountedRef.current) {
@@ -196,14 +208,8 @@ export function useReplay() {
emitterRef.current.emit('pause', timerValue.current);
- if (videoRef.current === replayRef.current) {
- if (audioRef.current) {
- audioRef.current.pause();
- }
- } else {
- videoRef.current?.pause();
- }
- }, [updateIsPlaying]);
+ getSecondaryMediaElements().forEach((media) => media.pause());
+ }, [getSecondaryMediaElements, updateIsPlaying]);
const handleEnded = useCallback(() => {
const mediaTime = replayRef.current?.currentTime;
@@ -231,25 +237,21 @@ export function useReplay() {
*/
const updateReplayRef = useCallback(() => {
const previousReplay = replayRef.current;
- const originalVideo = videoRef.current;
- const originalAudio = audioRef.current;
+ const mediaElements = getMediaElements();
previousReplay?.removeEventListener('play', handlePlay);
previousReplay?.removeEventListener('pause', handlePause);
previousReplay?.removeEventListener('seeked', handleSeeked);
previousReplay?.removeEventListener('ended', handleEnded);
- if (originalVideo) {
- originalVideo.playbackRate = internalSpeed.current;
- seekMedia(originalVideo, timerValue.current);
- }
-
- if (originalAudio) {
- originalAudio.playbackRate = internalSpeed.current;
- seekMedia(originalAudio, timerValue.current);
- }
+ mediaElements.forEach((media) => {
+ media.playbackRate = internalSpeed.current;
+ seekMedia(media, timerValue.current);
+ });
- replayRef.current = (videoRef.current?.src ? videoRef.current : null) ?? (audioRef.current?.src ? audioRef.current : null);
+ replayRef.current = (videoRef.current?.src ? videoRef.current : null)
+ ?? (webcamVideoRef.current?.src ? webcamVideoRef.current : null)
+ ?? (audioRef.current?.src ? audioRef.current : null);
if (replayRef.current) {
replayRef.current.addEventListener('play', handlePlay);
@@ -257,8 +259,9 @@ export function useReplay() {
replayRef.current.addEventListener('seeked', handleSeeked);
replayRef.current.addEventListener('ended', handleEnded);
}
+ updateMutedState();
forceEmitTimeUpdate();
- }, [handlePlay, handlePause, handleSeeked, handleEnded, forceEmitTimeUpdate]);
+ }, [forceEmitTimeUpdate, getMediaElements, handleEnded, handlePause, handlePlay, handleSeeked, updateMutedState]);
// this should be the only way to start video/audio
const setIsPlaying = useCallback((playing: boolean, isRemoteTriggered = false) => {
@@ -394,6 +397,8 @@ export function useReplay() {
() => ({
replayRef,
videoRef,
+ screenVideoRef: videoRef,
+ webcamVideoRef,
audioRef,
updateReplayRef,
seekTime,
diff --git a/src/store/store.tsx b/src/store/store.tsx
index 40544f53eb..df0541f3ae 100644
--- a/src/store/store.tsx
+++ b/src/store/store.tsx
@@ -175,6 +175,7 @@ export async function studyStoreCreator(
analysisIsPlaying: false,
analysisHasAudio: false,
analysisHasScreenRecording: false,
+ analysisHasWebcamRecording: false,
analysisCanPlayScreenRecording: true,
analysisHasProvenance: false,
provenanceJumpTime: 0,
@@ -274,6 +275,9 @@ export async function studyStoreCreator(
setAnalysisHasScreenRecording(state, { payload }: PayloadAction) {
state.analysisHasScreenRecording = payload;
},
+ setAnalysisHasWebcamRecording(state, { payload }: PayloadAction) {
+ state.analysisHasWebcamRecording = payload;
+ },
setAnalysisCanPlayScreenRecording(state, { payload }: PayloadAction) {
state.analysisCanPlayScreenRecording = payload;
},
diff --git a/src/store/types.ts b/src/store/types.ts
index 8921ab73ed..ba1b4daf8f 100644
--- a/src/store/types.ts
+++ b/src/store/types.ts
@@ -270,6 +270,7 @@ export interface StoreState {
analysisIsPlaying: boolean;
analysisHasAudio: boolean;
analysisHasScreenRecording: boolean;
+ analysisHasWebcamRecording: boolean;
analysisCanPlayScreenRecording: boolean;
provenanceJumpTime: number;
analysisHasProvenance: boolean;
diff --git a/src/tests/utils.ts b/src/tests/utils.ts
index c608fab21d..f56b490e9b 100644
--- a/src/tests/utils.ts
+++ b/src/tests/utils.ts
@@ -81,6 +81,8 @@ class TestStorageEngine extends StorageEngine {
protected _getScreenRecordingUrl = vi.fn(async () => null);
+ protected _getWebcamRecordingUrl = vi.fn(async () => null);
+
protected _testingReset = vi.fn(async () => { });
protected _directoryExists = vi.fn(async () => false);
@@ -122,6 +124,8 @@ class TestStorageEngine extends StorageEngine {
getScreenRecording = vi.fn(async () => null);
+ getWebcamRecording = vi.fn(async () => null);
+
saveAnswers = vi.fn(async () => { });
constructor() {
diff --git a/src/utils/handleDownloadFiles.ts b/src/utils/handleDownloadFiles.ts
index 8bcac482b7..ec874c418a 100644
--- a/src/utils/handleDownloadFiles.ts
+++ b/src/utils/handleDownloadFiles.ts
@@ -44,30 +44,77 @@ export async function handleTaskAudio({
}
}
-export async function handleTaskScreenRecording({
+type RecordingType = 'screenRecording' | 'webcamRecording';
+
+async function getTaskRecordingUrl({
+ storageEngine,
+ participantId,
+ identifier,
+ recordingType,
+ recordingUrl,
+}: {
+ storageEngine: StorageEngine;
+ participantId: string;
+ identifier: string;
+ recordingType: RecordingType;
+ recordingUrl?: string | null;
+}) {
+ if (recordingUrl) {
+ return recordingUrl;
+ }
+ return recordingType === 'screenRecording'
+ ? storageEngine.getScreenRecording(identifier, participantId)
+ : storageEngine.getWebcamRecording(identifier, participantId);
+}
+
+export async function handleTaskRecordings({
storageEngine,
participantId,
identifier,
+ includeScreen,
+ includeWebcam,
screenRecordingUrl,
+ webcamRecordingUrl,
}: {
storageEngine: StorageEngine;
participantId: string;
identifier: string;
+ includeScreen: boolean;
+ includeWebcam: boolean;
screenRecordingUrl?: string | null;
+ webcamRecordingUrl?: string | null;
}) {
- const finalScreenRecordingUrl = screenRecordingUrl || await storageEngine.getScreenRecording(identifier, participantId);
+ const recordings: Array<{ type: RecordingType; url?: string | null }> = [
+ ...(includeScreen ? [{ type: 'screenRecording' as const, url: screenRecordingUrl }] : []),
+ ...(includeWebcam ? [{ type: 'webcamRecording' as const, url: webcamRecordingUrl }] : []),
+ ];
+
+ await Promise.all(recordings.map(async ({ type, url: providedUrl }) => {
+ try {
+ const recordingUrl = await getTaskRecordingUrl({
+ storageEngine,
+ participantId,
+ identifier,
+ recordingType: type,
+ recordingUrl: providedUrl,
+ });
+ if (!recordingUrl) {
+ return;
+ }
- if (finalScreenRecordingUrl) {
- const blob = await (await fetch(finalScreenRecordingUrl)).blob();
- const url = URL.createObjectURL(blob);
+ const blob = await (await fetch(recordingUrl)).blob();
+ const objectUrl = URL.createObjectURL(blob);
- Object.assign(document.createElement('a'), {
- href: url,
- download: `${participantId}_${identifier}_screenRecording.webm`,
- }).click();
+ Object.assign(document.createElement('a'), {
+ href: objectUrl,
+ download: `${participantId}_${identifier}_${type}.webm`,
+ }).click();
- URL.revokeObjectURL(url);
- }
+ URL.revokeObjectURL(objectUrl);
+ } catch (error) {
+ console.warn(`Failed to fetch ${type} for ${identifier}:`, error);
+ }
+ }));
}
async function loadAssetToZip(zip: JSZip, fileName: string, assetUrl: string | null) {
@@ -191,48 +238,67 @@ export async function downloadParticipantsAudioZip({
await downloadZip(zip, `${namePrefix}_audio.zip`);
}
-async function downloadParticipantsScreenRecording({
+async function downloadParticipantsRecordings({
storageEngine,
participantId,
identifier,
namePrefix,
+ includeScreen,
+ includeWebcam,
zip,
}: {
storageEngine: StorageEngine;
participantId: string;
identifier: string;
namePrefix: string;
+ includeScreen: boolean;
+ includeWebcam: boolean;
zip?: JSZip;
}) {
- const screenRecordingZip = zip || new JSZip();
-
- try {
- const screenRecordingUrl = await storageEngine.getScreenRecording(identifier, participantId);
- await loadAssetToZip(screenRecordingZip, `${namePrefix}_${participantId}_${identifier}.webm`, screenRecordingUrl);
-
- if (!zip) {
- downloadZip(screenRecordingZip, `${namePrefix}_${participantId}_${identifier}_screenRecording.zip`);
+ const recordingsZip = zip || new JSZip();
+ const recordingTypes: RecordingType[] = [
+ ...(includeScreen ? ['screenRecording' as const] : []),
+ ...(includeWebcam ? ['webcamRecording' as const] : []),
+ ];
+
+ await Promise.all(recordingTypes.map(async (recordingType) => {
+ try {
+ const recordingUrl = await getTaskRecordingUrl({
+ storageEngine,
+ participantId,
+ identifier,
+ recordingType,
+ });
+ await loadAssetToZip(recordingsZip, `${namePrefix}_${participantId}_${identifier}_${recordingType}.webm`, recordingUrl);
+ } catch (error) {
+ console.warn(`Failed to fetch ${recordingType} for ${identifier}:`, error);
}
- } catch (error) {
- console.warn(`Failed to fetch files for ${identifier}:`, error);
+ }));
+
+ if (!zip) {
+ downloadZip(recordingsZip, `${namePrefix}_${participantId}_${identifier}_recordings.zip`);
}
}
-export async function downloadParticipantsScreenRecordingZip({
+export async function downloadParticipantsRecordingsZip({
storageEngine,
participants,
studyId,
+ includeScreen,
+ includeWebcam,
fileName,
}: {
storageEngine: StorageEngine;
participants: Array<{ participantId: string; answers: Record }>;
studyId: string;
+ includeScreen: boolean;
+ includeWebcam: boolean;
fileName?: string | null;
}) {
const namePrefix = fileName || studyId;
const zip = new JSZip();
- const screenRecordingPromises = participants.flatMap((participant) => {
+ const recordingPromises = participants.flatMap((participant) => {
const entries = Object.values(participant.answers)
.filter((ans) => ans.endTime > 0)
.sort((a, b) => a.startTime - b.startTime);
@@ -240,19 +306,21 @@ export async function downloadParticipantsScreenRecordingZip({
return entries.map(async (ans) => {
const identifier = `${ans.componentName}_${ans.trialOrder}`;
- await downloadParticipantsScreenRecording({
+ await downloadParticipantsRecordings({
storageEngine,
participantId: participant.participantId,
identifier,
namePrefix,
+ includeScreen,
+ includeWebcam,
zip,
});
});
});
- await Promise.all(screenRecordingPromises);
+ await Promise.all(recordingPromises);
- await downloadZip(zip, `${namePrefix}_screenRecording.zip`);
+ await downloadZip(zip, `${namePrefix}_recordings.zip`);
}
export async function downloadParticipantsProvenanceZip({
diff --git a/src/utils/tests/handleDownloadFiles.spec.ts b/src/utils/tests/handleDownloadFiles.spec.ts
index e9c2cd2e86..153449efe7 100644
--- a/src/utils/tests/handleDownloadFiles.spec.ts
+++ b/src/utils/tests/handleDownloadFiles.spec.ts
@@ -5,9 +5,9 @@ import {
downloadConfigFile,
downloadConfigFilesZip,
downloadParticipantsAudioZip,
- downloadParticipantsScreenRecordingZip,
+ downloadParticipantsRecordingsZip,
handleTaskAudio,
- handleTaskScreenRecording,
+ handleTaskRecordings,
} from '../handleDownloadFiles';
import { makeStudyConfig, makeStorageEngine } from '../../tests/utils';
@@ -74,13 +74,15 @@ describe('handleTaskAudio', () => {
});
});
-describe('handleTaskScreenRecording', () => {
+describe('handleTaskRecordings', () => {
test('fetches the provided screenRecordingUrl and triggers a click', async () => {
const storageEngine = makeStorageEngine();
- await handleTaskScreenRecording({
+ await handleTaskRecordings({
storageEngine,
participantId: 'p1',
identifier: 'trial_0',
+ includeScreen: true,
+ includeWebcam: false,
screenRecordingUrl: 'https://example.com/recording.webm',
});
@@ -93,7 +95,9 @@ describe('handleTaskScreenRecording', () => {
getScreenRecording: vi.fn(async () => 'https://example.com/rec.webm'),
});
- await handleTaskScreenRecording({ storageEngine, participantId: 'p2', identifier: 'trial_1' });
+ await handleTaskRecordings({
+ storageEngine, participantId: 'p2', identifier: 'trial_1', includeScreen: true, includeWebcam: false,
+ });
expect((storageEngine.getScreenRecording as ReturnType)).toHaveBeenCalledWith('trial_1', 'p2');
expect(clickSpy).toHaveBeenCalled();
@@ -101,9 +105,25 @@ describe('handleTaskScreenRecording', () => {
test('skips download when screen recording URL is null', async () => {
const storageEngine = makeStorageEngine();
- await handleTaskScreenRecording({ storageEngine, participantId: 'p3', identifier: 'trial_2' });
+ await handleTaskRecordings({
+ storageEngine, participantId: 'p3', identifier: 'trial_2', includeScreen: true, includeWebcam: false,
+ });
expect(clickSpy).not.toHaveBeenCalled();
});
+
+ test('downloads screen and webcam assets through the shared control', async () => {
+ const storageEngine = makeStorageEngine();
+ await handleTaskRecordings({
+ storageEngine,
+ participantId: 'p4',
+ identifier: 'trial_3',
+ includeScreen: true,
+ includeWebcam: true,
+ screenRecordingUrl: 'https://example.com/screen.webm',
+ webcamRecordingUrl: 'https://example.com/webcam.webm',
+ });
+ expect(clickSpy).toHaveBeenCalledTimes(2);
+ });
});
describe('downloadConfigFile', () => {
@@ -224,7 +244,7 @@ describe('downloadParticipantsAudioZip', () => {
});
});
-describe('downloadParticipantsScreenRecordingZip', () => {
+describe('downloadParticipantsRecordingsZip', () => {
test('fetches screen recordings for each completed trial and downloads zip', async () => {
const screenUrl = 'https://example.com/recording.webm';
const storageEngine = makeStorageEngine({
@@ -239,7 +259,9 @@ describe('downloadParticipantsScreenRecordingZip', () => {
},
}];
- await downloadParticipantsScreenRecordingZip({ storageEngine, participants, studyId: 'my-study' });
+ await downloadParticipantsRecordingsZip({
+ storageEngine, participants, studyId: 'my-study', includeScreen: true, includeWebcam: false,
+ });
expect((storageEngine.getScreenRecording as ReturnType)).toHaveBeenCalled();
expect(URL.createObjectURL).toHaveBeenCalledWith(expect.any(Blob));
@@ -259,7 +281,9 @@ describe('downloadParticipantsScreenRecordingZip', () => {
},
}];
- await downloadParticipantsScreenRecordingZip({ storageEngine, participants, studyId: 'my-study' });
+ await downloadParticipantsRecordingsZip({
+ storageEngine, participants, studyId: 'my-study', includeScreen: true, includeWebcam: false,
+ });
expect((storageEngine.getScreenRecording as ReturnType)).not.toHaveBeenCalled();
expect(clickSpy).toHaveBeenCalledOnce();
@@ -268,8 +292,8 @@ describe('downloadParticipantsScreenRecordingZip', () => {
test('uses the provided fileName as zip name prefix', async () => {
const storageEngine = makeStorageEngine();
- await downloadParticipantsScreenRecordingZip({
- storageEngine, participants: [], studyId: 'study', fileName: 'custom-name',
+ await downloadParticipantsRecordingsZip({
+ storageEngine, participants: [], studyId: 'study', fileName: 'custom-name', includeScreen: true, includeWebcam: false,
});
expect(clickSpy).toHaveBeenCalledOnce();
@@ -289,8 +313,37 @@ describe('downloadParticipantsScreenRecordingZip', () => {
}];
await expect(
- downloadParticipantsScreenRecordingZip({ storageEngine, participants, studyId: 'my-study' }),
+ downloadParticipantsRecordingsZip({
+ storageEngine, participants, studyId: 'my-study', includeScreen: true, includeWebcam: false,
+ }),
).resolves.not.toThrow();
expect(clickSpy).toHaveBeenCalledOnce();
});
+
+ test('fetches webcam recordings into the same recordings zip', async () => {
+ const storageEngine = makeStorageEngine({
+ getScreenRecording: vi.fn(async () => 'https://example.com/screen.webm'),
+ getWebcamRecording: vi.fn(async () => 'https://example.com/webcam.webm'),
+ });
+ const participants = [{
+ participantId: 'p1',
+ answers: {
+ trial_0: {
+ endTime: 1000, startTime: 0, componentName: 'trial', trialOrder: '0',
+ },
+ },
+ }];
+
+ await downloadParticipantsRecordingsZip({
+ storageEngine,
+ participants,
+ studyId: 'my-study',
+ includeScreen: true,
+ includeWebcam: true,
+ });
+
+ expect(storageEngine.getScreenRecording).toHaveBeenCalledWith('trial_0', 'p1');
+ expect(storageEngine.getWebcamRecording).toHaveBeenCalledWith('trial_0', 'p1');
+ expect(clickSpy).toHaveBeenCalledOnce();
+ });
});
diff --git a/src/utils/tests/useStudyRecordings.spec.ts b/src/utils/tests/useStudyRecordings.spec.ts
index 7c9e39f9b3..86de56d7c7 100644
--- a/src/utils/tests/useStudyRecordings.spec.ts
+++ b/src/utils/tests/useStudyRecordings.spec.ts
@@ -18,6 +18,7 @@ describe('useStudyRecordings', () => {
await waitFor(() => {
expect(result.current.hasAudioRecording).toBe(false);
expect(result.current.hasScreenRecording).toBe(false);
+ expect(result.current.hasWebcamRecording).toBe(false);
});
});
@@ -27,6 +28,7 @@ describe('useStudyRecordings', () => {
await waitFor(() => {
expect(result.current.hasAudioRecording).toBe(false);
expect(result.current.hasScreenRecording).toBe(false);
+ expect(result.current.hasWebcamRecording).toBe(false);
});
});
@@ -49,12 +51,26 @@ describe('useStudyRecordings', () => {
await waitFor(() => expect(result.current.hasScreenRecording).toBe(true));
});
+ test('hasWebcamRecording is true when webcam recording is configured', async () => {
+ const config = makeStudyConfig({ uiConfig: { recordWebcam: true } });
+ const { result } = renderHook(() => useStudyRecordings(config));
+ await waitFor(() => expect(result.current.hasWebcamRecording).toBe(true));
+ expect(result.current.hasScreenRecording).toBe(false);
+ });
+
+ test('hasWebcamRecording is true when a component enables it', async () => {
+ const config = makeStudyConfig({ components: { trial1: { recordWebcam: true } } });
+ const { result } = renderHook(() => useStudyRecordings(config));
+ await waitFor(() => expect(result.current.hasWebcamRecording).toBe(true));
+ });
+
test('both flags are false when config has no recording options', async () => {
const config = makeStudyConfig({ components: { trial1: { recordScreen: false } } });
const { result } = renderHook(() => useStudyRecordings(config));
await waitFor(() => {
expect(result.current.hasScreenRecording).toBe(false);
expect(result.current.hasAudioRecording).toBe(false);
+ expect(result.current.hasWebcamRecording).toBe(false);
});
});
});
diff --git a/src/utils/useStudyRecordings.ts b/src/utils/useStudyRecordings.ts
index ceb7c38f5a..133081f3f8 100644
--- a/src/utils/useStudyRecordings.ts
+++ b/src/utils/useStudyRecordings.ts
@@ -2,30 +2,43 @@ import { useEffect, useState } from 'react';
import { StudyConfig } from '../parser/types';
import { studyComponentToIndividualComponent } from './handleComponentInheritance';
+export function getStudyRecordings(studyConfig: StudyConfig | undefined) {
+ if (!studyConfig?.uiConfig || !studyConfig.components) {
+ return {
+ hasAudioRecording: false,
+ hasScreenRecording: false,
+ hasWebcamRecording: false,
+ };
+ }
+
+ const { recordAudio, recordScreen, recordWebcam } = studyConfig.uiConfig;
+ const componentConfig = Object.keys(studyConfig.components).map((componentId) => studyComponentToIndividualComponent(studyConfig.components[componentId], studyConfig));
+
+ return {
+ hasAudioRecording: !!recordAudio || componentConfig.some((component) => component.recordAudio),
+ hasScreenRecording: !!recordScreen || componentConfig.some((component) => component.recordScreen),
+ hasWebcamRecording: !!recordWebcam || componentConfig.some((component) => component.recordWebcam),
+ };
+}
+
/**
- * Determines if the study has audio and screen recordings.
+ * Determines if the study has audio, screen, and webcam recordings.
*/
export function useStudyRecordings(studyConfig: StudyConfig | undefined) {
const [hasAudioRecording, setHasAudioRecording] = useState(false);
const [hasScreenRecording, setHasScreenRecording] = useState(false);
+ const [hasWebcamRecording, setHasWebcamRecording] = useState(false);
useEffect(() => {
- if (!studyConfig?.uiConfig || !studyConfig.components) {
- setHasAudioRecording(false);
- setHasScreenRecording(false);
- return;
- }
-
- const { recordAudio, recordScreen } = studyConfig.uiConfig;
-
- const componentConfig = Object.keys(studyConfig.components).map((componentId) => studyComponentToIndividualComponent(studyConfig.components[componentId], studyConfig));
-
- setHasAudioRecording(recordAudio || componentConfig.some((a) => a.recordAudio));
- setHasScreenRecording(recordScreen || componentConfig.some((a) => a.recordScreen));
+ const recordings = getStudyRecordings(studyConfig);
+ setHasAudioRecording(recordings.hasAudioRecording);
+ setHasScreenRecording(recordings.hasScreenRecording);
+ setHasWebcamRecording(recordings.hasWebcamRecording);
}, [studyConfig]);
return {
hasAudioRecording,
hasScreenRecording,
+ hasWebcamRecording,
};
}
From 13b6bd59a5e07ff5f53c4504e3ff82038d7bde3c Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Mon, 24 Aug 2026 12:40:03 -0400
Subject: [PATCH 02/12] Harden webcam recording lifecycle and replay
---
.../assets/webcam-recording.md | 2 +-
public/library-webcam-recording/config.json | 2 +-
.../tests/ThinkAloudAnalysis.spec.tsx | 12 +-
.../audioAnalysis/AudioProvenanceVis.tsx | 31 +++-
src/components/interface/AnalysisFooter.tsx | 6 +-
src/components/interface/AppHeader.tsx | 28 +++-
.../interface/ScreenRecordingRejection.tsx | 4 +-
.../interface/tests/AnalysisFooter.spec.tsx | 1 +
.../interface/tests/AppHeader.spec.tsx | 2 +
.../tests/ScreenRecordingRejection.spec.tsx | 4 +-
.../screenRecording/ScreenRecordingReplay.tsx | 44 +++++-
src/components/tests/StepRenderer.spec.tsx | 1 +
.../assets/ScreenRecording.tsx | 9 +-
.../assets/WebcamRecording.tsx | 10 +-
src/storage/engines/types.ts | 19 ++-
src/storage/tests/highLevel.spec.ts | 8 ++
src/store/hooks/tests/useRecording.spec.ts | 134 +++++++++++++++++-
src/store/hooks/tests/useReplay.spec.tsx | 60 ++++++++
src/store/hooks/useRecording.ts | 86 ++++++++---
src/store/hooks/useReplay.ts | 40 ++++--
src/utils/handleDownloadFiles.ts | 15 +-
src/utils/tests/handleDownloadFiles.spec.ts | 29 ++++
22 files changed, 489 insertions(+), 58 deletions(-)
diff --git a/public/library-webcam-recording/assets/webcam-recording.md b/public/library-webcam-recording/assets/webcam-recording.md
index 39e245a024..d0a151dc98 100644
--- a/public/library-webcam-recording/assets/webcam-recording.md
+++ b/public/library-webcam-recording/assets/webcam-recording.md
@@ -1,4 +1,4 @@
-# Webcam Recording
+# WCR: Webcam Recording
This is a demo of the library `webcam-recording`.
diff --git a/public/library-webcam-recording/config.json b/public/library-webcam-recording/config.json
index 599d5d7138..ab78ae9a0f 100644
--- a/public/library-webcam-recording/config.json
+++ b/public/library-webcam-recording/config.json
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/revisit-studies/study/dev/src/parser/StudyConfigSchema.json",
"studyMetadata": {
- "title": "Webcam Recording",
+ "title": "WCR: Webcam Recording",
"version": "1.0.0",
"authors": [
"The reVISit Team"
diff --git a/src/analysis/individualStudy/thinkAloud/tests/ThinkAloudAnalysis.spec.tsx b/src/analysis/individualStudy/thinkAloud/tests/ThinkAloudAnalysis.spec.tsx
index 77bf51fb54..7b610551b4 100644
--- a/src/analysis/individualStudy/thinkAloud/tests/ThinkAloudAnalysis.spec.tsx
+++ b/src/analysis/individualStudy/thinkAloud/tests/ThinkAloudAnalysis.spec.tsx
@@ -15,7 +15,7 @@ import { makeParticipant, makeStoredAnswer as makeStoredAnswerBase, makeStorageE
import type { FirebaseStorageEngine } from '../../../../storage/engines/FirebaseStorageEngine';
import { useAsync } from '../../../../store/hooks/useAsync';
import { useReplayContext } from '../../../../store/hooks/useReplay';
-import { handleTaskScreenRecording } from '../../../../utils/handleDownloadFiles';
+import { handleTaskRecordings } from '../../../../utils/handleDownloadFiles';
import { Pills } from '../tags/Pills';
import { AddTagDropdown } from '../tags/AddTagDropdown';
import { TagEditor } from '../tags/TagEditor';
@@ -624,7 +624,7 @@ describe('ThinkAloudFooter', () => {
execute: vi.fn(),
error: null,
}));
- vi.mocked(handleTaskScreenRecording).mockClear();
+ vi.mocked(handleTaskRecordings).mockClear();
const view = render( );
await waitFor(() => expect(storageEngine.getAudioUrl).toHaveBeenCalledWith('trial_0', 'p1'));
@@ -641,13 +641,17 @@ describe('ThinkAloudFooter', () => {
screenP2.resolve('screen-p2');
const screenIcon = await waitFor(() => view.getByTestId('screen-recording-icon'));
fireEvent.click(screenIcon.closest('button')!);
- expect(handleTaskScreenRecording).toHaveBeenLastCalledWith(expect.objectContaining({ screenRecordingUrl: 'screen-p2' }));
+ expect(handleTaskRecordings).toHaveBeenLastCalledWith(expect.objectContaining({
+ includeScreen: true,
+ includeWebcam: false,
+ screenRecordingUrl: 'screen-p2',
+ }));
screenP1.resolve('screen-p1');
await act(async () => { await screenP1.promise; });
const liveScreenIcon = view.getByTestId('screen-recording-icon');
fireEvent.click(liveScreenIcon.closest('button')!);
- expect(handleTaskScreenRecording).toHaveBeenLastCalledWith(expect.objectContaining({
+ expect(handleTaskRecordings).toHaveBeenLastCalledWith(expect.objectContaining({
participantId: 'p2', identifier: 'trial_0', screenRecordingUrl: 'screen-p2',
}));
});
diff --git a/src/components/audioAnalysis/AudioProvenanceVis.tsx b/src/components/audioAnalysis/AudioProvenanceVis.tsx
index c535549f2d..a0685b67a1 100644
--- a/src/components/audioAnalysis/AudioProvenanceVis.tsx
+++ b/src/components/audioAnalysis/AudioProvenanceVis.tsx
@@ -97,6 +97,8 @@ export function AudioProvenanceVis({
const [playTime, setPlayTime] = useState(0);
const wavesurfer = useRef(null);
+ const loadedAnalysisUrls = useRef([]);
+ const analysisLoadGeneration = useRef(0);
const waveSurferDiv = useRef(null);
@@ -107,6 +109,12 @@ export function AudioProvenanceVis({
const trrackForTrial = useRef | null>(null);
const hasLoadableTask = Boolean(participantId && taskName && answers[taskName]);
+ useEffect(() => () => {
+ analysisLoadGeneration.current += 1;
+ loadedAnalysisUrls.current.forEach((url) => URL.revokeObjectURL(url));
+ loadedAnalysisUrls.current = [];
+ }, []);
+
useEffect(() => {
let canceled = false;
@@ -297,6 +305,10 @@ export function AudioProvenanceVis({
const handleWSMount = useEvent(
async (waveSurfer: WaveSurferType | null) => {
+ const loadGeneration = analysisLoadGeneration.current + 1;
+ analysisLoadGeneration.current = loadGeneration;
+ loadedAnalysisUrls.current.forEach((url) => URL.revokeObjectURL(url));
+ loadedAnalysisUrls.current = [];
wavesurfer.current = waveSurfer;
audioRef.current = null;
@@ -308,13 +320,19 @@ export function AudioProvenanceVis({
throw new Error('Participant ID is required to load audio');
}
- const [audioUrl, screenUrl, webcamUrl] = await Promise.all([
+ const [audioUrl, screenUrl] = await Promise.all([
safe(storageEngine.getAudio(taskName, participantId)),
safe(storageEngine.getScreenRecording(taskName, participantId)),
- safe(storageEngine.getWebcamRecording(taskName, participantId)),
]);
+ const loadedUrls = [audioUrl, screenUrl]
+ .filter((url): url is string => !!url && url.startsWith('blob:'));
+ if (loadGeneration !== analysisLoadGeneration.current) {
+ loadedUrls.forEach((url) => URL.revokeObjectURL(url));
+ return;
+ }
+ loadedAnalysisUrls.current = loadedUrls;
- const url = screenUrl ?? audioUrl ?? webcamUrl ?? null;
+ const url = screenUrl ?? audioUrl ?? null;
const hasAudioSource = !!(screenUrl ?? audioUrl);
if (!url) {
@@ -325,6 +343,10 @@ export function AudioProvenanceVis({
}
await waveSurfer.load(url!, undefined, duration);
+ if (loadGeneration !== analysisLoadGeneration.current) {
+ loadedUrls.forEach((loadedUrl) => URL.revokeObjectURL(loadedUrl));
+ return;
+ }
setWaveSurferLoading(false);
audioRef.current = waveSurfer.getMediaElement();
@@ -335,6 +357,9 @@ export function AudioProvenanceVis({
waveSurfer.seekTo(0);
waveSurfer.on('redrawcomplete', () => setWaveSurferWidth(waveSurfer.getWidth()));
} catch (error: unknown) {
+ if (loadGeneration !== analysisLoadGeneration.current) {
+ return;
+ }
setAnalysisHasAudio(false);
setWaveSurferLoading(false);
audioRef.current = null;
diff --git a/src/components/interface/AnalysisFooter.tsx b/src/components/interface/AnalysisFooter.tsx
index 5c499f9c6c..a3cb4f302c 100644
--- a/src/components/interface/AnalysisFooter.tsx
+++ b/src/components/interface/AnalysisFooter.tsx
@@ -1,4 +1,4 @@
-import { useParams } from 'react-router';
+import { useParams, useSearchParams } from 'react-router';
import { useCallback } from 'react';
import { useAsync } from '../../store/hooks/useAsync';
@@ -22,6 +22,8 @@ export function AnalysisFooter({ setHasAudio }: {setHasAudio: (b: boolean) => vo
const { value: allParticipants } = useAsync(getAllParticipantsNames, [storageEngine]);
const identifier = useCurrentIdentifier();
+ const [searchParams] = useSearchParams();
+ const participantId = searchParams.get('participantId') || '';
const storeDispatch = useStoreDispatch();
@@ -35,6 +37,6 @@ export function AnalysisFooter({ setHasAudio }: {setHasAudio: (b: boolean) => vo
const saveProvenance = useCallback((prov: any) => storeDispatch(saveAnalysisState(prov)), [storeDispatch, saveAnalysisState]);
return (
- {}} saveProvenance={saveProvenance} />
+ {}} saveProvenance={saveProvenance} />
);
}
diff --git a/src/components/interface/AppHeader.tsx b/src/components/interface/AppHeader.tsx
index c65c5aa952..7e515fe7d9 100644
--- a/src/components/interface/AppHeader.tsx
+++ b/src/components/interface/AppHeader.tsx
@@ -111,6 +111,7 @@ export function AppHeader({
const {
isScreenRecording,
isAudioRecording,
+ isWebcamRecording,
setIsMuted,
isMuted,
clickToRecord,
@@ -135,13 +136,21 @@ export function AppHeader({
const showAudioStatus = currentComponentHasAudioRecording
|| isAudioRecording
|| (isScreenRecordingPermission && audioStatus !== 'idle');
- const showRecordingStatus = showAudioStatus || isScreenRecording || !!screenRecordingError;
+ const showRecordingStatus = showAudioStatus || isScreenRecording || isWebcamRecording || !!screenRecordingError;
const isAudioActivelyRecording = audioStatus === 'recording' && !isMuted;
let recordingLabel = '';
- if (isScreenRecording && isAudioActivelyRecording) {
+ if (isScreenRecording && isWebcamRecording && isAudioActivelyRecording) {
+ recordingLabel = 'Recording screen, webcam, and audio';
+ } else if (isScreenRecording && isWebcamRecording) {
+ recordingLabel = 'Recording screen and webcam';
+ } else if (isScreenRecording && isAudioActivelyRecording) {
recordingLabel = 'Recording screen and audio';
} else if (isScreenRecording) {
recordingLabel = 'Recording screen';
+ } else if (isWebcamRecording && isAudioActivelyRecording) {
+ recordingLabel = 'Recording webcam and audio';
+ } else if (isWebcamRecording) {
+ recordingLabel = 'Recording webcam';
} else if (isAudioActivelyRecording) {
recordingLabel = 'Recording audio';
}
@@ -259,7 +268,20 @@ export function AppHeader({
) : (
- setIsMuted(false)} onMouseUp={() => setIsMuted(true)} onTouchStart={() => setIsMuted(false)} onTouchEnd={() => setIsMuted(true)}>
+ {
+ event.currentTarget.setPointerCapture(event.pointerId);
+ setIsMuted(false);
+ }}
+ onPointerUp={() => setIsMuted(true)}
+ onPointerCancel={() => setIsMuted(true)}
+ onLostPointerCapture={() => setIsMuted(true)}
+ >
{isMuted ? : }
diff --git a/src/components/interface/ScreenRecordingRejection.tsx b/src/components/interface/ScreenRecordingRejection.tsx
index 8ee390ffeb..8acde08f6f 100644
--- a/src/components/interface/ScreenRecordingRejection.tsx
+++ b/src/components/interface/ScreenRecordingRejection.tsx
@@ -8,10 +8,10 @@ export function ScreenRecordingRejection() {
{}} fullScreen withCloseButton={false}>
- Screen Recording Stopped
+ Recording Stopped
<>
- Thank you for participating in this study. Screen recording was stopped and you will not be able to continue.
+ Thank you for participating in this study. Recording was stopped and you will not be able to continue.
You may now close this page.
>
diff --git a/src/components/interface/tests/AnalysisFooter.spec.tsx b/src/components/interface/tests/AnalysisFooter.spec.tsx
index 1b743a3ebf..2cf99be15c 100644
--- a/src/components/interface/tests/AnalysisFooter.spec.tsx
+++ b/src/components/interface/tests/AnalysisFooter.spec.tsx
@@ -32,6 +32,7 @@ vi.mock('../../../store/store', () => ({
vi.mock('react-router', () => ({
useParams: () => ({ studyId: 'test-study' }),
+ useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('../../../analysis/individualStudy/thinkAloud/ThinkAloudFooter', () => ({
diff --git a/src/components/interface/tests/AppHeader.spec.tsx b/src/components/interface/tests/AppHeader.spec.tsx
index cdfde63faf..9dcf2d47ec 100644
--- a/src/components/interface/tests/AppHeader.spec.tsx
+++ b/src/components/interface/tests/AppHeader.spec.tsx
@@ -17,6 +17,7 @@ let mockIsAnalysis = false;
let mockedRecordingContext = {
isScreenRecording: false,
+ isWebcamRecording: false,
isAudioRecording: false,
setIsMuted: vi.fn(),
isMuted: false,
@@ -212,6 +213,7 @@ describe('AppHeader', () => {
mockedCurrentComponent = 'componentA';
mockedRecordingContext = {
isScreenRecording: false,
+ isWebcamRecording: false,
isAudioRecording: false,
setIsMuted: vi.fn(),
isMuted: false,
diff --git a/src/components/interface/tests/ScreenRecordingRejection.spec.tsx b/src/components/interface/tests/ScreenRecordingRejection.spec.tsx
index f3931c213f..7d7d5e50a9 100644
--- a/src/components/interface/tests/ScreenRecordingRejection.spec.tsx
+++ b/src/components/interface/tests/ScreenRecordingRejection.spec.tsx
@@ -19,12 +19,12 @@ vi.mock('@tabler/icons-react', () => ({
describe('ScreenRecordingRejection', () => {
test('renders the stopped title', () => {
const html = renderToStaticMarkup( );
- expect(html).toContain('Screen Recording Stopped');
+ expect(html).toContain('Recording Stopped');
});
test('renders the explanation text', () => {
const html = renderToStaticMarkup( );
- expect(html).toContain('Screen recording was stopped');
+ expect(html).toContain('Recording was stopped');
});
test('renders the close page instruction', () => {
diff --git a/src/components/screenRecording/ScreenRecordingReplay.tsx b/src/components/screenRecording/ScreenRecordingReplay.tsx
index a0ef743243..d3f7b5abd8 100644
--- a/src/components/screenRecording/ScreenRecordingReplay.tsx
+++ b/src/components/screenRecording/ScreenRecordingReplay.tsx
@@ -49,6 +49,28 @@ export function ScreenRecordingReplay() {
useEffect(
() => {
+ let cancelled = false;
+ let loadedUrls: string[] = [];
+
+ const releaseUrl = (url: string | null) => {
+ if (url?.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ };
+ const clearVideoSource = (video: HTMLVideoElement | null) => {
+ video?.removeAttribute('src');
+ };
+ const screenVideo = screenVideoRef.current;
+ const webcamVideo = webcamVideoRef.current;
+ clearVideoSource(screenVideo);
+ clearVideoSource(webcamVideo);
+ updateReplayRef();
+ setHasScreenVideo(false);
+ setHasWebcamVideo(false);
+ storeDispatch(setAnalysisHasScreenRecording(false));
+ storeDispatch(setAnalysisHasWebcamRecording(false));
+ storeDispatch(setAnalysisCanPlayScreenRecording(false));
+
async function getVideoURLs() {
if (isAnalysis && identifier && storageEngine) {
try {
@@ -68,6 +90,13 @@ export function ScreenRecordingReplay() {
safeGetRecording(() => storageEngine.getWebcamRecording(identifier, participantId)),
]);
+ if (cancelled) {
+ releaseUrl(screenUrl);
+ releaseUrl(webcamUrl);
+ return;
+ }
+ loadedUrls = [screenUrl, webcamUrl].filter((url): url is string => !!url);
+
const hasScreenRecording = !!screenUrl;
const hasWebcamRecording = !!webcamUrl;
const hasVideoRecording = hasScreenRecording || hasWebcamRecording;
@@ -80,16 +109,17 @@ export function ScreenRecordingReplay() {
if (screenVideoRef.current) {
screenVideoRef.current.preload = 'metadata';
- screenVideoRef.current.src = screenUrl || '';
+ if (screenUrl) screenVideoRef.current.src = screenUrl;
}
if (webcamVideoRef.current) {
webcamVideoRef.current.preload = 'metadata';
- webcamVideoRef.current.src = webcamUrl || '';
+ if (webcamUrl) webcamVideoRef.current.src = webcamUrl;
}
updateReplayRef();
} catch {
+ if (cancelled) return;
setHasScreenVideo(false);
setHasWebcamVideo(false);
storeDispatch(setAnalysisHasScreenRecording(false));
@@ -106,6 +136,15 @@ export function ScreenRecordingReplay() {
}
getVideoURLs();
+
+ return () => {
+ cancelled = true;
+ loadedUrls.forEach(releaseUrl);
+ loadedUrls = [];
+ clearVideoSource(screenVideo);
+ clearVideoSource(webcamVideo);
+ updateReplayRef();
+ };
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[
@@ -117,6 +156,7 @@ export function ScreenRecordingReplay() {
setAnalysisHasScreenRecording,
setAnalysisHasWebcamRecording,
setAnalysisCanPlayScreenRecording,
+ updateReplayRef,
],
);
diff --git a/src/components/tests/StepRenderer.spec.tsx b/src/components/tests/StepRenderer.spec.tsx
index 5cdd712e5d..f718a94494 100644
--- a/src/components/tests/StepRenderer.spec.tsx
+++ b/src/components/tests/StepRenderer.spec.tsx
@@ -220,6 +220,7 @@ vi.mock('../../utils/notifications', () => ({
vi.mock('react-router', () => ({
Outlet: () =>
,
+ useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock('@mantine/core', () => ({
diff --git a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx
index cd5b8b84f1..59e9bb5d91 100644
--- a/src/public/libraries/screen-recording/assets/ScreenRecording.tsx
+++ b/src/public/libraries/screen-recording/assets/ScreenRecording.tsx
@@ -29,9 +29,10 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) {
const setupComplete = useMemo(
() => screenCapturing
&& (!studyHasWebcamRecording || webcamCapturing)
- && (!studyHasAudioRecording || audioCapturingSuccess),
+ && (!studyHasAudioRecording || (audioCapturing && audioCapturingSuccess)),
[
audioCapturingSuccess,
+ audioCapturing,
screenCapturing,
studyHasAudioRecording,
studyHasWebcamRecording,
@@ -57,6 +58,12 @@ function ScreenRecordingPermission({ setAnswer }: StimulusParams) {
});
}, [dataCollectionEnabled, screenCapturing, setAnswer, setupComplete]);
+ useEffect(() => {
+ if (!audioCapturing) {
+ setAudioCapturingSuccess(false);
+ }
+ }, [audioCapturing]);
+
useEffect(() => {
if (!audioCapturing || !studyHasAudioRecording || !audioMediaStream.current) {
return undefined;
diff --git a/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx b/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
index b1fb2cdf3a..4861712464 100644
--- a/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
+++ b/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
@@ -22,10 +22,16 @@ function WebcamRecordingPermission({ setAnswer }: StimulusParams) {
const [audioCapturingSuccess, setAudioCapturingSuccess] = useState(false);
const setupComplete = useMemo(
- () => isWebcamCapturing && (!studyHasAudioRecording || audioCapturingSuccess),
- [audioCapturingSuccess, isWebcamCapturing, studyHasAudioRecording],
+ () => isWebcamCapturing && (!studyHasAudioRecording || (isAudioCapturing && audioCapturingSuccess)),
+ [audioCapturingSuccess, isAudioCapturing, isWebcamCapturing, studyHasAudioRecording],
);
+ useEffect(() => {
+ if (!isAudioCapturing) {
+ setAudioCapturingSuccess(false);
+ }
+ }, [isAudioCapturing]);
+
useEffect(() => {
setAnswer({
status: setupComplete,
diff --git a/src/storage/engines/types.ts b/src/storage/engines/types.ts
index 713adf4f28..d675a63127 100644
--- a/src/storage/engines/types.ts
+++ b/src/storage/engines/types.ts
@@ -1563,15 +1563,24 @@ export abstract class StorageEngine {
return null;
}
- const asset = new Promise((resolve) => {
+ const asset = new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.responseType = 'blob';
xhr.onload = () => {
const blob = xhr.response;
-
- const _url = URL.createObjectURL(blob);
-
- resolve(_url);
+ try {
+ resolve(URL.createObjectURL(blob));
+ } finally {
+ if (url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ }
+ };
+ xhr.onerror = () => {
+ if (url.startsWith('blob:')) {
+ URL.revokeObjectURL(url);
+ }
+ reject(new Error(`Failed to load asset: ${url}`));
};
xhr.open('GET', url);
xhr.send();
diff --git a/src/storage/tests/highLevel.spec.ts b/src/storage/tests/highLevel.spec.ts
index 7020f9b0bd..3b66de2925 100644
--- a/src/storage/tests/highLevel.spec.ts
+++ b/src/storage/tests/highLevel.spec.ts
@@ -1415,6 +1415,14 @@ describe.each([
expect(finalizeResult.status).toBe('complete');
});
+ test('saveWebcamRecording persists a webcam asset for the current participant', async () => {
+ await storageEngine.initializeParticipantSession({}, configSimple, participantMetadata);
+
+ await expect(
+ storageEngine.saveWebcamRecording(new Blob(['webcam'], { type: 'video/webm' }), 'intro_0'),
+ ).resolves.toBeUndefined();
+ });
+
// getAudio and saveAudio untestable due to browser-specific implementation
test('getSequenceArray returns the sequence array', async () => {
diff --git a/src/store/hooks/tests/useRecording.spec.ts b/src/store/hooks/tests/useRecording.spec.ts
index 8abeaa655a..b185088a1a 100644
--- a/src/store/hooks/tests/useRecording.spec.ts
+++ b/src/store/hooks/tests/useRecording.spec.ts
@@ -26,6 +26,7 @@ let mockCurrentComponent = 'intro';
let mockStorageEngine: Record> | null = null;
let mockStoredAnswer: { endTime: number } | null = null;
let mockModes = { dataCollectionEnabled: true, developmentModeEnabled: false, dataSharingEnabled: false };
+const mockRecorderStartAudioStates: boolean[][] = [];
// ── media mocks ────────────────────────────────────────────────────────────────
@@ -38,7 +39,11 @@ const mockTrackFactory = () => ({
});
class MockMediaStream {
- _tracks = [mockTrackFactory()];
+ _tracks: ReturnType[];
+
+ constructor(tracks: ReturnType[] = [mockTrackFactory()]) {
+ this._tracks = tracks;
+ }
getTracks = vi.fn(() => this._tracks);
@@ -63,6 +68,7 @@ class MockMediaRecorder {
constructor(s: MockMediaStream) { this.stream = s; }
start = vi.fn(() => {
+ mockRecorderStartAudioStates.push(this.stream.getAudioTracks().map((track) => track.enabled));
this.state = 'recording';
this._listeners.start?.({});
});
@@ -155,6 +161,7 @@ beforeEach(() => {
mockStorageEngine = null;
mockStoredAnswer = null;
mockModes = { dataCollectionEnabled: true, developmentModeEnabled: false, dataSharingEnabled: false };
+ mockRecorderStartAudioStates.length = 0;
vi.stubGlobal('MediaStream', MockMediaStream);
vi.stubGlobal('MediaRecorder', MockMediaRecorder);
@@ -309,6 +316,131 @@ describe('useRecording startScreenCapture', () => {
expect(result.current.isScreenCapturing).toBe(false);
expect(navigator.mediaDevices.getDisplayMedia).not.toHaveBeenCalled();
});
+
+ test('stops an already-granted screen stream when webcam permission is denied', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasWebcamRecording: true,
+ };
+ const screenStream = new MockMediaStream();
+ vi.mocked(navigator.mediaDevices.getDisplayMedia).mockResolvedValue(screenStream as unknown as MediaStream);
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockRejectedValue(new Error('denied'));
+ const { result } = renderHook(() => useRecording());
+
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.screenRecordingError).toBe('Recording permission denied'));
+ expect(screenStream.getTracks()[0].stop).toHaveBeenCalled();
+ });
+
+ test('does not start overlapping capture requests', async () => {
+ mockRecordingConfig = { ...mockRecordingConfig, studyHasScreenRecording: true };
+ let resolveCapture: ((stream: MediaStream) => void) | undefined;
+ vi.mocked(navigator.mediaDevices.getDisplayMedia).mockImplementation(() => new Promise((resolve) => {
+ resolveCapture = resolve;
+ }));
+ const { result } = renderHook(() => useRecording());
+
+ act(() => {
+ result.current.startScreenCapture();
+ result.current.startScreenCapture();
+ });
+ expect(navigator.mediaDevices.getDisplayMedia).toHaveBeenCalledOnce();
+ await act(async () => { resolveCapture?.(new MockMediaStream() as unknown as MediaStream); });
+ });
+
+ test('cleans up persistent capture on unmount', async () => {
+ mockRecordingConfig = { ...mockRecordingConfig, studyHasScreenRecording: true };
+ const screenStream = new MockMediaStream();
+ vi.mocked(navigator.mediaDevices.getDisplayMedia).mockResolvedValue(screenStream as unknown as MediaStream);
+ const { result, unmount } = renderHook(() => useRecording());
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.isMediaCapturing).toBe(true));
+ unmount();
+ expect(screenStream.getTracks()[0].stop).toHaveBeenCalled();
+ });
+
+ test('initializes the persistent microphone track with the current mute state', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasAudioRecording: true,
+ currentComponentHasClickToRecord: true,
+ };
+ const micStream = new MockMediaStream();
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValue(micStream as unknown as MediaStream);
+ const { result } = renderHook(() => useRecording());
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.isAudioCapturing).toBe(true));
+ expect(micStream.getAudioTracks()[0].enabled).toBe(false);
+ });
+
+ test('stops screen and webcam streams when required microphone permission is denied', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasWebcamRecording: true,
+ studyHasAudioRecording: true,
+ };
+ const screenStream = new MockMediaStream();
+ const webcamStream = new MockMediaStream();
+ vi.mocked(navigator.mediaDevices.getDisplayMedia).mockResolvedValue(screenStream as unknown as MediaStream);
+ vi.mocked(navigator.mediaDevices.getUserMedia)
+ .mockResolvedValueOnce(webcamStream as unknown as MediaStream)
+ .mockRejectedValueOnce(new Error('denied'));
+ const { result } = renderHook(() => useRecording());
+
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.audioRecordingError).toBe('Microphone permission denied'));
+ expect(result.current.isMediaCapturing).toBe(false);
+ expect(screenStream.getTracks()[0].stop).toHaveBeenCalled();
+ expect(webcamStream.getTracks()[0].stop).toHaveBeenCalled();
+ });
+
+ test('starts a click-to-record trial muted even if the prior component was not muted', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasAudioRecording: true,
+ currentComponentHasScreenRecording: true,
+ currentComponentHasAudioRecording: true,
+ };
+ const micStream = new MockMediaStream();
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockResolvedValue(micStream as unknown as MediaStream);
+ mockStorageEngine = {
+ saveScreenRecording: vi.fn(async () => {}),
+ saveAudioRecording: vi.fn(async () => {}),
+ };
+ vi.stubGlobal('AudioContext', class {
+ createAnalyser() {
+ return {
+ fftSize: 0,
+ smoothingTimeConstant: 0,
+ getFloatTimeDomainData: vi.fn(),
+ };
+ }
+
+ createMediaStreamSource() {
+ return { connect: vi.fn() };
+ }
+
+ close = vi.fn(async () => {});
+ });
+ const { result, rerender } = renderHook(() => useRecording());
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.isMediaCapturing).toBe(true));
+
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ currentComponentHasClickToRecord: true,
+ };
+ act(() => { rerender(); });
+ await waitFor(() => expect(result.current.isMuted).toBe(true));
+ act(() => { result.current.startScreenRecording('trial_0'); });
+
+ expect(micStream.getAudioTracks()[0].enabled).toBe(false);
+ expect(mockRecorderStartAudioStates.some((states) => states.includes(false))).toBe(true);
+ });
});
// ── startScreenRecording tests ─────────────────────────────────────────────────
diff --git a/src/store/hooks/tests/useReplay.spec.tsx b/src/store/hooks/tests/useReplay.spec.tsx
index 1f3e69a938..43ceac9408 100644
--- a/src/store/hooks/tests/useReplay.spec.tsx
+++ b/src/store/hooks/tests/useReplay.spec.tsx
@@ -300,6 +300,66 @@ describe('useReplay — handlePlay/Seeked/Pause via video element events', () =>
expect(webcam.muted).toBe(true);
});
+ test('does not select an empty screen source over an available webcam source', () => {
+ const { result } = renderHook(() => useReplay());
+ const screen = document.createElement('video');
+ const webcam = makeVideoWithSrc();
+ screen.setAttribute('src', '');
+
+ act(() => {
+ result.current.videoRef.current = screen;
+ result.current.webcamVideoRef.current = webcam;
+ result.current.updateReplayRef();
+ });
+
+ expect(result.current.replayRef.current).toBe(webcam);
+ });
+
+ test('uses audio as the authoritative source when replaying webcam and audio', () => {
+ const { result } = renderHook(() => useReplay());
+ const webcam = makeVideoWithSrc();
+ const audio = document.createElement('audio');
+ Object.defineProperty(audio, 'src', { value: 'fake.mp3', writable: true, configurable: true });
+
+ act(() => {
+ result.current.webcamVideoRef.current = webcam;
+ result.current.audioRef.current = audio;
+ result.current.updateReplayRef();
+ });
+
+ expect(result.current.replayRef.current).toBe(audio);
+ });
+
+ test('stops playback when a newly available source replaces the master', () => {
+ const { result } = renderHook(() => useReplay());
+ const screen = makeVideoWithSrc();
+ screen.play = vi.fn(async () => {});
+ screen.pause = vi.fn();
+ const webcam = makeVideoWithSrc();
+ webcam.pause = vi.fn();
+ const audio = document.createElement('audio');
+ Object.defineProperty(audio, 'src', { value: 'fake.mp3', writable: true, configurable: true });
+
+ act(() => {
+ result.current.videoRef.current = screen;
+ result.current.webcamVideoRef.current = webcam;
+ result.current.updateReplayRef();
+ result.current.setIsPlaying(true);
+ });
+ expect(result.current.isPlaying).toBe(true);
+
+ act(() => {
+ screen.removeAttribute('src');
+ Object.defineProperty(screen, 'src', { value: '', writable: true, configurable: true });
+ result.current.audioRef.current = audio;
+ result.current.updateReplayRef();
+ });
+
+ expect(result.current.isPlaying).toBe(false);
+ expect(screen.pause).toHaveBeenCalled();
+ expect(webcam.pause).toHaveBeenCalled();
+ });
+
test('screen playback starts and seeks the webcam recording', () => {
const { result } = renderHook(() => useReplay());
const screenVideo = makeVideoWithSrc();
diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts
index 3592472cf3..3039225ee5 100644
--- a/src/store/hooks/useRecording.ts
+++ b/src/store/hooks/useRecording.ts
@@ -1,5 +1,5 @@
import {
- createContext, useCallback, useContext, useEffect, useRef, useState,
+ createContext, useCallback, useContext, useEffect, useLayoutEffect, useRef, useState,
} from 'react';
import { useStudyConfig } from './useStudyConfig';
import { useCurrentComponent, useCurrentIdentifier } from '../../routes/utils';
@@ -61,8 +61,6 @@ export function useRecording() {
const [analysisStreamReady, setAnalysisStreamReady] = useState(false);
const [showMutedWarning, setShowMutedWarning] = useState(false);
- // currentMediaStream and recorder can be just screen, just audio, or screen and audio combined.
- const currentMediaStream = useRef(null);
const currentMediaRecorder = useRef(null);
const audioMediaStream = useRef(null);
const audioMediaRecorder = useRef(null); // recorder for audio. Necessary to save audio file to get transcription.
@@ -70,6 +68,9 @@ export function useRecording() {
const webcamMediaStream = useRef(null);
const webcamMediaRecorder = useRef(null);
const isStoppingCapture = useRef(false);
+ const captureAttempt = useRef(0);
+ const isStartingCapture = useRef(false);
+ const isMounted = useRef(true);
const currentTrialName = useRef(null);
const identifier = useCurrentIdentifier();
@@ -93,9 +94,9 @@ export function useRecording() {
currentComponentHasClickToRecord,
} = useRecordingConfig();
- useEffect(() => {
+ useLayoutEffect(() => {
setIsMuted(currentComponentHasClickToRecord);
- }, [currentComponentHasClickToRecord]);
+ }, [currentComponentHasClickToRecord, identifier]);
// Screen capture starts once and stops at the end of the study.
// At the beginning of each stimulus, recording starts by calling `startScreenRecording`.
@@ -187,12 +188,17 @@ export function useRecording() {
return;
}
+ if (wantsAudio && audioMediaStream.current) {
+ audioMediaStream.current.getAudioTracks().forEach((track) => {
+ track.enabled = !currentComponentHasClickToRecord;
+ });
+ }
+
if (wantsScreen && screenMediaStream.current) {
const screenStream = new MediaStream([
...screenMediaStream.current.getVideoTracks(),
...(wantsAudio ? audioMediaStream.current?.getAudioTracks() ?? [] : []),
]);
- currentMediaStream.current = screenStream;
const recorder = new MediaRecorder(screenStream);
currentMediaRecorder.current = recorder;
attachSaveHandler(
@@ -240,6 +246,7 @@ export function useRecording() {
}, [
attachSaveHandler,
currentComponentHasAudioRecording,
+ currentComponentHasClickToRecord,
currentComponentHasScreenRecording,
currentComponentHasWebcamRecording,
dataCollectionEnabled,
@@ -307,8 +314,6 @@ export function useRecording() {
audio: true,
}).then((s) => {
audioMediaStream.current = s;
- currentMediaStream.current = s;
-
const analysisTrack = s.getAudioTracks()[0]?.clone();
if (analysisTrack) {
analysisAudioStream.current = new MediaStream([analysisTrack]);
@@ -316,7 +321,7 @@ export function useRecording() {
}
s.getAudioTracks().forEach((track) => {
- track.enabled = !isMuted;
+ track.enabled = !currentComponentHasClickToRecord;
});
const recorder = new MediaRecorder(s);
@@ -350,7 +355,7 @@ export function useRecording() {
setAudioRecordingError('Microphone permission denied');
setIsAudioRecording(false);
});
- }, [dataCollectionEnabled, storageEngine, isMuted]);
+ }, [currentComponentHasClickToRecord, dataCollectionEnabled, storageEngine]);
// For study with just audio recording
useEffect(() => {
@@ -415,13 +420,32 @@ export function useRecording() {
return;
}
+ if (isStartingCapture.current) {
+ return;
+ }
+
+ isStartingCapture.current = true;
+ const attempt = captureAttempt.current + 1;
+ captureAttempt.current = attempt;
+ let screenStream: MediaStream | null = null;
+ let webcamStream: MediaStream | null = null;
+ let micStream: MediaStream | null = null;
+
+ const isCurrentAttempt = () => isMounted.current && captureAttempt.current === attempt;
+ const stopAcquiredStreams = () => {
+ [micStream, webcamStream, screenStream].forEach(stopMediaTracks);
+ if (screenMediaStream.current === screenStream) screenMediaStream.current = null;
+ if (webcamMediaStream.current === webcamStream) webcamMediaStream.current = null;
+ if (audioMediaStream.current === micStream) audioMediaStream.current = null;
+ };
+
document.title = includeScreen ? `RECORD THIS TAB: ${pageTitle}` : pageTitle;
try {
setRecordingError(null);
setAudioRecordingError(null);
- const screenStream = includeScreen ? await navigator.mediaDevices.getDisplayMedia({
+ screenStream = includeScreen ? await navigator.mediaDevices.getDisplayMedia({
video: { displaySurface: 'browser', ...(recordScreenFPS ? { frameRate: { ideal: recordScreenFPS } } : {}) },
audio: false,
// @ts-expect-error: experimental (selfBrowserSurface and preferCurrentTab are not yet standardized)
@@ -429,13 +453,22 @@ export function useRecording() {
selfBrowserSurface: 'include',
preferCurrentTab: true,
}) : null;
+ if (!isCurrentAttempt()) {
+ stopAcquiredStreams();
+ return;
+ }
+ screenMediaStream.current = screenStream;
- const webcamStream = includeWebcam ? await navigator.mediaDevices.getUserMedia({
+ webcamStream = includeWebcam ? await navigator.mediaDevices.getUserMedia({
video: true,
audio: false,
}) : null;
+ if (!isCurrentAttempt()) {
+ stopAcquiredStreams();
+ return;
+ }
+ webcamMediaStream.current = webcamStream;
- let micStream: MediaStream | null = null;
if (includeAudio) {
try {
micStream = await navigator.mediaDevices.getUserMedia({
@@ -445,12 +478,17 @@ export function useRecording() {
} catch (err) {
console.error('Error accessing microphone:', err);
setAudioRecordingError('Microphone permission denied');
+ throw err;
}
}
-
- screenMediaStream.current = screenStream;
- webcamMediaStream.current = webcamStream;
+ if (!isCurrentAttempt()) {
+ stopAcquiredStreams();
+ return;
+ }
audioMediaStream.current = micStream;
+ micStream?.getAudioTracks().forEach((track) => {
+ track.enabled = !currentComponentHasClickToRecord;
+ });
const analysisTrack = micStream?.getAudioTracks()[0]?.clone();
if (analysisTrack) {
@@ -489,12 +527,18 @@ export function useRecording() {
setIsRejected(false);
} catch (err) {
console.error('Error accessing recording media:', err);
- setRecordingError('Recording permission denied');
+ if (isCurrentAttempt()) {
+ setRecordingError('Recording permission denied');
+ }
+ stopAcquiredStreams();
stopScreenCapture();
} finally {
+ if (captureAttempt.current === attempt) {
+ isStartingCapture.current = false;
+ }
document.title = pageTitle;
}
- }, [dataCollectionEnabled, pageTitle, recordAudio, recordScreenFPS, stopScreenCapture]);
+ }, [currentComponentHasClickToRecord, dataCollectionEnabled, pageTitle, recordAudio, recordScreenFPS, stopScreenCapture]);
const startScreenCapture = useCallback(() => {
startMediaCapture({
@@ -529,6 +573,12 @@ export function useRecording() {
};
}, [currentComponentHasAudioRecording, isMuted]);
+ useEffect(() => () => {
+ isMounted.current = false;
+ captureAttempt.current += 1;
+ stopScreenCapture();
+ }, [stopScreenCapture]);
+
useEffect(() => {
if (!shouldMonitorMutedAudio(isMuted, currentComponentHasAudioRecording) || !analysisStreamReady || !analysisAudioStream.current) return undefined;
diff --git a/src/store/hooks/useReplay.ts b/src/store/hooks/useReplay.ts
index ce6c6eda11..143e972aab 100644
--- a/src/store/hooks/useReplay.ts
+++ b/src/store/hooks/useReplay.ts
@@ -17,6 +17,16 @@ function mediaIncludesTime(media: HTMLMediaElement, time: number) {
return !Number.isFinite(media.duration) || media.duration <= 0 || time < media.duration;
}
+function hasMediaSource(media: HTMLMediaElement) {
+ const sourceAttribute = media.getAttribute('src');
+ if (sourceAttribute !== null) {
+ return !!sourceAttribute;
+ }
+ return !!media.src
+ && media.src !== document.baseURI
+ && media.src !== window.location.href;
+}
+
/**
* Hook to subscribe to video/audio/provenance timing events for replay
*/
@@ -52,7 +62,7 @@ export function useReplay() {
), []);
const getActiveMediaElements = useCallback(() => (
- getMediaElements().filter((media) => !!media.src)
+ getMediaElements().filter(hasMediaSource)
), [getMediaElements]);
const getSecondaryMediaElements = useCallback(() => (
@@ -159,6 +169,9 @@ export function useReplay() {
updateIsPlaying(true);
const t = replayRef.current?.currentTime || 0;
+ getSecondaryMediaElements().forEach((media) => {
+ seekMedia(media, timerValue.current);
+ });
emitterRef.current.emit('play', t);
updateMutedState();
@@ -249,9 +262,14 @@ export function useReplay() {
seekMedia(media, timerValue.current);
});
- replayRef.current = (videoRef.current?.src ? videoRef.current : null)
- ?? (webcamVideoRef.current?.src ? webcamVideoRef.current : null)
- ?? (audioRef.current?.src ? audioRef.current : null);
+ replayRef.current = (videoRef.current && hasMediaSource(videoRef.current) ? videoRef.current : null)
+ ?? (audioRef.current && hasMediaSource(audioRef.current) ? audioRef.current : null)
+ ?? (webcamVideoRef.current && hasMediaSource(webcamVideoRef.current) ? webcamVideoRef.current : null);
+
+ if (previousReplay !== replayRef.current && internalIsPlaying.current) {
+ getMediaElements().forEach((media) => media.pause());
+ updateIsPlaying(false);
+ }
if (replayRef.current) {
replayRef.current.addEventListener('play', handlePlay);
@@ -261,7 +279,7 @@ export function useReplay() {
}
updateMutedState();
forceEmitTimeUpdate();
- }, [forceEmitTimeUpdate, getMediaElements, handleEnded, handlePause, handlePlay, handleSeeked, updateMutedState]);
+ }, [forceEmitTimeUpdate, getMediaElements, handleEnded, handlePause, handlePlay, handleSeeked, updateIsPlaying, updateMutedState]);
// this should be the only way to start video/audio
const setIsPlaying = useCallback((playing: boolean, isRemoteTriggered = false) => {
@@ -286,9 +304,9 @@ export function useReplay() {
) {
requestReplayPlayback(replayRef.current);
} else {
- replayRef.current?.pause();
+ getActiveMediaElements().forEach((media) => media.pause());
}
- }, [requestReplayPlayback, setSeekTime, updateIsPlaying]);
+ }, [getActiveMediaElements, requestReplayPlayback, setSeekTime, updateIsPlaying]);
useEffect(() => {
isMountedRef.current = true;
@@ -339,6 +357,12 @@ export function useReplay() {
timerValue.current = internalDuration.current > 0
? Math.min(nextTime, internalDuration.current)
: nextTime;
+ getSecondaryMediaElements().forEach((secondary) => {
+ if (!Number.isFinite(secondary.currentTime)
+ || Math.abs(secondary.currentTime - timerValue.current) > 0.15) {
+ seekMedia(secondary, timerValue.current);
+ }
+ });
emitterRef.current.emit('timeupdate', timerValue.current);
if (internalDuration.current > 0 && timerValue.current >= internalDuration.current) {
@@ -362,7 +386,7 @@ export function useReplay() {
syntheticReplayTimer.current = null;
}
};
- }, [isPlaying, setIsPlaying]);
+ }, [getSecondaryMediaElements, isPlaying, setIsPlaying]);
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
diff --git a/src/utils/handleDownloadFiles.ts b/src/utils/handleDownloadFiles.ts
index ec874c418a..1dd008cad1 100644
--- a/src/utils/handleDownloadFiles.ts
+++ b/src/utils/handleDownloadFiles.ts
@@ -46,6 +46,12 @@ export async function handleTaskAudio({
type RecordingType = 'screenRecording' | 'webcamRecording';
+function recordingArchiveName(includeScreen: boolean, includeWebcam: boolean) {
+ if (includeScreen && !includeWebcam) return 'screenRecording';
+ if (includeWebcam && !includeScreen) return 'webcamRecording';
+ return 'recordings';
+}
+
async function getTaskRecordingUrl({
storageEngine,
participantId,
@@ -269,14 +275,17 @@ async function downloadParticipantsRecordings({
identifier,
recordingType,
});
- await loadAssetToZip(recordingsZip, `${namePrefix}_${participantId}_${identifier}_${recordingType}.webm`, recordingUrl);
+ const memberName = includeScreen && !includeWebcam
+ ? `${namePrefix}_${participantId}_${identifier}.webm`
+ : `${namePrefix}_${participantId}_${identifier}_${recordingType}.webm`;
+ await loadAssetToZip(recordingsZip, memberName, recordingUrl);
} catch (error) {
console.warn(`Failed to fetch ${recordingType} for ${identifier}:`, error);
}
}));
if (!zip) {
- downloadZip(recordingsZip, `${namePrefix}_${participantId}_${identifier}_recordings.zip`);
+ downloadZip(recordingsZip, `${namePrefix}_${participantId}_${identifier}_${recordingArchiveName(includeScreen, includeWebcam)}.zip`);
}
}
@@ -320,7 +329,7 @@ export async function downloadParticipantsRecordingsZip({
await Promise.all(recordingPromises);
- await downloadZip(zip, `${namePrefix}_recordings.zip`);
+ await downloadZip(zip, `${namePrefix}_${recordingArchiveName(includeScreen, includeWebcam)}.zip`);
}
export async function downloadParticipantsProvenanceZip({
diff --git a/src/utils/tests/handleDownloadFiles.spec.ts b/src/utils/tests/handleDownloadFiles.spec.ts
index 153449efe7..3866801f0c 100644
--- a/src/utils/tests/handleDownloadFiles.spec.ts
+++ b/src/utils/tests/handleDownloadFiles.spec.ts
@@ -1,6 +1,7 @@
import {
afterEach, beforeEach, describe, expect, test, vi,
} from 'vitest';
+import JSZip from 'jszip';
import {
downloadConfigFile,
downloadConfigFilesZip,
@@ -268,6 +269,34 @@ describe('downloadParticipantsRecordingsZip', () => {
expect(clickSpy).toHaveBeenCalledOnce();
});
+ test('keeps the legacy screen-only archive and member names', async () => {
+ const storageEngine = makeStorageEngine({
+ getScreenRecording: vi.fn(async () => 'https://example.com/recording.webm'),
+ });
+ const participants = [{
+ participantId: 'p1',
+ answers: {
+ trial_0: {
+ endTime: 1000, startTime: 0, componentName: 'trial', trialOrder: '0',
+ },
+ },
+ }];
+
+ const zipFile = vi.spyOn(JSZip.prototype, 'file');
+ const anchorDownloads: string[] = [];
+ clickSpy.mockImplementation(function click(this: HTMLAnchorElement) {
+ anchorDownloads.push(this.download);
+ });
+
+ await downloadParticipantsRecordingsZip({
+ storageEngine, participants, studyId: 'study', includeScreen: true, includeWebcam: false,
+ });
+
+ expect(zipFile).toHaveBeenCalledWith('study_p1_trial_0.webm', expect.any(Blob));
+ expect(anchorDownloads).toContain('study_screenRecording.zip');
+ zipFile.mockRestore();
+ });
+
test('skips trials where endTime is 0 (not completed)', async () => {
const storageEngine = makeStorageEngine({
getScreenRecording: vi.fn(async () => 'https://example.com/rec.webm'),
From d79e7ed9a98114c6a43388fe3650bf21417a3d3f Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Tue, 8 Sep 2026 19:32:16 -0600
Subject: [PATCH 03/12] Fix webcam recording review findings
---
public/global.json | 5 +++
public/libraries/screen-recording/config.json | 1 +
public/libraries/webcam-recording/config.json | 1 +
.../replay/AllTasksTimeline.tsx | 3 +-
.../individualStudy/replay/SingleTask.tsx | 12 +++++-
.../replay/tests/AllTasksTimeline.spec.tsx | 24 +++++++++++-
.../thinkAloud/ThinkAloudFooter.tsx | 4 +-
.../screenRecording/ScreenRecordingReplay.tsx | 1 -
.../tests/ScreenRecordingReplay.spec.tsx | 18 +++++++++
src/store/hooks/tests/useRecording.spec.ts | 6 +++
.../hooks/tests/useRecordingConfig.spec.ts | 26 ++++++-------
src/store/hooks/useRecordingConfig.ts | 33 +++++++++-------
src/utils/handleDownloadFiles.ts | 29 ++++++++------
src/utils/tests/handleDownloadFiles.spec.ts | 39 +++++++++++++++++++
14 files changed, 155 insertions(+), 47 deletions(-)
diff --git a/public/global.json b/public/global.json
index b0f14b000d..59a5ca3875 100644
--- a/public/global.json
+++ b/public/global.json
@@ -49,6 +49,7 @@
"library-quis",
"library-sam",
"library-screen-recording",
+ "library-webcam-recording",
"library-smeq",
"library-sus",
"library-ueq",
@@ -223,6 +224,10 @@
"path": "library-screen-recording/config.json",
"test": true
},
+ "library-webcam-recording": {
+ "path": "library-webcam-recording/config.json",
+ "test": true
+ },
"library-smeq": {
"path": "library-smeq/config.json",
"test": true
diff --git a/public/libraries/screen-recording/config.json b/public/libraries/screen-recording/config.json
index b2f9c922c3..8adc0c3660 100644
--- a/public/libraries/screen-recording/config.json
+++ b/public/libraries/screen-recording/config.json
@@ -9,6 +9,7 @@
"nextButtonLocation": "belowStimulus",
"nextButtonText": "Continue",
"recordScreen": true,
+ "clickToRecord": false,
"response": [
{
"hidden": true,
diff --git a/public/libraries/webcam-recording/config.json b/public/libraries/webcam-recording/config.json
index 9d331cdebd..736bcb4ef7 100644
--- a/public/libraries/webcam-recording/config.json
+++ b/public/libraries/webcam-recording/config.json
@@ -9,6 +9,7 @@
"nextButtonLocation": "belowStimulus",
"nextButtonText": "Continue",
"recordAudio": false,
+ "clickToRecord": false,
"response": [{
"hidden": true,
"type": "reactive",
diff --git a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx
index f9fae0fda3..49e2498261 100644
--- a/src/analysis/individualStudy/replay/AllTasksTimeline.tsx
+++ b/src/analysis/individualStudy/replay/AllTasksTimeline.tsx
@@ -217,6 +217,7 @@ export function AllTasksTimeline({
const answerStatus = getComponentAnswerStatus(answer, correctAnswers, resolvedComponent?.response);
const hasAudio = resolvedComponent?.recordAudio ?? studyConfig?.uiConfig?.recordAudio ?? false;
const hasScreenRecording = resolvedComponent?.recordScreen ?? studyConfig?.uiConfig?.recordScreen ?? false;
+ const hasWebcamRecording = resolvedComponent?.recordWebcam ?? studyConfig?.uiConfig?.recordWebcam ?? false;
return {
identifier,
@@ -246,7 +247,7 @@ export function AllTasksTimeline({
)}
>
- setHoveredTaskIdentifier(identifier)} onHoverEnd={() => setHoveredTaskIdentifier(null)} />
+ setHoveredTaskIdentifier(identifier)} onHoverEnd={() => setHoveredTaskIdentifier(null)} />
),
};
diff --git a/src/analysis/individualStudy/replay/SingleTask.tsx b/src/analysis/individualStudy/replay/SingleTask.tsx
index a0e16c7180..eccc541394 100644
--- a/src/analysis/individualStudy/replay/SingleTask.tsx
+++ b/src/analysis/individualStudy/replay/SingleTask.tsx
@@ -3,7 +3,7 @@ import * as d3 from 'd3';
import { useResizeObserver } from '@mantine/hooks';
import {
- IconCheck, IconDeviceDesktop, IconMicrophone, IconProgress, IconX,
+ IconCamera, IconCheck, IconDeviceDesktop, IconMicrophone, IconProgress, IconX,
} from '@tabler/icons-react';
import { useNavigateToTrial } from '../../../utils/useNavigateToTrial';
import type { ComponentAnswerStatus } from '../../../utils/correctAnswer';
@@ -48,6 +48,7 @@ export function SingleTask({
answerStatus,
hasAudio,
hasScreenRecording,
+ hasWebcamRecording,
scaleStart,
scaleEnd,
incomplete,
@@ -67,6 +68,7 @@ export function SingleTask({
answerStatus: ComponentAnswerStatus | null,
hasAudio: boolean,
hasScreenRecording: boolean,
+ hasWebcamRecording: boolean,
scaleStart: number,
scaleEnd: number,
incomplete: boolean,
@@ -82,7 +84,7 @@ export function SingleTask({
const [ref, { width: labelWidth }] = useResizeObserver();
const navigateToTrial = useNavigateToTrial();
- const iconCount = (incomplete || answerStatus ? 1 : 0) + (hasAudio ? 1 : 0) + (hasScreenRecording ? 1 : 0);
+ const iconCount = (incomplete || answerStatus ? 1 : 0) + (hasAudio ? 1 : 0) + (hasScreenRecording ? 1 : 0) + (hasWebcamRecording ? 1 : 0);
const iconsWidth = iconCount * (ICON_SIZE + ICON_GAP);
const labelOpacity = isDimmed ? 0.35 : 1;
@@ -165,6 +167,12 @@ export function SingleTask({
size="14"
/>
)}
+ {hasWebcamRecording && (
+
+ )}
{incomplete ? (
({
'aria-label'?: string;
color?: string;
}) => icon-check ,
+ IconCamera: () => icon-camera ,
IconMicrophone: () => icon-microphone ,
IconProgress: () => icon-progress ,
IconX: () => icon-x ,
@@ -126,6 +127,7 @@ describe('SingleTask', () => {
answerStatus: null,
hasAudio: false,
hasScreenRecording: false,
+ hasWebcamRecording: false,
};
test('renders task name', () => {
@@ -166,6 +168,13 @@ describe('SingleTask', () => {
expect(html).toContain('icon-microphone');
});
+ test('shows camera icon when hasWebcamRecording', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('icon-camera');
+ });
+
test('shows progress icon when incomplete', () => {
const html = renderToStaticMarkup(
,
@@ -210,6 +219,19 @@ describe('AllTasksTimeline', () => {
expect(html).toContain('trial1_0');
});
+ test('shows the webcam icon for a webcam-recorded task', () => {
+ const html = renderToStaticMarkup(
+ ,
+ );
+ expect(html).toContain('icon-camera');
+ });
+
test('shows an unknown indicator and tooltip value for an answer without configured correctness', () => {
const participant = makeParticipant({
answers: {
diff --git a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx
index a6f4787fe8..c0abaa59ca 100644
--- a/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx
+++ b/src/analysis/individualStudy/thinkAloud/ThinkAloudFooter.tsx
@@ -463,7 +463,7 @@ export function ThinkAloudFooter({
const [browserWarningDismissed, setBrowserWarningDismissed] = useState(false);
useEffect(() => {
setBrowserWarningDismissed(false);
- }, [participantId, screenRecordingUrl]);
+ }, [participantId, screenRecordingUrl, webcamRecordingUrl]);
return (
@@ -475,7 +475,7 @@ export function ThinkAloudFooter({
} />
)}
- {participantMatchesSelection && screenRecordingUrl && !participantUsedSameBrowser && !browserWarningDismissed && (
+ {participantMatchesSelection && (screenRecordingUrl || webcamRecordingUrl) && !participantUsedSameBrowser && !browserWarningDismissed && (
{
mockVideoRef = { current: null };
mockWebcamVideoRef = { current: null };
mockDispatch.mockClear();
+ mockSetAnalysisHasScreenRecording.mockClear();
+ mockSetAnalysisHasWebcamRecording.mockClear();
+ mockSetAnalysisCanPlayScreenRecording.mockClear();
});
afterEach(() => { cleanup(); });
@@ -114,6 +117,21 @@ describe('ScreenRecordingReplay', () => {
expect(mockDispatch).toHaveBeenCalledWith(mockSetAnalysisHasScreenRecording(true));
});
+ test('does not clear the replay mount gate while recordings are loading', async () => {
+ mockIsAnalysis = true;
+ let resolveScreen!: (url: string | null) => void;
+ mockStorageEngine = {
+ getScreenRecording: vi.fn(() => new Promise
((resolve) => { resolveScreen = resolve; })),
+ getWebcamRecording: vi.fn().mockResolvedValue(null),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+
+ await act(async () => { render( ); });
+ expect(mockSetAnalysisCanPlayScreenRecording).not.toHaveBeenCalled();
+
+ await act(async () => { resolveScreen(null); });
+ });
+
// Error-path tests (missing participantId, getScreenRecording rejection) omitted
// because the component re-throws in the catch block, producing unhandled promise
// rejections that vitest flags as test instability.
diff --git a/src/store/hooks/tests/useRecording.spec.ts b/src/store/hooks/tests/useRecording.spec.ts
index b185088a1a..2a5728e12e 100644
--- a/src/store/hooks/tests/useRecording.spec.ts
+++ b/src/store/hooks/tests/useRecording.spec.ts
@@ -521,12 +521,17 @@ describe('useRecording startScreenRecording after startScreenCapture', () => {
currentComponentHasScreenRecording: true,
currentComponentHasAudioRecording: true,
currentComponentHasWebcamRecording: true,
+ currentComponentHasClickToRecord: false,
};
+ const micStream = new MockMediaStream();
mockStorageEngine = {
saveScreenRecording: vi.fn(async () => {}),
saveAudioRecording: vi.fn(async () => {}),
saveWebcamRecording: vi.fn(async () => {}),
};
+ vi.mocked(navigator.mediaDevices.getUserMedia)
+ .mockResolvedValueOnce(new MockMediaStream() as unknown as MediaStream)
+ .mockResolvedValueOnce(micStream as unknown as MediaStream);
const { result } = renderHook(() => useRecording());
act(() => { result.current.startScreenCapture(); });
@@ -536,6 +541,7 @@ describe('useRecording startScreenRecording after startScreenCapture', () => {
expect(result.current.isWebcamRecording).toBe(true);
expect(result.current.isAudioRecording).toBe(true);
});
+ expect(micStream.getAudioTracks()[0].enabled).toBe(true);
});
});
diff --git a/src/store/hooks/tests/useRecordingConfig.spec.ts b/src/store/hooks/tests/useRecordingConfig.spec.ts
index df5f75b680..8af26111a0 100644
--- a/src/store/hooks/tests/useRecordingConfig.spec.ts
+++ b/src/store/hooks/tests/useRecordingConfig.spec.ts
@@ -4,7 +4,6 @@ import {
} from 'vitest';
import { useStudyConfig } from '../useStudyConfig';
-import { useFlatSequence } from '../../store';
import { useRecordingConfig } from '../useRecordingConfig';
import { makeStudyConfig } from '../../../tests/utils';
@@ -17,10 +16,6 @@ vi.mock('../useStudyConfig', () => ({
})),
}));
-vi.mock('../../store', () => ({
- useFlatSequence: vi.fn(() => []),
-}));
-
vi.mock('../../../routes/utils', () => ({
useCurrentComponent: vi.fn(() => 'trial1'),
}));
@@ -45,13 +40,20 @@ describe('useRecordingConfig', () => {
expect(result.current.studyHasScreenRecording).toBe(true);
});
- test('studyHasScreenRecording is true when a participant sequence component has recordScreen', () => {
+ test('resolves recording options inherited by a component', () => {
vi.mocked(useStudyConfig).mockReturnValueOnce(
- makeStudyConfig({ components: { trial1: { recordScreen: true } } }),
+ makeStudyConfig({
+ uiConfig: { clickToRecord: true },
+ baseComponents: { recorded: { recordScreen: true, recordWebcam: true } },
+ components: { trial1: { baseComponent: 'recorded', clickToRecord: false } },
+ }),
);
- vi.mocked(useFlatSequence).mockReturnValueOnce(['trial1']);
const { result } = renderHook(() => useRecordingConfig());
expect(result.current.studyHasScreenRecording).toBe(true);
+ expect(result.current.studyHasWebcamRecording).toBe(true);
+ expect(result.current.currentComponentHasScreenRecording).toBe(true);
+ expect(result.current.currentComponentHasWebcamRecording).toBe(true);
+ expect(result.current.currentComponentHasClickToRecord).toBe(false);
});
test('studyHasAudioRecording is true when uiConfig.recordAudio is set', () => {
@@ -67,13 +69,11 @@ describe('useRecordingConfig', () => {
expect(result.current.currentComponentHasWebcamRecording).toBe(true);
});
- test('webcam recording can be enabled for one sequence component', () => {
+ test('finds recording options on components inside dynamic blocks', () => {
vi.mocked(useStudyConfig).mockReturnValueOnce(
- makeStudyConfig({ components: { trial1: { recordWebcam: true } } }),
+ makeStudyConfig({ components: { dynamicTrial: { recordAudio: true } } }),
);
- vi.mocked(useFlatSequence).mockReturnValueOnce(['trial1']);
const { result } = renderHook(() => useRecordingConfig());
- expect(result.current.studyHasWebcamRecording).toBe(true);
- expect(result.current.currentComponentHasWebcamRecording).toBe(true);
+ expect(result.current.studyHasAudioRecording).toBe(true);
});
});
diff --git a/src/store/hooks/useRecordingConfig.ts b/src/store/hooks/useRecordingConfig.ts
index 097e1e3c65..b91b7c96c4 100644
--- a/src/store/hooks/useRecordingConfig.ts
+++ b/src/store/hooks/useRecordingConfig.ts
@@ -1,13 +1,16 @@
import { useMemo } from 'react';
import { useStudyConfig } from './useStudyConfig';
-import { useFlatSequence } from '../store';
import { useCurrentComponent } from '../../routes/utils';
+import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance';
+import { getStudyRecordings } from '../../utils/useStudyRecordings';
export function useRecordingConfig() {
const studyConfig = useStudyConfig();
- const participantSequence = useFlatSequence();
const currentComponent = useCurrentComponent();
const stepConfig = studyConfig.components[currentComponent];
+ const resolvedStepConfig = stepConfig
+ ? studyComponentToIndividualComponent(stepConfig, studyConfig)
+ : undefined;
const {
recordScreen,
@@ -16,30 +19,30 @@ export function useRecordingConfig() {
clickToRecord,
} = studyConfig.uiConfig;
- const studyHasScreenRecording = useMemo(() => (recordScreen || participantSequence.some((comp) => studyConfig.components[comp]?.recordScreen)), [participantSequence, studyConfig, recordScreen]);
-
- const studyHasAudioRecording = useMemo(() => (recordAudio || participantSequence.some((comp) => studyConfig.components[comp]?.recordAudio)), [participantSequence, studyConfig, recordAudio]);
-
- const studyHasWebcamRecording = useMemo(() => (recordWebcam || participantSequence.some((comp) => studyConfig.components[comp]?.recordWebcam)), [participantSequence, studyConfig, recordWebcam]);
+ const {
+ hasAudioRecording: studyHasAudioRecording,
+ hasScreenRecording: studyHasScreenRecording,
+ hasWebcamRecording: studyHasWebcamRecording,
+ } = useMemo(() => getStudyRecordings(studyConfig), [studyConfig]);
const currentComponentHasScreenRecording = useMemo(
- () => stepConfig?.recordScreen ?? !!recordScreen,
- [recordScreen, stepConfig],
+ () => resolvedStepConfig?.recordScreen ?? !!recordScreen,
+ [recordScreen, resolvedStepConfig],
);
const currentComponentHasAudioRecording = useMemo(
- () => stepConfig?.recordAudio ?? !!recordAudio,
- [recordAudio, stepConfig],
+ () => resolvedStepConfig?.recordAudio ?? !!recordAudio,
+ [recordAudio, resolvedStepConfig],
);
const currentComponentHasWebcamRecording = useMemo(
- () => stepConfig?.recordWebcam ?? !!recordWebcam,
- [recordWebcam, stepConfig],
+ () => resolvedStepConfig?.recordWebcam ?? !!recordWebcam,
+ [recordWebcam, resolvedStepConfig],
);
const currentComponentHasClickToRecord = useMemo(
- () => stepConfig?.clickToRecord ?? !!clickToRecord,
- [clickToRecord, stepConfig],
+ () => resolvedStepConfig?.clickToRecord ?? !!clickToRecord,
+ [clickToRecord, resolvedStepConfig],
);
return {
diff --git a/src/utils/handleDownloadFiles.ts b/src/utils/handleDownloadFiles.ts
index 1dd008cad1..6bbf936c27 100644
--- a/src/utils/handleDownloadFiles.ts
+++ b/src/utils/handleDownloadFiles.ts
@@ -4,6 +4,9 @@ import { StudyConfig } from '../parser/types';
import { getLegacyStoredAnswerProvenance } from '../store/provenance';
import type { StoredAnswer } from '../store/types';
+type DownloadAnswer = Pick
+ & Partial>;
+
export async function handleTaskAudio({
storageEngine,
participantId,
@@ -214,7 +217,7 @@ export async function downloadParticipantsAudioZip({
fileName,
}: {
storageEngine: StorageEngine;
- participants: Array<{ participantId: string; answers: Record }>;
+ participants: Array<{ participantId: string; answers: Record }>;
studyId: string;
fileName?: string | null;
}) {
@@ -222,12 +225,13 @@ export async function downloadParticipantsAudioZip({
const zip = new JSZip();
const audioPromises = participants.flatMap((participant) => {
- const entries = Object.values(participant.answers)
- .filter((ans) => ans.endTime > 0)
- .sort((a, b) => a.startTime - b.startTime);
+ const entries = Object.entries(participant.answers)
+ .map(([storedIdentifier, answer]) => ({ storedIdentifier, answer }))
+ .filter(({ answer }) => answer.endTime > 0)
+ .sort((a, b) => a.answer.startTime - b.answer.startTime);
- return entries.map(async (ans) => {
- const identifier = `${ans.componentName}_${ans.trialOrder}`;
+ return entries.map(async ({ storedIdentifier, answer }) => {
+ const identifier = answer.identifier || storedIdentifier;
await downloadParticipantsAudio({
storageEngine,
@@ -298,7 +302,7 @@ export async function downloadParticipantsRecordingsZip({
fileName,
}: {
storageEngine: StorageEngine;
- participants: Array<{ participantId: string; answers: Record }>;
+ participants: Array<{ participantId: string; answers: Record }>;
studyId: string;
includeScreen: boolean;
includeWebcam: boolean;
@@ -308,12 +312,13 @@ export async function downloadParticipantsRecordingsZip({
const zip = new JSZip();
const recordingPromises = participants.flatMap((participant) => {
- const entries = Object.values(participant.answers)
- .filter((ans) => ans.endTime > 0)
- .sort((a, b) => a.startTime - b.startTime);
+ const entries = Object.entries(participant.answers)
+ .map(([storedIdentifier, answer]) => ({ storedIdentifier, answer }))
+ .filter(({ answer }) => answer.endTime > 0)
+ .sort((a, b) => a.answer.startTime - b.answer.startTime);
- return entries.map(async (ans) => {
- const identifier = `${ans.componentName}_${ans.trialOrder}`;
+ return entries.map(async ({ storedIdentifier, answer }) => {
+ const identifier = answer.identifier || storedIdentifier;
await downloadParticipantsRecordings({
storageEngine,
diff --git a/src/utils/tests/handleDownloadFiles.spec.ts b/src/utils/tests/handleDownloadFiles.spec.ts
index 3866801f0c..3617a65624 100644
--- a/src/utils/tests/handleDownloadFiles.spec.ts
+++ b/src/utils/tests/handleDownloadFiles.spec.ts
@@ -193,6 +193,25 @@ describe('downloadParticipantsAudioZip', () => {
expect(clickSpy).toHaveBeenCalledOnce();
});
+ test('uses the stored identifier for dynamic audio tasks', async () => {
+ const storageEngine = makeStorageEngine({
+ getAudioUrl: vi.fn(async () => 'https://example.com/audio.webm'),
+ getTranscriptUrl: vi.fn(async () => null),
+ });
+ const participants = [{
+ participantId: 'p1',
+ answers: {
+ stored_dynamic_id: {
+ endTime: 1000, startTime: 0, componentName: 'dynamic', trialOrder: '0', identifier: 'dynamic_generated_0',
+ },
+ },
+ }];
+
+ await downloadParticipantsAudioZip({ storageEngine, participants, studyId: 'my-study' });
+
+ expect(storageEngine.getAudioUrl).toHaveBeenCalledWith('dynamic_generated_0', 'p1');
+ });
+
test('skips trials where endTime is 0 (not yet completed)', async () => {
const storageEngine = makeStorageEngine({
getAudioUrl: vi.fn(async () => 'https://example.com/audio.webm'),
@@ -297,6 +316,26 @@ describe('downloadParticipantsRecordingsZip', () => {
zipFile.mockRestore();
});
+ test('uses the stored identifier for dynamic recording tasks', async () => {
+ const storageEngine = makeStorageEngine({
+ getScreenRecording: vi.fn(async () => 'https://example.com/recording.webm'),
+ });
+ const participants = [{
+ participantId: 'p1',
+ answers: {
+ stored_dynamic_id: {
+ endTime: 1000, startTime: 0, componentName: 'dynamic', trialOrder: '0', identifier: 'dynamic_generated_0',
+ },
+ },
+ }];
+
+ await downloadParticipantsRecordingsZip({
+ storageEngine, participants, studyId: 'my-study', includeScreen: true, includeWebcam: false,
+ });
+
+ expect(storageEngine.getScreenRecording).toHaveBeenCalledWith('dynamic_generated_0', 'p1');
+ });
+
test('skips trials where endTime is 0 (not completed)', async () => {
const storageEngine = makeStorageEngine({
getScreenRecording: vi.fn(async () => 'https://example.com/rec.webm'),
From 8343dd55931960e6fad2c9cbe0d9a423cb7853ab Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Tue, 8 Sep 2026 20:40:10 -0600
Subject: [PATCH 04/12] Harden recording capture lifecycle
---
.../assets/WebcamRecording.tsx | 13 ++-
src/store/hooks/tests/useRecording.spec.ts | 109 ++++++++++++++++++
src/store/hooks/useRecording.ts | 50 ++++----
3 files changed, 147 insertions(+), 25 deletions(-)
diff --git a/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx b/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
index 4861712464..6b4e1caf1b 100644
--- a/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
+++ b/src/public/libraries/webcam-recording/assets/WebcamRecording.tsx
@@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useRecordingContext } from '../../../../store/hooks/useRecording';
import { StimulusParams } from '../../../../store/types';
import { RecordingAudioWaveform } from '../../../../components/interface/RecordingAudioWaveform';
+import { useStoreSelector } from '../../../../store/store';
function WebcamRecordingPermission({ setAnswer }: StimulusParams) {
const {
@@ -20,10 +21,12 @@ function WebcamRecordingPermission({ setAnswer }: StimulusParams) {
} = useRecordingContext();
const [audioCapturingSuccess, setAudioCapturingSuccess] = useState(false);
+ const { dataCollectionEnabled } = useStoreSelector((state) => state.modes);
const setupComplete = useMemo(
- () => isWebcamCapturing && (!studyHasAudioRecording || (isAudioCapturing && audioCapturingSuccess)),
- [audioCapturingSuccess, isAudioCapturing, isWebcamCapturing, studyHasAudioRecording],
+ () => !dataCollectionEnabled
+ || (isWebcamCapturing && (!studyHasAudioRecording || (isAudioCapturing && audioCapturingSuccess))),
+ [audioCapturingSuccess, dataCollectionEnabled, isAudioCapturing, isWebcamCapturing, studyHasAudioRecording],
);
useEffect(() => {
@@ -37,10 +40,10 @@ function WebcamRecordingPermission({ setAnswer }: StimulusParams) {
status: setupComplete,
provenanceGraph: undefined,
answers: {
- webcamRecordingPermission: isWebcamCapturing,
+ webcamRecordingPermission: dataCollectionEnabled ? isWebcamCapturing : true,
},
});
- }, [isWebcamCapturing, setAnswer, setupComplete]);
+ }, [dataCollectionEnabled, isWebcamCapturing, setAnswer, setupComplete]);
useEffect(() => {
if (!isWebcamCapturing || !studyHasAudioRecording) {
@@ -117,7 +120,7 @@ function WebcamRecordingPermission({ setAnswer }: StimulusParams) {
Click the button below
{' '}
to enable webcam recording.
-
+
{isWebcamCapturing ? 'Stop Recording' : 'Start Recording'}
{error && {error}
}
diff --git a/src/store/hooks/tests/useRecording.spec.ts b/src/store/hooks/tests/useRecording.spec.ts
index 2a5728e12e..b937d05d74 100644
--- a/src/store/hooks/tests/useRecording.spec.ts
+++ b/src/store/hooks/tests/useRecording.spec.ts
@@ -10,6 +10,7 @@ import * as recordingHooks from '../useRecording';
import { useRecording, useRecordingContext } from '../useRecording';
import type { StoreState } from '../../types';
import ScreenRecordingPermission from '../../../public/libraries/screen-recording/assets/ScreenRecording';
+import WebcamRecordingPermission from '../../../public/libraries/webcam-recording/assets/WebcamRecording';
// ── mutable state ─────────────────────────────────────────────────────────────
@@ -277,6 +278,52 @@ describe('useRecording startScreenCapture', () => {
});
});
+ test('does not stop the persistent microphone track when audio is disabled on one screen trial', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasAudioRecording: true,
+ currentComponentHasScreenRecording: true,
+ currentComponentHasAudioRecording: true,
+ };
+ const micStream = new MockMediaStream();
+ mockStorageEngine = { saveScreenRecording: vi.fn(async () => {}), saveAudioRecording: vi.fn(async () => {}) };
+ vi.mocked(navigator.mediaDevices.getUserMedia)
+ .mockResolvedValueOnce(micStream as unknown as MediaStream);
+ const { result, rerender } = renderHook(() => useRecording());
+
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(result.current.isMediaCapturing).toBe(true));
+ await waitFor(() => expect(result.current.isAudioRecording).toBe(true));
+
+ mockCurrentComponent = 'audio-disabled';
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ currentComponentHasAudioRecording: false,
+ currentComponentHasScreenRecording: true,
+ };
+ act(() => { rerender(); });
+
+ expect(micStream.getTracks()[0].stop).not.toHaveBeenCalled();
+ });
+
+ test('restores capture after Strict Mode effect replay', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ };
+ const wrapper = ({ children }: { children: React.ReactNode }) => React.createElement(
+ React.StrictMode,
+ null,
+ children,
+ );
+ const { result } = renderHook(() => useRecording(), { wrapper });
+
+ act(() => { result.current.startScreenCapture(); });
+
+ await waitFor(() => expect(result.current.isScreenCapturing).toBe(true));
+ });
+
test('audio-only: getUserMedia called, isAudioCapturing true, isScreenCapturing false', async () => {
mockRecordingConfig = {
...mockRecordingConfig,
@@ -333,6 +380,31 @@ describe('useRecording startScreenCapture', () => {
expect(screenStream.getTracks()[0].stop).toHaveBeenCalled();
});
+ test('stops an acquired screen stream when webcam permission is still pending', async () => {
+ mockRecordingConfig = {
+ ...mockRecordingConfig,
+ studyHasScreenRecording: true,
+ studyHasWebcamRecording: true,
+ };
+ const screenStream = new MockMediaStream();
+ let resolveWebcam: ((stream: MediaStream) => void) | undefined;
+ vi.mocked(navigator.mediaDevices.getDisplayMedia).mockResolvedValue(screenStream as unknown as MediaStream);
+ vi.mocked(navigator.mediaDevices.getUserMedia).mockImplementationOnce(() => new Promise((resolve) => {
+ resolveWebcam = resolve;
+ }));
+ const { result } = renderHook(() => useRecording());
+
+ act(() => { result.current.startScreenCapture(); });
+ await waitFor(() => expect(screenStream.getTracks()[0].addEventListener).toHaveBeenCalledWith('ended', expect.any(Function)));
+
+ const endedHandler = screenStream.getTracks()[0].addEventListener.mock.calls[0][1] as () => void;
+ act(() => { endedHandler(); });
+ resolveWebcam?.(new MockMediaStream() as unknown as MediaStream);
+
+ await waitFor(() => expect(result.current.isScreenCapturing).toBe(false));
+ expect(screenStream.getTracks()[0].stop).toHaveBeenCalled();
+ });
+
test('does not start overlapping capture requests', async () => {
mockRecordingConfig = { ...mockRecordingConfig, studyHasScreenRecording: true };
let resolveCapture: ((stream: MediaStream) => void) | undefined;
@@ -848,3 +920,40 @@ describe('ScreenRecordingPermission component', () => {
expect(stopScreenCapture).not.toHaveBeenCalled();
});
});
+
+describe('WebcamRecordingPermission component', () => {
+ test('auto-completes and disables capture when data collection is off', () => {
+ mockModes = { ...mockModes, dataCollectionEnabled: false };
+ const startWebcamCapture = vi.fn();
+ const stopScreenCapture = vi.fn();
+ const setAnswer = vi.fn();
+ vi.spyOn(recordingHooks, 'useRecordingContext').mockReturnValue({
+ studyHasAudioRecording: false,
+ webcamVideoRef: { current: null },
+ startWebcamCapture,
+ stopScreenCapture,
+ isWebcamCapturing: false,
+ isAudioCapturing: false,
+ audioMediaStream: { current: null },
+ } as unknown as ReturnType);
+
+ renderWithMantine(
+ React.createElement(WebcamRecordingPermission, {
+ setAnswer,
+ parameters: undefined,
+ answers: {},
+ useTrrack: vi.fn(),
+ } as React.ComponentProps),
+ );
+
+ expect(setAnswer).toHaveBeenCalledWith(expect.objectContaining({
+ status: true,
+ answers: { webcamRecordingPermission: true },
+ }));
+ const button = screen.getByRole('button', { name: 'Start Recording' });
+ expect((button as HTMLButtonElement).disabled).toBe(true);
+ fireEvent.click(button);
+ expect(startWebcamCapture).not.toHaveBeenCalled();
+ expect(stopScreenCapture).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/store/hooks/useRecording.ts b/src/store/hooks/useRecording.ts
index 3039225ee5..af524372dc 100644
--- a/src/store/hooks/useRecording.ts
+++ b/src/store/hooks/useRecording.ts
@@ -104,6 +104,8 @@ export function useRecording() {
// Stop all persistent media capture streams.
const stopScreenCapture = useCallback(() => {
+ captureAttempt.current += 1;
+ isStartingCapture.current = false;
if (isStoppingCapture.current) {
return;
}
@@ -359,6 +361,10 @@ export function useRecording() {
// For study with just audio recording
useEffect(() => {
+ if (!studyConfig || studyHasScreenRecording || studyHasWebcamRecording || !studyHasAudioRecording || !storageEngine || (status && status.endTime > 0) || isAnalysis) {
+ return;
+ }
+
// Always stop recording when navigating to a trial without audio recording
if (!currentComponentHasAudioRecording && audioMediaRecorder.current) {
stopAudioRecording();
@@ -366,10 +372,6 @@ export function useRecording() {
return;
}
- if (!studyConfig || studyHasScreenRecording || studyHasWebcamRecording || !studyHasAudioRecording || !storageEngine || (status && status.endTime > 0) || isAnalysis) {
- return;
- }
-
if (audioMediaRecorder.current) {
stopAudioRecording();
currentTrialName.current = null;
@@ -432,6 +434,17 @@ export function useRecording() {
let micStream: MediaStream | null = null;
const isCurrentAttempt = () => isMounted.current && captureAttempt.current === attempt;
+ const hasLiveTracks = (stream: MediaStream | null) => !!stream
+ && stream.getTracks().length > 0
+ && stream.getTracks().every((track) => track.readyState !== 'ended');
+ const stopOnEnded = () => {
+ if (!isStoppingCapture.current) {
+ stopScreenCapture();
+ }
+ };
+ const attachEndedHandler = (stream: MediaStream | null) => {
+ stream?.getTracks().forEach((track) => track.addEventListener('ended', stopOnEnded));
+ };
const stopAcquiredStreams = () => {
[micStream, webcamStream, screenStream].forEach(stopMediaTracks);
if (screenMediaStream.current === screenStream) screenMediaStream.current = null;
@@ -453,7 +466,8 @@ export function useRecording() {
selfBrowserSurface: 'include',
preferCurrentTab: true,
}) : null;
- if (!isCurrentAttempt()) {
+ attachEndedHandler(screenStream);
+ if (!isCurrentAttempt() || (includeScreen && !hasLiveTracks(screenStream))) {
stopAcquiredStreams();
return;
}
@@ -463,7 +477,8 @@ export function useRecording() {
video: true,
audio: false,
}) : null;
- if (!isCurrentAttempt()) {
+ attachEndedHandler(webcamStream);
+ if (!isCurrentAttempt() || (includeWebcam && !hasLiveTracks(webcamStream))) {
stopAcquiredStreams();
return;
}
@@ -481,7 +496,8 @@ export function useRecording() {
throw err;
}
}
- if (!isCurrentAttempt()) {
+ attachEndedHandler(micStream);
+ if (!isCurrentAttempt() || (includeAudio && !hasLiveTracks(micStream))) {
stopAcquiredStreams();
return;
}
@@ -509,15 +525,6 @@ export function useRecording() {
}
}
- const stopOnEnded = () => {
- if (!isStoppingCapture.current) {
- stopScreenCapture();
- }
- };
- [screenStream, webcamStream, micStream].forEach((stream) => {
- stream?.getTracks().forEach((track) => track.addEventListener('ended', stopOnEnded));
- });
-
setIsScreenCapturing(!!screenStream);
setIsWebcamCapturing(!!webcamStream);
setIsAudioCapturing(!!micStream);
@@ -573,10 +580,13 @@ export function useRecording() {
};
}, [currentComponentHasAudioRecording, isMuted]);
- useEffect(() => () => {
- isMounted.current = false;
- captureAttempt.current += 1;
- stopScreenCapture();
+ useEffect(() => {
+ isMounted.current = true;
+
+ return () => {
+ isMounted.current = false;
+ stopScreenCapture();
+ };
}, [stopScreenCapture]);
useEffect(() => {
From 37ba8004ca6642eb249c415a14c3ae135e705326 Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Tue, 8 Sep 2026 20:51:41 -0600
Subject: [PATCH 05/12] Keep synchronized replay callbacks stable
---
src/store/hooks/tests/useReplay.spec.tsx | 9 ++++++++
src/store/hooks/useReplay.ts | 27 ++++++++++++++----------
2 files changed, 25 insertions(+), 11 deletions(-)
diff --git a/src/store/hooks/tests/useReplay.spec.tsx b/src/store/hooks/tests/useReplay.spec.tsx
index 43ceac9408..6b950a8e06 100644
--- a/src/store/hooks/tests/useReplay.spec.tsx
+++ b/src/store/hooks/tests/useReplay.spec.tsx
@@ -147,6 +147,15 @@ describe('useReplay — returned function invocation', () => {
expect(result.current.seekTime).toBe(5);
});
+ test('keeps updateReplayRef stable when replay control becomes remote', () => {
+ const { result } = renderHook(() => useReplay());
+ const initialUpdateReplayRef = result.current.updateReplayRef;
+
+ act(() => { result.current.setSeekTime(5, true); });
+
+ expect(result.current.updateReplayRef).toBe(initialUpdateReplayRef);
+ });
+
test('setSpeed with isRemoteTriggered=true covers remote path', () => {
const { result } = renderHook(() => useReplay());
act(() => { result.current.setSpeed(1.5, true); });
diff --git a/src/store/hooks/useReplay.ts b/src/store/hooks/useReplay.ts
index 143e972aab..5f29f6131b 100644
--- a/src/store/hooks/useReplay.ts
+++ b/src/store/hooks/useReplay.ts
@@ -39,6 +39,11 @@ export function useReplay() {
// isMasterplayer is true for the window where play button is clicked.
// This is set to false when the video / provenance is initiated via different tab/window
const [isMasterPlayer, setIsMasterPlayer] = useState(true);
+ const isMasterPlayerRef = useRef(isMasterPlayer);
+ const setMasterPlayer = useCallback((master: boolean) => {
+ isMasterPlayerRef.current = master;
+ setIsMasterPlayer(master);
+ }, []);
const emitterRef = useRef(new EventEmitter());
@@ -71,15 +76,15 @@ export function useReplay() {
const updateMutedState = useCallback(() => {
if (videoRef.current) {
- videoRef.current.muted = !isMasterPlayer || replayRef.current !== videoRef.current;
+ videoRef.current.muted = !isMasterPlayerRef.current || replayRef.current !== videoRef.current;
}
if (webcamVideoRef.current) {
webcamVideoRef.current.muted = true;
}
if (audioRef.current) {
- audioRef.current.muted = !isMasterPlayer || replayRef.current === videoRef.current;
+ audioRef.current.muted = !isMasterPlayerRef.current || replayRef.current === videoRef.current;
}
- }, [isMasterPlayer]);
+ }, []);
const updateIsPlaying = useCallback((playing: boolean) => {
internalIsPlaying.current = playing;
@@ -110,11 +115,11 @@ export function useReplay() {
}, []);
const setSpeed = useCallback((newSpeed: number, isRemoteTriggered = false) => {
- setIsMasterPlayer(!isRemoteTriggered);
+ setMasterPlayer(!isRemoteTriggered);
internalSpeed.current = newSpeed;
_setSpeed(newSpeed);
_setSeekTime(timerValue.current);
- }, []);
+ }, [setMasterPlayer]);
const syntheticReplayTimer = useRef | null>(null);
@@ -187,7 +192,7 @@ export function useReplay() {
}, []);
const setSeekTime = useCallback((time: number, isRemoteTriggered = false) => {
- setIsMasterPlayer(!isRemoteTriggered);
+ setMasterPlayer(!isRemoteTriggered);
_setSeekTime(time);
timerValue.current = time;
getMediaElements().forEach((media) => seekMedia(media, time));
@@ -200,7 +205,7 @@ export function useReplay() {
}
emitterRef.current.emit('timeupdate', time);
setHasEnded(internalDuration.current > 0 && time >= internalDuration.current);
- }, [getMediaElements, requestReplayPlayback]);
+ }, [getMediaElements, requestReplayPlayback, setMasterPlayer]);
const handlePause = useCallback(() => {
if (!isMountedRef.current) {
@@ -287,7 +292,7 @@ export function useReplay() {
return;
}
- setIsMasterPlayer(!isRemoteTriggered);
+ setMasterPlayer(!isRemoteTriggered);
if (
playing
&& internalDuration.current > 0
@@ -306,7 +311,7 @@ export function useReplay() {
} else {
getActiveMediaElements().forEach((media) => media.pause());
}
- }, [getActiveMediaElements, requestReplayPlayback, setSeekTime, updateIsPlaying]);
+ }, [getActiveMediaElements, requestReplayPlayback, setSeekTime, setMasterPlayer, updateIsPlaying]);
useEffect(() => {
isMountedRef.current = true;
@@ -394,7 +399,7 @@ export function useReplay() {
const {
seekTime: __seekTime, isPlaying: __isPlaying, speed: __speed,
} = newValue || {};
- setIsMasterPlayer(false);
+ setMasterPlayer(false);
setSpeed(__speed, true);
setSeekTime(__seekTime, true);
setIsPlaying(__isPlaying, true);
@@ -405,7 +410,7 @@ export function useReplay() {
return () => {
syncEmitter.off('replaySync');
};
- }, [setIsPlaying, setSeekTime, setSpeed]);
+ }, [setIsPlaying, setSeekTime, setMasterPlayer, setSpeed]);
useEffect(() => {
setSeekTime(initialTimestamp);
From b163c0824e90e63e13ba2d3ec9e47ff23ab88fc2 Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Tue, 8 Sep 2026 20:55:25 -0600
Subject: [PATCH 06/12] Refresh replay muting on ownership changes
---
src/store/hooks/tests/useReplay.spec.tsx | 21 +++++++++++++++++++++
src/store/hooks/useReplay.ts | 2 +-
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/src/store/hooks/tests/useReplay.spec.tsx b/src/store/hooks/tests/useReplay.spec.tsx
index 6b950a8e06..0cba811254 100644
--- a/src/store/hooks/tests/useReplay.spec.tsx
+++ b/src/store/hooks/tests/useReplay.spec.tsx
@@ -156,6 +156,27 @@ describe('useReplay — returned function invocation', () => {
expect(result.current.updateReplayRef).toBe(initialUpdateReplayRef);
});
+ test('updates muting when ownership changes during playback', () => {
+ const { result } = renderHook(() => useReplay());
+ const video = document.createElement('video');
+ Object.defineProperty(video, 'src', { value: 'fake.mp4', writable: true, configurable: true });
+ video.play = vi.fn(async () => {});
+ video.pause = vi.fn();
+
+ act(() => {
+ result.current.videoRef.current = video;
+ result.current.updateReplayRef();
+ result.current.setIsPlaying(true);
+ });
+ expect(video.muted).toBe(false);
+
+ act(() => { result.current.setSeekTime(5, true); });
+ expect(video.muted).toBe(true);
+
+ act(() => { result.current.setSeekTime(6); });
+ expect(video.muted).toBe(false);
+ });
+
test('setSpeed with isRemoteTriggered=true covers remote path', () => {
const { result } = renderHook(() => useReplay());
act(() => { result.current.setSpeed(1.5, true); });
diff --git a/src/store/hooks/useReplay.ts b/src/store/hooks/useReplay.ts
index 5f29f6131b..71ce901ab2 100644
--- a/src/store/hooks/useReplay.ts
+++ b/src/store/hooks/useReplay.ts
@@ -158,7 +158,7 @@ export function useReplay() {
useEffect(() => {
updateMutedState();
- }, [updateMutedState]);
+ }, [isMasterPlayer, updateMutedState]);
useEffect(() => {
getMediaElements().forEach((media) => {
From 872987912eb667e21434d3370e7e17b40d57dd5c Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Tue, 8 Sep 2026 21:55:49 -0600
Subject: [PATCH 07/12] Fix scoped recording capture and replay startup
---
.../hooks/tests/useRecordingConfig.spec.ts | 40 ++++++++++++++++++-
src/store/hooks/tests/useReplay.spec.tsx | 24 +++++++++++
src/store/hooks/useRecordingConfig.ts | 37 ++++++++++++++++-
src/store/hooks/useReplay.ts | 15 +++++--
src/utils/useStudyRecordings.ts | 7 +++-
5 files changed, 116 insertions(+), 7 deletions(-)
diff --git a/src/store/hooks/tests/useRecordingConfig.spec.ts b/src/store/hooks/tests/useRecordingConfig.spec.ts
index 8af26111a0..5a3952b7ad 100644
--- a/src/store/hooks/tests/useRecordingConfig.spec.ts
+++ b/src/store/hooks/tests/useRecordingConfig.spec.ts
@@ -7,6 +7,16 @@ import { useStudyConfig } from '../useStudyConfig';
import { useRecordingConfig } from '../useRecordingConfig';
import { makeStudyConfig } from '../../../tests/utils';
+const mockStoreState = vi.hoisted(() => ({
+ sequence: {
+ order: 'fixed' as const,
+ orderPath: 'root',
+ components: ['trial1'] as (string | { order: 'dynamic', id: string, components: [] })[],
+ skip: [],
+ },
+ funcSequence: {} as Record,
+}));
+
vi.mock('../useStudyConfig', () => ({
useStudyConfig: vi.fn(() => ({
uiConfig: {
@@ -20,7 +30,15 @@ vi.mock('../../../routes/utils', () => ({
useCurrentComponent: vi.fn(() => 'trial1'),
}));
-afterEach(() => vi.restoreAllMocks());
+vi.mock('../../store', () => ({
+ useStoreSelector: (selector: (state: typeof mockStoreState) => unknown) => selector(mockStoreState),
+}));
+
+afterEach(() => {
+ mockStoreState.sequence.components = ['trial1'];
+ mockStoreState.funcSequence = {};
+ vi.restoreAllMocks();
+});
describe('useRecordingConfig', () => {
test('returns all false when uiConfig has no recording options', () => {
@@ -70,10 +88,30 @@ describe('useRecordingConfig', () => {
});
test('finds recording options on components inside dynamic blocks', () => {
+ mockStoreState.sequence.components = [{ order: 'dynamic', id: 'dynamicBlock', components: [] }];
+ mockStoreState.funcSequence = { dynamicBlock: ['dynamicTrial'] };
vi.mocked(useStudyConfig).mockReturnValueOnce(
makeStudyConfig({ components: { dynamicTrial: { recordAudio: true } } }),
);
const { result } = renderHook(() => useRecordingConfig());
expect(result.current.studyHasAudioRecording).toBe(true);
});
+
+ test('ignores recording options on components outside the assigned sequence', () => {
+ mockStoreState.sequence.components = ['webcamTrial'];
+ vi.mocked(useStudyConfig).mockReturnValueOnce(
+ makeStudyConfig({
+ components: {
+ webcamTrial: { recordWebcam: true },
+ screenTrial: { recordScreen: true },
+ },
+ sequence: {
+ order: 'fixed', orderPath: 'root', components: ['webcamTrial'], skip: [],
+ },
+ }),
+ );
+ const { result } = renderHook(() => useRecordingConfig());
+ expect(result.current.studyHasWebcamRecording).toBe(true);
+ expect(result.current.studyHasScreenRecording).toBe(false);
+ });
});
diff --git a/src/store/hooks/tests/useReplay.spec.tsx b/src/store/hooks/tests/useReplay.spec.tsx
index 0cba811254..9842bf8b56 100644
--- a/src/store/hooks/tests/useReplay.spec.tsx
+++ b/src/store/hooks/tests/useReplay.spec.tsx
@@ -360,6 +360,30 @@ describe('useReplay — handlePlay/Seeked/Pause via video element events', () =>
expect(result.current.replayRef.current).toBe(audio);
});
+ test('starts a secondary webcam source added while audio playback is active', () => {
+ const { result } = renderHook(() => useReplay());
+ const audio = document.createElement('audio');
+ Object.defineProperty(audio, 'src', { value: 'fake.mp3', writable: true, configurable: true });
+ audio.play = vi.fn(async () => {});
+ const webcam = document.createElement('video');
+ webcam.play = vi.fn(async () => {});
+
+ act(() => {
+ result.current.audioRef.current = audio;
+ result.current.webcamVideoRef.current = webcam;
+ result.current.updateReplayRef();
+ result.current.setIsPlaying(true);
+ });
+ expect(result.current.replayRef.current).toBe(audio);
+
+ act(() => {
+ webcam.setAttribute('src', 'fake-webcam.mp4');
+ result.current.updateReplayRef();
+ });
+
+ expect(webcam.play).toHaveBeenCalled();
+ });
+
test('stops playback when a newly available source replaces the master', () => {
const { result } = renderHook(() => useReplay());
const screen = makeVideoWithSrc();
diff --git a/src/store/hooks/useRecordingConfig.ts b/src/store/hooks/useRecordingConfig.ts
index b91b7c96c4..5854c9d990 100644
--- a/src/store/hooks/useRecordingConfig.ts
+++ b/src/store/hooks/useRecordingConfig.ts
@@ -1,11 +1,43 @@
import { useMemo } from 'react';
import { useStudyConfig } from './useStudyConfig';
import { useCurrentComponent } from '../../routes/utils';
+import { useStoreSelector } from '../store';
+import { getSequenceFlatMap } from '../../utils/getSequenceFlatMap';
import { studyComponentToIndividualComponent } from '../../utils/handleComponentInheritance';
import { getStudyRecordings } from '../../utils/useStudyRecordings';
+import type { Sequence } from '../types';
+
+function getAssignedComponentNames(
+ participantSequence: Sequence,
+ funcSequence: Record,
+) {
+ const componentNames = new Set(getSequenceFlatMap(participantSequence));
+
+ const collectDynamicComponents = (sequence: Sequence) => {
+ sequence.components.forEach((component) => {
+ if (typeof component === 'string') {
+ return;
+ }
+
+ if (component.order === 'dynamic') {
+ if (component.id) {
+ funcSequence[component.id]?.forEach((componentName) => componentNames.add(componentName));
+ }
+ return;
+ }
+
+ collectDynamicComponents(component);
+ });
+ };
+
+ collectDynamicComponents(participantSequence);
+ return Array.from(componentNames);
+}
export function useRecordingConfig() {
const studyConfig = useStudyConfig();
+ const participantSequence = useStoreSelector((state) => state.sequence);
+ const funcSequence = useStoreSelector((state) => state.funcSequence);
const currentComponent = useCurrentComponent();
const stepConfig = studyConfig.components[currentComponent];
const resolvedStepConfig = stepConfig
@@ -23,7 +55,10 @@ export function useRecordingConfig() {
hasAudioRecording: studyHasAudioRecording,
hasScreenRecording: studyHasScreenRecording,
hasWebcamRecording: studyHasWebcamRecording,
- } = useMemo(() => getStudyRecordings(studyConfig), [studyConfig]);
+ } = useMemo(() => getStudyRecordings(
+ studyConfig,
+ getAssignedComponentNames(participantSequence, funcSequence),
+ ), [funcSequence, participantSequence, studyConfig]);
const currentComponentHasScreenRecording = useMemo(
() => resolvedStepConfig?.recordScreen ?? !!recordScreen,
diff --git a/src/store/hooks/useReplay.ts b/src/store/hooks/useReplay.ts
index 71ce901ab2..19d1c0e3f8 100644
--- a/src/store/hooks/useReplay.ts
+++ b/src/store/hooks/useReplay.ts
@@ -22,9 +22,11 @@ function hasMediaSource(media: HTMLMediaElement) {
if (sourceAttribute !== null) {
return !!sourceAttribute;
}
+ const baseUri = typeof document !== 'undefined' ? document.baseURI : '';
+ const locationHref = typeof window !== 'undefined' ? window.location.href : '';
return !!media.src
- && media.src !== document.baseURI
- && media.src !== window.location.href;
+ && (!baseUri || media.src !== baseUri)
+ && (!locationHref || media.src !== locationHref);
}
/**
@@ -274,6 +276,13 @@ export function useReplay() {
if (previousReplay !== replayRef.current && internalIsPlaying.current) {
getMediaElements().forEach((media) => media.pause());
updateIsPlaying(false);
+ } else if (internalIsPlaying.current) {
+ getSecondaryMediaElements().forEach((media) => {
+ if (media.paused && mediaIncludesTime(media, timerValue.current)) {
+ seekMedia(media, timerValue.current);
+ media.play().catch(() => undefined);
+ }
+ });
}
if (replayRef.current) {
@@ -284,7 +293,7 @@ export function useReplay() {
}
updateMutedState();
forceEmitTimeUpdate();
- }, [forceEmitTimeUpdate, getMediaElements, handleEnded, handlePause, handlePlay, handleSeeked, updateIsPlaying, updateMutedState]);
+ }, [forceEmitTimeUpdate, getMediaElements, getSecondaryMediaElements, handleEnded, handlePause, handlePlay, handleSeeked, updateIsPlaying, updateMutedState]);
// this should be the only way to start video/audio
const setIsPlaying = useCallback((playing: boolean, isRemoteTriggered = false) => {
diff --git a/src/utils/useStudyRecordings.ts b/src/utils/useStudyRecordings.ts
index 133081f3f8..1d9f2b6c3e 100644
--- a/src/utils/useStudyRecordings.ts
+++ b/src/utils/useStudyRecordings.ts
@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
import { StudyConfig } from '../parser/types';
import { studyComponentToIndividualComponent } from './handleComponentInheritance';
-export function getStudyRecordings(studyConfig: StudyConfig | undefined) {
+export function getStudyRecordings(studyConfig: StudyConfig | undefined, componentNames?: string[]) {
if (!studyConfig?.uiConfig || !studyConfig.components) {
return {
hasAudioRecording: false,
@@ -12,7 +12,10 @@ export function getStudyRecordings(studyConfig: StudyConfig | undefined) {
}
const { recordAudio, recordScreen, recordWebcam } = studyConfig.uiConfig;
- const componentConfig = Object.keys(studyConfig.components).map((componentId) => studyComponentToIndividualComponent(studyConfig.components[componentId], studyConfig));
+ const componentConfig = (componentNames ?? Object.keys(studyConfig.components)).flatMap((componentId) => {
+ const component = studyConfig.components[componentId];
+ return component ? [studyComponentToIndividualComponent(component, studyConfig)] : [];
+ });
return {
hasAudioRecording: !!recordAudio || componentConfig.some((component) => component.recordAudio),
From d6e39a54b1044e300b249676b8346cf2140b31c9 Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Wed, 9 Sep 2026 22:58:04 -0600
Subject: [PATCH 08/12] Add replay layout modes for webcam recordings
---
.../screenRecording/ScreenRecordingReplay.tsx | 44 +++++++++++++++---
.../tests/ScreenRecordingReplay.spec.tsx | 45 ++++++++++++++++++-
src/store/hooks/tests/useReplay.spec.tsx | 7 +++
src/store/hooks/useReplay.ts | 7 ++-
4 files changed, 95 insertions(+), 8 deletions(-)
diff --git a/src/components/screenRecording/ScreenRecordingReplay.tsx b/src/components/screenRecording/ScreenRecordingReplay.tsx
index d1b8cb7b7d..6f45db8e5f 100644
--- a/src/components/screenRecording/ScreenRecordingReplay.tsx
+++ b/src/components/screenRecording/ScreenRecordingReplay.tsx
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router';
import {
- Box, Flex, Text,
+ Box, Flex, Group, SegmentedControl, Text,
} from '@mantine/core';
import { useStorageEngine } from '../../storage/storageEngineHooks';
import {
@@ -10,7 +10,7 @@ import {
} from '../../store/store';
import { useCurrentIdentifier } from '../../routes/utils';
import { useIsAnalysis } from '../../store/hooks/useIsAnalysis';
-import { useReplayContext } from '../../store/hooks/useReplay';
+import { ReplayLayout, useReplayContext } from '../../store/hooks/useReplay';
export function ScreenRecordingReplay() {
const [searchParams] = useSearchParams();
@@ -24,6 +24,8 @@ export function ScreenRecordingReplay() {
webcamVideoRef,
updateReplayRef,
isPlaying,
+ replayLayout,
+ setReplayLayout,
} = useReplayContext();
const [hasScreenVideo, setHasScreenVideo] = useState(false);
@@ -170,14 +172,46 @@ export function ScreenRecordingReplay() {
border: `5px solid ${isPlaying ? '#ccc' : 'black'}`,
}), [isPlaying]);
+ const hasBothVideos = hasScreenVideo && hasWebcamVideo;
+ const layoutDirection = replayLayout === 'side-by-side' && hasBothVideos
+ ? { base: 'column' as const, md: 'row' as const }
+ : 'column' as const;
+ const screenContainerStyle = replayLayout === 'webcam-top' && hasBothVideos
+ ? { order: 2 }
+ : undefined;
+ const webcamContainerStyle = replayLayout === 'picture-in-picture' && hasBothVideos
+ ? {
+ position: 'absolute' as const, top: 16, right: 16, width: '28%', zIndex: 1,
+ }
+ : replayLayout === 'webcam-top' && hasBothVideos
+ ? { order: 1, width: '32%', alignSelf: 'center' as const }
+ : undefined;
+
return (
+ {hasBothVideos && (
+
+ Replay layout
+ setReplayLayout(value as ReplayLayout)}
+ data={[
+ { label: 'Side by side', value: 'side-by-side' },
+ { label: 'Picture in picture', value: 'picture-in-picture' },
+ { label: 'Webcam on top', value: 'webcam-top' },
+ ]}
+ />
+
+ )}
-
+
Screen Recording
@@ -194,7 +228,7 @@ export function ScreenRecordingReplay() {
-
+
Webcam Recording
diff --git a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
index c24b44a7d7..869eb47d16 100644
--- a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
+++ b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
@@ -1,5 +1,5 @@
import {
- render, act, cleanup, waitFor,
+ render, act, cleanup, fireEvent, waitFor,
} from '@testing-library/react';
import {
afterEach, beforeEach, describe, expect, test, vi,
@@ -13,6 +13,8 @@ let mockStorageEngine: Record> | null = null;
let mockSearchParams = new URLSearchParams();
let mockUpdateReplayRef = vi.fn();
let mockIsPlaying = false;
+let mockReplayLayout = 'side-by-side';
+const mockSetReplayLayout = vi.fn();
let mockVideoRef: { current: HTMLVideoElement | null } = { current: null };
let mockWebcamVideoRef: { current: HTMLVideoElement | null } = { current: null };
@@ -24,7 +26,13 @@ vi.mock('react-router', () => ({
vi.mock('@mantine/core', () => ({
Box: ({ children }: { children?: React.ReactNode }) => {children}
,
- Flex: ({ children }: { children?: React.ReactNode }) => {children}
,
+ Flex: ({ children, 'data-replay-layout': layout }: { children?: React.ReactNode; 'data-replay-layout'?: string }) => {children}
,
+ Group: ({ children }: { children?: React.ReactNode }) => {children}
,
+ SegmentedControl: ({ data, value, onChange }: { data: { label: string; value: string }[]; value: string; onChange: (value: string) => void }) => (
+
+ {data.map((item) => onChange(item.value)}>{item.label} )}
+
+ ),
Text: ({ children }: { children?: React.ReactNode }) => {children} ,
}));
@@ -60,6 +68,8 @@ vi.mock('../../../store/hooks/useReplay', () => ({
webcamVideoRef: mockWebcamVideoRef,
updateReplayRef: mockUpdateReplayRef,
isPlaying: mockIsPlaying,
+ replayLayout: mockReplayLayout,
+ setReplayLayout: mockSetReplayLayout,
}),
}));
@@ -72,6 +82,8 @@ describe('ScreenRecordingReplay', () => {
mockSearchParams = new URLSearchParams();
mockUpdateReplayRef = vi.fn();
mockIsPlaying = false;
+ mockReplayLayout = 'side-by-side';
+ mockSetReplayLayout.mockClear();
mockVideoRef = { current: null };
mockWebcamVideoRef = { current: null };
mockDispatch.mockClear();
@@ -162,6 +174,35 @@ describe('ScreenRecordingReplay', () => {
expect(mockDispatch).toHaveBeenCalledWith(mockSetAnalysisHasWebcamRecording(true));
});
+ test('offers replay layout controls when both recordings exist', async () => {
+ mockIsAnalysis = true;
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue('http://example.com/video.mp4'),
+ getWebcamRecording: vi.fn().mockResolvedValue('http://example.com/webcam.webm'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ const view = await act(async () => render( ));
+
+ await waitFor(() => expect(view.container.querySelectorAll('video')[0].src).toBe('http://example.com/video.mp4'));
+ expect(view.getByRole('radiogroup', { name: 'Replay layout' })).toBeDefined();
+ expect(view.getByRole('button', { name: 'Side by side' })).toBeDefined();
+ expect(view.getByRole('button', { name: 'Picture in picture' })).toBeDefined();
+ expect(view.getByRole('button', { name: 'Webcam on top' })).toBeDefined();
+
+ fireEvent.click(view.getByRole('button', { name: 'Picture in picture' }));
+ expect(mockSetReplayLayout).toHaveBeenCalledWith('picture-in-picture');
+
+ fireEvent.click(view.getByRole('button', { name: 'Webcam on top' }));
+ expect(mockSetReplayLayout).toHaveBeenCalledWith('webcam-top');
+
+ fireEvent.click(view.getByRole('button', { name: 'Side by side' }));
+ expect(mockSetReplayLayout).toHaveBeenCalledWith('side-by-side');
+
+ mockReplayLayout = 'picture-in-picture';
+ view.rerender( );
+ expect(view.container.querySelector('[data-replay-layout]')?.getAttribute('data-replay-layout')).toBe('picture-in-picture');
+ });
+
test('video border is grey when isPlaying is true', async () => {
mockIsPlaying = true;
const { container } = await act(async () => render( ));
diff --git a/src/store/hooks/tests/useReplay.spec.tsx b/src/store/hooks/tests/useReplay.spec.tsx
index 9842bf8b56..9635916294 100644
--- a/src/store/hooks/tests/useReplay.spec.tsx
+++ b/src/store/hooks/tests/useReplay.spec.tsx
@@ -91,6 +91,13 @@ describe('useReplay — returned state defaults', () => {
expect(result.current.speed).toBe(1);
});
+ test('starts with the side-by-side replay layout and updates it locally', () => {
+ const { result } = renderHook(() => useReplay());
+ expect(result.current.replayLayout).toBe('side-by-side');
+ act(() => { result.current.setReplayLayout('picture-in-picture'); });
+ expect(result.current.replayLayout).toBe('picture-in-picture');
+ });
+
test('starts with hasEnded false', () => {
const { result } = renderHook(() => useReplay());
expect(result.current.hasEnded).toBe(false);
diff --git a/src/store/hooks/useReplay.ts b/src/store/hooks/useReplay.ts
index 19d1c0e3f8..e7ffa0c117 100644
--- a/src/store/hooks/useReplay.ts
+++ b/src/store/hooks/useReplay.ts
@@ -6,6 +6,8 @@ import { syncChannel, syncEmitter } from '../../utils/syncReplay';
import EventEmitter from '../../utils/EventEmitter';
import { getNextSyntheticReplayTime } from './replayTimer';
+export type ReplayLayout = 'side-by-side' | 'picture-in-picture' | 'webcam-top';
+
function seekMedia(media: HTMLMediaElement, time: number) {
const mediaTime = Number.isFinite(media.duration) && media.duration > 0
? Math.min(time, media.duration)
@@ -60,6 +62,7 @@ export function useReplay() {
const internalSpeed = useRef(1);
const [speed, _setSpeed] = useState(1);
const [isPlaying, _setIsPlaying] = useState(false);
+ const [replayLayout, setReplayLayout] = useState('side-by-side');
const internalIsPlaying = useRef(false);
const timerValue = useRef(0);
@@ -447,11 +450,13 @@ export function useReplay() {
setSpeed,
isPlaying,
setIsPlaying,
+ replayLayout,
+ setReplayLayout,
replayEvent,
forceEmitTimeUpdate,
hasEnded,
}),
- [replayEvent, seekTime, setSeekTime, duration, speed, isPlaying, setIsPlaying, updateReplayRef, setSpeed, forceEmitTimeUpdate, setDuration, hasEnded],
+ [replayEvent, seekTime, setSeekTime, duration, speed, isPlaying, setIsPlaying, replayLayout, updateReplayRef, setSpeed, forceEmitTimeUpdate, setDuration, hasEnded],
);
return value;
From 7b2419b7c954b1635785cea0cfb3e22d20e5123b Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Wed, 9 Sep 2026 23:28:57 -0600
Subject: [PATCH 09/12] Add webcam capture to screen recording demo
---
public/demo-screen-recording/assets/introduction.md | 4 ++--
public/demo-screen-recording/config.json | 12 ++++++++----
2 files changed, 10 insertions(+), 6 deletions(-)
diff --git a/public/demo-screen-recording/assets/introduction.md b/public/demo-screen-recording/assets/introduction.md
index 483f2f1a4f..44829491d8 100644
--- a/public/demo-screen-recording/assets/introduction.md
+++ b/public/demo-screen-recording/assets/introduction.md
@@ -1,8 +1,8 @@
-This example demonstrates how to capture screen recordings for components where provenance tracking is difficult to implement. In this case, we illustrate the process using simple website components that are recorded directly from the screen.
+This example demonstrates how to capture screen and webcam recordings for components where provenance tracking is difficult to implement. In this case, we illustrate the process using simple website components that are recorded directly from the screen and webcam.
## Relevant files:
* [The Config](https://github.com/revisit-studies/study/blob/main/public/demo-screen-recording/config.json)
* [bar-chart.html](https://github.com/revisit-studies/study/blob/main/public/demo-screen-recording/assets/bar-chart.html)
## Relevant documentation:
- * [Recording Screen and Audio](https://revisit.dev/docs/designing-studies/record-screen/)
+ * [Recording Screen, Webcam, and Audio](https://revisit.dev/docs/designing-studies/record-screen/)
diff --git a/public/demo-screen-recording/config.json b/public/demo-screen-recording/config.json
index 0dd160e64e..48c255c58c 100644
--- a/public/demo-screen-recording/config.json
+++ b/public/demo-screen-recording/config.json
@@ -7,7 +7,7 @@
"The reVISit Team"
],
"date": "2026-02-23",
- "description": "A simple demo of using screen recording on stimuli that render an external website, where provenance tracking is difficult to implement.",
+ "description": "A simple demo of using screen and webcam recording on stimuli that render an external website, where provenance tracking is difficult to implement.",
"organizations": [
"University of Utah",
"WPI"
@@ -23,6 +23,7 @@
"windowEventDebounceTime": 200,
"recordAudio": true,
"recordScreen": true,
+ "recordWebcam": true,
"recordScreenFPS": 30
},
"importedLibraries": [
@@ -40,15 +41,17 @@
"response": [],
"instruction": "On this page, only audio recording is enabled.",
"recordAudio": true,
- "recordScreen": false
+ "recordScreen": false,
+ "recordWebcam": false
},
"external_website_audio_screen": {
"type": "website",
"path": "https://www.revisit.dev",
"response": [],
- "instruction": "On this page, screen recording is enabled, and audio recording is only enabled while clicking and holding the mic icon.",
+ "instruction": "On this page, screen and webcam recording are enabled, and audio recording is only enabled while clicking and holding the mic icon.",
"recordAudio": true,
"recordScreen": true,
+ "recordWebcam": true,
"clickToRecord": true
},
"barChart_audio_screen": {
@@ -58,7 +61,8 @@
"difficulty": "hard"
},
"description": "Question that asks users to count the number of bars that have a value greater than 1.",
- "instruction": "How many bars have a value greater than 1? (Both audio and screen recording are enabled on this page)",
+ "instruction": "How many bars have a value greater than 1? (Audio, screen, and webcam recording are enabled on this page)",
+ "recordWebcam": true,
"path": "demo-screen-recording/assets/bar-chart.html",
"response": [
{
From 18845ecf608e07f38e11341116b2f21b5b8f3aa4 Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Wed, 9 Sep 2026 23:40:22 -0600
Subject: [PATCH 10/12] Keep webcam-only replay beside stimulus
---
.../screenRecording/ScreenRecordingReplay.tsx | 108 +++++++++++++++++-
.../tests/ScreenRecordingReplay.spec.tsx | 31 ++++-
src/controllers/ComponentController.tsx | 10 +-
.../tests/ComponentController.spec.tsx | 30 ++++-
4 files changed, 174 insertions(+), 5 deletions(-)
diff --git a/src/components/screenRecording/ScreenRecordingReplay.tsx b/src/components/screenRecording/ScreenRecordingReplay.tsx
index 6f45db8e5f..5441737958 100644
--- a/src/components/screenRecording/ScreenRecordingReplay.tsx
+++ b/src/components/screenRecording/ScreenRecordingReplay.tsx
@@ -1,4 +1,7 @@
-import { useEffect, useMemo, useState } from 'react';
+import {
+ useCallback, useEffect, useMemo, useRef, useState,
+} from 'react';
+import type { PointerEvent as ReactPointerEvent } from 'react';
import { useSearchParams } from 'react-router';
import {
Box, Flex, Group, SegmentedControl, Text,
@@ -12,7 +15,17 @@ import { useCurrentIdentifier } from '../../routes/utils';
import { useIsAnalysis } from '../../store/hooks/useIsAnalysis';
import { ReplayLayout, useReplayContext } from '../../store/hooks/useReplay';
-export function ScreenRecordingReplay() {
+type ScreenRecordingReplayProps = {
+ webcamOnly?: boolean;
+};
+
+type WebcamDrag = {
+ pointerId: number;
+ offsetX: number;
+ offsetY: number;
+};
+
+export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingReplayProps) {
const [searchParams] = useSearchParams();
const participantId = useMemo(
() => searchParams.get('participantId') || undefined,
@@ -30,6 +43,42 @@ export function ScreenRecordingReplay() {
const [hasScreenVideo, setHasScreenVideo] = useState(false);
const [hasWebcamVideo, setHasWebcamVideo] = useState(false);
+ const webcamOverlayRef = useRef(null);
+ const webcamDragRef = useRef(null);
+ const [webcamPosition, setWebcamPosition] = useState<{ left: number; top: number } | null>(null);
+
+ const handleWebcamPointerDown = useCallback((event: ReactPointerEvent) => {
+ const overlay = webcamOverlayRef.current;
+ if (!overlay) return;
+
+ const rect = overlay.getBoundingClientRect();
+ webcamDragRef.current = {
+ pointerId: event.pointerId,
+ offsetX: event.clientX - rect.left,
+ offsetY: event.clientY - rect.top,
+ };
+ setWebcamPosition({ left: rect.left, top: rect.top });
+ event.currentTarget.setPointerCapture?.(event.pointerId);
+ }, []);
+
+ const handleWebcamPointerMove = useCallback((event: ReactPointerEvent) => {
+ const drag = webcamDragRef.current;
+ const overlay = webcamOverlayRef.current;
+ if (!drag || !overlay || drag.pointerId !== event.pointerId) return;
+
+ const { width, height } = overlay.getBoundingClientRect();
+ const maxLeft = Math.max(0, window.innerWidth - width);
+ const maxTop = Math.max(0, window.innerHeight - height);
+ const left = Math.min(Math.max(event.clientX - drag.offsetX, 0), maxLeft);
+ const top = Math.min(Math.max(event.clientY - drag.offsetY, 0), maxTop);
+ setWebcamPosition({ left, top });
+ }, []);
+
+ const handleWebcamPointerUp = useCallback((event: ReactPointerEvent) => {
+ if (webcamDragRef.current?.pointerId !== event.pointerId) return;
+ webcamDragRef.current = null;
+ event.currentTarget.releasePointerCapture?.(event.pointerId);
+ }, []);
useEffect(() => {
updateReplayRef();
@@ -187,6 +236,61 @@ export function ScreenRecordingReplay() {
? { order: 1, width: '32%', alignSelf: 'center' as const }
: undefined;
+ if (webcamOnly) {
+ return (
+
+
+ Webcam Recording · Drag to move
+
+
+
+ Your browser does not support the video tag.
+
+
+ );
+ }
+
return (
{hasBothVideos && (
diff --git a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
index 869eb47d16..0be2f3a1b6 100644
--- a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
+++ b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
@@ -18,6 +18,20 @@ const mockSetReplayLayout = vi.fn();
let mockVideoRef: { current: HTMLVideoElement | null } = { current: null };
let mockWebcamVideoRef: { current: HTMLVideoElement | null } = { current: null };
+type MockBoxProps = {
+ children?: React.ReactNode;
+ style?: React.CSSProperties;
+ role?: string;
+ 'aria-label'?: string;
+ 'data-replay-layout'?: string;
+};
+
+function MockBox({
+ children, style, role, 'aria-label': ariaLabel, 'data-replay-layout': layout,
+}: MockBoxProps) {
+ return {children}
;
+}
+
// ── mocks ─────────────────────────────────────────────────────────────────────
vi.mock('react-router', () => ({
@@ -25,7 +39,7 @@ vi.mock('react-router', () => ({
}));
vi.mock('@mantine/core', () => ({
- Box: ({ children }: { children?: React.ReactNode }) => {children}
,
+ Box: MockBox,
Flex: ({ children, 'data-replay-layout': layout }: { children?: React.ReactNode; 'data-replay-layout'?: string }) => {children}
,
Group: ({ children }: { children?: React.ReactNode }) => {children}
,
SegmentedControl: ({ data, value, onChange }: { data: { label: string; value: string }[]; value: string; onChange: (value: string) => void }) => (
@@ -174,6 +188,21 @@ describe('ScreenRecordingReplay', () => {
expect(mockDispatch).toHaveBeenCalledWith(mockSetAnalysisHasWebcamRecording(true));
});
+ test('renders a movable webcam-only overlay', async () => {
+ mockIsAnalysis = true;
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue(null),
+ getWebcamRecording: vi.fn().mockResolvedValue('http://example.com/webcam.webm'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ const view = await act(async () => render( ));
+
+ await waitFor(() => expect(view.container.querySelector('video')?.src).toBe('http://example.com/webcam.webm'));
+ const moveHandle = view.getByRole('button', { name: 'Move webcam replay' });
+ expect(view.container.querySelector('[data-replay-layout="webcam-only-overlay"]')).not.toBeNull();
+ expect(moveHandle.style.cursor).toBe('move');
+ });
+
test('offers replay layout controls when both recordings exist', async () => {
mockIsAnalysis = true;
mockStorageEngine = {
diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx
index ec00dcbc03..1033f1f7ed 100644
--- a/src/controllers/ComponentController.tsx
+++ b/src/controllers/ComponentController.tsx
@@ -297,7 +297,14 @@ export function ComponentController() {
const instructionLocation = currentConfig.instructionLocation ?? studyConfig.uiConfig.instructionLocation ?? 'sidebar';
const instructionInSideBar = instructionLocation === 'sidebar';
- if ((studyHasScreenRecording || studyHasWebcamRecording) && isAnalysis && analysisCanPlayScreenRecording) return ;
+ const shouldShowRecordingReplay = (studyHasScreenRecording || studyHasWebcamRecording)
+ && isAnalysis
+ && analysisCanPlayScreenRecording;
+ const isWebcamOnlyReplay = shouldShowRecordingReplay
+ && studyHasWebcamRecording
+ && !studyHasScreenRecording;
+
+ if (shouldShowRecordingReplay && !isWebcamOnlyReplay) return ;
return (
<>
@@ -337,6 +344,7 @@ export function ComponentController() {
config={currentConfig}
location="belowStimulus"
/>
+ {isWebcamOnlyReplay && }
>
);
}
diff --git a/src/controllers/tests/ComponentController.spec.tsx b/src/controllers/tests/ComponentController.spec.tsx
index 4aaf616fbe..5867f6e372 100644
--- a/src/controllers/tests/ComponentController.spec.tsx
+++ b/src/controllers/tests/ComponentController.spec.tsx
@@ -92,7 +92,9 @@ vi.mock('../../components/response/ResponseBlock', () => ({
}));
vi.mock('../../components/screenRecording/ScreenRecordingReplay', () => ({
- ScreenRecordingReplay: () => ScreenRecordingReplay
,
+ ScreenRecordingReplay: ({ webcamOnly }: { webcamOnly?: boolean }) => (
+ {webcamOnly ? 'WebcamOnlyReplay' : 'ScreenRecordingReplay'}
+ ),
}));
vi.mock('../../utils/getStaticAsset', () => ({
@@ -724,6 +726,32 @@ describe('ComponentController — effect coverage (render-based)', () => {
const { container } = render( );
await waitFor(() => expect(container.textContent).toContain('ScreenRecordingReplay'));
});
+
+ test('webcam-only replay renders alongside the stimulus', async () => {
+ vi.mocked(useCurrentComponent).mockReturnValue('testTrial');
+ vi.mocked(useIsAnalysis).mockReturnValue(true);
+ vi.mocked(useStorageEngine).mockReturnValue({
+ storageEngine: makeStorageEngine(),
+ setStorageEngine: vi.fn(),
+ });
+ vi.mocked(useRecordingConfig).mockReturnValue({
+ studyHasScreenRecording: false,
+ studyHasAudioRecording: false,
+ studyHasWebcamRecording: true,
+ currentComponentHasAudioRecording: false,
+ currentComponentHasScreenRecording: false,
+ currentComponentHasWebcamRecording: true,
+ currentComponentHasClickToRecord: false,
+ });
+ const stableStateCanPlay = makeStableState({ analysisCanPlayScreenRecording: true });
+ vi.mocked(useStoreSelector).mockImplementation(
+ (selector) => selector(stableStateCanPlay),
+ );
+
+ const { container } = render( );
+ await waitFor(() => expect(container.textContent).toContain('WebcamOnlyReplay'));
+ expect(container.textContent).toContain('ResponseBlock');
+ });
});
// ── VegaController — signal and event coverage ────────────────────────────────
From 8d286c086530e14a0729568f0b0070f7ca620b5f Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Thu, 10 Sep 2026 08:08:46 -0600
Subject: [PATCH 11/12] Improve webcam replay layouts
---
.../screenRecording/ScreenRecordingReplay.tsx | 241 +++++++++++-------
.../tests/ScreenRecordingReplay.spec.tsx | 48 +++-
2 files changed, 194 insertions(+), 95 deletions(-)
diff --git a/src/components/screenRecording/ScreenRecordingReplay.tsx b/src/components/screenRecording/ScreenRecordingReplay.tsx
index 5441737958..d9dbca5f20 100644
--- a/src/components/screenRecording/ScreenRecordingReplay.tsx
+++ b/src/components/screenRecording/ScreenRecordingReplay.tsx
@@ -1,7 +1,9 @@
import {
useCallback, useEffect, useMemo, useRef, useState,
} from 'react';
-import type { PointerEvent as ReactPointerEvent } from 'react';
+import type {
+ CSSProperties, PointerEvent as ReactPointerEvent, RefObject,
+} from 'react';
import { useSearchParams } from 'react-router';
import {
Box, Flex, Group, SegmentedControl, Text,
@@ -25,24 +27,26 @@ type WebcamDrag = {
offsetY: number;
};
-export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingReplayProps) {
- const [searchParams] = useSearchParams();
- const participantId = useMemo(
- () => searchParams.get('participantId') || undefined,
- [searchParams],
- );
+type WebcamOverlayMode = 'webcam-only' | 'picture-in-picture';
- const {
- screenVideoRef,
- webcamVideoRef,
- updateReplayRef,
- isPlaying,
- replayLayout,
- setReplayLayout,
- } = useReplayContext();
+type WebcamTopSize = 'small' | 'medium' | 'large';
- const [hasScreenVideo, setHasScreenVideo] = useState(false);
- const [hasWebcamVideo, setHasWebcamVideo] = useState(false);
+type WebcamReplayOverlayProps = {
+ mode: WebcamOverlayMode;
+ videoRef: RefObject;
+ videoStyle: CSSProperties;
+ hasWebcamVideo: boolean;
+};
+
+const webcamTopWidths: Record = {
+ small: '20%',
+ medium: '28%',
+ large: '36%',
+};
+
+function WebcamReplayOverlay({
+ mode, videoRef, videoStyle, hasWebcamVideo,
+}: WebcamReplayOverlayProps) {
const webcamOverlayRef = useRef(null);
const webcamDragRef = useRef(null);
const [webcamPosition, setWebcamPosition] = useState<{ left: number; top: number } | null>(null);
@@ -80,6 +84,83 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
event.currentTarget.releasePointerCapture?.(event.pointerId);
}, []);
+ return (
+
+
+ Webcam Recording · Drag to move
+
+
+
+ Your browser does not support the video tag.
+
+
+ );
+}
+
+export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingReplayProps) {
+ const [searchParams] = useSearchParams();
+ const participantId = useMemo(
+ () => searchParams.get('participantId') || undefined,
+ [searchParams],
+ );
+
+ const {
+ screenVideoRef,
+ webcamVideoRef,
+ updateReplayRef,
+ isPlaying,
+ replayLayout,
+ setReplayLayout,
+ } = useReplayContext();
+
+ const [hasScreenVideo, setHasScreenVideo] = useState(false);
+ const [hasWebcamVideo, setHasWebcamVideo] = useState(false);
+ const [webcamTopSize, setWebcamTopSize] = useState('small');
+
useEffect(() => {
updateReplayRef();
}, [updateReplayRef]);
@@ -228,66 +309,19 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
const screenContainerStyle = replayLayout === 'webcam-top' && hasBothVideos
? { order: 2 }
: undefined;
- const webcamContainerStyle = replayLayout === 'picture-in-picture' && hasBothVideos
- ? {
- position: 'absolute' as const, top: 16, right: 16, width: '28%', zIndex: 1,
- }
- : replayLayout === 'webcam-top' && hasBothVideos
- ? { order: 1, width: '32%', alignSelf: 'center' as const }
- : undefined;
+ const webcamContainerStyle = replayLayout === 'webcam-top' && hasBothVideos
+ ? { order: 1, width: webcamTopWidths[webcamTopSize], alignSelf: 'center' as const }
+ : undefined;
+ const isPictureInPictureLayout = replayLayout === 'picture-in-picture';
if (webcamOnly) {
return (
-
-
- Webcam Recording · Drag to move
-
-
-
- Your browser does not support the video tag.
-
-
+
);
}
@@ -306,6 +340,18 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
{ label: 'Webcam on top', value: 'webcam-top' },
]}
/>
+ {replayLayout === 'webcam-top' && (
+ setWebcamTopSize(value as WebcamTopSize)}
+ data={[
+ { label: 'Small', value: 'small' },
+ { label: 'Medium', value: 'medium' },
+ { label: 'Large', value: 'large' },
+ ]}
+ />
+ )}
)}
@@ -332,24 +377,38 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
-
-
- Webcam Recording
-
-
-
- Your browser does not support the video tag.
-
-
+
+ Webcam Recording
+
+
+
+ Your browser does not support the video tag.
+
+
+ )}
+ {isPictureInPictureLayout && (
+
+ )}
);
}
diff --git a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
index 0be2f3a1b6..7ed3221a7b 100644
--- a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
+++ b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
@@ -24,12 +24,13 @@ type MockBoxProps = {
role?: string;
'aria-label'?: string;
'data-replay-layout'?: string;
+ 'data-webcam-size'?: string;
};
function MockBox({
- children, style, role, 'aria-label': ariaLabel, 'data-replay-layout': layout,
+ children, style, role, 'aria-label': ariaLabel, 'data-replay-layout': layout, 'data-webcam-size': webcamSize,
}: MockBoxProps) {
- return {children}
;
+ return {children}
;
}
// ── mocks ─────────────────────────────────────────────────────────────────────
@@ -42,8 +43,15 @@ vi.mock('@mantine/core', () => ({
Box: MockBox,
Flex: ({ children, 'data-replay-layout': layout }: { children?: React.ReactNode; 'data-replay-layout'?: string }) => {children}
,
Group: ({ children }: { children?: React.ReactNode }) => {children}
,
- SegmentedControl: ({ data, value, onChange }: { data: { label: string; value: string }[]; value: string; onChange: (value: string) => void }) => (
-
+ SegmentedControl: ({
+ data, value, onChange, 'aria-label': ariaLabel,
+ }: {
+ data: { label: string; value: string }[];
+ value: string;
+ onChange: (value: string) => void;
+ 'aria-label'?: string;
+ }) => (
+
{data.map((item) => onChange(item.value)}>{item.label} )}
),
@@ -203,6 +211,38 @@ describe('ScreenRecordingReplay', () => {
expect(moveHandle.style.cursor).toBe('move');
});
+ test('renders the picture-in-picture webcam as a movable overlay', async () => {
+ mockIsAnalysis = true;
+ mockReplayLayout = 'picture-in-picture';
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue('http://example.com/video.mp4'),
+ getWebcamRecording: vi.fn().mockResolvedValue('http://example.com/webcam.webm'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ const view = await act(async () => render(
));
+
+ await waitFor(() => expect(view.container.querySelectorAll('video')[1]?.src).toBe('http://example.com/webcam.webm'));
+ expect(view.container.querySelector('[data-replay-layout="picture-in-picture-overlay"]')).not.toBeNull();
+ expect(view.getByRole('button', { name: 'Move webcam replay' })).toBeDefined();
+ });
+
+ test('allows webcam-top replay to use a smaller size', async () => {
+ mockIsAnalysis = true;
+ mockReplayLayout = 'webcam-top';
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue('http://example.com/video.mp4'),
+ getWebcamRecording: vi.fn().mockResolvedValue('http://example.com/webcam.webm'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ const view = await act(async () => render(
));
+
+ await waitFor(() => expect(view.container.querySelector('[data-webcam-size="small"]')).not.toBeNull());
+ const sizeControl = view.getByRole('radiogroup', { name: 'Webcam size' });
+ expect(sizeControl).toBeDefined();
+ fireEvent.click(view.getByRole('button', { name: 'Medium' }));
+ expect(view.container.querySelector('[data-webcam-size="medium"]')).not.toBeNull();
+ });
+
test('offers replay layout controls when both recordings exist', async () => {
mockIsAnalysis = true;
mockStorageEngine = {
From f0ab321e9c45137aacaeb63375cc7c81a0a0d1d7 Mon Sep 17 00:00:00 2001
From: Jack Wilburn
Date: Tue, 22 Sep 2026 22:34:39 -0600
Subject: [PATCH 12/12] Keep webcam replay available across layouts and dynamic
trials
---
.../screenRecording/ScreenRecordingReplay.tsx | 10 +++++++-
.../tests/ScreenRecordingReplay.spec.tsx | 23 +++++++++++++++++++
src/controllers/ComponentController.tsx | 2 +-
.../tests/ComponentController.spec.tsx | 15 +++++++++++-
4 files changed, 47 insertions(+), 3 deletions(-)
diff --git a/src/components/screenRecording/ScreenRecordingReplay.tsx b/src/components/screenRecording/ScreenRecordingReplay.tsx
index d9dbca5f20..a025eb2b96 100644
--- a/src/components/screenRecording/ScreenRecordingReplay.tsx
+++ b/src/components/screenRecording/ScreenRecordingReplay.tsx
@@ -160,10 +160,15 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
const [hasScreenVideo, setHasScreenVideo] = useState(false);
const [hasWebcamVideo, setHasWebcamVideo] = useState(false);
const [webcamTopSize, setWebcamTopSize] = useState('small');
+ const webcamRecordingUrl = useRef(null);
useEffect(() => {
+ if (webcamVideoRef.current && webcamRecordingUrl.current
+ && webcamVideoRef.current.src !== webcamRecordingUrl.current) {
+ webcamVideoRef.current.src = webcamRecordingUrl.current;
+ }
updateReplayRef();
- }, [updateReplayRef]);
+ }, [replayLayout, updateReplayRef, webcamVideoRef]);
const { storageEngine } = useStorageEngine();
@@ -194,6 +199,7 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
};
const screenVideo = screenVideoRef.current;
const webcamVideo = webcamVideoRef.current;
+ webcamRecordingUrl.current = null;
clearVideoSource(screenVideo);
clearVideoSource(webcamVideo);
updateReplayRef();
@@ -227,6 +233,7 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
return;
}
loadedUrls = [screenUrl, webcamUrl].filter((url): url is string => !!url);
+ webcamRecordingUrl.current = webcamUrl;
const hasScreenRecording = !!screenUrl;
const hasWebcamRecording = !!webcamUrl;
@@ -270,6 +277,7 @@ export function ScreenRecordingReplay({ webcamOnly = false }: ScreenRecordingRep
return () => {
cancelled = true;
+ webcamRecordingUrl.current = null;
loadedUrls.forEach(releaseUrl);
loadedUrls = [];
clearVideoSource(screenVideo);
diff --git a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
index 7ed3221a7b..1aff821a07 100644
--- a/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
+++ b/src/components/screenRecording/tests/ScreenRecordingReplay.spec.tsx
@@ -226,6 +226,29 @@ describe('ScreenRecordingReplay', () => {
expect(view.getByRole('button', { name: 'Move webcam replay' })).toBeDefined();
});
+ test('keeps the webcam source when switching replay layouts', async () => {
+ mockIsAnalysis = true;
+ mockStorageEngine = {
+ getScreenRecording: vi.fn().mockResolvedValue('http://example.com/video.mp4'),
+ getWebcamRecording: vi.fn().mockResolvedValue('http://example.com/webcam.webm'),
+ };
+ mockSearchParams = new URLSearchParams({ participantId: 'p1' });
+ const view = await act(async () => render( ));
+ const firstWebcam = view.container.querySelectorAll('video')[1];
+ await waitFor(() => expect(firstWebcam.src).toBe('http://example.com/webcam.webm'));
+ const replayUpdatesBeforeSwitch = mockUpdateReplayRef.mock.calls.length;
+
+ mockReplayLayout = 'picture-in-picture';
+ view.rerender( );
+ const pictureInPictureWebcam = view.container.querySelectorAll('video')[1];
+ expect(pictureInPictureWebcam.src).toBe('http://example.com/webcam.webm');
+ expect(mockUpdateReplayRef).toHaveBeenCalledTimes(replayUpdatesBeforeSwitch + 1);
+
+ mockReplayLayout = 'side-by-side';
+ view.rerender( );
+ expect(view.container.querySelectorAll('video')[1].src).toBe('http://example.com/webcam.webm');
+ });
+
test('allows webcam-top replay to use a smaller size', async () => {
mockIsAnalysis = true;
mockReplayLayout = 'webcam-top';
diff --git a/src/controllers/ComponentController.tsx b/src/controllers/ComponentController.tsx
index 82895dd72d..c6488126f9 100644
--- a/src/controllers/ComponentController.tsx
+++ b/src/controllers/ComponentController.tsx
@@ -216,7 +216,7 @@ export function ComponentController() {
// Assume that screen recording video exists.
// The value is set to false from ScreenRecordingReplay component if video starts after stimulus start time.
storeDispatch(setAnalysisCanPlayScreenRecording(true));
- }, [currentStep, setAnalysisCanPlayScreenRecording, storeDispatch]);
+ }, [currentIdentifier, setAnalysisCanPlayScreenRecording, storeDispatch]);
useFetchStylesheet(currentConfig?.stylesheetPath);
diff --git a/src/controllers/tests/ComponentController.spec.tsx b/src/controllers/tests/ComponentController.spec.tsx
index fb6f192e53..03e36a5ba5 100644
--- a/src/controllers/tests/ComponentController.spec.tsx
+++ b/src/controllers/tests/ComponentController.spec.tsx
@@ -24,7 +24,7 @@ import { MarkdownController } from '../MarkdownController';
import { ReactComponentController } from '../ReactComponentController';
import { VegaController } from '../VegaController';
import { VideoController } from '../VideoController';
-import { useCurrentComponent, useCurrentStep } from '../../routes/utils';
+import { useCurrentComponent, useCurrentIdentifier, useCurrentStep } from '../../routes/utils';
import { useStorageEngine } from '../../storage/storageEngineHooks';
import { getStaticAssetByPath, getJsonAssetByPath } from '../../utils/getStaticAsset';
import { useStoreDispatch, useStoreSelector } from '../../store/store';
@@ -604,6 +604,7 @@ describe('ComponentController — effect coverage (render-based)', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(useCurrentComponent).mockReturnValue('end');
+ vi.mocked(useCurrentIdentifier).mockReturnValue('trial1_0');
vi.mocked(useCurrentStep).mockReturnValue(0);
vi.mocked(useIsAnalysis).mockReturnValue(false);
vi.mocked(useRecordingConfig).mockReturnValue({
@@ -724,6 +725,18 @@ describe('ComponentController — effect coverage (render-based)', () => {
await waitFor(() => expect(setAnalysisCanPlaySpy).toHaveBeenCalledWith(true));
});
+ test('resets recording replay availability for the next dynamic child', async () => {
+ const setAnalysisCanPlaySpy = vi.fn();
+ mockStoreActions.setAnalysisCanPlayScreenRecording = setAnalysisCanPlaySpy;
+ vi.mocked(useCurrentIdentifier).mockReturnValue('block_0_first_0');
+ const view = render( );
+ await waitFor(() => expect(setAnalysisCanPlaySpy).toHaveBeenCalledTimes(1));
+
+ vi.mocked(useCurrentIdentifier).mockReturnValue('block_0_second_1');
+ view.rerender( );
+ await waitFor(() => expect(setAnalysisCanPlaySpy).toHaveBeenCalledTimes(2));
+ });
+
test('auto-forward navigates when last answer step > currentStep', async () => {
const mockNavigate = vi.fn();
vi.mocked(useNavigate).mockReturnValue(mockNavigate);