diff --git a/stylesheets/components/MiniPlayer.scss b/stylesheets/components/MiniPlayer.scss new file mode 100644 index 000000000000..572925fff25b --- /dev/null +++ b/stylesheets/components/MiniPlayer.scss @@ -0,0 +1,107 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@mixin audio-icon($name, $icon, $color) { + &--#{$name}::before { + @include color-svg('../images/icons/v2/#{$icon}.svg', $color, false); + -webkit-mask-size: 100%; + } +} + +@mixin all-audio-icons($color) { + @include audio-icon(play, play-solid-20, $color); + @include audio-icon(pause, pause-solid-20, $color); + @include audio-icon(pending, audio-spinner-arc-22, $color); +} + +.MiniPlayer { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: calc($z-index-above-above-base + 1); + display: flex; + align-items: center; + gap: 18px; + padding: 8px 16px; + + @include light-theme { + background-color: $color-gray-02; + } + @include dark-theme { + background-color: $color-gray-75; + } + + &__playback-button { + @include button-reset; + + &::before { + display: block; + width: 14px; + height: 14px; + content: ''; + } + + @include light-theme { + @include all-audio-icons($color-gray-60); + } + + @include dark-theme { + @include all-audio-icons($color-gray-15); + } + + &--pending::before { + animation: rotate 1000ms linear infinite; + } + } + + &__state { + flex: 1; + } + + &__middot { + padding: 0 5px; + } + + &__close-button { + @include button-reset; + + border-radius: 4px; + height: 10px; + width: 10px; + + &::before { + content: ''; + display: block; + width: 100%; + height: 100%; + + @include light-theme { + @include color-svg('../images/icons/v2/x-8.svg', $color-gray-75); + } + + @include dark-theme { + @include color-svg('../images/icons/v2/x-8.svg', $color-gray-15); + } + } + + @include light-theme { + &:hover, + &:focus { + background: $color-gray-02; + } + &:active { + background: $color-gray-05; + } + } + @include dark-theme { + &:hover, + &:focus { + background: $color-gray-80; + } + &:active { + background: $color-gray-75; + } + } + } +} diff --git a/stylesheets/components/PlaybackRateButton.scss b/stylesheets/components/PlaybackRateButton.scss new file mode 100644 index 000000000000..5a7218da330d --- /dev/null +++ b/stylesheets/components/PlaybackRateButton.scss @@ -0,0 +1,68 @@ +// Copyright 2022 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.PlaybackRateButton { + @include button-reset; + + @include font-body-2-bold; + + width: 38px; + height: 18px; + text-align: center; + font-weight: 700; + border-radius: 4px; + font-size: 11px; + padding: 1px 2px; + margin: -2px 0; + line-height: 16px; + letter-spacing: 0.05px; + user-select: none; + + &--message-incoming { + @include light-theme { + color: $color-gray-60; + background: $color-black-alpha-08; + } + @include dark-theme { + color: $color-gray-25; + background: $color-white-alpha-08; + } + } + &--message-outgoing { + color: $color-white-alpha-80; + background: $color-white-alpha-20; + } + &--mini-player { + @include light-theme { + color: $color-gray-60; + background: $color-black-alpha-08; + } + @include dark-theme { + color: $color-gray-25; + background: $color-white-alpha-08; + } + } + + &::after { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + margin-left: 2px; + + @mixin x-icon($color) { + @include color-svg('../images/icons/v2/x-8.svg', $color, false); + } + + @include light-theme { + @include x-icon($color-gray-60); + } + @include dark-theme { + @include x-icon($color-gray-25); + } + + .module-message__audio-attachment--outgoing & { + @include x-icon($color-white-alpha-80); + } + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index a96d1a8fd44f..97dc696775d6 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -95,10 +95,12 @@ @import './components/MessageAudio.scss'; @import './components/MessageBody.scss'; @import './components/MessageDetail.scss'; +@import './components/MiniPlayer.scss'; @import './components/Modal.scss'; @import './components/MyStories.scss'; @import './components/OutgoingGiftBadgeModal.scss'; @import './components/PermissionsPopup.scss'; +@import './components/PlaybackRateButton.scss'; @import './components/Preferences.scss'; @import './components/ProfileEditor.scss'; @import './components/Quote.scss'; diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 6238ae91ffd8..41c0d5c97916 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -42,5 +42,6 @@ global.WebSocket = {}; /* eslint max-classes-per-file: ["error", 2] */ global.AudioContext = class {}; global.Audio = class { + pause() {} addEventListener() {} }; diff --git a/ts/components/AvatarLightbox.tsx b/ts/components/AvatarLightbox.tsx index c8fd1af82bfc..58b20558390b 100644 --- a/ts/components/AvatarLightbox.tsx +++ b/ts/components/AvatarLightbox.tsx @@ -34,6 +34,7 @@ export function AvatarLightbox({ media={[]} saveAttachment={noop} toggleForwardMessageModal={noop} + onMediaPlaybackStart={noop} > = {}): PropsType => ({ toggleSettings: action('toggle-settings'), toggleSpeakerView: action('toggle-speaker-view'), isConversationTooBigToRing: false, + pauseVoiceNotePlayer: action('pause-audio-player'), }); export default { diff --git a/ts/components/CallManager.tsx b/ts/components/CallManager.tsx index cfe414167bd6..1ac3f19f5d12 100644 --- a/ts/components/CallManager.tsx +++ b/ts/components/CallManager.tsx @@ -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( diff --git a/ts/components/Lightbox.stories.tsx b/ts/components/Lightbox.stories.tsx index ceda8fa66d53..1513a034ae7a 100644 --- a/ts/components/Lightbox.stories.tsx +++ b/ts/components/Lightbox.stories.tsx @@ -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 => ({ saveAttachment: action('saveAttachment'), selectedIndex: number('selectedIndex', overrideProps.selectedIndex || 0), toggleForwardMessageModal: action('toggleForwardMessageModal'), + onMediaPlaybackStart: noop, }); export function Multimedia(): JSX.Element { diff --git a/ts/components/Lightbox.tsx b/ts/components/Lightbox.tsx index 870e15009674..523f407205ae 100644 --- a/ts/components/Lightbox.tsx +++ b/ts/components/Lightbox.tsx @@ -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(); 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'); diff --git a/ts/components/MiniPlayer.stories.tsx b/ts/components/MiniPlayer.stories.tsx new file mode 100644 index 000000000000..f85f7f2741b4 --- /dev/null +++ b/ts/components/MiniPlayer.stories.tsx @@ -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 && ( + + )} + + {!active && ( + + )} + + ); +} diff --git a/ts/components/MiniPlayer.tsx b/ts/components/MiniPlayer.tsx new file mode 100644 index 000000000000..ce92ec95cd5d --- /dev/null +++ b/ts/components/MiniPlayer.tsx @@ -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 ( +
+
+ ); +} diff --git a/ts/components/MyStories.tsx b/ts/components/MyStories.tsx index f73c95e3ad2d..7185758d91be 100644 --- a/ts/components/MyStories.tsx +++ b/ts/components/MyStories.tsx @@ -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} />
diff --git a/ts/components/MyStoryButton.tsx b/ts/components/MyStoryButton.tsx index 7815a51b1a90..692e6d5595b0 100644 --- a/ts/components/MyStoryButton.tsx +++ b/ts/components/MyStoryButton.tsx @@ -21,6 +21,7 @@ export type PropsType = { myStories: Array; 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} />
diff --git a/ts/components/PlaybackRateButton.tsx b/ts/components/PlaybackRateButton.tsx new file mode 100644 index 000000000000..4750915ef8ea --- /dev/null +++ b/ts/components/PlaybackRateButton.tsx @@ -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 ( + + + + ); +} + +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 + ]; +}; diff --git a/ts/components/SendStoryModal.tsx b/ts/components/SendStoryModal.tsx index ab113edc33d9..0e21931d5094 100644 --- a/ts/components/SendStoryModal.tsx +++ b/ts/components/SendStoryModal.tsx @@ -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(Page.SendStory); @@ -875,6 +877,7 @@ export function SendStoryModal({ label="label" moduleClassName="SendStoryModal__story" attachment={attachment} + onMediaPlaybackStart={onMediaPlaybackStart} />
diff --git a/ts/components/Stories.tsx b/ts/components/Stories.tsx index 1dc829160be7..8e680c589b7c 100644 --- a/ts/components/Stories.tsx +++ b/ts/components/Stories.tsx @@ -39,6 +39,7 @@ export type PropsType = { myStories: Array; 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} diff --git a/ts/components/StoriesPane.tsx b/ts/components/StoriesPane.tsx index 1741be4d0bb1..0ec9a25daf32 100644 --- a/ts/components/StoriesPane.tsx +++ b/ts/components/StoriesPane.tsx @@ -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 => ( & 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 } diff --git a/ts/components/StoryImage.stories.tsx b/ts/components/StoryImage.stories.tsx index ccdfa0f386da..e50c6b47cdd4 100644 --- a/ts/components/StoryImage.stories.tsx +++ b/ts/components/StoryImage.stories.tsx @@ -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, }; } diff --git a/ts/components/StoryImage.tsx b/ts/components/StoryImage.tsx index 5dd93c14c5d1..d81d20182ca3 100644 --- a/ts/components/StoryImage.tsx +++ b/ts/components/StoryImage.tsx @@ -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); diff --git a/ts/components/StoryListItem.tsx b/ts/components/StoryListItem.tsx index e9adef967400..5fbd1909bb87 100644 --- a/ts/components/StoryListItem.tsx +++ b/ts/components/StoryListItem.tsx @@ -28,6 +28,7 @@ export type PropsType = Pick & { 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} />
diff --git a/ts/components/StoryViewer.tsx b/ts/components/StoryViewer.tsx index 20749c4c22cc..d7968dce82be 100644 --- a/ts/components/StoryViewer.tsx +++ b/ts/components/StoryViewer.tsx @@ -96,6 +96,7 @@ export type PropsType = { story: StoryViewType ) => unknown; onUseEmoji: (_: EmojiPickDataType) => unknown; + onMediaPlaybackStart: () => void; preferredReactionEmoji: ReadonlyArray; queueStoryDownload: (storyId: string) => unknown; recentEmojis?: ReadonlyArray; @@ -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 && (
diff --git a/ts/components/GlobalAudioContext.tsx b/ts/components/VoiceNotesPlaybackContext.tsx similarity index 90% rename from ts/components/GlobalAudioContext.tsx rename to ts/components/VoiceNotesPlaybackContext.tsx index a1208f263f66..d1bc8eb94ba8 100644 --- a/ts/components/GlobalAudioContext.tsx +++ b/ts/components/VoiceNotesPlaybackContext.tsx @@ -14,7 +14,7 @@ const MAX_AUDIO_DURATION = 15 * 60; // 15 minutes export type ComputePeaksResult = { duration: number; - peaks: ReadonlyArray; + peaks: ReadonlyArray; // 0 < peak < 1 }; export type Contents = { @@ -174,9 +174,10 @@ const globalContents: Contents = { computePeaks, }; -export const GlobalAudioContext = React.createContext(globalContents); +export const VoiceNotesPlaybackContext = + React.createContext(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 ( - + {children} - + ); } diff --git a/ts/components/conversation/ConversationView.tsx b/ts/components/conversation/ConversationView.tsx index d0b9fe96a747..b30477f65017 100644 --- a/ts/components/conversation/ConversationView.tsx +++ b/ts/components/conversation/ConversationView.tsx @@ -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()}
+
{renderTimeline()} diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx index fb61f971e694..c8a0f1f7b4a1 100644 --- a/ts/components/conversation/MessageAudio.tsx +++ b/ts/components/conversation/MessageAudio.tsx @@ -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; - 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; + 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( +/** Handles animations, key events, and stopping event propagation */ +const PlaybackButton = React.forwardRef( 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( 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} - + /> ); } @@ -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 = ( - + />
{!withContentBelow && !collapseMetadata && ( diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index f315a4d4600a..726ab5d49a50 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -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> = 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 ( { 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', }), ], diff --git a/ts/services/globalMessageAudio.ts b/ts/services/globalMessageAudio.ts index 16a6fa52a47f..08433687a7ed 100644 --- a/ts/services/globalMessageAudio.ts +++ b/ts/services/globalMessageAudio.ts @@ -30,12 +30,14 @@ class GlobalMessageAudio { load({ src, + playbackRate, onLoadedMetadata, onTimeUpdate, onDurationChange, onEnded, }: { src: string; + playbackRate: number; onLoadedMetadata: () => void; onTimeUpdate: () => void; onDurationChange: () => void; @@ -50,7 +52,9 @@ class GlobalMessageAudio { this.#onDurationChange = onDurationChange; this.#onEnded = onEnded; + // changing src resets the playback rate this.#audio.src = src; + this.#audio.playbackRate = playbackRate; } play(): Promise { diff --git a/ts/state/ducks/audioPlayer.ts b/ts/state/ducks/audioPlayer.ts index 1b022b5cd099..56f511eaf687 100644 --- a/ts/state/ducks/audioPlayer.ts +++ b/ts/state/ducks/audioPlayer.ts @@ -6,62 +6,75 @@ import type { ReadonlyDeep } from 'type-fest'; import type { BoundActionCreatorsMapObject } 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 { setVoiceNotePlaybackRate, markViewed } from './conversations'; +import { extractVoiceNoteForPlayback } from '../selectors/audioPlayer'; +import type { + VoiceNoteAndConsecutiveForPlayback, + VoiceNoteForPlayback, +} from '../selectors/audioPlayer'; import type { + MessagesAddedActionType, MessageDeletedActionType, MessageChangedActionType, SelectedConversationChangedActionType, ConversationChangedActionType, } from './conversations'; -import { - SELECTED_CONVERSATION_CHANGED, - setVoiceNotePlaybackRate, - markViewed, -} from './conversations'; import * as log from '../../logging/log'; +import * as Errors from '../../types/errors'; import { strictAssert } from '../../util/assert'; import { globalMessageAudio } from '../../services/globalMessageAudio'; -import { isPlayed } from '../../types/Attachment'; -import { getMessageIdForLogging } from '../../util/idForLogging'; -import { getMessagePropStatus } from '../selectors/message'; +import { getUserConversationId } from '../selectors/user'; +import { isAudio } from '../../types/Attachment'; +import { getAttachmentUrlForPath } from '../selectors/message'; +import { SeenStatus } from '../../MessageSeenStatus'; // State +export type AudioPlayerContent = ReadonlyDeep<{ + conversationId: string; + context: string; + current: VoiceNoteForPlayback; + queue: ReadonlyArray; + nextMessageTimestamp: number | undefined; + // playing because it followed a message + // false on the first of a consecutive group + isConsecutive: boolean; + ourConversationId: string | undefined; + startPosition: number; +}>; + export type ActiveAudioPlayerStateType = ReadonlyDeep<{ playing: boolean; currentTime: number; playbackRate: number; duration: number; + content: AudioPlayerContent | undefined; }>; export type AudioPlayerStateType = ReadonlyDeep<{ - active: - | (ActiveAudioPlayerStateType & { id: string; context: string }) - | undefined; + active: ActiveAudioPlayerStateType | undefined; }>; // Actions -/** - * Sets the current "active" message audio for a particular rendering "context" - */ export type SetMessageAudioAction = ReadonlyDeep<{ type: 'audioPlayer/SET_MESSAGE_AUDIO'; payload: | { - id: string; + conversationId: string; context: string; + current: VoiceNoteForPlayback; + queue: ReadonlyArray; + isConsecutive: boolean; + // timestamp of the message following the last one in the queue + nextMessageTimestamp: number | undefined; + ourConversationId: string | undefined; + startPosition: number; playbackRate: number; - duration: number; } | undefined; }>; @@ -71,7 +84,7 @@ type SetPlaybackRate = ReadonlyDeep<{ payload: number; }>; -type SetIsPlayingAction = ReadonlyDeep<{ +export type SetIsPlayingAction = ReadonlyDeep<{ type: 'audioPlayer/SET_IS_PLAYING'; payload: boolean; }>; @@ -90,6 +103,11 @@ type DurationChanged = ReadonlyDeep<{ payload: number; }>; +type UpdateQueueAction = ReadonlyDeep<{ + type: 'audioPlayer/UPDATE_QUEUE'; + payload: ReadonlyArray; +}>; + type AudioPlayerActionType = ReadonlyDeep< | SetMessageAudioAction | SetIsPlayingAction @@ -97,20 +115,24 @@ type AudioPlayerActionType = ReadonlyDeep< | MessageAudioEnded | CurrentTimeUpdated | DurationChanged + | UpdateQueueAction >; // Action Creators export const actions = { - loadAndPlayMessageAudio, - unloadMessageAudio, + loadMessageAudio, + playMessageAudio, setPlaybackRate, setCurrentTime, setIsPlaying, + pauseVoiceNotePlayer, + unloadMessageAudio, }; -export const useActions = (): BoundActionCreatorsMapObject => - useBoundActions(actions); +export const useAudioPlayerActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); function setCurrentTime(value: number): CurrentTimeUpdated { globalMessageAudio.currentTime = value; @@ -120,20 +142,7 @@ function setCurrentTime(value: number): CurrentTimeUpdated { }; } -function setIsPlaying(value: boolean): SetIsPlayingAction { - if (!value) { - globalMessageAudio.pause(); - } else { - void globalMessageAudio.play(); - } - return { - type: 'audioPlayer/SET_IS_PLAYING', - payload: value, - }; -} - function setPlaybackRate( - conversationId: string, rate: number ): ThunkAction< void, @@ -141,14 +150,23 @@ function setPlaybackRate( unknown, SetPlaybackRate | ConversationChangedActionType > { - return dispatch => { + return (dispatch, getState) => { + const { audioPlayer } = getState(); + const { active } = audioPlayer; + if (!active?.content) { + log.warn('audioPlayer.setPlaybackRate: No active message audio'); + return; + } + globalMessageAudio.playbackRate = rate; + dispatch({ type: 'audioPlayer/SET_PLAYBACK_RATE', payload: rate, }); // update the preference for the conversation + const { conversationId } = active.content; dispatch( setVoiceNotePlaybackRate({ conversationId, @@ -158,14 +176,6 @@ function setPlaybackRate( }; } -function unloadMessageAudio(): SetMessageAudioAction { - globalMessageAudio.pause(); - return { - type: 'audioPlayer/SET_MESSAGE_AUDIO', - payload: undefined, - }; -} - const stateChangeConfirmUpSound = new Sound({ src: 'sounds/state-change_confirm-up.ogg', }); @@ -173,30 +183,52 @@ 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 +/** plays a message that has been loaded into content */ +function playMessageAudio( + playConsecutiveSound: boolean ): ThunkAction< void, RootStateType, unknown, - | SetMessageAudioAction - | MessageAudioEnded - | CurrentTimeUpdated - | SetIsPlayingAction - | DurationChanged + CurrentTimeUpdated | SetIsPlayingAction | DurationChanged | MessageAudioEnded > { return (dispatch, getState) => { + const ourConversationId = getUserConversationId(getState()); + + if (!ourConversationId) { + log.error('playMessageAudio: No ourConversationId'); + return; + } + + const { audioPlayer } = getState(); + const { active } = audioPlayer; + + if (!active) { + log.error('playMessageAudio: Not active'); + return; + } + + const { content } = active; + + if (!content) { + log.error('playMessageAudio: No message audio loaded'); + return; + } + const { current } = content; + + if (!current.url) { + log.error('playMessageAudio: pending download'); + return; + } + + if (playConsecutiveSound) { + void stateChangeConfirmUpSound.play(); + } + // set source to new message and start playing globalMessageAudio.load({ - src: url, - + src: current.url, + playbackRate: active.playbackRate, onTimeUpdate: () => { dispatch({ type: 'audioPlayer/CURRENT_TIME_UPDATED', @@ -210,18 +242,16 @@ function loadAndPlayMessageAudio( 'Audio should have definite duration on `loadedmetadata` event' ); - log.info('MessageAudio: `loadedmetadata` event', id); + log.info('playMessageAudio: `loadedmetadata` event', current.id); - // Sync-up audio's time in case if