Fixes to voice notes playback
This commit is contained in:
parent
fad0529080
commit
3d4248e070
9 changed files with 285 additions and 274 deletions
|
@ -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">·</span>
|
||||
<span>
|
||||
{durationToPlaybackText(
|
||||
state === PlayerState.loading ? duration : currentTime
|
||||
)}
|
||||
</span>
|
||||
{duration !== undefined && (
|
||||
<span>
|
||||
{durationToPlaybackText(
|
||||
state === PlayerState.loading ? duration : currentTime
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PlaybackRateButton
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -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} />;
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue