diff --git a/ts/components/MiniPlayer.tsx b/ts/components/MiniPlayer.tsx
index ce92ec95cd..185ffb4b25 100644
--- a/ts/components/MiniPlayer.tsx
+++ b/ts/components/MiniPlayer.tsx
@@ -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({
·
-
- {durationToPlaybackText(
- state === PlayerState.loading ? duration : currentTime
- )}
-
+ {duration !== undefined && (
+
+ {durationToPlaybackText(
+ state === PlayerState.loading ? duration : currentTime
+ )}
+
+ )}
doComputePeaks(url, barCount));
inProgressMap.set(computeKey, promise);
@@ -178,10 +181,7 @@ export const VoiceNotesPlaybackContext =
React.createContext(globalContents);
export type VoiceNotesPlaybackProps = {
- conversationId: string | undefined;
- isPaused: boolean;
children?: React.ReactNode | React.ReactChildren;
- unloadMessageAudio: () => void;
};
/**
diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx
index c8a0f1f7b4..ba1662eaf7 100644
--- a/ts/components/conversation/MessageAudio.tsx
+++ b/ts/components/conversation/MessageAudio.tsx
@@ -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>(
@@ -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();
diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx
index 726ab5d49a..5fccff06b1 100644
--- a/ts/components/conversation/TimelineMessage.stories.tsx
+++ b/ts/components/conversation/TimelineMessage.stories.tsx
@@ -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}
/>
diff --git a/ts/services/globalMessageAudio.ts b/ts/services/globalMessageAudio.ts
index 08433687a7..f6ae6ed06f 100644
--- a/ts/services/globalMessageAudio.ts
+++ b/ts/services/globalMessageAudio.ts
@@ -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 {
- 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;
}
diff --git a/ts/state/ducks/audioPlayer.ts b/ts/state/ducks/audioPlayer.ts
index 56f511eaf6..bdef46c013 100644
--- a/ts/state/ducks/audioPlayer.ts
+++ b/ts/state/ducks/audioPlayer.ts
@@ -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;
+ 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 {
}
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,
};
}
diff --git a/ts/state/smart/MessageAudio.tsx b/ts/state/smart/MessageAudio.tsx
index bb4f029972..d7209a4df6 100644
--- a/ts/state/smart/MessageAudio.tsx
+++ b/ts/state/smart/MessageAudio.tsx
@@ -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}
/>
diff --git a/ts/state/smart/MiniPlayer.tsx b/ts/state/smart/MiniPlayer.tsx
index fa14ec51b0..158ec9bfcc 100644
--- a/ts/state/smart/MiniPlayer.tsx
+++ b/ts/state/smart/MiniPlayer.tsx
@@ -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;
}
diff --git a/ts/state/smart/VoiceNotesPlaybackProvider.tsx b/ts/state/smart/VoiceNotesPlaybackProvider.tsx
index 118be27153..5e186bbd6e 100644
--- a/ts/state/smart/VoiceNotesPlaybackProvider.tsx
+++ b/ts/state/smart/VoiceNotesPlaybackProvider.tsx
@@ -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 ;
+}