Fixes to voice notes playback

This commit is contained in:
Alvaro 2023-02-28 06:07:40 -07:00 committed by GitHub
parent fad0529080
commit 3d4248e070
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 285 additions and 274 deletions

View file

@ -18,7 +18,8 @@ export type Props = Readonly<{
i18n: LocalizerType;
title: string;
currentTime: number;
duration: number;
// not available until audio has loaded
duration: number | undefined;
playbackRate: number;
state: PlayerState;
onPlay: () => void;
@ -91,11 +92,13 @@ export function MiniPlayer({
<div className="MiniPlayer__state">
<Emojify text={title} />
<span className="MiniPlayer__middot">&middot;</span>
<span>
{durationToPlaybackText(
state === PlayerState.loading ? duration : currentTime
)}
</span>
{duration !== undefined && (
<span>
{durationToPlaybackText(
state === PlayerState.loading ? duration : currentTime
)}
</span>
)}
</div>
<PlaybackRateButton

View file

@ -155,11 +155,14 @@ export async function computePeaks(
const pending = inProgressMap.get(computeKey);
if (pending) {
log.info('GlobalAudioContext: already computing peaks for', computeKey);
log.info(
'VoiceNotesPlaybackContext: already computing peaks for',
computeKey
);
return pending;
}
log.info('GlobalAudioContext: queue computing peaks for', computeKey);
log.info('VoiceNotesPlaybackContext: queue computing peaks for', computeKey);
const promise = computeQueue.add(() => doComputePeaks(url, barCount));
inProgressMap.set(computeKey, promise);
@ -178,10 +181,7 @@ export const VoiceNotesPlaybackContext =
React.createContext<Contents>(globalContents);
export type VoiceNotesPlaybackProps = {
conversationId: string | undefined;
isPaused: boolean;
children?: React.ReactNode | React.ReactChildren;
unloadMessageAudio: () => void;
};
/**

View file

@ -51,7 +51,7 @@ export type OwnProps = Readonly<{
export type DispatchProps = Readonly<{
pushPanelForConversation: PushPanelForConversationActionType;
setCurrentTime: (currentTime: number) => void;
setPosition: (positionAsRatio: number) => void;
setPlaybackRate: (rate: number) => void;
setIsPlaying: (value: boolean) => void;
}>;
@ -226,7 +226,7 @@ export function MessageAudio(props: Props): JSX.Element {
setPlaybackRate,
onPlayMessage,
pushPanelForConversation,
setCurrentTime,
setPosition,
setIsPlaying,
} = props;
@ -239,11 +239,7 @@ export function MessageAudio(props: Props): JSX.Element {
// 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 [duration, setDuration] = useState(active?.duration ?? 1e-23);
const [hasPeaks, setHasPeaks] = useState(false);
const [peaks, setPeaks] = useState<ReadonlyArray<number>>(
@ -353,6 +349,14 @@ export function MessageAudio(props: Props): JSX.Element {
progress = 0;
}
if (active) {
setPosition(progress);
if (!active.playing) {
setIsPlaying(true);
}
return;
}
if (attachment.url) {
onPlayMessage(id, progress);
} else {
@ -385,12 +389,10 @@ export function MessageAudio(props: Props): JSX.Element {
return;
}
setCurrentTime(
Math.min(
Number.isNaN(duration) ? Infinity : duration,
Math.max(0, active.currentTime + increment)
)
);
const currentPosition = active.currentTime / duration;
const positionIncrement = increment / duration;
setPosition(currentPosition + positionIncrement);
if (!isPlaying) {
toggleIsPlaying();

View file

@ -189,9 +189,9 @@ function MessageAudioContainer({
setPlaying(value);
};
const setCurrentTimeAction = (value: number) => {
audio.currentTime = value;
setCurrentTime(currentTime);
const setPosition = (value: number) => {
audio.currentTime = value * audio.duration;
setCurrentTime(audio.currentTime);
};
const active = isActive
@ -203,11 +203,10 @@ function MessageAudioContainer({
{...props}
active={active}
computePeaks={computePeaks}
id="storybook"
onPlayMessage={handlePlayMessage}
played={_played}
pushPanelForConversation={action('pushPanelForConversation')}
setCurrentTime={setCurrentTimeAction}
setPosition={setPosition}
setIsPlaying={setIsPlayingAction}
setPlaybackRate={setPlaybackRateAction}
/>

View file

@ -9,11 +9,16 @@ import { noop } from 'lodash';
*/
class GlobalMessageAudio {
#audio: HTMLAudioElement = new Audio();
#url: string | undefined;
// true immediately after play() is called, even if still loading
#playing = false;
#onLoadedMetadata = noop;
#onTimeUpdate = noop;
#onEnded = noop;
#onDurationChange = noop;
#onError = noop;
constructor() {
// callbacks must be wrapped by function (not attached directly)
@ -29,40 +34,46 @@ class GlobalMessageAudio {
}
load({
src,
url,
playbackRate,
onLoadedMetadata,
onTimeUpdate,
onDurationChange,
onEnded,
onError,
}: {
src: string;
url: string;
playbackRate: number;
onLoadedMetadata: () => void;
onTimeUpdate: () => void;
onDurationChange: () => void;
onEnded: () => void;
onError: (error: unknown) => void;
}) {
this.#audio.pause();
this.#audio.currentTime = 0;
this.#url = url;
// update callbacks
this.#onLoadedMetadata = onLoadedMetadata;
this.#onTimeUpdate = onTimeUpdate;
this.#onDurationChange = onDurationChange;
this.#onEnded = onEnded;
this.#onError = onError;
// changing src resets the playback rate
this.#audio.src = src;
this.#audio.src = this.#url;
this.#audio.playbackRate = playbackRate;
}
play(): Promise<void> {
return this.#audio.play();
play(): void {
this.#playing = true;
this.#audio.play().catch(error => {
this.#onError(error);
});
}
pause(): void {
this.#audio.pause();
this.#playing = false;
}
get playbackRate() {
@ -73,6 +84,14 @@ class GlobalMessageAudio {
this.#audio.playbackRate = rate;
}
get playing() {
return this.#playing;
}
get url() {
return this.#url;
}
get duration() {
return this.#audio.duration;
}

View file

@ -5,10 +5,9 @@ import type { ThunkAction } from 'redux-thunk';
import type { ReadonlyDeep } from 'type-fest';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
import { Sound } from '../../util/Sound';
import type { StateType as RootStateType } from '../reducer';
import { setVoiceNotePlaybackRate, markViewed } from './conversations';
import { setVoiceNotePlaybackRate } from './conversations';
import { extractVoiceNoteForPlayback } from '../selectors/audioPlayer';
import type {
VoiceNoteAndConsecutiveForPlayback,
@ -23,14 +22,9 @@ import type {
ConversationChangedActionType,
} from './conversations';
import * as log from '../../logging/log';
import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import { globalMessageAudio } from '../../services/globalMessageAudio';
import { getUserConversationId } from '../selectors/user';
import { isAudio } from '../../types/Attachment';
import { getAttachmentUrlForPath } from '../selectors/message';
import { SeenStatus } from '../../MessageSeenStatus';
import { assertDev } from '../../util/assert';
// State
@ -44,15 +38,15 @@ export type AudioPlayerContent = ReadonlyDeep<{
// false on the first of a consecutive group
isConsecutive: boolean;
ourConversationId: string | undefined;
startPosition: number;
}>;
export type ActiveAudioPlayerStateType = ReadonlyDeep<{
playing: boolean;
currentTime: number;
playbackRate: number;
duration: number;
content: AudioPlayerContent | undefined;
duration: number | undefined; // never zero or NaN
startPosition: number;
content: AudioPlayerContent;
}>;
export type AudioPlayerStateType = ReadonlyDeep<{
@ -94,18 +88,18 @@ type CurrentTimeUpdated = ReadonlyDeep<{
payload: number;
}>;
type SetPosition = ReadonlyDeep<{
type: 'audioPlayer/SET_POSITION';
payload: number;
}>;
type MessageAudioEnded = ReadonlyDeep<{
type: 'audioPlayer/MESSAGE_AUDIO_ENDED';
}>;
type DurationChanged = ReadonlyDeep<{
type: 'audioPlayer/DURATION_CHANGED';
payload: number;
}>;
type UpdateQueueAction = ReadonlyDeep<{
type: 'audioPlayer/UPDATE_QUEUE';
payload: ReadonlyArray<VoiceNoteForPlayback>;
payload: number | undefined;
}>;
type AudioPlayerActionType = ReadonlyDeep<
@ -115,33 +109,58 @@ type AudioPlayerActionType = ReadonlyDeep<
| MessageAudioEnded
| CurrentTimeUpdated
| DurationChanged
| UpdateQueueAction
| SetPosition
>;
// Action Creators
export const actions = {
loadMessageAudio,
playMessageAudio,
setPlaybackRate,
setCurrentTime,
currentTimeUpdated,
durationChanged,
setIsPlaying,
setPosition,
pauseVoiceNotePlayer,
unloadMessageAudio,
messageAudioEnded,
};
function messageAudioEnded(): MessageAudioEnded {
return {
type: 'audioPlayer/MESSAGE_AUDIO_ENDED',
};
}
function durationChanged(value: number | undefined): DurationChanged {
assertDev(
!Number.isNaN(value) && (value === undefined || value > 0),
`Duration must be > 0 if defined, got ${value}`
);
return {
type: 'audioPlayer/DURATION_CHANGED',
payload: value,
};
}
export const useAudioPlayerActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function setCurrentTime(value: number): CurrentTimeUpdated {
globalMessageAudio.currentTime = value;
function currentTimeUpdated(value: number): CurrentTimeUpdated {
return {
type: 'audioPlayer/CURRENT_TIME_UPDATED',
payload: value,
};
}
function setPosition(positionAsRatio: number): SetPosition {
return {
type: 'audioPlayer/SET_POSITION',
payload: positionAsRatio,
};
}
function setPlaybackRate(
rate: number
): ThunkAction<
@ -153,13 +172,10 @@ function setPlaybackRate(
return (dispatch, getState) => {
const { audioPlayer } = getState();
const { active } = audioPlayer;
if (!active?.content) {
if (!active) {
log.warn('audioPlayer.setPlaybackRate: No active message audio');
return;
}
globalMessageAudio.playbackRate = rate;
dispatch({
type: 'audioPlayer/SET_PLAYBACK_RATE',
payload: rate,
@ -176,117 +192,6 @@ function setPlaybackRate(
};
}
const stateChangeConfirmUpSound = new Sound({
src: 'sounds/state-change_confirm-up.ogg',
});
const stateChangeConfirmDownSound = new Sound({
src: 'sounds/state-change_confirm-down.ogg',
});
/** plays a message that has been loaded into content */
function playMessageAudio(
playConsecutiveSound: boolean
): ThunkAction<
void,
RootStateType,
unknown,
CurrentTimeUpdated | SetIsPlayingAction | DurationChanged | MessageAudioEnded
> {
return (dispatch, getState) => {
const ourConversationId = getUserConversationId(getState());
if (!ourConversationId) {
log.error('playMessageAudio: No ourConversationId');
return;
}
const { audioPlayer } = getState();
const { active } = audioPlayer;
if (!active) {
log.error('playMessageAudio: Not active');
return;
}
const { content } = active;
if (!content) {
log.error('playMessageAudio: No message audio loaded');
return;
}
const { current } = content;
if (!current.url) {
log.error('playMessageAudio: pending download');
return;
}
if (playConsecutiveSound) {
void stateChangeConfirmUpSound.play();
}
// set source to new message and start playing
globalMessageAudio.load({
src: current.url,
playbackRate: active.playbackRate,
onTimeUpdate: () => {
dispatch({
type: 'audioPlayer/CURRENT_TIME_UPDATED',
payload: globalMessageAudio.currentTime,
});
},
onLoadedMetadata: () => {
strictAssert(
!Number.isNaN(globalMessageAudio.duration),
'Audio should have definite duration on `loadedmetadata` event'
);
log.info('playMessageAudio: `loadedmetadata` event', current.id);
dispatch(
setCurrentTime(content.startPosition * globalMessageAudio.duration)
);
dispatch(setIsPlaying(true));
},
onDurationChange: () => {
log.info('playMessageAudio: `durationchange` event', current.id);
if (!Number.isNaN(globalMessageAudio.duration)) {
dispatch({
type: 'audioPlayer/DURATION_CHANGED',
payload: Math.max(globalMessageAudio.duration, 1e-23),
});
}
},
onEnded: () => {
const { audioPlayer: innerAudioPlayer } = getState();
const { active: innerActive } = innerAudioPlayer;
if (
innerActive?.content?.isConsecutive &&
innerActive.content?.queue.length === 0
) {
void stateChangeConfirmDownSound.play();
}
dispatch({ type: 'audioPlayer/MESSAGE_AUDIO_ENDED' });
},
});
if (!current.isPlayed) {
const message = getState().conversations.messagesLookup[current.id];
if (message && message.seenStatus !== SeenStatus.Unseen) {
markViewed(current.id);
}
} else {
log.info('audioPlayer.loadMessageAudio: message already played', {
message: current.messageIdForLogging,
});
}
};
}
/**
* Load message audio into the "content", the smart MiniPlayer will then play it
*/
@ -324,32 +229,10 @@ function loadMessageAudio({
};
}
export function setIsPlaying(
value: boolean
): ThunkAction<
void,
RootStateType,
unknown,
SetMessageAudioAction | SetIsPlayingAction
> {
return (dispatch, getState) => {
if (!value) {
globalMessageAudio.pause();
} else {
const { audioPlayer } = getState();
globalMessageAudio.play().catch(error => {
log.error(
'MessageAudio: resume error',
audioPlayer.active?.content?.current.id,
Errors.toLogFormat(error)
);
dispatch(unloadMessageAudio());
});
}
dispatch({
type: 'audioPlayer/SET_IS_PLAYING',
payload: value,
});
function setIsPlaying(value: boolean): SetIsPlayingAction {
return {
type: 'audioPlayer/SET_IS_PLAYING',
payload: value,
};
}
@ -362,7 +245,6 @@ export function pauseVoiceNotePlayer(): ReturnType<typeof setIsPlaying> {
}
export function unloadMessageAudio(): SetMessageAudioAction {
globalMessageAudio.pause();
return {
type: 'audioPlayer/SET_MESSAGE_AUDIO',
payload: undefined,
@ -392,15 +274,17 @@ export function reducer(
return {
...state,
active: {
// defaults
playing: false,
currentTime: 0,
duration: 0,
...active,
playbackRate: payload?.playbackRate ?? 1,
content: payload,
},
active:
payload === undefined
? undefined
: {
currentTime: 0,
duration: undefined,
playing: true,
playbackRate: payload.playbackRate,
content: payload,
startPosition: payload.startPosition,
},
};
}
@ -443,6 +327,19 @@ export function reducer(
};
}
if (action.type === 'audioPlayer/SET_POSITION') {
if (!active) {
return state;
}
return {
...state,
active: {
...active,
startPosition: action.payload,
},
};
}
if (action.type === 'audioPlayer/SET_PLAYBACK_RATE') {
if (!active) {
return state;
@ -548,12 +445,12 @@ export function reducer(
...state,
active: {
...active,
startPosition: 0,
content: {
...content,
current: nextVoiceNote,
queue: newQueue,
isConsecutive: true,
startPosition: 0,
},
},
};
@ -561,10 +458,7 @@ export function reducer(
return {
...state,
active: {
...active,
content: undefined,
},
active: undefined,
};
}
@ -581,10 +475,6 @@ export function reducer(
}
const { content } = active;
if (!content) {
return state;
}
// if we deleted the message currently being played
// move on to the next message
if (content.current.id === id) {
@ -593,10 +483,7 @@ export function reducer(
if (!next) {
return {
...state,
active: {
...active,
content: undefined,
},
active: undefined,
};
}

View file

@ -24,7 +24,7 @@ export function SmartMessageAudio({
...props
}: Props): JSX.Element | null {
const active = useSelector(selectAudioPlayerActive);
const { loadMessageAudio, setIsPlaying, setPlaybackRate, setCurrentTime } =
const { loadMessageAudio, setIsPlaying, setPlaybackRate, setPosition } =
useAudioPlayerActions();
const { pushPanelForConversation } = useConversationsActions();
@ -71,7 +71,7 @@ export function SmartMessageAudio({
onPlayMessage={handlePlayMessage}
setPlaybackRate={setPlaybackRate}
setIsPlaying={setIsPlaying}
setCurrentTime={setCurrentTime}
setPosition={setPosition}
pushPanelForConversation={pushPanelForConversation}
{...props}
/>

View file

@ -1,10 +1,9 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { MiniPlayer, PlayerState } from '../../components/MiniPlayer';
import { usePrevious } from '../../hooks/usePrevious';
import { useAudioPlayerActions } from '../ducks/audioPlayer';
import {
selectAudioPlayerActive,
@ -22,42 +21,12 @@ export function SmartMiniPlayer(): JSX.Element | null {
const i18n = useSelector(getIntl);
const active = useSelector(selectAudioPlayerActive);
const getVoiceNoteTitle = useSelector(selectVoiceNoteTitle);
const {
setIsPlaying,
setPlaybackRate,
unloadMessageAudio,
playMessageAudio,
} = useAudioPlayerActions();
const { setIsPlaying, setPlaybackRate, unloadMessageAudio } =
useAudioPlayerActions();
const handlePlay = useCallback(() => setIsPlaying(true), [setIsPlaying]);
const handlePause = useCallback(() => setIsPlaying(false), [setIsPlaying]);
const previousContent = usePrevious(undefined, active?.content);
useEffect(() => {
if (!active) {
return;
}
const { content } = active;
// if no content, stop playing
if (!content) {
if (active.playing) {
setIsPlaying(false);
}
return;
}
// if the content changed, play the new content
if (content.current.id !== previousContent?.current.id) {
playMessageAudio(content.isConsecutive);
}
// if the start position changed, play at new position
if (content.startPosition !== previousContent?.startPosition) {
playMessageAudio(false);
}
});
if (!active?.content) {
if (!active) {
return null;
}

View file

@ -1,22 +1,154 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions';
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import type { VoiceNotesPlaybackProps } from '../../components/VoiceNotesPlaybackContext';
import { VoiceNotesPlaybackProvider } from '../../components/VoiceNotesPlaybackContext';
import type { StateType } from '../reducer';
import { getSelectedConversationId } from '../selectors/conversations';
import { isPaused } from '../selectors/audioPlayer';
import { selectAudioPlayerActive } from '../selectors/audioPlayer';
import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { globalMessageAudio } from '../../services/globalMessageAudio';
import { strictAssert } from '../../util/assert';
import * as log from '../../logging/log';
import { Sound } from '../../util/Sound';
import { getConversations } from '../selectors/conversations';
import { SeenStatus } from '../../MessageSeenStatus';
import { markViewed } from '../ducks/conversations';
import * as Errors from '../../types/errors';
import { usePrevious } from '../../hooks/usePrevious';
const mapStateToProps = (state: StateType) => {
return {
conversationId: getSelectedConversationId(state),
isPaused: isPaused(state),
};
};
const stateChangeConfirmUpSound = new Sound({
src: 'sounds/state-change_confirm-up.ogg',
});
const stateChangeConfirmDownSound = new Sound({
src: 'sounds/state-change_confirm-down.ogg',
});
const smart = connect(mapStateToProps, mapDispatchToProps);
/**
* Synchronizes the audioPlayer redux state with globalMessageAudio
*/
export function SmartVoiceNotesPlaybackProvider(
props: VoiceNotesPlaybackProps
): JSX.Element | null {
const active = useSelector(selectAudioPlayerActive);
const conversations = useSelector(getConversations);
export const SmartVoiceNotesPlaybackProvider = smart(
VoiceNotesPlaybackProvider
);
const previousStartPosition = usePrevious(undefined, active?.startPosition);
const content = active?.content;
const current = content?.current;
const url = current?.url;
const {
messageAudioEnded,
currentTimeUpdated,
durationChanged,
unloadMessageAudio,
} = useAudioPlayerActions();
useEffect(() => {
// if we don't have a new audio source
// just control playback
if (!content || !current || !url || url === globalMessageAudio.url) {
if (!active?.playing && globalMessageAudio.playing) {
globalMessageAudio.pause();
}
if (active?.playing && !globalMessageAudio.playing) {
globalMessageAudio.play();
}
if (active && active.playbackRate !== globalMessageAudio.playbackRate) {
globalMessageAudio.playbackRate = active.playbackRate;
}
if (
active &&
active.startPosition !== undefined &&
active.startPosition !== previousStartPosition
) {
globalMessageAudio.currentTime =
active.startPosition * globalMessageAudio.duration;
}
return;
}
// otherwise we have a new audio source
// we just load it and play it
globalMessageAudio.load({
url,
playbackRate: active.playbackRate,
onLoadedMetadata() {
strictAssert(
!Number.isNaN(globalMessageAudio.duration),
'Audio should have definite duration on `loadedmetadata` event'
);
log.info(
'SmartVoiceNotesPlaybackProvider: `loadedmetadata` event',
current.id
);
if (active.startPosition !== 0) {
globalMessageAudio.currentTime =
active.startPosition * globalMessageAudio.duration;
}
},
onDurationChange() {
log.info(
'SmartVoiceNotesPlaybackProvider: `durationchange` event',
current.id
);
const reportedDuration = globalMessageAudio.duration;
// the underlying Audio element can return NaN if the audio hasn't loaded
// we filter out 0 or NaN as they are not useful values downstream
const newDuration =
Number.isNaN(reportedDuration) || reportedDuration === 0
? undefined
: reportedDuration;
durationChanged(newDuration);
},
onTimeUpdate() {
currentTimeUpdated(globalMessageAudio.currentTime);
},
onEnded() {
if (content.isConsecutive && content.queue.length === 0) {
void stateChangeConfirmDownSound.play();
}
messageAudioEnded();
},
onError(error) {
log.error(
'SmartVoiceNotesPlaybackProvider: playback error',
current.messageIdForLogging,
Errors.toLogFormat(error)
);
unloadMessageAudio();
},
});
// if this message was part of the queue (consecutive, added indirectly)
// we play a note to let the user we're onto a new message
// (false for the first message in a consecutive group, since the user initiated it)
if (content.isConsecutive) {
// eslint-disable-next-line more/no-then
void stateChangeConfirmUpSound.play().then(() => {
globalMessageAudio.play();
});
} else {
globalMessageAudio.play();
}
if (!current.isPlayed) {
const message = conversations.messagesLookup[current.id];
if (message && message.seenStatus !== SeenStatus.Unseen) {
markViewed(current.id);
}
} else {
log.info('SmartVoiceNotesPlaybackProvider: message already played', {
message: current.messageIdForLogging,
});
}
});
return <VoiceNotesPlaybackProvider {...props} />;
}