From 3d4248e070085967103cfe65ebeaf41e7ea3efa3 Mon Sep 17 00:00:00 2001 From: Alvaro <110414366+alvaro-signal@users.noreply.github.com> Date: Tue, 28 Feb 2023 06:07:40 -0700 Subject: [PATCH] Fixes to voice notes playback --- ts/components/MiniPlayer.tsx | 15 +- ts/components/VoiceNotesPlaybackContext.tsx | 10 +- ts/components/conversation/MessageAudio.tsx | 28 +- .../conversation/TimelineMessage.stories.tsx | 9 +- ts/services/globalMessageAudio.ts | 33 ++- ts/state/ducks/audioPlayer.ts | 259 +++++------------- ts/state/smart/MessageAudio.tsx | 4 +- ts/state/smart/MiniPlayer.tsx | 39 +-- ts/state/smart/VoiceNotesPlaybackProvider.tsx | 162 ++++++++++- 9 files changed, 285 insertions(+), 274 deletions(-) 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 ; +}