2021-03-10 20:36:58 +00:00
|
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
|
|
|
|
import React, { useRef, useEffect, useState } from 'react';
|
|
|
|
import classNames from 'classnames';
|
|
|
|
import { noop } from 'lodash';
|
|
|
|
|
|
|
|
import { assert } from '../../util/assert';
|
|
|
|
import { LocalizerType } from '../../types/Util';
|
2021-03-16 00:59:48 +00:00
|
|
|
import { hasNotDownloaded, AttachmentType } from '../../types/Attachment';
|
2021-07-09 20:27:16 +00:00
|
|
|
import type { DirectionType, MessageStatusType } from './Message';
|
2021-03-10 20:36:58 +00:00
|
|
|
|
2021-04-15 21:02:24 +00:00
|
|
|
import { ComputePeaksResult } from '../GlobalAudioContext';
|
2021-07-09 20:27:16 +00:00
|
|
|
import { MessageMetadata } from './MessageMetadata';
|
2021-09-17 18:27:53 +00:00
|
|
|
import * as log from '../../logging/log';
|
2021-04-15 21:02:24 +00:00
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
export type Props = {
|
2021-06-29 19:58:29 +00:00
|
|
|
renderingContext: string;
|
2021-03-10 20:36:58 +00:00
|
|
|
i18n: LocalizerType;
|
2021-03-16 00:59:48 +00:00
|
|
|
attachment: AttachmentType;
|
2021-03-10 20:36:58 +00:00
|
|
|
withContentAbove: boolean;
|
|
|
|
withContentBelow: boolean;
|
|
|
|
|
2021-07-09 20:27:16 +00:00
|
|
|
// Message properties. Many are needed for rendering metadata
|
|
|
|
direction: DirectionType;
|
|
|
|
expirationLength?: number;
|
|
|
|
expirationTimestamp?: number;
|
|
|
|
id: string;
|
2021-07-27 15:42:25 +00:00
|
|
|
played: boolean;
|
2021-07-09 20:27:16 +00:00
|
|
|
showMessageDetail: (id: string) => void;
|
|
|
|
status?: MessageStatusType;
|
|
|
|
textPending?: boolean;
|
|
|
|
timestamp: number;
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
// See: GlobalAudioContext.tsx
|
|
|
|
audio: HTMLAudioElement;
|
|
|
|
|
|
|
|
buttonRef: React.RefObject<HTMLButtonElement>;
|
2021-03-16 00:59:48 +00:00
|
|
|
kickOffAttachmentDownload(): void;
|
2021-03-22 18:51:53 +00:00
|
|
|
onCorrupted(): void;
|
2021-08-12 18:15:55 +00:00
|
|
|
onFirstPlayed(): void;
|
2021-03-10 20:36:58 +00:00
|
|
|
|
2021-04-15 21:02:24 +00:00
|
|
|
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
2021-03-10 20:36:58 +00:00
|
|
|
activeAudioID: string | undefined;
|
2021-06-29 19:58:29 +00:00
|
|
|
activeAudioContext: string | undefined;
|
|
|
|
setActiveAudioID: (id: string | undefined, context: string) => void;
|
2021-03-10 20:36:58 +00:00
|
|
|
};
|
|
|
|
|
2021-03-16 00:59:48 +00:00
|
|
|
type ButtonProps = {
|
|
|
|
i18n: LocalizerType;
|
|
|
|
buttonRef: React.RefObject<HTMLButtonElement>;
|
|
|
|
|
|
|
|
mod: string;
|
|
|
|
label: string;
|
|
|
|
onClick: () => void;
|
|
|
|
};
|
|
|
|
|
|
|
|
enum State {
|
|
|
|
NotDownloaded = 'NotDownloaded',
|
|
|
|
Pending = 'Pending',
|
2021-04-15 21:02:24 +00:00
|
|
|
Computing = 'Computing',
|
2021-03-16 00:59:48 +00:00
|
|
|
Normal = 'Normal',
|
|
|
|
}
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
// Constants
|
|
|
|
|
|
|
|
const CSS_BASE = 'module-message__audio-attachment';
|
2021-03-24 23:08:57 +00:00
|
|
|
const BAR_COUNT = 47;
|
2021-03-16 00:59:48 +00:00
|
|
|
const BAR_NOT_DOWNLOADED_HEIGHT = 2;
|
2021-03-10 20:36:58 +00:00
|
|
|
const BAR_MIN_HEIGHT = 4;
|
|
|
|
const BAR_MAX_HEIGHT = 20;
|
|
|
|
|
2021-03-24 23:08:57 +00:00
|
|
|
const REWIND_BAR_COUNT = 2;
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
// Increments for keyboard audio seek (in seconds)
|
|
|
|
const SMALL_INCREMENT = 1;
|
|
|
|
const BIG_INCREMENT = 5;
|
|
|
|
|
|
|
|
// 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}`;
|
|
|
|
};
|
|
|
|
|
2021-03-16 00:59:48 +00:00
|
|
|
const Button: React.FC<ButtonProps> = props => {
|
|
|
|
const { i18n, buttonRef, mod, label, onClick } = props;
|
|
|
|
// Clicking button toggle playback
|
|
|
|
const onButtonClick = (event: React.MouseEvent) => {
|
|
|
|
event.stopPropagation();
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
onClick();
|
|
|
|
};
|
|
|
|
|
|
|
|
// Keyboard playback toggle
|
|
|
|
const onButtonKeyDown = (event: React.KeyboardEvent) => {
|
|
|
|
if (event.key !== 'Enter' && event.key !== 'Space') {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
event.stopPropagation();
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
|
|
onClick();
|
|
|
|
};
|
|
|
|
|
|
|
|
return (
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
ref={buttonRef}
|
|
|
|
className={classNames(
|
|
|
|
`${CSS_BASE}__button`,
|
|
|
|
`${CSS_BASE}__button--${mod}`
|
|
|
|
)}
|
|
|
|
onClick={onButtonClick}
|
|
|
|
onKeyDown={onButtonKeyDown}
|
|
|
|
tabIndex={0}
|
|
|
|
aria-label={i18n(label)}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
/**
|
|
|
|
* 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
|
2021-06-29 19:58:29 +00:00
|
|
|
* `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.
|
2021-03-10 20:36:58 +00:00
|
|
|
*/
|
|
|
|
export const MessageAudio: React.FC<Props> = (props: Props) => {
|
|
|
|
const {
|
|
|
|
i18n,
|
2021-06-29 19:58:29 +00:00
|
|
|
renderingContext,
|
2021-03-16 00:59:48 +00:00
|
|
|
attachment,
|
2021-03-10 20:36:58 +00:00
|
|
|
withContentAbove,
|
|
|
|
withContentBelow,
|
|
|
|
|
2021-07-09 20:27:16 +00:00
|
|
|
direction,
|
|
|
|
expirationLength,
|
|
|
|
expirationTimestamp,
|
|
|
|
id,
|
2021-07-27 15:42:25 +00:00
|
|
|
played,
|
2021-07-09 20:27:16 +00:00
|
|
|
showMessageDetail,
|
|
|
|
status,
|
|
|
|
textPending,
|
|
|
|
timestamp,
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
buttonRef,
|
2021-03-16 00:59:48 +00:00
|
|
|
kickOffAttachmentDownload,
|
2021-03-22 18:51:53 +00:00
|
|
|
onCorrupted,
|
2021-08-12 18:15:55 +00:00
|
|
|
onFirstPlayed,
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
audio,
|
2021-04-15 21:02:24 +00:00
|
|
|
computePeaks,
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
activeAudioID,
|
2021-06-29 19:58:29 +00:00
|
|
|
activeAudioContext,
|
2021-03-10 20:36:58 +00:00
|
|
|
setActiveAudioID,
|
|
|
|
} = props;
|
|
|
|
|
|
|
|
assert(audio !== null, 'GlobalAudioContext always provides audio');
|
|
|
|
|
2021-06-29 19:58:29 +00:00
|
|
|
const isActive =
|
|
|
|
activeAudioID === id && activeAudioContext === renderingContext;
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
const waveformRef = useRef<HTMLDivElement | null>(null);
|
2021-09-16 15:02:23 +00:00
|
|
|
const [isPlaying, setIsPlaying] = useState(
|
|
|
|
isActive && !(audio.paused || audio.ended)
|
|
|
|
);
|
2021-03-10 20:36:58 +00:00
|
|
|
const [currentTime, setCurrentTime] = useState(
|
|
|
|
isActive ? audio.currentTime : 0
|
|
|
|
);
|
|
|
|
|
|
|
|
// NOTE: Avoid division by zero
|
|
|
|
const [duration, setDuration] = useState(1e-23);
|
|
|
|
|
2021-04-15 21:02:24 +00:00
|
|
|
const [hasPeaks, setHasPeaks] = useState(false);
|
2021-03-10 20:36:58 +00:00
|
|
|
const [peaks, setPeaks] = useState<ReadonlyArray<number>>(
|
2021-03-24 23:08:57 +00:00
|
|
|
new Array(BAR_COUNT).fill(0)
|
2021-03-10 20:36:58 +00:00
|
|
|
);
|
|
|
|
|
2021-03-16 00:59:48 +00:00
|
|
|
let state: State;
|
|
|
|
|
|
|
|
if (attachment.pending) {
|
|
|
|
state = State.Pending;
|
|
|
|
} else if (hasNotDownloaded(attachment)) {
|
|
|
|
state = State.NotDownloaded;
|
2021-04-15 21:02:24 +00:00
|
|
|
} else if (!hasPeaks) {
|
|
|
|
state = State.Computing;
|
2021-03-16 00:59:48 +00:00
|
|
|
} else {
|
|
|
|
state = State.Normal;
|
|
|
|
}
|
|
|
|
|
2021-07-16 18:05:11 +00:00
|
|
|
// This effect loads audio file and computes its RMS peak for displaying the
|
2021-03-10 20:36:58 +00:00
|
|
|
// waveform.
|
|
|
|
useEffect(() => {
|
2021-04-15 21:02:24 +00:00
|
|
|
if (state !== State.Computing) {
|
2021-03-10 20:36:58 +00:00
|
|
|
return noop;
|
|
|
|
}
|
|
|
|
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: loading audio and computing waveform');
|
2021-03-18 15:06:14 +00:00
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
let canceled = false;
|
|
|
|
|
|
|
|
(async () => {
|
|
|
|
try {
|
2021-03-16 17:49:19 +00:00
|
|
|
if (!attachment.url) {
|
|
|
|
throw new Error(
|
|
|
|
'Expected attachment url in the MessageAudio with ' +
|
|
|
|
`state: ${state}`
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2021-04-15 21:02:24 +00:00
|
|
|
const { peaks: newPeaks, duration: newDuration } = await computePeaks(
|
|
|
|
attachment.url,
|
|
|
|
BAR_COUNT
|
|
|
|
);
|
2021-03-10 20:36:58 +00:00
|
|
|
if (canceled) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
setPeaks(newPeaks);
|
2021-04-15 21:02:24 +00:00
|
|
|
setHasPeaks(true);
|
2021-03-10 20:36:58 +00:00
|
|
|
setDuration(Math.max(newDuration, 1e-23));
|
|
|
|
} catch (err) {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.error(
|
2021-04-15 21:02:24 +00:00
|
|
|
'MessageAudio: computePeaks error, marking as corrupted',
|
2021-03-22 18:51:53 +00:00
|
|
|
err
|
|
|
|
);
|
|
|
|
|
|
|
|
onCorrupted();
|
2021-03-10 20:36:58 +00:00
|
|
|
}
|
|
|
|
})();
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
canceled = true;
|
|
|
|
};
|
2021-03-22 18:51:53 +00:00
|
|
|
}, [
|
|
|
|
attachment,
|
2021-04-15 21:02:24 +00:00
|
|
|
computePeaks,
|
2021-03-22 18:51:53 +00:00
|
|
|
setDuration,
|
|
|
|
setPeaks,
|
2021-04-15 21:02:24 +00:00
|
|
|
setHasPeaks,
|
2021-03-22 18:51:53 +00:00
|
|
|
onCorrupted,
|
|
|
|
state,
|
|
|
|
]);
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
// This effect attaches/detaches event listeners to the global <audio/>
|
|
|
|
// instance that we reuse from the GlobalAudioContext.
|
|
|
|
//
|
|
|
|
// Audio playback changes `audio.currentTime` so we have to propagate this
|
|
|
|
// to the waveform UI.
|
|
|
|
//
|
|
|
|
// When audio ends - we have to change state and reset the position of the
|
|
|
|
// waveform.
|
|
|
|
useEffect(() => {
|
|
|
|
// Owner of Audio instance changed
|
|
|
|
if (!isActive) {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: pausing old owner', id);
|
2021-03-10 20:36:58 +00:00
|
|
|
setIsPlaying(false);
|
|
|
|
setCurrentTime(0);
|
|
|
|
return noop;
|
|
|
|
}
|
|
|
|
|
|
|
|
const onTimeUpdate = () => {
|
|
|
|
setCurrentTime(audio.currentTime);
|
2021-07-16 18:05:11 +00:00
|
|
|
if (audio.currentTime > duration) {
|
|
|
|
setDuration(audio.currentTime);
|
|
|
|
}
|
2021-03-10 20:36:58 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
const onEnded = () => {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: ended, changing UI', id);
|
2021-03-10 20:36:58 +00:00
|
|
|
setIsPlaying(false);
|
|
|
|
setCurrentTime(0);
|
|
|
|
};
|
|
|
|
|
|
|
|
const onLoadedMetadata = () => {
|
|
|
|
assert(
|
|
|
|
!Number.isNaN(audio.duration),
|
|
|
|
'Audio should have definite duration on `loadedmetadata` event'
|
|
|
|
);
|
|
|
|
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: `loadedmetadata` event', id);
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
// Sync-up audio's time in case if <audio/> loaded its source after
|
|
|
|
// user clicked on waveform
|
|
|
|
audio.currentTime = currentTime;
|
|
|
|
};
|
|
|
|
|
2021-05-24 16:30:50 +00:00
|
|
|
const onDurationChange = () => {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: `durationchange` event', id);
|
2021-05-24 16:30:50 +00:00
|
|
|
|
|
|
|
if (!Number.isNaN(audio.duration)) {
|
|
|
|
setDuration(Math.max(audio.duration, 1e-23));
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
audio.addEventListener('timeupdate', onTimeUpdate);
|
|
|
|
audio.addEventListener('ended', onEnded);
|
|
|
|
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
2021-05-24 16:30:50 +00:00
|
|
|
audio.addEventListener('durationchange', onDurationChange);
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
return () => {
|
|
|
|
audio.removeEventListener('timeupdate', onTimeUpdate);
|
|
|
|
audio.removeEventListener('ended', onEnded);
|
|
|
|
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
2021-05-24 16:30:50 +00:00
|
|
|
audio.removeEventListener('durationchange', onDurationChange);
|
2021-03-10 20:36:58 +00:00
|
|
|
};
|
2021-07-16 18:05:11 +00:00
|
|
|
}, [id, audio, isActive, currentTime, duration]);
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
// This effect detects `isPlaying` changes and starts/pauses playback when
|
|
|
|
// needed (+keeps waveform position and audio position in sync).
|
|
|
|
useEffect(() => {
|
|
|
|
if (!isActive) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (isPlaying) {
|
|
|
|
if (!audio.paused) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: resuming playback for', id);
|
2021-03-10 20:36:58 +00:00
|
|
|
audio.currentTime = currentTime;
|
|
|
|
audio.play().catch(error => {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: resume error', id, error.stack || error);
|
2021-03-10 20:36:58 +00:00
|
|
|
});
|
|
|
|
} else {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: pausing playback for', id);
|
2021-03-10 20:36:58 +00:00
|
|
|
audio.pause();
|
|
|
|
}
|
|
|
|
}, [id, audio, isActive, isPlaying, currentTime]);
|
|
|
|
|
|
|
|
const toggleIsPlaying = () => {
|
|
|
|
setIsPlaying(!isPlaying);
|
|
|
|
|
|
|
|
if (!isActive && !isPlaying) {
|
2021-09-17 18:27:53 +00:00
|
|
|
log.info('MessageAudio: changing owner', id);
|
2021-06-29 19:58:29 +00:00
|
|
|
setActiveAudioID(id, renderingContext);
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
// Pause old audio
|
|
|
|
if (!audio.paused) {
|
|
|
|
audio.pause();
|
|
|
|
}
|
|
|
|
|
2021-03-16 17:49:19 +00:00
|
|
|
if (!attachment.url) {
|
|
|
|
throw new Error(
|
|
|
|
'Expected attachment url in the MessageAudio with ' +
|
|
|
|
`state: ${state}`
|
|
|
|
);
|
|
|
|
}
|
2021-03-16 00:59:48 +00:00
|
|
|
audio.src = attachment.url;
|
2021-03-10 20:36:58 +00:00
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-08-12 18:15:55 +00:00
|
|
|
useEffect(() => {
|
|
|
|
if (!played && isPlaying) {
|
|
|
|
onFirstPlayed();
|
|
|
|
}
|
|
|
|
}, [played, isPlaying, onFirstPlayed]);
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
// Clicking waveform moves playback head position and starts playback.
|
|
|
|
const onWaveformClick = (event: React.MouseEvent) => {
|
|
|
|
event.preventDefault();
|
|
|
|
event.stopPropagation();
|
|
|
|
|
2021-03-16 00:59:48 +00:00
|
|
|
if (state !== State.Normal) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
if (!isPlaying) {
|
|
|
|
toggleIsPlaying();
|
|
|
|
}
|
|
|
|
|
|
|
|
if (!waveformRef.current) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const boundingRect = waveformRef.current.getBoundingClientRect();
|
2021-03-24 23:08:57 +00:00
|
|
|
let progress = (event.pageX - boundingRect.left) / boundingRect.width;
|
|
|
|
|
|
|
|
if (progress <= REWIND_BAR_COUNT / BAR_COUNT) {
|
|
|
|
progress = 0;
|
|
|
|
}
|
2021-03-10 20:36:58 +00:00
|
|
|
|
|
|
|
if (isPlaying && !Number.isNaN(audio.duration)) {
|
|
|
|
audio.currentTime = audio.duration * progress;
|
|
|
|
} else {
|
|
|
|
setCurrentTime(duration * progress);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
|
|
|
// 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 (!isActive) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
audio.currentTime = Math.min(
|
|
|
|
Number.isNaN(audio.duration) ? Infinity : audio.duration,
|
|
|
|
Math.max(0, audio.currentTime + increment)
|
|
|
|
);
|
|
|
|
|
|
|
|
if (!isPlaying) {
|
|
|
|
toggleIsPlaying();
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2021-03-16 00:59:48 +00:00
|
|
|
const peakPosition = peaks.length * (currentTime / 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={currentTime}
|
|
|
|
aria-valuemin={0}
|
|
|
|
aria-valuemax={duration}
|
|
|
|
aria-valuetext={timeToText(currentTime)}
|
|
|
|
>
|
|
|
|
{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>
|
2021-03-10 20:36:58 +00:00
|
|
|
);
|
|
|
|
|
2021-03-16 00:59:48 +00:00
|
|
|
let button: React.ReactElement;
|
2021-04-15 21:02:24 +00:00
|
|
|
if (state === State.Pending || state === State.Computing) {
|
2021-03-16 00:59:48 +00:00
|
|
|
// 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}
|
|
|
|
buttonRef={buttonRef}
|
|
|
|
mod="download"
|
|
|
|
label="MessageAudio--download"
|
|
|
|
onClick={kickOffAttachmentDownload}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
} else {
|
|
|
|
// State.Normal
|
|
|
|
button = (
|
|
|
|
<Button
|
|
|
|
i18n={i18n}
|
|
|
|
buttonRef={buttonRef}
|
|
|
|
mod={isPlaying ? 'pause' : 'play'}
|
|
|
|
label={isPlaying ? 'MessageAudio--pause' : 'MessageAudio--play'}
|
|
|
|
onClick={toggleIsPlaying}
|
|
|
|
/>
|
|
|
|
);
|
|
|
|
}
|
2021-03-10 20:36:58 +00:00
|
|
|
|
2021-07-16 18:05:11 +00:00
|
|
|
const countDown = Math.max(0, duration - currentTime);
|
2021-03-22 18:15:59 +00:00
|
|
|
|
2021-07-09 20:27:16 +00:00
|
|
|
const metadata = (
|
|
|
|
<div className={`${CSS_BASE}__metadata`}>
|
|
|
|
{!withContentBelow && (
|
|
|
|
<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}
|
|
|
|
/>
|
|
|
|
)}
|
2021-07-27 15:42:25 +00:00
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
`${CSS_BASE}__countdown`,
|
|
|
|
`${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
|
|
|
|
)}
|
|
|
|
>
|
|
|
|
{timeToText(countDown)}
|
|
|
|
</div>
|
2021-07-09 20:27:16 +00:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
|
2021-03-10 20:36:58 +00:00
|
|
|
return (
|
|
|
|
<div
|
|
|
|
className={classNames(
|
|
|
|
CSS_BASE,
|
|
|
|
`${CSS_BASE}--${direction}`,
|
|
|
|
withContentBelow ? `${CSS_BASE}--with-content-below` : null,
|
|
|
|
withContentAbove ? `${CSS_BASE}--with-content-above` : null
|
|
|
|
)}
|
|
|
|
>
|
2021-07-09 20:27:16 +00:00
|
|
|
<div className={`${CSS_BASE}__button-and-waveform`}>
|
|
|
|
{button}
|
|
|
|
{waveform}
|
|
|
|
</div>
|
|
|
|
{metadata}
|
2021-03-10 20:36:58 +00:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
};
|