Voice notes drafts

This commit is contained in:
Alvaro 2023-03-02 13:55:40 -07:00 committed by GitHub
parent 356fb301e1
commit 99015d7b96
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
48 changed files with 2113 additions and 909 deletions

View file

@ -1,7 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useRef, useEffect, useState } from 'react';
import React, { useCallback } from 'react';
import type { RefObject } from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
@ -18,6 +18,9 @@ import { MessageMetadata } from './MessageMetadata';
import * as log from '../../logging/log';
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
import { PlaybackRateButton } from '../PlaybackRateButton';
import { PlaybackButton } from '../PlaybackButton';
import { WaveformScrubber } from './WaveformScrubber';
import { useComputePeaks } from '../../hooks/useComputePeaks';
import { durationToPlaybackText } from '../../util/durationToPlaybackText';
export type OwnProps = Readonly<{
@ -58,15 +61,6 @@ export type DispatchProps = Readonly<{
export type Props = OwnProps & DispatchProps;
type ButtonProps = {
mod?: string;
label: string;
visible?: boolean;
onClick: () => void;
onMouseDown?: () => void;
onMouseUp?: () => void;
};
enum State {
NotDownloaded = 'NotDownloaded',
Pending = 'Pending',
@ -82,12 +76,6 @@ const BAR_NOT_DOWNLOADED_HEIGHT = 2;
const BAR_MIN_HEIGHT = 4;
const BAR_MAX_HEIGHT = 20;
const REWIND_BAR_COUNT = 2;
// Increments for keyboard audio seek (in seconds)
const SMALL_INCREMENT = 1;
const BIG_INCREMENT = 5;
const SPRING_CONFIG = {
mass: 0.5,
tension: 350,
@ -97,62 +85,6 @@ const SPRING_CONFIG = {
const DOT_DIV_WIDTH = 14;
/** Handles animations, key events, and stopping event propagation */
const PlaybackButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ButtonInner(props, ref) {
const { mod, label, onClick, visible = true } = props;
const [animProps] = useSpring(
{
config: SPRING_CONFIG,
to: { scale: visible ? 1 : 0 },
},
[visible]
);
// Clicking button toggle playback
const onButtonClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onClick();
},
[onClick]
);
// Keyboard playback toggle
const onButtonKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
event.stopPropagation();
event.preventDefault();
onClick();
},
[onClick]
);
return (
<animated.div style={animProps}>
<button
type="button"
ref={ref}
className={classNames(
`${CSS_BASE}__play-button`,
mod ? `${CSS_BASE}__play-button--${mod}` : undefined
)}
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
tabIndex={0}
aria-label={label}
/>
</animated.div>
);
}
);
function PlayedDot({
played,
onHide,
@ -222,7 +154,6 @@ export function MessageAudio(props: Props): JSX.Element {
kickOffAttachmentDownload,
onCorrupted,
computePeaks,
setPlaybackRate,
onPlayMessage,
pushPanelForConversation,
@ -230,21 +161,18 @@ export function MessageAudio(props: Props): JSX.Element {
setIsPlaying,
} = props;
const waveformRef = useRef<HTMLDivElement | null>(null);
const isPlaying = active?.playing ?? false;
const [isPlayedDotVisible, setIsPlayedDotVisible] = React.useState(!played);
// if it's playing, use the duration passed as props as it might
// change during loading/playback (?)
// NOTE: Avoid division by zero
const [duration, setDuration] = useState(active?.duration ?? 1e-23);
const audioUrl = isDownloaded(attachment) ? attachment.url : undefined;
const [hasPeaks, setHasPeaks] = useState(false);
const [peaks, setPeaks] = useState<ReadonlyArray<number>>(
new Array(BAR_COUNT).fill(0)
);
const { duration, hasPeaks, peaks } = useComputePeaks({
audioUrl,
activeDuration: active?.duration,
barCount: BAR_COUNT,
onCorrupted,
});
let state: State;
@ -258,60 +186,7 @@ export function MessageAudio(props: Props): JSX.Element {
state = State.Normal;
}
// This effect loads audio file and computes its RMS peak for displaying the
// waveform.
useEffect(() => {
if (state !== State.Computing) {
return noop;
}
log.info('MessageAudio: loading audio and computing waveform');
let canceled = false;
void (async () => {
try {
if (!attachment.url) {
throw new Error(
'Expected attachment url in the MessageAudio with ' +
`state: ${state}`
);
}
const { peaks: newPeaks, duration: newDuration } = await computePeaks(
attachment.url,
BAR_COUNT
);
if (canceled) {
return;
}
setPeaks(newPeaks);
setHasPeaks(true);
setDuration(Math.max(newDuration, 1e-23));
} catch (err) {
log.error(
'MessageAudio: computePeaks error, marking as corrupted',
err
);
onCorrupted();
}
})();
return () => {
canceled = true;
};
}, [
attachment,
computePeaks,
setDuration,
setPeaks,
setHasPeaks,
onCorrupted,
state,
]);
const toggleIsPlaying = () => {
const toggleIsPlaying = useCallback(() => {
if (!isPlaying) {
if (!attachment.url) {
throw new Error(
@ -328,144 +203,96 @@ export function MessageAudio(props: Props): JSX.Element {
} else {
setIsPlaying(false);
}
};
// Clicking waveform moves playback head position and starts playback.
const onWaveformClick = (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
if (state !== State.Normal) {
return;
}
if (!waveformRef.current) {
return;
}
const boundingRect = waveformRef.current.getBoundingClientRect();
let progress = (event.pageX - boundingRect.left) / boundingRect.width;
if (progress <= REWIND_BAR_COUNT / BAR_COUNT) {
progress = 0;
}
if (active) {
setPosition(progress);
if (!active.playing) {
setIsPlaying(true);
}
return;
}
if (attachment.url) {
onPlayMessage(id, progress);
} else {
log.warn('Waveform clicked on attachment with no url');
}
};
// Keyboard navigation for waveform. Pressing keys moves playback head
// forward/backwards.
const onWaveformKeyDown = (event: React.KeyboardEvent) => {
let increment: number;
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
increment = +SMALL_INCREMENT;
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
increment = -SMALL_INCREMENT;
} else if (event.key === 'PageUp') {
increment = +BIG_INCREMENT;
} else if (event.key === 'PageDown') {
increment = -BIG_INCREMENT;
} else {
// We don't handle other keys
return;
}
event.preventDefault();
event.stopPropagation();
// There is no audio to rewind
if (!active) {
return;
}
const currentPosition = active.currentTime / duration;
const positionIncrement = increment / duration;
setPosition(currentPosition + positionIncrement);
if (!isPlaying) {
toggleIsPlaying();
}
};
}, [
isPlaying,
attachment.url,
active,
state,
setIsPlaying,
id,
onPlayMessage,
]);
const currentTimeOrZero = active?.currentTime ?? 0;
const peakPosition = peaks.length * (currentTimeOrZero / duration);
const updatePosition = useCallback(
(newPosition: number) => {
if (active) {
setPosition(newPosition);
if (!active.playing) {
setIsPlaying(true);
}
return;
}
if (attachment.url) {
onPlayMessage(id, newPosition);
} else {
log.warn('Waveform clicked on attachment with no url');
}
},
[active, attachment.url, id, onPlayMessage, setIsPlaying, setPosition]
);
const handleWaveformClick = useCallback(
(positionAsRatio: number) => {
if (state !== State.Normal) {
return;
}
updatePosition(positionAsRatio);
},
[state, updatePosition]
);
const handleWaveformScrub = useCallback(
(amountInSeconds: number) => {
const currentPosition = currentTimeOrZero / duration;
const positionIncrement = amountInSeconds / duration;
updatePosition(
Math.min(Math.max(0, currentPosition + positionIncrement), duration)
);
},
[currentTimeOrZero, duration, updatePosition]
);
const waveform = (
<div
ref={waveformRef}
className={`${CSS_BASE}__waveform`}
onClick={onWaveformClick}
onKeyDown={onWaveformKeyDown}
tabIndex={0}
role="slider"
aria-label={i18n('MessageAudio--slider')}
aria-orientation="horizontal"
aria-valuenow={currentTimeOrZero}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuetext={durationToPlaybackText(currentTimeOrZero)}
>
{peaks.map((peak, i) => {
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
if (state !== State.Normal) {
height = BAR_NOT_DOWNLOADED_HEIGHT;
}
const highlight = i < peakPosition;
// Use maximum height for current audio position
if (highlight && i + 1 >= peakPosition) {
height = BAR_MAX_HEIGHT;
}
const key = i;
return (
<div
className={classNames([
`${CSS_BASE}__waveform__bar`,
highlight ? `${CSS_BASE}__waveform__bar--active` : null,
])}
key={key}
style={{ height }}
/>
);
})}
</div>
<WaveformScrubber
i18n={i18n}
peaks={peaks}
duration={duration}
currentTime={currentTimeOrZero}
barMinHeight={
state !== State.Normal ? BAR_NOT_DOWNLOADED_HEIGHT : BAR_MIN_HEIGHT
}
barMaxHeight={BAR_MAX_HEIGHT}
onClick={handleWaveformClick}
onScrub={handleWaveformScrub}
/>
);
let button: React.ReactElement;
if (state === State.Pending || state === State.Computing) {
// Not really a button, but who cares?
button = (
<div
className={classNames(
`${CSS_BASE}__spinner`,
`${CSS_BASE}__spinner--pending`
)}
title={i18n('MessageAudio--pending')}
<PlaybackButton
variant="message"
mod="pending"
onClick={noop}
label={i18n('MessageAudio--pending')}
context={direction}
/>
);
} else if (state === State.NotDownloaded) {
button = (
<PlaybackButton
ref={buttonRef}
variant="message"
mod="download"
label="MessageAudio--download"
label={i18n('MessageAudio--download')}
onClick={kickOffAttachmentDownload}
context={direction}
/>
);
} else {
@ -473,11 +300,13 @@ export function MessageAudio(props: Props): JSX.Element {
button = (
<PlaybackButton
ref={buttonRef}
variant="message"
mod={isPlaying ? 'pause' : 'play'}
label={
isPlaying ? i18n('MessageAudio--pause') : i18n('MessageAudio--play')
}
onClick={toggleIsPlaying}
context={direction}
/>
);
}