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
BIN
sounds/state-change_confirm-down.ogg
Executable file
BIN
sounds/state-change_confirm-down.ogg
Executable file
Binary file not shown.
BIN
sounds/state-change_confirm-up.ogg
Executable file
BIN
sounds/state-change_confirm-up.ogg
Executable file
Binary file not shown.
|
@ -37,3 +37,10 @@ global.window = {
|
||||||
// For ducks/network.getEmptyState()
|
// For ducks/network.getEmptyState()
|
||||||
global.navigator = {};
|
global.navigator = {};
|
||||||
global.WebSocket = {};
|
global.WebSocket = {};
|
||||||
|
|
||||||
|
// For GlobalAudioContext.tsx
|
||||||
|
/* eslint max-classes-per-file: ["error", 2] */
|
||||||
|
global.AudioContext = class {};
|
||||||
|
global.Audio = class {
|
||||||
|
addEventListener() {}
|
||||||
|
};
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
@ -18,7 +18,6 @@ export type ComputePeaksResult = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Contents = {
|
export type Contents = {
|
||||||
audio: HTMLAudioElement;
|
|
||||||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -168,7 +167,6 @@ export async function computePeaks(
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalContents: Contents = {
|
const globalContents: Contents = {
|
||||||
audio: new Audio(),
|
|
||||||
computePeaks,
|
computePeaks,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -178,6 +176,7 @@ export type GlobalAudioProps = {
|
||||||
conversationId: string | undefined;
|
conversationId: string | undefined;
|
||||||
isPaused: boolean;
|
isPaused: boolean;
|
||||||
children?: React.ReactNode | React.ReactChildren;
|
children?: React.ReactNode | React.ReactChildren;
|
||||||
|
unloadMessageAudio: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -186,22 +185,15 @@ export type GlobalAudioProps = {
|
||||||
*/
|
*/
|
||||||
export const GlobalAudioProvider: React.FC<GlobalAudioProps> = ({
|
export const GlobalAudioProvider: React.FC<GlobalAudioProps> = ({
|
||||||
conversationId,
|
conversationId,
|
||||||
isPaused,
|
|
||||||
children,
|
children,
|
||||||
|
unloadMessageAudio,
|
||||||
}) => {
|
}) => {
|
||||||
// When moving between conversations - stop audio
|
// When moving between conversations - stop audio
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
globalContents.audio.pause();
|
unloadMessageAudio();
|
||||||
};
|
};
|
||||||
}, [conversationId]);
|
}, [conversationId, unloadMessageAudio]);
|
||||||
|
|
||||||
// Pause when requested by parent
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (isPaused) {
|
|
||||||
globalContents.audio.pause();
|
|
||||||
}
|
|
||||||
}, [isPaused]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalAudioContext.Provider value={globalContents}>
|
<GlobalAudioContext.Provider value={globalContents}>
|
||||||
|
|
|
@ -116,24 +116,99 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({
|
||||||
|
|
||||||
const renderReactionPicker: Props['renderReactionPicker'] = () => <div />;
|
const renderReactionPicker: Props['renderReactionPicker'] = () => <div />;
|
||||||
|
|
||||||
const MessageAudioContainer: React.FC<AudioAttachmentProps> = props => {
|
/**
|
||||||
const [active, setActive] = React.useState<{
|
* It doesn't handle consecutive playback
|
||||||
id?: string;
|
* since that logic mostly lives in the audioPlayer duck
|
||||||
context?: string;
|
*/
|
||||||
}>({});
|
const MessageAudioContainer: React.FC<AudioAttachmentProps> = ({
|
||||||
const audio = React.useMemo(() => new Audio(), []);
|
played,
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const [isActive, setIsActive] = React.useState<boolean>(false);
|
||||||
|
const [currentTime, setCurrentTime] = React.useState<number>(0);
|
||||||
|
const [playbackRate, setPlaybackRate] = React.useState<number>(1);
|
||||||
|
const [playing, setPlaying] = React.useState<boolean>(false);
|
||||||
|
const [_played, setPlayed] = React.useState<boolean>(played);
|
||||||
|
|
||||||
|
const audio = React.useMemo(() => {
|
||||||
|
const a = new Audio();
|
||||||
|
|
||||||
|
a.addEventListener('timeupdate', () => {
|
||||||
|
setCurrentTime(a.currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
a.addEventListener('ended', () => {
|
||||||
|
setIsActive(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
a.addEventListener('loadeddata', () => {
|
||||||
|
a.currentTime = currentTime;
|
||||||
|
});
|
||||||
|
|
||||||
|
return a;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadAndPlayMessageAudio = (
|
||||||
|
_id: string,
|
||||||
|
url: string,
|
||||||
|
_context: string,
|
||||||
|
position: number
|
||||||
|
) => {
|
||||||
|
if (!active) {
|
||||||
|
audio.src = url;
|
||||||
|
setIsActive(true);
|
||||||
|
}
|
||||||
|
if (!playing) {
|
||||||
|
audio.play();
|
||||||
|
setPlaying(true);
|
||||||
|
}
|
||||||
|
audio.currentTime = audio.duration * position;
|
||||||
|
if (!Number.isNaN(audio.currentTime)) {
|
||||||
|
setCurrentTime(audio.currentTime);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPlaybackRateAction = (_conversationId: string, rate: number) => {
|
||||||
|
audio.playbackRate = rate;
|
||||||
|
setPlaybackRate(rate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setIsPlayingAction = (value: boolean) => {
|
||||||
|
if (value) {
|
||||||
|
audio.play();
|
||||||
|
} else {
|
||||||
|
audio.pause();
|
||||||
|
}
|
||||||
|
setPlaying(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const setCurrentTimeAction = (value: number) => {
|
||||||
|
audio.currentTime = value;
|
||||||
|
setCurrentTime(currentTime);
|
||||||
|
};
|
||||||
|
|
||||||
|
const active = isActive
|
||||||
|
? { playing, playbackRate, currentTime, duration: audio.duration }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const setPlayedAction = () => {
|
||||||
|
setPlayed(true);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageAudio
|
<MessageAudio
|
||||||
{...props}
|
{...props}
|
||||||
id="storybook"
|
id="storybook"
|
||||||
renderingContext="storybook"
|
renderingContext="storybook"
|
||||||
audio={audio}
|
|
||||||
computePeaks={computePeaks}
|
computePeaks={computePeaks}
|
||||||
setActiveAudioID={(id, context) => setActive({ id, context })}
|
active={active}
|
||||||
onFirstPlayed={action('onFirstPlayed')}
|
played={_played}
|
||||||
activeAudioID={active.id}
|
loadAndPlayMessageAudio={loadAndPlayMessageAudio}
|
||||||
activeAudioContext={active.context}
|
onFirstPlayed={setPlayedAction}
|
||||||
|
setIsPlaying={setIsPlayingAction}
|
||||||
|
setPlaybackRate={setPlaybackRateAction}
|
||||||
|
setCurrentTime={setCurrentTimeAction}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -1263,6 +1338,7 @@ export const _Audio = (): JSX.Element => {
|
||||||
contentType: AUDIO_MP3,
|
contentType: AUDIO_MP3,
|
||||||
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
|
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
|
||||||
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
||||||
|
path: 'somepath',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
...(isPlayed
|
...(isPlayed
|
||||||
|
@ -1305,6 +1381,7 @@ LongAudio.args = {
|
||||||
contentType: AUDIO_MP3,
|
contentType: AUDIO_MP3,
|
||||||
fileName: 'long-audio.mp3',
|
fileName: 'long-audio.mp3',
|
||||||
url: '/fixtures/long-audio.mp3',
|
url: '/fixtures/long-audio.mp3',
|
||||||
|
path: 'somepath',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
@ -1317,6 +1394,7 @@ AudioWithCaption.args = {
|
||||||
contentType: AUDIO_MP3,
|
contentType: AUDIO_MP3,
|
||||||
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
|
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
|
||||||
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
|
||||||
|
path: 'somepath',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
status: 'sent',
|
status: 'sent',
|
||||||
|
|
|
@ -170,6 +170,7 @@ export type AudioAttachmentProps = {
|
||||||
expirationLength?: number;
|
expirationLength?: number;
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
id: string;
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
played: boolean;
|
played: boolean;
|
||||||
showMessageDetail: (id: string) => void;
|
showMessageDetail: (id: string) => void;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
|
@ -898,6 +899,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
i18n,
|
i18n,
|
||||||
id,
|
id,
|
||||||
|
conversationId,
|
||||||
isSticker,
|
isSticker,
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
markAttachmentAsCorrupted,
|
markAttachmentAsCorrupted,
|
||||||
|
@ -1044,6 +1046,7 @@ export class Message extends React.PureComponent<Props, State> {
|
||||||
expirationLength,
|
expirationLength,
|
||||||
expirationTimestamp,
|
expirationTimestamp,
|
||||||
id,
|
id,
|
||||||
|
conversationId,
|
||||||
played,
|
played,
|
||||||
showMessageDetail,
|
showMessageDetail,
|
||||||
status,
|
status,
|
||||||
|
|
|
@ -1,28 +1,22 @@
|
||||||
// Copyright 2021-2022 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import React, {
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
useRef,
|
|
||||||
useEffect,
|
|
||||||
useState,
|
|
||||||
useReducer,
|
|
||||||
useCallback,
|
|
||||||
} from 'react';
|
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
import { assertDev } from '../../util/assert';
|
|
||||||
import type { LocalizerType } from '../../types/Util';
|
import type { LocalizerType } from '../../types/Util';
|
||||||
import type { AttachmentType } from '../../types/Attachment';
|
import type { AttachmentType } from '../../types/Attachment';
|
||||||
import { isDownloaded } from '../../types/Attachment';
|
import { isDownloaded } from '../../types/Attachment';
|
||||||
import { missingCaseError } from '../../util/missingCaseError';
|
|
||||||
import type { DirectionType, MessageStatusType } from './Message';
|
import type { DirectionType, MessageStatusType } from './Message';
|
||||||
|
|
||||||
import type { ComputePeaksResult } from '../GlobalAudioContext';
|
import type { ComputePeaksResult } from '../GlobalAudioContext';
|
||||||
import { MessageMetadata } from './MessageMetadata';
|
import { MessageMetadata } from './MessageMetadata';
|
||||||
import * as log from '../../logging/log';
|
import * as log from '../../logging/log';
|
||||||
|
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
|
||||||
|
|
||||||
export type Props = {
|
export type OwnProps = Readonly<{
|
||||||
|
active: ActiveAudioPlayerStateType | undefined;
|
||||||
renderingContext: string;
|
renderingContext: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
@ -35,25 +29,33 @@ export type Props = {
|
||||||
expirationLength?: number;
|
expirationLength?: number;
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
id: string;
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
played: boolean;
|
played: boolean;
|
||||||
showMessageDetail: (id: string) => void;
|
showMessageDetail: (id: string) => void;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
textPending?: boolean;
|
textPending?: boolean;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
|
|
||||||
// See: GlobalAudioContext.tsx
|
|
||||||
audio: HTMLAudioElement;
|
|
||||||
|
|
||||||
buttonRef: React.RefObject<HTMLButtonElement>;
|
buttonRef: React.RefObject<HTMLButtonElement>;
|
||||||
kickOffAttachmentDownload(): void;
|
kickOffAttachmentDownload(): void;
|
||||||
onCorrupted(): void;
|
onCorrupted(): void;
|
||||||
onFirstPlayed(): void;
|
onFirstPlayed(): void;
|
||||||
|
|
||||||
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
|
||||||
activeAudioID: string | undefined;
|
}>;
|
||||||
activeAudioContext: string | undefined;
|
|
||||||
setActiveAudioID: (id: string | undefined, context: string) => void;
|
export type DispatchProps = Readonly<{
|
||||||
};
|
loadAndPlayMessageAudio: (
|
||||||
|
id: string,
|
||||||
|
url: string,
|
||||||
|
context: string,
|
||||||
|
position: number,
|
||||||
|
isConsecutive: boolean
|
||||||
|
) => void;
|
||||||
|
setCurrentTime: (currentTime: number) => void;
|
||||||
|
setPlaybackRate: (conversationId: string, rate: number) => void;
|
||||||
|
setIsPlaying: (value: boolean) => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type Props = OwnProps & DispatchProps;
|
||||||
|
|
||||||
type ButtonProps = {
|
type ButtonProps = {
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
|
@ -142,45 +144,6 @@ const Button: React.FC<ButtonProps> = props => {
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type StateType = Readonly<{
|
|
||||||
isPlaying: boolean;
|
|
||||||
currentTime: number;
|
|
||||||
lastAriaTime: number;
|
|
||||||
playbackRate: number;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
type ActionType = Readonly<
|
|
||||||
| {
|
|
||||||
type: 'SET_IS_PLAYING';
|
|
||||||
value: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'SET_CURRENT_TIME';
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'SET_PLAYBACK_RATE';
|
|
||||||
value: number;
|
|
||||||
}
|
|
||||||
>;
|
|
||||||
|
|
||||||
function reducer(state: StateType, action: ActionType): StateType {
|
|
||||||
if (action.type === 'SET_IS_PLAYING') {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isPlaying: action.value,
|
|
||||||
lastAriaTime: state.currentTime,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (action.type === 'SET_CURRENT_TIME') {
|
|
||||||
return { ...state, currentTime: action.value };
|
|
||||||
}
|
|
||||||
if (action.type === 'SET_PLAYBACK_RATE') {
|
|
||||||
return { ...state, playbackRate: action.value };
|
|
||||||
}
|
|
||||||
throw missingCaseError(action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display message audio attachment along with its waveform, duration, and
|
* Display message audio attachment along with its waveform, duration, and
|
||||||
* toggle Play/Pause button.
|
* toggle Play/Pause button.
|
||||||
|
@ -196,10 +159,12 @@ function reducer(state: StateType, action: ActionType): StateType {
|
||||||
*/
|
*/
|
||||||
export const MessageAudio: React.FC<Props> = (props: Props) => {
|
export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
const {
|
const {
|
||||||
|
active,
|
||||||
i18n,
|
i18n,
|
||||||
renderingContext,
|
renderingContext,
|
||||||
attachment,
|
attachment,
|
||||||
collapseMetadata,
|
collapseMetadata,
|
||||||
|
conversationId,
|
||||||
withContentAbove,
|
withContentAbove,
|
||||||
withContentBelow,
|
withContentBelow,
|
||||||
|
|
||||||
|
@ -217,52 +182,25 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
kickOffAttachmentDownload,
|
kickOffAttachmentDownload,
|
||||||
onCorrupted,
|
onCorrupted,
|
||||||
onFirstPlayed,
|
onFirstPlayed,
|
||||||
|
|
||||||
audio,
|
|
||||||
computePeaks,
|
computePeaks,
|
||||||
|
setPlaybackRate,
|
||||||
activeAudioID,
|
loadAndPlayMessageAudio,
|
||||||
activeAudioContext,
|
setCurrentTime,
|
||||||
setActiveAudioID,
|
setIsPlaying,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
assertDev(audio != null, 'GlobalAudioContext always provides audio');
|
|
||||||
|
|
||||||
const isActive =
|
|
||||||
activeAudioID === id && activeAudioContext === renderingContext;
|
|
||||||
|
|
||||||
const waveformRef = useRef<HTMLDivElement | null>(null);
|
const waveformRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [{ isPlaying, currentTime, lastAriaTime, playbackRate }, dispatch] =
|
|
||||||
useReducer(reducer, {
|
|
||||||
isPlaying: isActive && !(audio.paused || audio.ended),
|
|
||||||
currentTime: isActive ? audio.currentTime : 0,
|
|
||||||
lastAriaTime: isActive ? audio.currentTime : 0,
|
|
||||||
playbackRate: isActive ? audio.playbackRate : 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
const setIsPlaying = useCallback(
|
const isPlaying = active?.playing ?? false;
|
||||||
(value: boolean) => {
|
|
||||||
dispatch({ type: 'SET_IS_PLAYING', value });
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setCurrentTime = useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
dispatch({ type: 'SET_CURRENT_TIME', value });
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
const setPlaybackRate = useCallback(
|
|
||||||
(value: number) => {
|
|
||||||
dispatch({ type: 'SET_PLAYBACK_RATE', value });
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
// if it's playing, use the duration passed as props as it might
|
||||||
|
// change during loading/playback (?)
|
||||||
// NOTE: Avoid division by zero
|
// NOTE: Avoid division by zero
|
||||||
const [duration, setDuration] = useState(1e-23);
|
const activeDuration =
|
||||||
|
active?.duration && !Number.isNaN(active.duration)
|
||||||
|
? active.duration
|
||||||
|
: undefined;
|
||||||
|
const [duration, setDuration] = useState(activeDuration ?? 1e-23);
|
||||||
|
|
||||||
const [hasPeaks, setHasPeaks] = useState(false);
|
const [hasPeaks, setHasPeaks] = useState(false);
|
||||||
const [peaks, setPeaks] = useState<ReadonlyArray<number>>(
|
const [peaks, setPeaks] = useState<ReadonlyArray<number>>(
|
||||||
|
@ -334,122 +272,23 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
state,
|
state,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// This effect attaches/detaches event listeners to the global <audio/>
|
|
||||||
// instance that we reuse from the GlobalAudioContext.
|
|
||||||
//
|
|
||||||
// Audio playback changes `audio.currentTime` so we have to propagate this
|
|
||||||
// to the waveform UI.
|
|
||||||
//
|
|
||||||
// When audio ends - we have to change state and reset the position of the
|
|
||||||
// waveform.
|
|
||||||
useEffect(() => {
|
|
||||||
// Owner of Audio instance changed
|
|
||||||
if (!isActive) {
|
|
||||||
log.info('MessageAudio: pausing old owner', id);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(0);
|
|
||||||
return noop;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onTimeUpdate = () => {
|
|
||||||
setCurrentTime(audio.currentTime);
|
|
||||||
if (audio.currentTime > duration) {
|
|
||||||
setDuration(audio.currentTime);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onEnded = () => {
|
|
||||||
log.info('MessageAudio: ended, changing UI', id);
|
|
||||||
setIsPlaying(false);
|
|
||||||
setCurrentTime(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onLoadedMetadata = () => {
|
|
||||||
assertDev(
|
|
||||||
!Number.isNaN(audio.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
|
|
||||||
audio.currentTime = currentTime;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDurationChange = () => {
|
|
||||||
log.info('MessageAudio: `durationchange` event', id);
|
|
||||||
|
|
||||||
if (!Number.isNaN(audio.duration)) {
|
|
||||||
setDuration(Math.max(audio.duration, 1e-23));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
|
||||||
audio.addEventListener('ended', onEnded);
|
|
||||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
||||||
audio.addEventListener('durationchange', onDurationChange);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
|
||||||
audio.removeEventListener('ended', onEnded);
|
|
||||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
||||||
audio.removeEventListener('durationchange', onDurationChange);
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
id,
|
|
||||||
audio,
|
|
||||||
isActive,
|
|
||||||
currentTime,
|
|
||||||
duration,
|
|
||||||
setCurrentTime,
|
|
||||||
setIsPlaying,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// This effect detects `isPlaying` changes and starts/pauses playback when
|
|
||||||
// needed (+keeps waveform position and audio position in sync).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isActive) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
audio.playbackRate = playbackRate;
|
|
||||||
|
|
||||||
if (isPlaying) {
|
|
||||||
if (!audio.paused) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('MessageAudio: resuming playback for', id);
|
|
||||||
audio.currentTime = currentTime;
|
|
||||||
audio.play().catch(error => {
|
|
||||||
log.info('MessageAudio: resume error', id, error.stack || error);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
log.info('MessageAudio: pausing playback for', id);
|
|
||||||
audio.pause();
|
|
||||||
}
|
|
||||||
}, [id, audio, isActive, isPlaying, currentTime, playbackRate]);
|
|
||||||
|
|
||||||
const toggleIsPlaying = () => {
|
const toggleIsPlaying = () => {
|
||||||
setIsPlaying(!isPlaying);
|
if (!isPlaying) {
|
||||||
|
|
||||||
if (!isActive && !isPlaying) {
|
|
||||||
log.info('MessageAudio: changing owner', id);
|
|
||||||
setActiveAudioID(id, renderingContext);
|
|
||||||
|
|
||||||
// Pause old audio
|
|
||||||
if (!audio.paused) {
|
|
||||||
audio.pause();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!attachment.url) {
|
if (!attachment.url) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Expected attachment url in the MessageAudio with ' +
|
'Expected attachment url in the MessageAudio with ' +
|
||||||
`state: ${state}`
|
`state: ${state}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
audio.src = attachment.url;
|
|
||||||
|
if (active) {
|
||||||
|
setIsPlaying(true);
|
||||||
|
} else {
|
||||||
|
loadAndPlayMessageAudio(id, attachment.url, renderingContext, 0, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// stop
|
||||||
|
setIsPlaying(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -467,11 +306,6 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
if (state !== State.Normal) {
|
if (state !== State.Normal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlaying) {
|
|
||||||
toggleIsPlaying();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!waveformRef.current) {
|
if (!waveformRef.current) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -483,10 +317,16 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
progress = 0;
|
progress = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPlaying && !Number.isNaN(audio.duration)) {
|
if (attachment.url) {
|
||||||
audio.currentTime = audio.duration * progress;
|
loadAndPlayMessageAudio(
|
||||||
|
id,
|
||||||
|
attachment.url,
|
||||||
|
renderingContext,
|
||||||
|
progress,
|
||||||
|
false
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setCurrentTime(duration * progress);
|
log.warn('Waveform clicked on attachment with no url');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -511,13 +351,15 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
|
|
||||||
// There is no audio to rewind
|
// There is no audio to rewind
|
||||||
if (!isActive) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
audio.currentTime = Math.min(
|
setCurrentTime(
|
||||||
Number.isNaN(audio.duration) ? Infinity : audio.duration,
|
Math.min(
|
||||||
Math.max(0, audio.currentTime + increment)
|
Number.isNaN(duration) ? Infinity : duration,
|
||||||
|
Math.max(0, active.currentTime + increment)
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isPlaying) {
|
if (!isPlaying) {
|
||||||
|
@ -525,7 +367,9 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const peakPosition = peaks.length * (currentTime / duration);
|
const currentTimeOrZero = active?.currentTime ?? 0;
|
||||||
|
|
||||||
|
const peakPosition = peaks.length * (currentTimeOrZero / duration);
|
||||||
|
|
||||||
const waveform = (
|
const waveform = (
|
||||||
<div
|
<div
|
||||||
|
@ -537,10 +381,10 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
role="slider"
|
role="slider"
|
||||||
aria-label={i18n('MessageAudio--slider')}
|
aria-label={i18n('MessageAudio--slider')}
|
||||||
aria-orientation="horizontal"
|
aria-orientation="horizontal"
|
||||||
aria-valuenow={lastAriaTime}
|
aria-valuenow={currentTimeOrZero}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={duration}
|
aria-valuemax={duration}
|
||||||
aria-valuetext={timeToText(lastAriaTime)}
|
aria-valuetext={timeToText(currentTimeOrZero)}
|
||||||
>
|
>
|
||||||
{peaks.map((peak, i) => {
|
{peaks.map((peak, i) => {
|
||||||
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
|
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
|
||||||
|
@ -606,7 +450,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const countDown = Math.max(0, duration - currentTime);
|
const countDown = Math.max(0, duration - (active?.currentTime ?? 0));
|
||||||
|
|
||||||
const nextPlaybackRate = (currentRate: number): number => {
|
const nextPlaybackRate = (currentRate: number): number => {
|
||||||
// cycle through the rates
|
// cycle through the rates
|
||||||
|
@ -642,17 +486,20 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
|
||||||
`${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
|
`${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{isPlaying && (
|
{active && active.playing && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={classNames(`${CSS_BASE}__playback-rate-button`)}
|
className={classNames(`${CSS_BASE}__playback-rate-button`)}
|
||||||
onClick={ev => {
|
onClick={ev => {
|
||||||
ev.stopPropagation();
|
ev.stopPropagation();
|
||||||
setPlaybackRate(nextPlaybackRate(playbackRate));
|
setPlaybackRate(
|
||||||
|
conversationId,
|
||||||
|
nextPlaybackRate(active.playbackRate)
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{playbackRateLabels[playbackRate]}
|
{playbackRateLabels[active.playbackRate]}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -298,6 +298,7 @@ export type ConversationAttributesType = {
|
||||||
sealedSender?: unknown;
|
sealedSender?: unknown;
|
||||||
sentMessageCount?: number;
|
sentMessageCount?: number;
|
||||||
sharedGroupNames?: Array<string>;
|
sharedGroupNames?: Array<string>;
|
||||||
|
voiceNotePlaybackRate?: number;
|
||||||
|
|
||||||
id: string;
|
id: string;
|
||||||
type: ConversationAttributesTypeType;
|
type: ConversationAttributesTypeType;
|
||||||
|
|
|
@ -1871,6 +1871,7 @@ export class ConversationModel extends window.Backbone
|
||||||
this.get('acknowledgedGroupNameCollisions') || {},
|
this.get('acknowledgedGroupNameCollisions') || {},
|
||||||
sharedGroupNames: [],
|
sharedGroupNames: [],
|
||||||
}),
|
}),
|
||||||
|
voiceNotePlaybackRate: this.get('voiceNotePlaybackRate'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
85
ts/services/globalMessageAudio.ts
Normal file
85
ts/services/globalMessageAudio.ts
Normal file
|
@ -0,0 +1,85 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around a global HTMLAudioElement that can update the
|
||||||
|
* source and callbacks without requiring removeEventListener
|
||||||
|
*/
|
||||||
|
class GlobalMessageAudio {
|
||||||
|
#audio: HTMLAudioElement = new Audio();
|
||||||
|
|
||||||
|
#onLoadedMetadata = noop;
|
||||||
|
#onTimeUpdate = noop;
|
||||||
|
#onEnded = noop;
|
||||||
|
#onDurationChange = noop;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// callbacks must be wrapped by function (not attached directly)
|
||||||
|
// so changes to the callbacks are effected
|
||||||
|
this.#audio.addEventListener('loadedmetadata', () =>
|
||||||
|
this.#onLoadedMetadata()
|
||||||
|
);
|
||||||
|
this.#audio.addEventListener('timeupdate', () => this.#onTimeUpdate());
|
||||||
|
this.#audio.addEventListener('durationchange', () =>
|
||||||
|
this.#onDurationChange()
|
||||||
|
);
|
||||||
|
this.#audio.addEventListener('ended', () => this.#onEnded());
|
||||||
|
}
|
||||||
|
|
||||||
|
load({
|
||||||
|
src,
|
||||||
|
onLoadedMetadata,
|
||||||
|
onTimeUpdate,
|
||||||
|
onDurationChange,
|
||||||
|
onEnded,
|
||||||
|
}: {
|
||||||
|
src: string;
|
||||||
|
onLoadedMetadata: () => void;
|
||||||
|
onTimeUpdate: () => void;
|
||||||
|
onDurationChange: () => void;
|
||||||
|
onEnded: () => void;
|
||||||
|
}) {
|
||||||
|
this.#audio.pause();
|
||||||
|
this.#audio.currentTime = 0;
|
||||||
|
|
||||||
|
// update callbacks
|
||||||
|
this.#onLoadedMetadata = onLoadedMetadata;
|
||||||
|
this.#onTimeUpdate = onTimeUpdate;
|
||||||
|
this.#onDurationChange = onDurationChange;
|
||||||
|
this.#onEnded = onEnded;
|
||||||
|
|
||||||
|
this.#audio.src = src;
|
||||||
|
}
|
||||||
|
|
||||||
|
play(): Promise<void> {
|
||||||
|
return this.#audio.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
pause(): void {
|
||||||
|
this.#audio.pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
get playbackRate() {
|
||||||
|
return this.#audio.playbackRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
set playbackRate(rate: number) {
|
||||||
|
this.#audio.playbackRate = rate;
|
||||||
|
}
|
||||||
|
|
||||||
|
get duration() {
|
||||||
|
return this.#audio.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
get currentTime() {
|
||||||
|
return this.#audio.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
set currentTime(value: number) {
|
||||||
|
this.#audio.currentTime = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const globalMessageAudio = new GlobalMessageAudio();
|
|
@ -1,10 +1,14 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { ipcRenderer } from 'electron';
|
import { ipcRenderer } from 'electron';
|
||||||
import type { Middleware } from 'redux';
|
import type { Middleware } from 'redux';
|
||||||
|
|
||||||
import { COLORS_CHANGED, COLOR_SELECTED } from '../state/ducks/conversations';
|
import {
|
||||||
|
COLORS_CHANGED,
|
||||||
|
COLOR_SELECTED,
|
||||||
|
SET_VOICE_NOTE_PLAYBACK_RATE,
|
||||||
|
} from '../state/ducks/conversations';
|
||||||
|
|
||||||
export const dispatchItemsMiddleware: Middleware =
|
export const dispatchItemsMiddleware: Middleware =
|
||||||
({ getState }) =>
|
({ getState }) =>
|
||||||
|
@ -18,7 +22,8 @@ export const dispatchItemsMiddleware: Middleware =
|
||||||
action.type === 'items/REMOVE_EXTERNAL' ||
|
action.type === 'items/REMOVE_EXTERNAL' ||
|
||||||
action.type === 'items/RESET' ||
|
action.type === 'items/RESET' ||
|
||||||
action.type === COLOR_SELECTED ||
|
action.type === COLOR_SELECTED ||
|
||||||
action.type === COLORS_CHANGED
|
action.type === COLORS_CHANGED ||
|
||||||
|
action.type === SET_VOICE_NOTE_PLAYBACK_RATE
|
||||||
) {
|
) {
|
||||||
ipcRenderer.send('preferences-changed', getState().items);
|
ipcRenderer.send('preferences-changed', getState().items);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,58 +1,290 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { ThunkAction } from 'redux-thunk';
|
||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
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 {
|
import type {
|
||||||
MessageDeletedActionType,
|
MessageDeletedActionType,
|
||||||
MessageChangedActionType,
|
MessageChangedActionType,
|
||||||
SelectedConversationChangedActionType,
|
SelectedConversationChangedActionType,
|
||||||
|
ConversationChangedActionType,
|
||||||
} from './conversations';
|
} 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
|
// State
|
||||||
|
|
||||||
|
export type ActiveAudioPlayerStateType = {
|
||||||
|
readonly playing: boolean;
|
||||||
|
readonly currentTime: number;
|
||||||
|
readonly playbackRate: number;
|
||||||
|
readonly duration: number;
|
||||||
|
};
|
||||||
|
|
||||||
export type AudioPlayerStateType = {
|
export type AudioPlayerStateType = {
|
||||||
readonly activeAudioID: string | undefined;
|
readonly active:
|
||||||
readonly activeAudioContext: string | undefined;
|
| (ActiveAudioPlayerStateType & { id: string; context: string })
|
||||||
|
| undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
|
|
||||||
type SetActiveAudioIDAction = {
|
/**
|
||||||
type: 'audioPlayer/SET_ACTIVE_AUDIO_ID';
|
* Sets the current "active" message audio for a particular rendering "context"
|
||||||
payload: {
|
*/
|
||||||
id: string | undefined;
|
export type SetMessageAudioAction = {
|
||||||
context: string | undefined;
|
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
|
// Action Creators
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
setActiveAudioID,
|
loadAndPlayMessageAudio,
|
||||||
|
unloadMessageAudio,
|
||||||
|
setPlaybackRate,
|
||||||
|
setCurrentTime,
|
||||||
|
setIsPlaying,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useActions = (): typeof actions => useBoundActions(actions);
|
export const useActions = (): typeof actions => useBoundActions(actions);
|
||||||
|
|
||||||
function setActiveAudioID(
|
function setCurrentTime(value: number): CurrentTimeUpdated {
|
||||||
id: string | undefined,
|
globalMessageAudio.currentTime = value;
|
||||||
context: string
|
|
||||||
): SetActiveAudioIDAction {
|
|
||||||
return {
|
return {
|
||||||
type: 'audioPlayer/SET_ACTIVE_AUDIO_ID',
|
type: 'audioPlayer/CURRENT_TIME_UPDATED',
|
||||||
payload: { id, context },
|
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 {
|
export function getEmptyState(): AudioPlayerStateType {
|
||||||
return {
|
return {
|
||||||
activeAudioID: undefined,
|
active: undefined,
|
||||||
activeAudioContext: undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,13 +297,18 @@ export function reducer(
|
||||||
| SelectedConversationChangedActionType
|
| SelectedConversationChangedActionType
|
||||||
>
|
>
|
||||||
): AudioPlayerStateType {
|
): AudioPlayerStateType {
|
||||||
if (action.type === 'audioPlayer/SET_ACTIVE_AUDIO_ID') {
|
if (action.type === 'audioPlayer/SET_MESSAGE_AUDIO') {
|
||||||
const { payload } = action;
|
const { payload } = action;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
activeAudioID: payload.id,
|
active: payload
|
||||||
activeAudioContext: payload.context,
|
? {
|
||||||
|
...payload,
|
||||||
|
playing: true,
|
||||||
|
currentTime: 0,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -79,20 +316,75 @@ export function reducer(
|
||||||
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
if (action.type === SELECTED_CONVERSATION_CHANGED) {
|
||||||
return {
|
return {
|
||||||
...state,
|
...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.
|
// Reset activeAudioID on when played message is deleted on expiration.
|
||||||
if (action.type === 'MESSAGE_DELETED') {
|
if (action.type === 'MESSAGE_DELETED') {
|
||||||
const { id } = action.payload;
|
const { id } = action.payload;
|
||||||
if (state.activeAudioID !== id) {
|
if (state.active?.id !== id) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
activeAudioID: undefined,
|
active: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -100,7 +392,7 @@ export function reducer(
|
||||||
if (action.type === 'MESSAGE_CHANGED') {
|
if (action.type === 'MESSAGE_CHANGED') {
|
||||||
const { id, data } = action.payload;
|
const { id, data } = action.payload;
|
||||||
|
|
||||||
if (state.activeAudioID !== id) {
|
if (state.active?.id !== id) {
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -110,7 +402,7 @@ export function reducer(
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
activeAudioID: undefined,
|
active: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -209,6 +209,7 @@ export type ConversationType = {
|
||||||
publicParams?: string;
|
publicParams?: string;
|
||||||
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
|
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
|
||||||
profileKey?: string;
|
profileKey?: string;
|
||||||
|
voiceNotePlaybackRate?: number;
|
||||||
|
|
||||||
badges: Array<
|
badges: Array<
|
||||||
| {
|
| {
|
||||||
|
@ -402,6 +403,9 @@ const UPDATE_USERNAME_SAVE_STATE = 'conversations/UPDATE_USERNAME_SAVE_STATE';
|
||||||
export const SELECTED_CONVERSATION_CHANGED =
|
export const SELECTED_CONVERSATION_CHANGED =
|
||||||
'conversations/SELECTED_CONVERSATION_CHANGED';
|
'conversations/SELECTED_CONVERSATION_CHANGED';
|
||||||
|
|
||||||
|
export const SET_VOICE_NOTE_PLAYBACK_RATE =
|
||||||
|
'conversations/SET_VOICE_NOTE_PLAYBACK_RATE';
|
||||||
|
|
||||||
export type CancelVerificationDataByConversationActionType = {
|
export type CancelVerificationDataByConversationActionType = {
|
||||||
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
type: typeof CANCEL_CONVERSATION_PENDING_VERIFICATION;
|
||||||
payload: {
|
payload: {
|
||||||
|
@ -855,6 +859,7 @@ export const actions = {
|
||||||
setRecentMediaItems,
|
setRecentMediaItems,
|
||||||
setSelectedConversationHeaderTitle,
|
setSelectedConversationHeaderTitle,
|
||||||
setSelectedConversationPanelDepth,
|
setSelectedConversationPanelDepth,
|
||||||
|
setVoiceNotePlaybackRate,
|
||||||
showArchivedConversations,
|
showArchivedConversations,
|
||||||
showChooseGroupMembers,
|
showChooseGroupMembers,
|
||||||
showInbox,
|
showInbox,
|
||||||
|
@ -1270,6 +1275,42 @@ function resetAllChatColors(): ThunkAction<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update the conversation voice note playback rate preference for the conversation
|
||||||
|
export function setVoiceNotePlaybackRate({
|
||||||
|
conversationId,
|
||||||
|
rate,
|
||||||
|
}: {
|
||||||
|
conversationId: string;
|
||||||
|
rate: number;
|
||||||
|
}): ThunkAction<void, RootStateType, unknown, ConversationChangedActionType> {
|
||||||
|
return async dispatch => {
|
||||||
|
const conversationModel = window.ConversationController.get(conversationId);
|
||||||
|
if (conversationModel) {
|
||||||
|
if (rate === 1) {
|
||||||
|
delete conversationModel.attributes.voiceNotePlaybackRate;
|
||||||
|
} else {
|
||||||
|
conversationModel.attributes.voiceNotePlaybackRate = rate;
|
||||||
|
}
|
||||||
|
await window.Signal.Data.updateConversation(conversationModel.attributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversation = conversationModel?.format();
|
||||||
|
|
||||||
|
if (conversation) {
|
||||||
|
dispatch({
|
||||||
|
type: 'CONVERSATION_CHANGED',
|
||||||
|
payload: {
|
||||||
|
id: conversationId,
|
||||||
|
data: {
|
||||||
|
...conversation,
|
||||||
|
voiceNotePlaybackRate: rate,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function colorSelected({
|
function colorSelected({
|
||||||
conversationId,
|
conversationId,
|
||||||
conversationColor,
|
conversationColor,
|
||||||
|
|
|
@ -1,8 +1,68 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { collectFirst } from '../../util/iterables';
|
||||||
import type { StateType } from '../reducer';
|
import type { StateType } from '../reducer';
|
||||||
|
import { getConversations } from './conversations';
|
||||||
|
import { getPropsForAttachment } from './message';
|
||||||
|
|
||||||
export const isPaused = (state: StateType): boolean => {
|
export const isPaused = (state: StateType): boolean => {
|
||||||
return state.audioPlayer.activeAudioID === undefined;
|
return state.audioPlayer.active === undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const selectActiveVoiceNoteMessageId = (
|
||||||
|
state: StateType
|
||||||
|
): string | undefined => state.audioPlayer.active?.id;
|
||||||
|
|
||||||
|
export const selectNextConsecutiveVoiceNoteMessageId = createSelector(
|
||||||
|
getConversations,
|
||||||
|
selectActiveVoiceNoteMessageId,
|
||||||
|
(
|
||||||
|
conversations,
|
||||||
|
activeVoiceNoteMessageId
|
||||||
|
): { id: string; url: string } | undefined => {
|
||||||
|
if (!activeVoiceNoteMessageId) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentMessage =
|
||||||
|
conversations.messagesLookup[activeVoiceNoteMessageId];
|
||||||
|
const conversationMessages =
|
||||||
|
conversations.messagesByConversation[currentMessage.conversationId];
|
||||||
|
|
||||||
|
if (!conversationMessages) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = conversationMessages.messageIds.indexOf(
|
||||||
|
activeVoiceNoteMessageId
|
||||||
|
);
|
||||||
|
const nextIdx = idx + 1;
|
||||||
|
|
||||||
|
if (!(nextIdx in conversationMessages.messageIds)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextMessageId = conversationMessages.messageIds[nextIdx];
|
||||||
|
const nextMessage = conversations.messagesLookup[nextMessageId];
|
||||||
|
|
||||||
|
if (!nextMessage.attachments) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const voiceNoteUrl = collectFirst(
|
||||||
|
nextMessage.attachments.map(getPropsForAttachment),
|
||||||
|
a => (a && a.isVoiceMessage && a.url ? a.url : undefined)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!voiceNoteUrl) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nextMessageId,
|
||||||
|
url: voiceNoteUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
|
@ -2,8 +2,10 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
import { pick } from 'underscore';
|
||||||
|
|
||||||
import { MessageAudio } from '../../components/conversation/MessageAudio';
|
import { MessageAudio } from '../../components/conversation/MessageAudio';
|
||||||
|
import type { OwnProps as MessageAudioOwnProps } from '../../components/conversation/MessageAudio';
|
||||||
import type { ComputePeaksResult } from '../../components/GlobalAudioContext';
|
import type { ComputePeaksResult } from '../../components/GlobalAudioContext';
|
||||||
|
|
||||||
import { mapDispatchToProps } from '../actions';
|
import { mapDispatchToProps } from '../actions';
|
||||||
|
@ -14,10 +16,9 @@ import type {
|
||||||
DirectionType,
|
DirectionType,
|
||||||
MessageStatusType,
|
MessageStatusType,
|
||||||
} from '../../components/conversation/Message';
|
} from '../../components/conversation/Message';
|
||||||
|
import type { ActiveAudioPlayerStateType } from '../ducks/audioPlayer';
|
||||||
|
|
||||||
export type Props = {
|
export type Props = {
|
||||||
audio: HTMLAudioElement;
|
|
||||||
|
|
||||||
renderingContext: string;
|
renderingContext: string;
|
||||||
i18n: LocalizerType;
|
i18n: LocalizerType;
|
||||||
attachment: AttachmentType;
|
attachment: AttachmentType;
|
||||||
|
@ -29,6 +30,7 @@ export type Props = {
|
||||||
expirationLength?: number;
|
expirationLength?: number;
|
||||||
expirationTimestamp?: number;
|
expirationTimestamp?: number;
|
||||||
id: string;
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
played: boolean;
|
played: boolean;
|
||||||
showMessageDetail: (id: string) => void;
|
showMessageDetail: (id: string) => void;
|
||||||
status?: MessageStatusType;
|
status?: MessageStatusType;
|
||||||
|
@ -43,10 +45,21 @@ export type Props = {
|
||||||
onFirstPlayed(): void;
|
onFirstPlayed(): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = (state: StateType, props: Props) => {
|
const mapStateToProps = (
|
||||||
|
state: StateType,
|
||||||
|
props: Props
|
||||||
|
): MessageAudioOwnProps => {
|
||||||
|
const { active } = state.audioPlayer;
|
||||||
|
|
||||||
|
const messageActive: ActiveAudioPlayerStateType | undefined =
|
||||||
|
active &&
|
||||||
|
active.id === props.id &&
|
||||||
|
active.context === props.renderingContext
|
||||||
|
? pick(active, 'playing', 'playbackRate', 'currentTime', 'duration')
|
||||||
|
: undefined;
|
||||||
return {
|
return {
|
||||||
...props,
|
...props,
|
||||||
...state.audioPlayer,
|
active: messageActive,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
|
@ -7,7 +7,7 @@ import { GlobalAudioContext } from '../../components/GlobalAudioContext';
|
||||||
import type { Props as MessageAudioProps } from './MessageAudio';
|
import type { Props as MessageAudioProps } from './MessageAudio';
|
||||||
import { SmartMessageAudio } from './MessageAudio';
|
import { SmartMessageAudio } from './MessageAudio';
|
||||||
|
|
||||||
type AudioAttachmentProps = Omit<MessageAudioProps, 'audio' | 'computePeaks'>;
|
type AudioAttachmentProps = Omit<MessageAudioProps, 'computePeaks'>;
|
||||||
|
|
||||||
export function renderAudioAttachment(
|
export function renderAudioAttachment(
|
||||||
props: AudioAttachmentProps
|
props: AudioAttachmentProps
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
|
|
||||||
import { actions } from '../../../state/ducks/audioPlayer';
|
import type { SetMessageAudioAction } from '../../../state/ducks/audioPlayer';
|
||||||
import type { SelectedConversationChangedActionType } from '../../../state/ducks/conversations';
|
import type { SelectedConversationChangedActionType } from '../../../state/ducks/conversations';
|
||||||
import {
|
import {
|
||||||
SELECTED_CONVERSATION_CHANGED,
|
SELECTED_CONVERSATION_CHANGED,
|
||||||
|
@ -18,6 +18,20 @@ const { messageDeleted, messageChanged } = conversationsActions;
|
||||||
|
|
||||||
const MESSAGE_ID = 'message-id';
|
const MESSAGE_ID = 'message-id';
|
||||||
|
|
||||||
|
// can't use the actual action since it's a ThunkAction
|
||||||
|
const setMessageAudio = (
|
||||||
|
id: string,
|
||||||
|
context: string
|
||||||
|
): SetMessageAudioAction => ({
|
||||||
|
type: 'audioPlayer/SET_MESSAGE_AUDIO',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
context,
|
||||||
|
playbackRate: 1,
|
||||||
|
duration: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('both/state/ducks/audioPlayer', () => {
|
describe('both/state/ducks/audioPlayer', () => {
|
||||||
const getEmptyRootState = (): StateType => {
|
const getEmptyRootState = (): StateType => {
|
||||||
return rootReducer(undefined, noopAction());
|
return rootReducer(undefined, noopAction());
|
||||||
|
@ -25,14 +39,10 @@ describe('both/state/ducks/audioPlayer', () => {
|
||||||
|
|
||||||
const getInitializedState = (): StateType => {
|
const getInitializedState = (): StateType => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
|
const updated = rootReducer(state, setMessageAudio(MESSAGE_ID, 'context'));
|
||||||
|
|
||||||
const updated = rootReducer(
|
assert.strictEqual(updated.audioPlayer.active?.id, MESSAGE_ID);
|
||||||
state,
|
assert.strictEqual(updated.audioPlayer.active?.context, 'context');
|
||||||
actions.setActiveAudioID(MESSAGE_ID, 'context')
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioID, MESSAGE_ID);
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context');
|
|
||||||
|
|
||||||
return updated;
|
return updated;
|
||||||
};
|
};
|
||||||
|
@ -40,14 +50,11 @@ describe('both/state/ducks/audioPlayer', () => {
|
||||||
describe('setActiveAudioID', () => {
|
describe('setActiveAudioID', () => {
|
||||||
it("updates `activeAudioID` in the audioPlayer's state", () => {
|
it("updates `activeAudioID` in the audioPlayer's state", () => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
assert.strictEqual(state.audioPlayer.activeAudioID, undefined);
|
assert.strictEqual(state.audioPlayer.active, undefined);
|
||||||
|
|
||||||
const updated = rootReducer(
|
const updated = rootReducer(state, setMessageAudio('test', 'context'));
|
||||||
state,
|
assert.strictEqual(updated.audioPlayer.active?.id, 'test');
|
||||||
actions.setActiveAudioID('test', 'context')
|
assert.strictEqual(updated.audioPlayer.active?.context, 'context');
|
||||||
);
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioID, 'test');
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -59,8 +66,7 @@ describe('both/state/ducks/audioPlayer', () => {
|
||||||
payload: { id: 'any' },
|
payload: { id: 'any' },
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioID, undefined);
|
assert.strictEqual(updated.audioPlayer.active, undefined);
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets activeAudioID when message was deleted', () => {
|
it('resets activeAudioID when message was deleted', () => {
|
||||||
|
@ -71,8 +77,7 @@ describe('both/state/ducks/audioPlayer', () => {
|
||||||
messageDeleted(MESSAGE_ID, 'conversation-id')
|
messageDeleted(MESSAGE_ID, 'conversation-id')
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioID, undefined);
|
assert.strictEqual(updated.audioPlayer.active, undefined);
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('resets activeAudioID when message was erased', () => {
|
it('resets activeAudioID when message was erased', () => {
|
||||||
|
@ -92,7 +97,6 @@ describe('both/state/ducks/audioPlayer', () => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioID, undefined);
|
assert.strictEqual(updated.audioPlayer.active, undefined);
|
||||||
assert.strictEqual(updated.audioPlayer.activeAudioContext, 'context');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,31 +1,42 @@
|
||||||
// Copyright 2021 Signal Messenger, LLC
|
// Copyright 2021-2022 Signal Messenger, LLC
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { assert } from 'chai';
|
import { assert } from 'chai';
|
||||||
import { actions } from '../../../state/ducks/audioPlayer';
|
import type { SetMessageAudioAction } from '../../../state/ducks/audioPlayer';
|
||||||
import { noopAction } from '../../../state/ducks/noop';
|
import { noopAction } from '../../../state/ducks/noop';
|
||||||
import { isPaused } from '../../../state/selectors/audioPlayer';
|
import { isPaused } from '../../../state/selectors/audioPlayer';
|
||||||
import type { StateType } from '../../../state/reducer';
|
import type { StateType } from '../../../state/reducer';
|
||||||
import { reducer as rootReducer } from '../../../state/reducer';
|
import { reducer as rootReducer } from '../../../state/reducer';
|
||||||
|
|
||||||
|
// can't use the actual action since it's a ThunkAction
|
||||||
|
const setActiveAudioID = (
|
||||||
|
id: string,
|
||||||
|
context: string
|
||||||
|
): SetMessageAudioAction => ({
|
||||||
|
type: 'audioPlayer/SET_MESSAGE_AUDIO',
|
||||||
|
payload: {
|
||||||
|
id,
|
||||||
|
context,
|
||||||
|
playbackRate: 1,
|
||||||
|
duration: 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
describe('state/selectors/audioPlayer', () => {
|
describe('state/selectors/audioPlayer', () => {
|
||||||
const getEmptyRootState = (): StateType => {
|
const getEmptyRootState = (): StateType => {
|
||||||
return rootReducer(undefined, noopAction());
|
return rootReducer(undefined, noopAction());
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('isPaused', () => {
|
describe('isPaused', () => {
|
||||||
it('returns true if state.audioPlayer.activeAudioID is undefined', () => {
|
it('returns true if state.audioPlayer.active is undefined', () => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
assert.isTrue(isPaused(state));
|
assert.isTrue(isPaused(state));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false if state.audioPlayer.activeAudioID is not undefined', () => {
|
it('returns false if state.audioPlayer.active is not undefined', () => {
|
||||||
const state = getEmptyRootState();
|
const state = getEmptyRootState();
|
||||||
|
|
||||||
const updated = rootReducer(
|
const updated = rootReducer(state, setActiveAudioID('id', 'context'));
|
||||||
state,
|
|
||||||
actions.setActiveAudioID('id', 'context')
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.isFalse(isPaused(updated));
|
assert.isFalse(isPaused(updated));
|
||||||
});
|
});
|
||||||
|
|
|
@ -112,6 +112,16 @@ export function collect<T, S>(
|
||||||
return new CollectIterable(iterable, fn);
|
return new CollectIterable(iterable, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function collectFirst<T, S>(
|
||||||
|
iterable: Iterable<T>,
|
||||||
|
fn: (value: T) => S | undefined
|
||||||
|
): S | undefined {
|
||||||
|
for (const v of collect(iterable, fn)) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
class CollectIterable<T, S> implements Iterable<S> {
|
class CollectIterable<T, S> implements Iterable<S> {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly iterable: Iterable<T>,
|
private readonly iterable: Iterable<T>,
|
||||||
|
|
|
@ -69,6 +69,20 @@
|
||||||
"updated": "2018-09-15T00:16:19.197Z",
|
"updated": "2018-09-15T00:16:19.197Z",
|
||||||
"line": " Module['load'] = function load(f) {"
|
"line": " Module['load'] = function load(f) {"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/services/globalMessageAudio.ts",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2022-09-09T15:04:29.812Z",
|
||||||
|
"line": " load({"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"rule": "jQuery-load(",
|
||||||
|
"path": "ts/state/ducks/audioPlayer.ts",
|
||||||
|
"line": " globalMessageAudio.load({",
|
||||||
|
"reasonCategory": "falseMatch",
|
||||||
|
"updated": "2022-09-09T15:04:29.812Z"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"rule": "jQuery-load(",
|
"rule": "jQuery-load(",
|
||||||
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
|
"path": "components/mp3lameencoder/lib/Mp3LameEncoder.js",
|
||||||
|
|
Loading…
Add table
Reference in a new issue