diff --git a/ts/components/conversation/WaveformScrubber.tsx b/ts/components/conversation/WaveformScrubber.tsx index a5d73aec2d6..f28c5c879fc 100644 --- a/ts/components/conversation/WaveformScrubber.tsx +++ b/ts/components/conversation/WaveformScrubber.tsx @@ -7,6 +7,7 @@ import type { LocalizerType } from '../../types/Util'; import { durationToPlaybackText } from '../../util/durationToPlaybackText'; import { Waveform } from './Waveform'; import { arrow } from '../../util/keyboard'; +import { globalMessageAudio } from '../../services/globalMessageAudio'; type Props = Readonly<{ i18n: LocalizerType; @@ -20,10 +21,9 @@ type Props = Readonly<{ }>; const BAR_COUNT = 47; - const REWIND_BAR_COUNT = 2; -// Increments for keyboard audio seek (in seconds)\ +// Increments for keyboard audio seek (in seconds) const SMALL_INCREMENT = 1; const BIG_INCREMENT = 5; @@ -41,7 +41,6 @@ export const WaveformScrubber = React.forwardRef(function WaveformScrubber( ref ): JSX.Element { const refMerger = useRefMerger(); - const waveformRef = useRef(null); // Clicking waveform moves playback head position and starts playback. @@ -63,11 +62,62 @@ export const WaveformScrubber = React.forwardRef(function WaveformScrubber( onClick(progress); }, - [waveformRef, onClick] + [onClick] + ); + + const handleMouseDrag = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + + let isDragging = true; + const wasPlayingBeforeDrag = globalMessageAudio?.playing || false; + + if (globalMessageAudio?.playing) { + globalMessageAudio.pause(); + } + + globalMessageAudio?.muted(true); + + const handleMouseMove = (moveEvent: MouseEvent) => { + if (!isDragging || !waveformRef.current) { + return; + } + + const rect = waveformRef.current.getBoundingClientRect(); + let positionAsRatio = (moveEvent.clientX - rect.left) / rect.width; + positionAsRatio = Math.min(Math.max(0, positionAsRatio), 1); + + onScrub(positionAsRatio); + + const durationVal = globalMessageAudio?.duration; + if ( + durationVal !== undefined && + !Number.isNaN(durationVal) && + durationVal > 0 + ) { + globalMessageAudio.currentTime = positionAsRatio * durationVal; + } + }; + + const handleMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + + globalMessageAudio?.muted(false); + + if (wasPlayingBeforeDrag) { + globalMessageAudio?.play(); + } + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, + [onScrub] ); - // Keyboard navigation for waveform. Pressing keys moves playback head - // forward/backwards. + // Keyboard navigation for waveform const handleKeyDown = (event: React.KeyboardEvent) => { if (!duration) { return; @@ -84,7 +134,6 @@ export const WaveformScrubber = React.forwardRef(function WaveformScrubber( } else if (event.key === 'PageDown') { increment = -BIG_INCREMENT; } else { - // We don't handle other keys return; } @@ -103,6 +152,7 @@ export const WaveformScrubber = React.forwardRef(function WaveformScrubber( ref={refMerger(waveformRef, ref)} className="WaveformScrubber" onClick={handleClick} + onMouseDown={handleMouseDrag} onKeyDown={handleKeyDown} tabIndex={0} role="slider" diff --git a/ts/services/globalMessageAudio.ts b/ts/services/globalMessageAudio.ts index b6f3560a41d..8da8948e75f 100644 --- a/ts/services/globalMessageAudio.ts +++ b/ts/services/globalMessageAudio.ts @@ -80,6 +80,10 @@ class GlobalMessageAudio { this.#playing = false; } + muted(value: boolean): void { + this.#audio.muted = value; + } + get playbackRate() { return this.#audio.playbackRate; } diff --git a/ts/test-node/components/conversation/WaveformScrubberDragging_test.ts b/ts/test-node/components/conversation/WaveformScrubberDragging_test.ts new file mode 100644 index 00000000000..084fbbc0800 --- /dev/null +++ b/ts/test-node/components/conversation/WaveformScrubberDragging_test.ts @@ -0,0 +1,133 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import { describe, it, beforeEach } from 'mocha'; +import * as sinon from 'sinon'; + +describe('WaveformScrubberDragging', () => { + let isDragging = false; + let wasPlayingBeforeDrag = false; + let onScrubMock: sinon.SinonSpy; + let mockDocument: { + addEventListener: sinon.SinonSpy; + removeEventListener: sinon.SinonSpy; + }; + let mockAudio: { + playing: boolean; + duration: number; + currentTime: number; + play: sinon.SinonSpy; + pause: sinon.SinonSpy; + }; + + beforeEach(() => { + mockDocument = { + addEventListener: sinon.spy(), + removeEventListener: sinon.spy(), + }; + mockAudio = { + playing: true, + duration: 10, + currentTime: 0, + play: sinon.spy(), + pause: sinon.spy(), + }; + isDragging = false; + wasPlayingBeforeDrag = false; + }); + + function calculatePositionAsRatio(clientX: number): number { + return clientX / 100; + } + + function handleMouseDown(): void { + isDragging = true; + wasPlayingBeforeDrag = mockAudio.playing; + + if (wasPlayingBeforeDrag) { + mockAudio.pause(); + } + + mockDocument.addEventListener('mousemove', handleMouseMove); + mockDocument.addEventListener('mouseup', handleMouseUp); + } + + function handleMouseMove(event: { clientX: number }): void { + if (!isDragging) { + return; + } + + const positionAsRatio = calculatePositionAsRatio(event.clientX); + onScrubMock(positionAsRatio); + mockAudio.currentTime = positionAsRatio * mockAudio.duration; + } + + function handleMouseUp(): void { + isDragging = false; + + mockDocument.removeEventListener('mousemove', handleMouseMove); + mockDocument.removeEventListener('mouseup', handleMouseUp); + + if (wasPlayingBeforeDrag) { + mockAudio.play(); + } + } + + it('should call onScrub with correct ratio while dragging', () => { + onScrubMock = sinon.spy(); + + handleMouseDown(); + + assert.isTrue(mockAudio.pause.calledOnce, 'pause should be called'); + + const mouseMoveHandlerEntry = mockDocument.addEventListener.args.find( + (args: Array) => args[0] === 'mousemove' + ); + + if (!mouseMoveHandlerEntry) { + throw new Error('mousemove handler not found'); + } + + const mouseMoveHandler = mouseMoveHandlerEntry[1] as (event: { + clientX: number; + }) => void; + + mouseMoveHandler({ clientX: 40 }); + + assert.isTrue(onScrubMock.calledOnce, 'onScrub should be called once'); + assert.closeTo( + onScrubMock.args[0][0] as number, + 0.4, + 0.05, + 'onScrub called with ~0.4' + ); + + assert.equal(mockAudio.currentTime, 4, 'currentTime should be 4 seconds'); + + const mouseUpHandlerEntry = mockDocument.addEventListener.args.find( + (args: Array) => args[0] === 'mouseup' + ); + + if (!mouseUpHandlerEntry) { + throw new Error('mouseup handler not found'); + } + + const mouseUpHandler = mouseUpHandlerEntry[1] as () => void; + + mouseUpHandler(); + + assert.isFalse(isDragging, 'Dragging should be stopped'); + assert.isTrue(mockAudio.play.calledOnce, 'play should be called'); + + assert.isTrue( + mockDocument.removeEventListener.calledWith('mousemove', handleMouseMove), + 'mousemove listener should be removed' + ); + + assert.isTrue( + mockDocument.removeEventListener.calledWith('mouseup', handleMouseUp), + 'mouseup listener should be removed' + ); + }); +});