Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 57 additions & 7 deletions ts/components/conversation/WaveformScrubber.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -41,7 +41,6 @@ export const WaveformScrubber = React.forwardRef(function WaveformScrubber(
ref
): JSX.Element {
const refMerger = useRefMerger();

const waveformRef = useRef<HTMLDivElement | null>(null);

// Clicking waveform moves playback head position and starts playback.
Expand All @@ -63,11 +62,62 @@ export const WaveformScrubber = React.forwardRef(function WaveformScrubber(

onClick(progress);
},
[waveformRef, onClick]
[onClick]
);

const handleMouseDrag = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
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;
Expand All @@ -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;
}

Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions ts/services/globalMessageAudio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ class GlobalMessageAudio {
this.#playing = false;
}

muted(value: boolean): void {
this.#audio.muted = value;
}

get playbackRate() {
return this.#audio.playbackRate;
}
Expand Down
133 changes: 133 additions & 0 deletions ts/test-node/components/conversation/WaveformScrubberDragging_test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => 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<unknown>) => 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'
);
});
});