618 lines
15 KiB
TypeScript
618 lines
15 KiB
TypeScript
// Copyright 2021-2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
import React, { useCallback, useRef, useEffect, useState } from 'react';
|
|
import classNames from 'classnames';
|
|
import { noop } from 'lodash';
|
|
import { animated, useSpring } from '@react-spring/web';
|
|
|
|
import type { LocalizerType } from '../../types/Util';
|
|
import type { AttachmentType } from '../../types/Attachment';
|
|
import { isDownloaded } from '../../types/Attachment';
|
|
import type { DirectionType, MessageStatusType } from './Message';
|
|
|
|
import type { ComputePeaksResult } from '../GlobalAudioContext';
|
|
import { MessageMetadata } from './MessageMetadata';
|
|
import * as log from '../../logging/log';
|
|
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
|
|
|
|
export type OwnProps = Readonly<{
|
|
active: ActiveAudioPlayerStateType | undefined;
|
|
renderingContext: string;
|
|
i18n: LocalizerType;
|
|
attachment: AttachmentType;
|
|
collapseMetadata: boolean;
|
|
withContentAbove: boolean;
|
|
withContentBelow: boolean;
|
|
|
|
// Message properties. Many are needed for rendering metadata
|
|
direction: DirectionType;
|
|
expirationLength?: number;
|
|
expirationTimestamp?: number;
|
|
id: string;
|
|
conversationId: string;
|
|
played: boolean;
|
|
showMessageDetail: (id: string) => void;
|
|
status?: MessageStatusType;
|
|
textPending?: boolean;
|
|
timestamp: number;
|
|
kickOffAttachmentDownload(): void;
|
|
onCorrupted(): void;
|
|
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
|
}>;
|
|
|
|
export type DispatchProps = Readonly<{
|
|
loadAndPlayMessageAudio: (
|
|
id: string,
|
|
url: string,
|
|
context: string,
|
|
position: number,
|
|
isConsecutive: boolean
|
|
) => void;
|
|
setCurrentTime: (currentTime: number) => void;
|
|
setPlaybackRate: (conversationId: string, rate: number) => void;
|
|
setIsPlaying: (value: boolean) => void;
|
|
}>;
|
|
|
|
export type Props = OwnProps & DispatchProps;
|
|
|
|
type ButtonProps = {
|
|
i18n: LocalizerType;
|
|
variant: 'play' | 'playback-rate';
|
|
mod?: string;
|
|
label: string;
|
|
visible?: boolean;
|
|
animateClick?: boolean;
|
|
onClick: () => void;
|
|
onMouseDown?: () => void;
|
|
onMouseUp?: () => void;
|
|
};
|
|
|
|
enum State {
|
|
NotDownloaded = 'NotDownloaded',
|
|
Pending = 'Pending',
|
|
Computing = 'Computing',
|
|
Normal = 'Normal',
|
|
}
|
|
|
|
// Constants
|
|
|
|
const CSS_BASE = 'module-message__audio-attachment';
|
|
const BAR_COUNT = 47;
|
|
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 PLAYBACK_RATES = [1, 1.5, 2, 0.5];
|
|
|
|
const SPRING_CONFIG = {
|
|
mass: 0.5,
|
|
tension: 350,
|
|
friction: 20,
|
|
velocity: 0.01,
|
|
};
|
|
|
|
const DOT_DIV_WIDTH = 14;
|
|
|
|
// Utils
|
|
|
|
const timeToText = (time: number): string => {
|
|
const hours = Math.floor(time / 3600);
|
|
let minutes = Math.floor((time % 3600) / 60).toString();
|
|
let seconds = Math.floor(time % 60).toString();
|
|
|
|
if (hours !== 0 && minutes.length < 2) {
|
|
minutes = `0${minutes}`;
|
|
}
|
|
|
|
if (seconds.length < 2) {
|
|
seconds = `0${seconds}`;
|
|
}
|
|
|
|
return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`;
|
|
};
|
|
|
|
/**
|
|
* Handles animations, key events, and stoping event propagation
|
|
* for play button and playback rate button
|
|
*/
|
|
const Button: React.FC<ButtonProps> = props => {
|
|
const {
|
|
i18n,
|
|
variant,
|
|
mod,
|
|
label,
|
|
children,
|
|
onClick,
|
|
visible = true,
|
|
animateClick = true,
|
|
} = props;
|
|
const [isDown, setIsDown] = useState(false);
|
|
|
|
const [animProps] = useSpring(
|
|
{
|
|
config: SPRING_CONFIG,
|
|
to: isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
|
|
},
|
|
[visible, isDown, animateClick]
|
|
);
|
|
|
|
// 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"
|
|
className={classNames(
|
|
`${CSS_BASE}__${variant}-button`,
|
|
mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined
|
|
)}
|
|
onClick={onButtonClick}
|
|
onKeyDown={onButtonKeyDown}
|
|
onMouseDown={() => setIsDown(true)}
|
|
onMouseUp={() => setIsDown(false)}
|
|
onMouseLeave={() => setIsDown(false)}
|
|
tabIndex={0}
|
|
aria-label={i18n(label)}
|
|
>
|
|
{children}
|
|
</button>
|
|
</animated.div>
|
|
);
|
|
};
|
|
|
|
const PlayedDot = ({
|
|
played,
|
|
onHide,
|
|
}: {
|
|
played: boolean;
|
|
onHide: () => void;
|
|
}) => {
|
|
const start = played ? 1 : 0;
|
|
const end = played ? 0 : 1;
|
|
|
|
const [animProps] = useSpring(
|
|
{
|
|
config: SPRING_CONFIG,
|
|
from: { scale: start, opacity: start, width: start },
|
|
to: { scale: end, opacity: end, width: end * DOT_DIV_WIDTH },
|
|
onRest: () => {
|
|
if (played) {
|
|
onHide();
|
|
}
|
|
},
|
|
},
|
|
[played]
|
|
);
|
|
|
|
return (
|
|
<animated.div
|
|
style={animProps}
|
|
aria-hidden="true"
|
|
className={classNames(
|
|
`${CSS_BASE}__dot`,
|
|
`${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
|
|
)}
|
|
/>
|
|
);
|
|
};
|
|
|
|
/**
|
|
* Display message audio attachment along with its waveform, duration, and
|
|
* toggle Play/Pause button.
|
|
*
|
|
* A global audio player is used for playback and access is managed by the
|
|
* `activeAudioID` and `activeAudioContext` properties. Whenever both
|
|
* `activeAudioID` and `activeAudioContext` are equal to `id` and `context`
|
|
* respectively the instance of the `MessageAudio` assumes the ownership of the
|
|
* `Audio` instance and fully manages it.
|
|
*
|
|
* `context` is required for displaying separate MessageAudio instances in
|
|
* MessageDetails and Message React components.
|
|
*/
|
|
export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|
const {
|
|
active,
|
|
i18n,
|
|
renderingContext,
|
|
attachment,
|
|
collapseMetadata,
|
|
conversationId,
|
|
withContentAbove,
|
|
withContentBelow,
|
|
|
|
direction,
|
|
expirationLength,
|
|
expirationTimestamp,
|
|
id,
|
|
played,
|
|
showMessageDetail,
|
|
status,
|
|
textPending,
|
|
timestamp,
|
|
|
|
kickOffAttachmentDownload,
|
|
onCorrupted,
|
|
computePeaks,
|
|
setPlaybackRate,
|
|
loadAndPlayMessageAudio,
|
|
setCurrentTime,
|
|
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 activeDuration =
|
|
active?.duration && !Number.isNaN(active.duration)
|
|
? active.duration
|
|
: undefined;
|
|
const [duration, setDuration] = useState(activeDuration ?? 1e-23);
|
|
|
|
const [hasPeaks, setHasPeaks] = useState(false);
|
|
const [peaks, setPeaks] = useState<ReadonlyArray<number>>(
|
|
new Array(BAR_COUNT).fill(0)
|
|
);
|
|
|
|
let state: State;
|
|
|
|
if (attachment.pending) {
|
|
state = State.Pending;
|
|
} else if (!isDownloaded(attachment)) {
|
|
state = State.NotDownloaded;
|
|
} else if (!hasPeaks) {
|
|
state = State.Computing;
|
|
} else {
|
|
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;
|
|
|
|
(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 = () => {
|
|
if (!isPlaying) {
|
|
if (!attachment.url) {
|
|
throw new Error(
|
|
'Expected attachment url in the MessageAudio with ' +
|
|
`state: ${state}`
|
|
);
|
|
}
|
|
|
|
if (active) {
|
|
setIsPlaying(true);
|
|
} else {
|
|
loadAndPlayMessageAudio(id, attachment.url, renderingContext, 0, false);
|
|
}
|
|
} else {
|
|
// stop
|
|
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 (attachment.url) {
|
|
loadAndPlayMessageAudio(
|
|
id,
|
|
attachment.url,
|
|
renderingContext,
|
|
progress,
|
|
false
|
|
);
|
|
} 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;
|
|
}
|
|
|
|
setCurrentTime(
|
|
Math.min(
|
|
Number.isNaN(duration) ? Infinity : duration,
|
|
Math.max(0, active.currentTime + increment)
|
|
)
|
|
);
|
|
|
|
if (!isPlaying) {
|
|
toggleIsPlaying();
|
|
}
|
|
};
|
|
|
|
const currentTimeOrZero = active?.currentTime ?? 0;
|
|
|
|
const peakPosition = peaks.length * (currentTimeOrZero / duration);
|
|
|
|
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={timeToText(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>
|
|
);
|
|
|
|
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')}
|
|
/>
|
|
);
|
|
} else if (state === State.NotDownloaded) {
|
|
button = (
|
|
<Button
|
|
i18n={i18n}
|
|
variant="play"
|
|
mod="download"
|
|
label="MessageAudio--download"
|
|
animateClick={false}
|
|
onClick={kickOffAttachmentDownload}
|
|
/>
|
|
);
|
|
} else {
|
|
// State.Normal
|
|
button = (
|
|
<Button
|
|
i18n={i18n}
|
|
variant="play"
|
|
mod={isPlaying ? 'pause' : 'play'}
|
|
label={isPlaying ? 'MessageAudio--pause' : 'MessageAudio--play'}
|
|
animateClick={false}
|
|
onClick={toggleIsPlaying}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const countDown = Math.max(0, duration - (active?.currentTime ?? 0));
|
|
|
|
const nextPlaybackRate = (currentRate: number): number => {
|
|
// cycle through the rates
|
|
return PLAYBACK_RATES[
|
|
(PLAYBACK_RATES.indexOf(currentRate) + 1) % PLAYBACK_RATES.length
|
|
];
|
|
};
|
|
|
|
const playbackRateLabels: { [key: number]: string } = {
|
|
1: i18n('MessageAudio--playbackRate1'),
|
|
1.5: i18n('MessageAudio--playbackRate1p5'),
|
|
2: i18n('MessageAudio--playbackRate2'),
|
|
0.5: i18n('MessageAudio--playbackRatep5'),
|
|
};
|
|
|
|
const metadata = (
|
|
<div className={`${CSS_BASE}__metadata`}>
|
|
<div
|
|
aria-hidden="true"
|
|
className={classNames(
|
|
`${CSS_BASE}__countdown`,
|
|
`${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
|
|
)}
|
|
>
|
|
{timeToText(countDown)}
|
|
</div>
|
|
|
|
<div className={`${CSS_BASE}__controls`}>
|
|
<PlayedDot
|
|
played={played}
|
|
onHide={() => setIsPlayedDotVisible(false)}
|
|
/>
|
|
<Button
|
|
variant="playback-rate"
|
|
i18n={i18n}
|
|
label={(active && playbackRateLabels[active.playbackRate]) ?? ''}
|
|
visible={isPlaying && (!played || !isPlayedDotVisible)}
|
|
onClick={() => {
|
|
if (active) {
|
|
setPlaybackRate(
|
|
conversationId,
|
|
nextPlaybackRate(active.playbackRate)
|
|
);
|
|
}
|
|
}}
|
|
>
|
|
{active && playbackRateLabels[active.playbackRate]}
|
|
</Button>
|
|
</div>
|
|
|
|
{!withContentBelow && !collapseMetadata && (
|
|
<MessageMetadata
|
|
direction={direction}
|
|
expirationLength={expirationLength}
|
|
expirationTimestamp={expirationTimestamp}
|
|
hasText={withContentBelow}
|
|
i18n={i18n}
|
|
id={id}
|
|
isShowingImage={false}
|
|
isSticker={false}
|
|
isTapToViewExpired={false}
|
|
showMessageDetail={showMessageDetail}
|
|
status={status}
|
|
textPending={textPending}
|
|
timestamp={timestamp}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div
|
|
className={classNames(
|
|
CSS_BASE,
|
|
`${CSS_BASE}--${direction}`,
|
|
withContentBelow ? `${CSS_BASE}--with-content-below` : null,
|
|
withContentAbove ? `${CSS_BASE}--with-content-above` : null
|
|
)}
|
|
>
|
|
<div className={`${CSS_BASE}__button-and-waveform`}>
|
|
{button}
|
|
{waveform}
|
|
</div>
|
|
{metadata}
|
|
</div>
|
|
);
|
|
};
|