Voice notes mini-player

This commit is contained in:
Alvaro 2023-02-24 16:18:57 -07:00 committed by GitHub
parent b5849f872a
commit 0e655ceeed
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 1599 additions and 487 deletions

View file

@ -34,6 +34,7 @@ export function AvatarLightbox({
media={[]}
saveAttachment={noop}
toggleForwardMessageModal={noop}
onMediaPlaybackStart={noop}
>
<AvatarPreview
avatarColor={avatarColor}

View file

@ -117,6 +117,7 @@ const createProps = (storyProps: Partial<PropsType> = {}): PropsType => ({
toggleSettings: action('toggle-settings'),
toggleSpeakerView: action('toggle-speaker-view'),
isConversationTooBigToRing: false,
pauseVoiceNotePlayer: action('pause-audio-player'),
});
export default {

View file

@ -100,6 +100,7 @@ export type PropsType = {
toggleSettings: () => void;
toggleSpeakerView: () => void;
isConversationTooBigToRing: boolean;
pauseVoiceNotePlayer: () => void;
};
type ActiveCallManagerPropsType = PropsType & {
@ -138,6 +139,7 @@ function ActiveCallManager({
toggleScreenRecordingPermissionsDialog,
toggleSettings,
toggleSpeakerView,
pauseVoiceNotePlayer,
}: ActiveCallManagerPropsType): JSX.Element {
const {
conversation,
@ -157,6 +159,9 @@ function ActiveCallManager({
}, [cancelCall, conversation.id]);
const joinActiveCall = useCallback(() => {
// pause any voice note playback
pauseVoiceNotePlayer();
startCall({
callMode: activeCall.callMode,
conversationId: conversation.id,
@ -169,6 +174,7 @@ function ActiveCallManager({
conversation.id,
hasLocalAudio,
hasLocalVideo,
pauseVoiceNotePlayer,
]);
const getGroupCallVideoFrameSourceForActiveCall = useCallback(

View file

@ -5,6 +5,7 @@ import * as React from 'react';
import { action } from '@storybook/addon-actions';
import { number } from '@storybook/addon-knobs';
import { noop } from 'lodash';
import enMessages from '../../_locales/en/messages.json';
import type { PropsType } from './Lightbox';
@ -62,6 +63,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
saveAttachment: action('saveAttachment'),
selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0),
toggleForwardMessageModal: action('toggleForwardMessageModal'),
onMediaPlaybackStart: noop,
});
export function Multimedia(): JSX.Element {

View file

@ -34,6 +34,7 @@ export type PropsType = {
saveAttachment: SaveAttachmentActionCreatorType;
selectedIndex?: number;
toggleForwardMessageModal: (messageId: string) => unknown;
onMediaPlaybackStart: () => void;
};
const ZOOM_SCALE = 3;
@ -60,6 +61,7 @@ export function Lightbox({
saveAttachment,
selectedIndex: initialSelectedIndex = 0,
toggleForwardMessageModal,
onMediaPlaybackStart,
}: PropsType): JSX.Element | null {
const [root, setRoot] = React.useState<HTMLElement | undefined>();
const [selectedIndex, setSelectedIndex] =
@ -204,11 +206,12 @@ export function Lightbox({
}
if (videoElement.paused) {
onMediaPlaybackStart();
void videoElement.play();
} else {
videoElement.pause();
}
}, [videoElement]);
}, [videoElement, onMediaPlaybackStart]);
useEffect(() => {
const div = document.createElement('div');

View file

@ -0,0 +1,96 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useEffect, useState } from 'react';
import { MiniPlayer, PlayerState } from './MiniPlayer';
import { setupI18n } from '../util/setupI18n';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const audio = new Audio();
audio.src = '/fixtures/incompetech-com-Agnus-Dei-X.mp3';
export default {
title: 'components/MiniPlayer',
component: MiniPlayer,
};
export function Basic(): JSX.Element {
const [active, setActive] = useState(false);
const [playerState, setPlayerState] = useState(PlayerState.loading);
const [playbackRate, setPlaybackRate] = useState(1);
const [currentTime, setCurrentTime] = useState(0);
const activate = () => {
setActive(true);
setTimeout(() => {
setPlayerState(PlayerState.playing);
void audio.play();
}, 2000);
};
const deactivate = () => {
setActive(false);
setPlayerState(PlayerState.loading);
audio.pause();
audio.currentTime = 0;
};
useEffect(() => {
const handleUpdateTime = () => {
setCurrentTime(audio.currentTime);
};
const handleEnded = () => {
deactivate();
};
audio.addEventListener('timeupdate', handleUpdateTime);
audio.addEventListener('ended', handleEnded);
return () => {
audio.removeEventListener('timeupdate', handleUpdateTime);
audio.removeEventListener('ended', handleEnded);
};
}, [setCurrentTime]);
const playAction = () => {
setPlayerState(PlayerState.playing);
void audio.play();
};
const pauseAction = () => {
setPlayerState(PlayerState.paused);
audio.pause();
};
const setPlaybackRateAction = (rate: number) => {
setPlaybackRate(rate);
audio.playbackRate = rate;
};
return (
<>
{active && (
<MiniPlayer
title="Paige Hall 😉"
i18n={i18n}
onPlay={playAction}
onPause={pauseAction}
onPlaybackRate={setPlaybackRateAction}
state={playerState}
currentTime={currentTime}
duration={Number.isFinite(audio.duration) ? audio.duration : 0}
playbackRate={playbackRate}
onClose={deactivate}
/>
)}
{!active && (
<button type="button" onClick={activate}>
Activate
</button>
)}
</>
);
}

View file

@ -0,0 +1,117 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React, { useCallback } from 'react';
import type { LocalizerType } from '../types/Util';
import { durationToPlaybackText } from '../util/durationToPlaybackText';
import { Emojify } from './conversation/Emojify';
import { PlaybackRateButton } from './PlaybackRateButton';
export enum PlayerState {
loading = 'loading',
playing = 'playing',
paused = 'paused',
}
export type Props = Readonly<{
i18n: LocalizerType;
title: string;
currentTime: number;
duration: number;
playbackRate: number;
state: PlayerState;
onPlay: () => void;
onPause: () => void;
onPlaybackRate: (rate: number) => void;
onClose: () => void;
}>;
export function MiniPlayer({
i18n,
title,
state,
currentTime,
duration,
playbackRate,
onPlay,
onPause,
onPlaybackRate,
onClose,
}: Props): JSX.Element {
const updatePlaybackRate = useCallback(() => {
onPlaybackRate(PlaybackRateButton.nextPlaybackRate(playbackRate));
}, [playbackRate, onPlaybackRate]);
const handleClick = useCallback(() => {
switch (state) {
case PlayerState.playing:
onPause();
break;
case PlayerState.paused:
onPlay();
break;
case PlayerState.loading:
break;
default:
throw new TypeError(`Missing case: ${state}`);
}
}, [state, onPause, onPlay]);
let label: string | undefined;
switch (state) {
case PlayerState.playing:
label = i18n('MessageAudio--pause');
break;
case PlayerState.paused:
label = i18n('MessageAudio--play');
break;
case PlayerState.loading:
label = i18n('MessageAudio--pending');
break;
default:
throw new TypeError(`Missing case ${state}`);
}
return (
<div className="MiniPlayer">
<button
type="button"
className={classNames(
'MiniPlayer__playback-button',
state === 'playing' && 'MiniPlayer__playback-button--pause',
state === 'paused' && 'MiniPlayer__playback-button--play',
state === 'loading' && 'MiniPlayer__playback-button--pending'
)}
onClick={handleClick}
aria-label={label}
disabled={state === PlayerState.loading}
/>
<div className="MiniPlayer__state">
<Emojify text={title} />
<span className="MiniPlayer__middot">&middot;</span>
<span>
{durationToPlaybackText(
state === PlayerState.loading ? duration : currentTime
)}
</span>
</div>
<PlaybackRateButton
i18n={i18n}
variant="mini-player"
playbackRate={playbackRate}
onClick={updatePlaybackRate}
visible={state === 'playing'}
/>
<button
type="button"
className="MiniPlayer__close-button"
onClick={onClose}
aria-label={i18n('close')}
/>
</div>
);
}

View file

@ -26,6 +26,7 @@ export type PropsType = {
onDelete: (story: StoryViewType) => unknown;
onForward: (storyId: string) => unknown;
onSave: (story: StoryViewType) => unknown;
onMediaPlaybackStart: () => void;
queueStoryDownload: (storyId: string) => unknown;
retryMessageSend: (messageId: string) => unknown;
viewStory: ViewStoryActionCreatorType;
@ -43,6 +44,7 @@ export function MyStories({
retryMessageSend,
viewStory,
hasViewReceiptSetting,
onMediaPlaybackStart,
}: PropsType): JSX.Element {
const [confirmDeleteStory, setConfirmDeleteStory] = useState<
StoryViewType | undefined
@ -94,6 +96,7 @@ export function MyStories({
key={story.messageId}
onForward={onForward}
onSave={onSave}
onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload}
retryMessageSend={retryMessageSend}
setConfirmDeleteStory={setConfirmDeleteStory}
@ -122,6 +125,7 @@ type StorySentPropsType = Pick<
| 'queueStoryDownload'
| 'retryMessageSend'
| 'viewStory'
| 'onMediaPlaybackStart'
> & {
setConfirmDeleteStory: (_: StoryViewType | undefined) => unknown;
story: StoryViewType;
@ -132,6 +136,7 @@ function StorySent({
i18n,
onForward,
onSave,
onMediaPlaybackStart,
queueStoryDownload,
retryMessageSend,
setConfirmDeleteStory,
@ -177,6 +182,7 @@ function StorySent({
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={story.messageId}
onMediaPlaybackStart={onMediaPlaybackStart}
/>
</div>
<div className="MyStories__story__details">

View file

@ -21,6 +21,7 @@ export type PropsType = {
myStories: Array<MyStoryType>;
onAddStory: () => unknown;
onClick: () => unknown;
onMediaPlaybackStart: () => void;
queueStoryDownload: (storyId: string) => unknown;
showToast: ShowToastActionCreatorType;
};
@ -35,6 +36,7 @@ export function MyStoryButton({
myStories,
onAddStory,
onClick,
onMediaPlaybackStart,
queueStoryDownload,
showToast,
}: PropsType): JSX.Element {
@ -190,6 +192,7 @@ export function MyStoryButton({
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={newestStory.messageId}
onMediaPlaybackStart={onMediaPlaybackStart}
/>
</div>
</div>

View file

@ -0,0 +1,107 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React, { useCallback, useState } from 'react';
import { animated, useSpring } from '@react-spring/web';
import type { LocalizerType } from '../types/Util';
const SPRING_CONFIG = {
mass: 0.5,
tension: 350,
friction: 20,
velocity: 0.01,
};
type Props = {
// undefined if not playing
playbackRate: number | undefined;
variant: 'message-outgoing' | 'message-incoming' | 'mini-player';
onClick: () => void;
visible?: boolean;
i18n: LocalizerType;
};
export function PlaybackRateButton({
playbackRate,
variant,
visible = true,
i18n,
onClick,
}: Props): JSX.Element {
const [isDown, setIsDown] = useState(false);
const [animProps] = useSpring(
{
config: SPRING_CONFIG,
to: isDown ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
},
[visible, isDown]
);
// Clicking button toggle playback
const onButtonClick = useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
event.preventDefault();
onClick();
},
[onClick]
);
// Keyboard playback toggle
const onButtonKeyDown = useCallback(
(event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== 'Space') {
return;
}
event.stopPropagation();
event.preventDefault();
onClick();
},
[onClick]
);
const playbackRateLabels: { [key: number]: string } = {
1: i18n('MessageAudio--playbackRate1'),
1.5: i18n('MessageAudio--playbackRate1p5'),
2: i18n('MessageAudio--playbackRate2'),
0.5: i18n('MessageAudio--playbackRatep5'),
};
const label = playbackRate
? playbackRateLabels[playbackRate].toString()
: undefined;
return (
<animated.div style={animProps}>
<button
type="button"
className={classNames(
'PlaybackRateButton',
`PlaybackRateButton--${variant}`
)}
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
onMouseDown={() => setIsDown(true)}
onMouseUp={() => setIsDown(false)}
onMouseLeave={() => setIsDown(false)}
aria-label={label}
tabIndex={0}
>
{label}
</button>
</animated.div>
);
}
const playbackRates = [1, 1.5, 2, 0.5];
PlaybackRateButton.nextPlaybackRate = (currentRate: number): number => {
// cycle through the rates
return playbackRates[
(playbackRates.indexOf(currentRate) + 1) % playbackRates.length
];
};

View file

@ -72,6 +72,7 @@ export type PropsType = {
string,
number
>;
onMediaPlaybackStart: () => void;
} & Pick<
StoriesSettingsModalPropsType,
| 'onHideMyStoriesFrom'
@ -138,6 +139,7 @@ export function SendStoryModal({
toggleGroupsForStorySend,
mostRecentActiveStoryTimestampByGroupOrDistributionList,
toggleSignalConnectionsModal,
onMediaPlaybackStart,
}: PropsType): JSX.Element {
const [page, setPage] = useState<PageType>(Page.SendStory);
@ -875,6 +877,7 @@ export function SendStoryModal({
label="label"
moduleClassName="SendStoryModal__story"
attachment={attachment}
onMediaPlaybackStart={onMediaPlaybackStart}
/>
</div>
<div className="SendStoryModal__top-bar">

View file

@ -39,6 +39,7 @@ export type PropsType = {
myStories: Array<MyStoryType>;
onForwardStory: (storyId: string) => unknown;
onSaveStory: (story: StoryViewType) => unknown;
onMediaPlaybackStart: () => void;
preferredWidthFromStorage: number;
queueStoryDownload: (storyId: string) => unknown;
renderStoryCreator: () => JSX.Element;
@ -67,6 +68,7 @@ export function Stories({
myStories,
onForwardStory,
onSaveStory,
onMediaPlaybackStart,
preferredWidthFromStorage,
queueStoryDownload,
renderStoryCreator,
@ -110,6 +112,7 @@ export function Stories({
onDelete={deleteStoryForEveryone}
onForward={onForwardStory}
onSave={onSaveStory}
onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload}
retryMessageSend={retryMessageSend}
viewStory={viewStory}
@ -134,6 +137,7 @@ export function Stories({
}
}}
onStoriesSettings={showStoriesSettings}
onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload}
showConversation={showConversation}
showToast={showToast}

View file

@ -64,6 +64,7 @@ export type PropsType = {
onAddStory: (file?: File) => unknown;
onMyStoriesClicked: () => unknown;
onStoriesSettings: () => unknown;
onMediaPlaybackStart: () => void;
queueStoryDownload: (storyId: string) => unknown;
showConversation: ShowConversationType;
showToast: ShowToastActionCreatorType;
@ -82,6 +83,7 @@ export function StoriesPane({
onAddStory,
onMyStoriesClicked,
onStoriesSettings,
onMediaPlaybackStart,
queueStoryDownload,
showConversation,
showToast,
@ -159,6 +161,7 @@ export function StoriesPane({
onClick={onMyStoriesClicked}
queueStoryDownload={queueStoryDownload}
showToast={showToast}
onMediaPlaybackStart={onMediaPlaybackStart}
/>
{renderedStories.map(story => (
<StoryListItem
@ -174,6 +177,7 @@ export function StoriesPane({
toggleStoriesView();
}}
onHideStory={toggleHideStories}
onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload}
story={story.storyView}
viewUserStories={viewUserStories}
@ -204,6 +208,7 @@ export function StoriesPane({
toggleStoriesView();
}}
onHideStory={toggleHideStories}
onMediaPlaybackStart={onMediaPlaybackStart}
queueStoryDownload={queueStoryDownload}
story={story.storyView}
viewUserStories={viewUserStories}

View file

@ -71,6 +71,7 @@ export type PropsType = {
| 'toggleGroupsForStorySend'
| 'mostRecentActiveStoryTimestampByGroupOrDistributionList'
| 'toggleSignalConnectionsModal'
| 'onMediaPlaybackStart'
> &
Pick<
TextStoryCreatorPropsType,
@ -104,6 +105,7 @@ export function StoryCreator({
onSetSkinTone,
onUseEmoji,
onViewersUpdated,
onMediaPlaybackStart,
ourConversationId,
processAttachment,
recentEmojis,
@ -194,6 +196,7 @@ export function StoryCreator({
setDraftAttachment(undefined);
}}
onViewersUpdated={onViewersUpdated}
onMediaPlaybackStart={onMediaPlaybackStart}
setMyStoriesToAllSignalConnections={
setMyStoriesToAllSignalConnections
}

View file

@ -4,6 +4,7 @@
import React from 'react';
import { v4 as uuid } from 'uuid';
import { action } from '@storybook/addon-actions';
import { noop } from 'lodash';
import type { PropsType } from './StoryImage';
import { StoryImage } from './StoryImage';
@ -32,6 +33,7 @@ function getDefaultProps(): PropsType {
label: 'A story',
queueStoryDownload: action('queueStoryDownload'),
storyId: uuid(),
onMediaPlaybackStart: noop,
};
}

View file

@ -35,6 +35,7 @@ export type PropsType = {
readonly moduleClassName?: string;
readonly queueStoryDownload: (storyId: string) => unknown;
readonly storyId: string;
readonly onMediaPlaybackStart: () => void;
};
export function StoryImage({
@ -50,6 +51,7 @@ export function StoryImage({
moduleClassName,
queueStoryDownload,
storyId,
onMediaPlaybackStart,
}: PropsType): JSX.Element | null {
const shouldDownloadAttachment =
(!isDownloaded(attachment) && !isDownloading(attachment)) ||
@ -72,9 +74,10 @@ export function StoryImage({
if (isPaused) {
videoRef.current.pause();
} else {
onMediaPlaybackStart();
void videoRef.current.play();
}
}, [isPaused]);
}, [isPaused, onMediaPlaybackStart]);
useEffect(() => {
setHasImgError(false);

View file

@ -28,6 +28,7 @@ export type PropsType = Pick<ConversationStoryType, 'group' | 'isHidden'> & {
onGoToConversation: (conversationId: string) => unknown;
onHideStory: (conversationId: string) => unknown;
queueStoryDownload: (storyId: string) => unknown;
onMediaPlaybackStart: () => void;
story: StoryViewType;
viewUserStories: ViewUserStoriesActionCreatorType;
};
@ -88,6 +89,7 @@ export function StoryListItem({
isHidden,
onGoToConversation,
onHideStory,
onMediaPlaybackStart,
queueStoryDownload,
story,
viewUserStories,
@ -195,6 +197,7 @@ export function StoryListItem({
moduleClassName="StoryListItem__previews--image"
queueStoryDownload={queueStoryDownload}
storyId={story.messageId}
onMediaPlaybackStart={onMediaPlaybackStart}
/>
</div>
</ContextMenu>

View file

@ -96,6 +96,7 @@ export type PropsType = {
story: StoryViewType
) => unknown;
onUseEmoji: (_: EmojiPickDataType) => unknown;
onMediaPlaybackStart: () => void;
preferredReactionEmoji: ReadonlyArray<string>;
queueStoryDownload: (storyId: string) => unknown;
recentEmojis?: ReadonlyArray<string>;
@ -148,6 +149,7 @@ export function StoryViewer({
onSetSkinTone,
onTextTooLong,
onUseEmoji,
onMediaPlaybackStart,
preferredReactionEmoji,
queueStoryDownload,
recentEmojis,
@ -618,6 +620,7 @@ export function StoryViewer({
moduleClassName="StoryViewer__story"
queueStoryDownload={queueStoryDownload}
storyId={messageId}
onMediaPlaybackStart={onMediaPlaybackStart}
>
{reactionEmoji && (
<div className="StoryViewer__animated-emojis">

View file

@ -14,7 +14,7 @@ const MAX_AUDIO_DURATION = 15 * 60; // 15 minutes
export type ComputePeaksResult = {
duration: number;
peaks: ReadonlyArray<number>;
peaks: ReadonlyArray<number>; // 0 < peak < 1
};
export type Contents = {
@ -174,9 +174,10 @@ const globalContents: Contents = {
computePeaks,
};
export const GlobalAudioContext = React.createContext<Contents>(globalContents);
export const VoiceNotesPlaybackContext =
React.createContext<Contents>(globalContents);
export type GlobalAudioProps = {
export type VoiceNotesPlaybackProps = {
conversationId: string | undefined;
isPaused: boolean;
children?: React.ReactNode | React.ReactChildren;
@ -187,21 +188,12 @@ export type GlobalAudioProps = {
* A global context that holds Audio, AudioContext, LRU instances that are used
* inside the conversation by ts/components/conversation/MessageAudio.tsx
*/
export function GlobalAudioProvider({
conversationId,
export function VoiceNotesPlaybackProvider({
children,
unloadMessageAudio,
}: GlobalAudioProps): JSX.Element {
// When moving between conversations - stop audio
React.useEffect(() => {
return () => {
unloadMessageAudio();
};
}, [conversationId, unloadMessageAudio]);
}: VoiceNotesPlaybackProps): JSX.Element {
return (
<GlobalAudioContext.Provider value={globalContents}>
<VoiceNotesPlaybackContext.Provider value={globalContents}>
{children}
</GlobalAudioContext.Provider>
</VoiceNotesPlaybackContext.Provider>
);
}

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { SmartMiniPlayer } from '../../state/smart/MiniPlayer';
export type PropsType = {
conversationId: string;
@ -86,6 +87,7 @@ export function ConversationView({
{renderConversationHeader()}
</div>
<div className="ConversationView__pane main panel">
<SmartMiniPlayer />
<div className="ConversationView__timeline--container">
<div aria-live="polite" className="ConversationView__timeline">
{renderTimeline()}

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useRef, useEffect, useState } from 'react';
import type { RefObject, ReactNode } from 'react';
import type { RefObject } from 'react';
import classNames from 'classnames';
import { noop } from 'lodash';
import { animated, useSpring } from '@react-spring/web';
@ -13,15 +13,21 @@ import type { PushPanelForConversationActionType } from '../../state/ducks/conve
import { isDownloaded } from '../../types/Attachment';
import type { DirectionType, MessageStatusType } from './Message';
import type { ComputePeaksResult } from '../GlobalAudioContext';
import type { ComputePeaksResult } from '../VoiceNotesPlaybackContext';
import { MessageMetadata } from './MessageMetadata';
import * as log from '../../logging/log';
import type { ActiveAudioPlayerStateType } from '../../state/ducks/audioPlayer';
import { PlaybackRateButton } from '../PlaybackRateButton';
import { durationToPlaybackText } from '../../util/durationToPlaybackText';
export type OwnProps = Readonly<{
active: ActiveAudioPlayerStateType | undefined;
active:
| Pick<
ActiveAudioPlayerStateType,
'currentTime' | 'duration' | 'playing' | 'playbackRate'
>
| undefined;
buttonRef: RefObject<HTMLButtonElement>;
renderingContext: string;
i18n: LocalizerType;
attachment: AttachmentType;
collapseMetadata: boolean;
@ -33,7 +39,6 @@ export type OwnProps = Readonly<{
expirationLength?: number;
expirationTimestamp?: number;
id: string;
conversationId: string;
played: boolean;
status?: MessageStatusType;
textPending?: boolean;
@ -41,34 +46,25 @@ export type OwnProps = Readonly<{
kickOffAttachmentDownload(): void;
onCorrupted(): void;
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
onPlayMessage: (id: string, position: number) => void;
}>;
export type DispatchProps = Readonly<{
loadAndPlayMessageAudio: (
id: string,
url: string,
context: string,
position: number,
isConsecutive: boolean
) => void;
pushPanelForConversation: PushPanelForConversationActionType;
setCurrentTime: (currentTime: number) => void;
setPlaybackRate: (conversationId: string, rate: number) => void;
setPlaybackRate: (rate: number) => void;
setIsPlaying: (value: boolean) => void;
}>;
export type Props = OwnProps & DispatchProps;
type ButtonProps = {
variant: 'play' | 'playback-rate';
mod?: string;
label: string;
visible?: boolean;
animateClick?: boolean;
onClick: () => void;
onMouseDown?: () => void;
onMouseUp?: () => void;
children?: ReactNode;
};
enum State {
@ -92,8 +88,6 @@ const REWIND_BAR_COUNT = 2;
const SMALL_INCREMENT = 1;
const BIG_INCREMENT = 5;
const PLAYBACK_RATES = [1, 1.5, 2, 0.5];
const SPRING_CONFIG = {
mass: 0.5,
tension: 350,
@ -103,48 +97,16 @@ const SPRING_CONFIG = {
const DOT_DIV_WIDTH = 14;
// Utils
const timeToText = (time: number): string => {
const hours = Math.floor(time / 3600);
let minutes = Math.floor((time % 3600) / 60).toString();
let seconds = Math.floor(time % 60).toString();
if (hours !== 0 && minutes.length < 2) {
minutes = `0${minutes}`;
}
if (seconds.length < 2) {
seconds = `0${seconds}`;
}
return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`;
};
/**
* Handles animations, key events, and stopping event propagation
* for play button and playback rate button
*/
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
/** Handles animations, key events, and stopping event propagation */
const PlaybackButton = React.forwardRef<HTMLButtonElement, ButtonProps>(
function ButtonInner(props, ref) {
const {
variant,
mod,
label,
children,
onClick,
visible = true,
animateClick = true,
} = props;
const [isDown, setIsDown] = useState(false);
const { mod, label, onClick, visible = true } = props;
const [animProps] = useSpring(
{
config: SPRING_CONFIG,
to:
isDown && animateClick ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
to: { scale: visible ? 1 : 0 },
},
[visible, isDown, animateClick]
[visible]
);
// Clicking button toggle playback
@ -178,19 +140,14 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
type="button"
ref={ref}
className={classNames(
`${CSS_BASE}__${variant}-button`,
mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined
`${CSS_BASE}__play-button`,
mod ? `${CSS_BASE}__play-button--${mod}` : undefined
)}
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
onMouseDown={() => setIsDown(true)}
onMouseUp={() => setIsDown(false)}
onMouseLeave={() => setIsDown(false)}
tabIndex={0}
aria-label={label}
>
{children}
</button>
/>
</animated.div>
);
}
@ -237,10 +194,9 @@ function PlayedDot({
* toggle Play/Pause button.
*
* A global audio player is used for playback and access is managed by the
* `activeAudioID` and `activeAudioContext` properties. Whenever both
* `activeAudioID` and `activeAudioContext` are equal to `id` and `context`
* respectively the instance of the `MessageAudio` assumes the ownership of the
* `Audio` instance and fully manages it.
* `active.content.current.id` and the `active.content.context` properties. Whenever both
* are equal to `id` and `context` respectively the instance of the `MessageAudio`
* assumes the ownership of the `Audio` instance and fully manages it.
*
* `context` is required for displaying separate MessageAudio instances in
* MessageDetails and Message React components.
@ -250,10 +206,8 @@ export function MessageAudio(props: Props): JSX.Element {
active,
buttonRef,
i18n,
renderingContext,
attachment,
collapseMetadata,
conversationId,
withContentAbove,
withContentBelow,
@ -270,7 +224,7 @@ export function MessageAudio(props: Props): JSX.Element {
onCorrupted,
computePeaks,
setPlaybackRate,
loadAndPlayMessageAudio,
onPlayMessage,
pushPanelForConversation,
setCurrentTime,
setIsPlaying,
@ -373,10 +327,9 @@ export function MessageAudio(props: Props): JSX.Element {
if (active) {
setIsPlaying(true);
} else {
loadAndPlayMessageAudio(id, attachment.url, renderingContext, 0, false);
onPlayMessage(id, 0);
}
} else {
// stop
setIsPlaying(false);
}
};
@ -401,13 +354,7 @@ export function MessageAudio(props: Props): JSX.Element {
}
if (attachment.url) {
loadAndPlayMessageAudio(
id,
attachment.url,
renderingContext,
progress,
false
);
onPlayMessage(id, progress);
} else {
log.warn('Waveform clicked on attachment with no url');
}
@ -467,7 +414,7 @@ export function MessageAudio(props: Props): JSX.Element {
aria-valuenow={currentTimeOrZero}
aria-valuemin={0}
aria-valuemax={duration}
aria-valuetext={timeToText(currentTimeOrZero)}
aria-valuetext={durationToPlaybackText(currentTimeOrZero)}
>
{peaks.map((peak, i) => {
let height = Math.max(BAR_MIN_HEIGHT, BAR_MAX_HEIGHT * peak);
@ -512,26 +459,22 @@ export function MessageAudio(props: Props): JSX.Element {
);
} else if (state === State.NotDownloaded) {
button = (
<Button
<PlaybackButton
ref={buttonRef}
variant="play"
mod="download"
label="MessageAudio--download"
animateClick={false}
onClick={kickOffAttachmentDownload}
/>
);
} else {
// State.Normal
button = (
<Button
<PlaybackButton
ref={buttonRef}
variant="play"
mod={isPlaying ? 'pause' : 'play'}
label={
isPlaying ? i18n('MessageAudio--pause') : i18n('MessageAudio--play')
}
animateClick={false}
onClick={toggleIsPlaying}
/>
);
@ -539,20 +482,6 @@ export function MessageAudio(props: Props): JSX.Element {
const countDown = Math.max(0, duration - (active?.currentTime ?? 0));
const nextPlaybackRate = (currentRate: number): number => {
// cycle through the rates
return PLAYBACK_RATES[
(PLAYBACK_RATES.indexOf(currentRate) + 1) % PLAYBACK_RATES.length
];
};
const playbackRateLabels: { [key: number]: string } = {
1: i18n('MessageAudio--playbackRate1'),
1.5: i18n('MessageAudio--playbackRate1p5'),
2: i18n('MessageAudio--playbackRate2'),
0.5: i18n('MessageAudio--playbackRatep5'),
};
const metadata = (
<div className={`${CSS_BASE}__metadata`}>
<div
@ -562,7 +491,7 @@ export function MessageAudio(props: Props): JSX.Element {
`${CSS_BASE}__countdown--${played ? 'played' : 'unplayed'}`
)}
>
{timeToText(countDown)}
{durationToPlaybackText(countDown)}
</div>
<div className={`${CSS_BASE}__controls`}>
@ -570,21 +499,20 @@ export function MessageAudio(props: Props): JSX.Element {
played={played}
onHide={() => setIsPlayedDotVisible(false)}
/>
<Button
variant="playback-rate"
label={playbackRateLabels[active?.playbackRate ?? 1]}
<PlaybackRateButton
i18n={i18n}
variant={`message-${direction}`}
playbackRate={active?.playbackRate}
visible={isPlaying && (!played || !isPlayedDotVisible)}
onClick={() => {
if (active) {
setPlaybackRate(
conversationId,
nextPlaybackRate(active.playbackRate)
PlaybackRateButton.nextPlaybackRate(active.playbackRate)
);
}
}}
>
{playbackRateLabels[active?.playbackRate ?? 1]}
</Button>
/>
</div>
{!withContentBelow && !collapseMetadata && (

View file

@ -27,7 +27,7 @@ import {
} from '../../types/MIME';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { MessageAudio } from './MessageAudio';
import { computePeaks } from '../GlobalAudioContext';
import { computePeaks } from '../VoiceNotesPlaybackContext';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { pngUrl } from '../../storybook/Fixtures';
@ -89,6 +89,10 @@ const Template: Story<Partial<Props>> = args => {
});
};
const messageIdToAudioUrl = {
'incompetech-com-Agnus-Dei-X': '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
};
function getJoyReaction() {
return {
emoji: '😂',
@ -152,14 +156,9 @@ function MessageAudioContainer({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadAndPlayMessageAudio = (
_id: string,
url: string,
_context: string,
position: number
) => {
const handlePlayMessage = (id: string, position: number) => {
if (!active) {
audio.src = url;
audio.src = messageIdToAudioUrl[id as keyof typeof messageIdToAudioUrl];
setIsActive(true);
}
if (!playing) {
@ -176,7 +175,7 @@ function MessageAudioContainer({
}
};
const setPlaybackRateAction = (_conversationId: string, rate: number) => {
const setPlaybackRateAction = (rate: number) => {
audio.playbackRate = rate;
setPlaybackRate(rate);
};
@ -202,14 +201,12 @@ function MessageAudioContainer({
return (
<MessageAudio
{...props}
conversationId="some-conversation-id"
active={active}
computePeaks={computePeaks}
id="storybook"
loadAndPlayMessageAudio={loadAndPlayMessageAudio}
onPlayMessage={handlePlayMessage}
played={_played}
pushPanelForConversation={action('pushPanelForConversation')}
renderingContext="storybook"
setCurrentTime={setCurrentTimeAction}
setIsPlaying={setIsPlayingAction}
setPlaybackRate={setPlaybackRateAction}
@ -427,11 +424,12 @@ export function EmojiMessages(): JSX.Element {
<br />
<TimelineMessage
{...createProps({
id: 'incompetech-com-Agnus-Dei-X',
attachments: [
fakeAttachment({
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
url: messageIdToAudioUrl['incompetech-com-Agnus-Dei-X'],
}),
],
text: '😀',
@ -1353,11 +1351,12 @@ export const _Audio = (): JSX.Element => {
const [isPlayed, setIsPlayed] = React.useState(false);
const messageProps = createProps({
id: 'incompetech-com-Agnus-Dei-X',
attachments: [
fakeAttachment({
contentType: AUDIO_MP3,
fileName: 'incompetech-com-Agnus-Dei-X.mp3',
url: '/fixtures/incompetech-com-Agnus-Dei-X.mp3',
url: messageIdToAudioUrl['incompetech-com-Agnus-Dei-X'],
path: 'somepath',
}),
],