Consecutive playback and per-conversation playback rate
This commit is contained in:
parent
eb10aafd7c
commit
6cfe2a09df
20 changed files with 783 additions and 319 deletions
|
@ -1,58 +1,290 @@
|
|||
// Copyright 2021 Signal Messenger, LLC
|
||||
// Copyright 2021-2022 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { ThunkAction } from 'redux-thunk';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
import { Sound } from '../../util/Sound';
|
||||
import * as Errors from '../../types/errors';
|
||||
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
import { selectNextConsecutiveVoiceNoteMessageId } from '../selectors/audioPlayer';
|
||||
import {
|
||||
getConversationByIdSelector,
|
||||
getSelectedConversationId,
|
||||
} from '../selectors/conversations';
|
||||
|
||||
import type {
|
||||
MessageDeletedActionType,
|
||||
MessageChangedActionType,
|
||||
SelectedConversationChangedActionType,
|
||||
ConversationChangedActionType,
|
||||
} from './conversations';
|
||||
import { SELECTED_CONVERSATION_CHANGED } from './conversations';
|
||||
import {
|
||||
SELECTED_CONVERSATION_CHANGED,
|
||||
setVoiceNotePlaybackRate,
|
||||
} from './conversations';
|
||||
import * as log from '../../logging/log';
|
||||
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { globalMessageAudio } from '../../services/globalMessageAudio';
|
||||
|
||||
// State
|
||||
|
||||
export type ActiveAudioPlayerStateType = {
|
||||
readonly playing: boolean;
|
||||
readonly currentTime: number;
|
||||
readonly playbackRate: number;
|
||||
readonly duration: number;
|
||||
};
|
||||
|
||||
export type AudioPlayerStateType = {
|
||||
readonly activeAudioID: string | undefined;
|
||||
readonly activeAudioContext: string | undefined;
|
||||
readonly active:
|
||||
| (ActiveAudioPlayerStateType & { id: string; context: string })
|
||||
| undefined;
|
||||
};
|
||||
|
||||
// Actions
|
||||
|
||||
type SetActiveAudioIDAction = {
|
||||
type: 'audioPlayer/SET_ACTIVE_AUDIO_ID';
|
||||
payload: {
|
||||
id: string | undefined;
|
||||
context: string | undefined;
|
||||
};
|
||||
/**
|
||||
* Sets the current "active" message audio for a particular rendering "context"
|
||||
*/
|
||||
export type SetMessageAudioAction = {
|
||||
type: 'audioPlayer/SET_MESSAGE_AUDIO';
|
||||
payload:
|
||||
| {
|
||||
id: string;
|
||||
context: string;
|
||||
playbackRate: number;
|
||||
duration: number;
|
||||
}
|
||||
| undefined;
|
||||
};
|
||||
|
||||
type AudioPlayerActionType = SetActiveAudioIDAction;
|
||||
type SetPlaybackRate = {
|
||||
type: 'audioPlayer/SET_PLAYBACK_RATE';
|
||||
payload: number;
|
||||
};
|
||||
|
||||
type SetIsPlayingAction = {
|
||||
type: 'audioPlayer/SET_IS_PLAYING';
|
||||
payload: boolean;
|
||||
};
|
||||
|
||||
type CurrentTimeUpdated = {
|
||||
type: 'audioPlayer/CURRENT_TIME_UPDATED';
|
||||
payload: number;
|
||||
};
|
||||
|
||||
type MessageAudioEnded = {
|
||||
type: 'audioPlayer/MESSAGE_AUDIO_ENDED';
|
||||
};
|
||||
|
||||
type DurationChanged = {
|
||||
type: 'audioPlayer/DURATION_CHANGED';
|
||||
payload: number;
|
||||
};
|
||||
|
||||
type AudioPlayerActionType =
|
||||
| SetMessageAudioAction
|
||||
| SetIsPlayingAction
|
||||
| SetPlaybackRate
|
||||
| MessageAudioEnded
|
||||
| CurrentTimeUpdated
|
||||
| DurationChanged;
|
||||
|
||||
// Action Creators
|
||||
|
||||
export const actions = {
|
||||
setActiveAudioID,
|
||||
loadAndPlayMessageAudio,
|
||||
unloadMessageAudio,
|
||||
setPlaybackRate,
|
||||
setCurrentTime,
|
||||
setIsPlaying,
|
||||
};
|
||||
|
||||
export const useActions = (): typeof actions => useBoundActions(actions);
|
||||
|
||||
function setActiveAudioID(
|
||||
id: string | undefined,
|
||||
context: string
|
||||
): SetActiveAudioIDAction {
|
||||
function setCurrentTime(value: number): CurrentTimeUpdated {
|
||||
globalMessageAudio.currentTime = value;
|
||||
return {
|
||||
type: 'audioPlayer/SET_ACTIVE_AUDIO_ID',
|
||||
payload: { id, context },
|
||||
type: 'audioPlayer/CURRENT_TIME_UPDATED',
|
||||
payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
// Reducer
|
||||
function setIsPlaying(value: boolean): SetIsPlayingAction {
|
||||
if (!value) {
|
||||
globalMessageAudio.pause();
|
||||
} else {
|
||||
globalMessageAudio.play();
|
||||
}
|
||||
return {
|
||||
type: 'audioPlayer/SET_IS_PLAYING',
|
||||
payload: value,
|
||||
};
|
||||
}
|
||||
|
||||
function setPlaybackRate(
|
||||
conversationId: string,
|
||||
rate: number
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
SetPlaybackRate | ConversationChangedActionType
|
||||
> {
|
||||
return dispatch => {
|
||||
globalMessageAudio.playbackRate = rate;
|
||||
dispatch({
|
||||
type: 'audioPlayer/SET_PLAYBACK_RATE',
|
||||
payload: rate,
|
||||
});
|
||||
|
||||
// update the preference for the conversation
|
||||
dispatch(
|
||||
setVoiceNotePlaybackRate({
|
||||
conversationId,
|
||||
rate,
|
||||
})
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function unloadMessageAudio(): SetMessageAudioAction {
|
||||
globalMessageAudio.pause();
|
||||
return {
|
||||
type: 'audioPlayer/SET_MESSAGE_AUDIO',
|
||||
payload: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const stateChangeConfirmUpSound = new Sound({
|
||||
src: 'sounds/state-change_confirm-up.ogg',
|
||||
});
|
||||
const stateChangeConfirmDownSound = new Sound({
|
||||
src: 'sounds/state-change_confirm-down.ogg',
|
||||
});
|
||||
|
||||
/**
|
||||
* @param isConsecutive Is this part of a consecutive group (not first though)
|
||||
*/
|
||||
function loadAndPlayMessageAudio(
|
||||
id: string,
|
||||
url: string,
|
||||
context: string,
|
||||
position: number,
|
||||
isConsecutive: boolean
|
||||
): ThunkAction<
|
||||
void,
|
||||
RootStateType,
|
||||
unknown,
|
||||
| SetMessageAudioAction
|
||||
| MessageAudioEnded
|
||||
| CurrentTimeUpdated
|
||||
| SetIsPlayingAction
|
||||
| DurationChanged
|
||||
> {
|
||||
return (dispatch, getState) => {
|
||||
// set source to new message and start playing
|
||||
globalMessageAudio.load({
|
||||
src: url,
|
||||
|
||||
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('MessageAudio: `loadedmetadata` event', id);
|
||||
|
||||
// Sync-up audio's time in case if <audio/> loaded its source after
|
||||
// user clicked on waveform
|
||||
if (getState().audioPlayer.active) {
|
||||
globalMessageAudio.currentTime =
|
||||
position * globalMessageAudio.duration;
|
||||
}
|
||||
},
|
||||
|
||||
onDurationChange: () => {
|
||||
log.info('MessageAudio: `durationchange` event', id);
|
||||
|
||||
if (!Number.isNaN(globalMessageAudio.duration)) {
|
||||
dispatch({
|
||||
type: 'audioPlayer/DURATION_CHANGED',
|
||||
payload: Math.max(globalMessageAudio.duration, 1e-23),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onEnded: () => {
|
||||
const nextVoiceNoteMessage = selectNextConsecutiveVoiceNoteMessageId(
|
||||
getState()
|
||||
);
|
||||
|
||||
dispatch({
|
||||
type: 'audioPlayer/MESSAGE_AUDIO_ENDED',
|
||||
});
|
||||
|
||||
// play the next message
|
||||
// for now we can just read the current conversation
|
||||
// this won't work when we allow a message to continue to play as the user
|
||||
// navigates away from the conversation
|
||||
// TODO: DESKTOP-4158
|
||||
if (nextVoiceNoteMessage) {
|
||||
stateChangeConfirmUpSound.play();
|
||||
dispatch(
|
||||
loadAndPlayMessageAudio(
|
||||
nextVoiceNoteMessage.id,
|
||||
nextVoiceNoteMessage.url,
|
||||
context,
|
||||
0,
|
||||
true
|
||||
)
|
||||
);
|
||||
} else if (isConsecutive) {
|
||||
stateChangeConfirmDownSound.play();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// set the playback rate to the stored value for the selected conversation
|
||||
const conversationId = getSelectedConversationId(getState());
|
||||
if (conversationId) {
|
||||
const conversation = getConversationByIdSelector(getState())(
|
||||
conversationId
|
||||
);
|
||||
globalMessageAudio.playbackRate =
|
||||
conversation?.voiceNotePlaybackRate ?? 1;
|
||||
}
|
||||
globalMessageAudio.play().catch(error => {
|
||||
log.error('MessageAudio: resume error', id, Errors.toLogFormat(error));
|
||||
dispatch(unloadMessageAudio());
|
||||
});
|
||||
|
||||
dispatch({
|
||||
type: 'audioPlayer/SET_MESSAGE_AUDIO',
|
||||
payload: {
|
||||
id,
|
||||
context,
|
||||
playbackRate: globalMessageAudio.playbackRate,
|
||||
duration: globalMessageAudio.duration,
|
||||
},
|
||||
});
|
||||
|
||||
dispatch(setIsPlaying(true));
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmptyState(): AudioPlayerStateType {
|
||||
return {
|
||||
activeAudioID: undefined,
|
||||
activeAudioContext: undefined,
|
||||
active: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -65,13 +297,18 @@ export function reducer(
|
|||
| SelectedConversationChangedActionType
|
||||
>
|
||||
): AudioPlayerStateType {
|
||||
if (action.type === 'audioPlayer/SET_ACTIVE_AUDIO_ID') {
|
||||
if (action.type === 'audioPlayer/SET_MESSAGE_AUDIO') {
|
||||
const { payload } = action;
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeAudioID: payload.id,
|
||||
activeAudioContext: payload.context,
|
||||
active: payload
|
||||
? {
|
||||
...payload,
|
||||
playing: true,
|
||||
currentTime: 0,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -79,20 +316,75 @@ export function reducer(
|
|||
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
||||
return {
|
||||
...state,
|
||||
activeAudioID: undefined,
|
||||
active: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'audioPlayer/CURRENT_TIME_UPDATED') {
|
||||
return {
|
||||
...state,
|
||||
active: state.active
|
||||
? {
|
||||
...state.active,
|
||||
currentTime: action.payload,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'audioPlayer/DURATION_CHANGED') {
|
||||
return {
|
||||
...state,
|
||||
active: state.active
|
||||
? {
|
||||
...state.active,
|
||||
duration: action.payload,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'audioPlayer/MESSAGE_AUDIO_ENDED') {
|
||||
return {
|
||||
...state,
|
||||
active: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'audioPlayer/SET_IS_PLAYING') {
|
||||
return {
|
||||
...state,
|
||||
active: state.active
|
||||
? {
|
||||
...state.active,
|
||||
playing: action.payload,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === 'audioPlayer/SET_PLAYBACK_RATE') {
|
||||
return {
|
||||
...state,
|
||||
active: state.active
|
||||
? {
|
||||
...state.active,
|
||||
playbackRate: action.payload,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Reset activeAudioID on when played message is deleted on expiration.
|
||||
if (action.type === 'MESSAGE_DELETED') {
|
||||
const { id } = action.payload;
|
||||
if (state.activeAudioID !== id) {
|
||||
if (state.active?.id !== id) {
|
||||
return state;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
activeAudioID: undefined,
|
||||
active: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -100,7 +392,7 @@ export function reducer(
|
|||
if (action.type === 'MESSAGE_CHANGED') {
|
||||
const { id, data } = action.payload;
|
||||
|
||||
if (state.activeAudioID !== id) {
|
||||
if (state.active?.id !== id) {
|
||||
return state;
|
||||
}
|
||||
|
||||
|
@ -110,7 +402,7 @@ export function reducer(
|
|||
|
||||
return {
|
||||
...state,
|
||||
activeAudioID: undefined,
|
||||
active: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue