diff --git a/sounds/state-change_confirm-down.ogg b/sounds/state-change_confirm-down.ogg new file mode 100755 index 000000000..b2b5d58fc Binary files /dev/null and b/sounds/state-change_confirm-down.ogg differ diff --git a/sounds/state-change_confirm-up.ogg b/sounds/state-change_confirm-up.ogg new file mode 100755 index 000000000..36dd7e99b Binary files /dev/null and b/sounds/state-change_confirm-up.ogg differ diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 845705847..4e030043a 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -37,3 +37,10 @@ global.window = { // For ducks/network.getEmptyState() global.navigator = {}; global.WebSocket = {}; + +// For GlobalAudioContext.tsx +/* eslint max-classes-per-file: ["error", 2] */ +global.AudioContext = class {}; +global.Audio = class { + addEventListener() {} +}; diff --git a/ts/components/GlobalAudioContext.tsx b/ts/components/GlobalAudioContext.tsx index 9453dd617..cd2cfff84 100644 --- a/ts/components/GlobalAudioContext.tsx +++ b/ts/components/GlobalAudioContext.tsx @@ -1,4 +1,4 @@ -// Copyright 2021 Signal Messenger, LLC +// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; @@ -18,7 +18,6 @@ export type ComputePeaksResult = { }; export type Contents = { - audio: HTMLAudioElement; computePeaks(url: string, barCount: number): Promise; }; @@ -168,7 +167,6 @@ export async function computePeaks( } const globalContents: Contents = { - audio: new Audio(), computePeaks, }; @@ -178,6 +176,7 @@ export type GlobalAudioProps = { conversationId: string | undefined; isPaused: boolean; children?: React.ReactNode | React.ReactChildren; + unloadMessageAudio: () => void; }; /** @@ -186,22 +185,15 @@ export type GlobalAudioProps = { */ export const GlobalAudioProvider: React.FC = ({ conversationId, - isPaused, children, + unloadMessageAudio, }) => { // When moving between conversations - stop audio React.useEffect(() => { return () => { - globalContents.audio.pause(); + unloadMessageAudio(); }; - }, [conversationId]); - - // Pause when requested by parent - React.useEffect(() => { - if (isPaused) { - globalContents.audio.pause(); - } - }, [isPaused]); + }, [conversationId, unloadMessageAudio]); return ( diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 93c847199..a848c4bca 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -116,24 +116,99 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({ const renderReactionPicker: Props['renderReactionPicker'] = () =>
; -const MessageAudioContainer: React.FC = props => { - const [active, setActive] = React.useState<{ - id?: string; - context?: string; - }>({}); - const audio = React.useMemo(() => new Audio(), []); +/** + * It doesn't handle consecutive playback + * since that logic mostly lives in the audioPlayer duck + */ +const MessageAudioContainer: React.FC = ({ + played, + ...props +}) => { + const [isActive, setIsActive] = React.useState(false); + const [currentTime, setCurrentTime] = React.useState(0); + const [playbackRate, setPlaybackRate] = React.useState(1); + const [playing, setPlaying] = React.useState(false); + const [_played, setPlayed] = React.useState(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 ( setActive({ id, context })} - onFirstPlayed={action('onFirstPlayed')} - activeAudioID={active.id} - activeAudioContext={active.context} + active={active} + played={_played} + loadAndPlayMessageAudio={loadAndPlayMessageAudio} + onFirstPlayed={setPlayedAction} + setIsPlaying={setIsPlayingAction} + setPlaybackRate={setPlaybackRateAction} + setCurrentTime={setCurrentTimeAction} /> ); }; @@ -1263,6 +1338,7 @@ export const _Audio = (): JSX.Element => { contentType: AUDIO_MP3, fileName: 'incompetech-com-Agnus-Dei-X.mp3', url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', + path: 'somepath', }), ], ...(isPlayed @@ -1305,6 +1381,7 @@ LongAudio.args = { contentType: AUDIO_MP3, fileName: 'long-audio.mp3', url: '/fixtures/long-audio.mp3', + path: 'somepath', }), ], status: 'sent', @@ -1317,6 +1394,7 @@ AudioWithCaption.args = { contentType: AUDIO_MP3, fileName: 'incompetech-com-Agnus-Dei-X.mp3', url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3', + path: 'somepath', }), ], status: 'sent', diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index fd7a49548..b7f15ab93 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -170,6 +170,7 @@ export type AudioAttachmentProps = { expirationLength?: number; expirationTimestamp?: number; id: string; + conversationId: string; played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; @@ -898,6 +899,7 @@ export class Message extends React.PureComponent { expirationTimestamp, i18n, id, + conversationId, isSticker, kickOffAttachmentDownload, markAttachmentAsCorrupted, @@ -1044,6 +1046,7 @@ export class Message extends React.PureComponent { expirationLength, expirationTimestamp, id, + conversationId, played, showMessageDetail, status, diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index ae3d6a2a7..3adec475f 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -1,28 +1,22 @@ // Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { - useRef, - useEffect, - useState, - useReducer, - useCallback, -} from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import classNames from 'classnames'; import { noop } from 'lodash'; -import { assertDev } from '../../util/assert'; import type { LocalizerType } from '../../types/Util'; import type { AttachmentType } from '../../types/Attachment'; import { isDownloaded } from '../../types/Attachment'; -import { missingCaseError } from '../../util/missingCaseError'; import type { DirectionType, MessageStatusType } from './Message'; import type { ComputePeaksResult } from '../GlobalAudioContext'; import { MessageMetadata } from './MessageMetadata'; 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; i18n: LocalizerType; attachment: AttachmentType; @@ -35,25 +29,33 @@ export type Props = { expirationLength?: number; expirationTimestamp?: number; id: string; + conversationId: string; played: boolean; showMessageDetail: (id: string) => void; status?: MessageStatusType; textPending?: boolean; timestamp: number; - - // See: GlobalAudioContext.tsx - audio: HTMLAudioElement; - buttonRef: React.RefObject; kickOffAttachmentDownload(): void; onCorrupted(): void; onFirstPlayed(): void; - computePeaks(url: string, barCount: number): Promise; - 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 = { i18n: LocalizerType; @@ -142,45 +144,6 @@ const Button: React.FC = 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 * toggle Play/Pause button. @@ -196,10 +159,12 @@ function reducer(state: StateType, action: ActionType): StateType { */ export const MessageAudio: React.FC = (props: Props) => { const { + active, i18n, renderingContext, attachment, collapseMetadata, + conversationId, withContentAbove, withContentBelow, @@ -217,52 +182,25 @@ export const MessageAudio: React.FC = (props: Props) => { kickOffAttachmentDownload, onCorrupted, onFirstPlayed, - - audio, computePeaks, - - activeAudioID, - activeAudioContext, - setActiveAudioID, + setPlaybackRate, + loadAndPlayMessageAudio, + setCurrentTime, + setIsPlaying, } = props; - assertDev(audio != null, 'GlobalAudioContext always provides audio'); - - const isActive = - activeAudioID === id && activeAudioContext === renderingContext; - const waveformRef = useRef(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( - (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] - ); + const isPlaying = active?.playing ?? false; + // if it's playing, use the duration passed as props as it might + // change during loading/playback (?) // 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 [peaks, setPeaks] = useState>( @@ -334,122 +272,23 @@ export const MessageAudio: React.FC = (props: Props) => { state, ]); - // This effect attaches/detaches event listeners to the global