diff --git a/.changeset/great-kings-cry.md b/.changeset/great-kings-cry.md new file mode 100644 index 0000000000000..21daef46ee29c --- /dev/null +++ b/.changeset/great-kings-cry.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': patch +--- + +Fixes an issue where the camera could stay on after closing the video recording modal. diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts new file mode 100644 index 0000000000000..7f1de8c00e1aa --- /dev/null +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.spec.ts @@ -0,0 +1,200 @@ +import { VideoRecorder } from './videoRecorder'; +import { createDeferredPromise } from '../../../../../tests/mocks/utils/createDeferredMockFn'; + +jest.mock('meteor/reactive-var', () => ({ + ReactiveVar: jest.fn().mockImplementation((initialValue) => { + let value = initialValue; + return { + get: jest.fn(() => value), + set: jest.fn((newValue) => { + value = newValue; + }), + }; + }), +})); + +describe('VideoRecorder', () => { + let mockStream: MediaStream; + let mockVideoTrack: MediaStreamTrack; + let mockAudioTrack: MediaStreamTrack; + let mockVideoElement: HTMLVideoElement; + let getUserMediaMock: jest.Mock; + + const createMockStream = (videoTrack?: MediaStreamTrack, audioTrack?: MediaStreamTrack): MediaStream => { + return { + getVideoTracks: jest.fn(() => [videoTrack || ({ stop: jest.fn() } as unknown as MediaStreamTrack)]), + getAudioTracks: jest.fn(() => [audioTrack || ({ stop: jest.fn() } as unknown as MediaStreamTrack)]), + } as unknown as MediaStream; + }; + + beforeEach(() => { + jest.useFakeTimers(); + + mockVideoTrack = { + stop: jest.fn(), + } as unknown as MediaStreamTrack; + + mockAudioTrack = { + stop: jest.fn(), + } as unknown as MediaStreamTrack; + + mockStream = { + getVideoTracks: jest.fn(() => [mockVideoTrack]), + getAudioTracks: jest.fn(() => [mockAudioTrack]), + } as unknown as MediaStream; + + mockVideoElement = document.createElement('video'); + + getUserMediaMock = jest.fn(); + + Object.defineProperty(global.navigator, 'mediaDevices', { + writable: true, + value: { + getUserMedia: getUserMediaMock, + }, + }); + + global.MediaRecorder = { + isTypeSupported: jest.fn((type: string) => type === 'video/webm'), + } as any; + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.useRealTimers(); + }); + + describe('Asynchronous start and stop handling', () => { + it('should stop camera tracks when stop is called before getUserMedia resolves', async () => { + const streamDeferred = createDeferredPromise(); + + getUserMediaMock.mockReturnValue(streamDeferred.promise); + + const callback = jest.fn(); + VideoRecorder.start(mockVideoElement, callback); + VideoRecorder.stop(); + + streamDeferred.resolve(mockStream); + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(mockVideoTrack.stop).toHaveBeenCalled(); + expect(mockAudioTrack.stop).toHaveBeenCalled(); + expect(callback).not.toHaveBeenCalledWith(true); + }); + + it('should not initialize camera when stopped early', async () => { + const streamDeferred = createDeferredPromise(); + + getUserMediaMock.mockReturnValue(streamDeferred.promise); + + VideoRecorder.start(mockVideoElement, jest.fn()); + VideoRecorder.stop(); + + streamDeferred.resolve(mockStream); + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(VideoRecorder.cameraStarted.get()).toBe(false); + }); + + it('should handle multiple start/stop cycles', async () => { + const stream1 = createMockStream(); + const stream2 = createMockStream(mockVideoTrack, mockAudioTrack); + + getUserMediaMock.mockReturnValueOnce(Promise.resolve(stream1)); + + VideoRecorder.start(mockVideoElement, jest.fn()); + VideoRecorder.stop(); + + const stream2Deferred = createDeferredPromise(); + getUserMediaMock.mockReturnValueOnce(stream2Deferred.promise); + + const cb = jest.fn(); + VideoRecorder.start(mockVideoElement, cb); + + stream2Deferred.resolve(stream2); + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(cb).toHaveBeenCalledWith(true); + expect(VideoRecorder.cameraStarted.get()).toBe(true); + }); + + it('should invalidate pending callbacks from previous start when new start is called', async () => { + const firstStream = createMockStream(); + const secondStream = createMockStream(mockVideoTrack, mockAudioTrack); + + const firstDeferred = createDeferredPromise(); + const secondDeferred = createDeferredPromise(); + + getUserMediaMock.mockReturnValueOnce(firstDeferred.promise).mockReturnValueOnce(secondDeferred.promise); + + const cb1 = jest.fn(); + const cb2 = jest.fn(); + + VideoRecorder.start(mockVideoElement, cb1); + VideoRecorder.start(mockVideoElement, cb2); + + secondDeferred.resolve(secondStream); + await Promise.resolve(); + firstDeferred.resolve(firstStream); + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(firstStream.getVideoTracks).toHaveBeenCalled(); + expect(firstStream.getAudioTracks).toHaveBeenCalled(); + expect(cb2).toHaveBeenCalledWith(true); + expect(cb1).not.toHaveBeenCalledWith(true); + }); + }); + + describe('Normal operation', () => { + it('should initialize camera', async () => { + getUserMediaMock.mockResolvedValue(mockStream); + + const cb = jest.fn(); + VideoRecorder.start(mockVideoElement, cb); + + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(cb).toHaveBeenCalledWith(true); + expect(VideoRecorder.cameraStarted.get()).toBe(true); + }); + + it('should stop camera tracks', () => { + (VideoRecorder as any).stream = mockStream; + (VideoRecorder as any).started = true; + VideoRecorder.cameraStarted.set(true); + + VideoRecorder.stop(); + + expect(mockVideoTrack.stop).toHaveBeenCalled(); + expect(mockAudioTrack.stop).toHaveBeenCalled(); + expect(VideoRecorder.cameraStarted.get()).toBe(false); + }); + + it('should return supported mime types', () => { + expect(VideoRecorder.getSupportedMimeTypes()).toBe('video/webm; codecs=vp8,opus'); + }); + + it('should handle permission errors', async () => { + getUserMediaMock.mockRejectedValue(new Error('Permission denied')); + + const cb = jest.fn(); + VideoRecorder.start(mockVideoElement, cb); + + await Promise.resolve(); + jest.runAllTimers(); + await Promise.resolve(); + + expect(cb).toHaveBeenCalledWith(false); + }); + }); +}); diff --git a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts index 10424c5b3f860..381b6aa297637 100644 --- a/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts +++ b/apps/meteor/app/ui/client/lib/recorderjs/videoRecorder.ts @@ -17,6 +17,10 @@ class VideoRecorder { private mediaRecorder: MediaRecorder | undefined; + // Session ID to handle race conditions between start/stop calls + // Prevents camera from staying active when modal is closed before camera initializes + private sessionId = 0; + public getSupportedMimeTypes() { if (window.MediaRecorder.isTypeSupported('video/webm')) { return 'video/webm; codecs=vp8,opus'; @@ -29,8 +33,16 @@ class VideoRecorder { public start(videoel?: HTMLVideoElement, cb?: (this: this, success: boolean) => void) { this.videoel = videoel; + // Increment and capture session ID for this start request + const currentSessionId = ++this.sessionId; const handleSuccess = (stream: MediaStream) => { + // If stop() was called before this async callback, session IDs won't match + // Clean up the stream immediately to prevent camera from staying active + if (this.sessionId !== currentSessionId) { + this.stopStreamTracks(stream); + return; + } this.startUserMedia(stream); cb?.call(this, true); }; @@ -72,6 +84,18 @@ class VideoRecorder { return this.recording.set(true); } + private stopStreamTracks(stream: MediaStream) { + const vtracks = stream.getVideoTracks(); + for (const vtrack of Array.from(vtracks)) { + vtrack.stop(); + } + + const atracks = stream.getAudioTracks(); + for (const atrack of Array.from(atracks)) { + atrack.stop(); + } + } + private startUserMedia(stream: MediaStream) { if (!this.videoel) { return; @@ -90,34 +114,26 @@ class VideoRecorder { } public stop(cb?: (blob: Blob) => void) { - if (!this.started) { - return; - } + // Increment session ID to invalidate any pending start() callbacks + this.sessionId++; this.stopRecording(); if (this.stream) { - const vtracks = this.stream.getVideoTracks(); - for (const vtrack of Array.from(vtracks)) { - vtrack.stop(); - } - - const atracks = this.stream.getAudioTracks(); - for (const atrack of Array.from(atracks)) { - atrack.stop(); - } + this.stopStreamTracks(this.stream); } if (this.videoel) { - this.videoel.pause; + this.videoel.pause(); this.videoel.src = ''; } + const wasStarted = this.started; this.started = false; this.cameraStarted.set(false); this.recordingAvailable.set(false); - if (cb && this.chunks) { + if (cb && this.chunks && wasStarted) { const blob = new Blob(this.chunks); cb(blob); } diff --git a/apps/meteor/jest.config.ts b/apps/meteor/jest.config.ts index 6c13cd68ca135..0f6545c5df98e 100644 --- a/apps/meteor/jest.config.ts +++ b/apps/meteor/jest.config.ts @@ -15,6 +15,7 @@ export default { '/app/ui-message/client/**/**.spec.[jt]s?(x)', '/tests/unit/client/views/**/*.spec.{ts,tsx}', '/tests/unit/client/providers/**/*.spec.{ts,tsx}', + '/app/ui/client/**/**.spec.[jt]s?(x)', ], moduleNameMapper: {