Animated voice-note controls and used image x for playback rate

This commit is contained in:
Alvaro 2022-09-19 18:28:10 -06:00 committed by GitHub
parent f9453c64dd
commit b4c9c3051b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 168 additions and 68 deletions

View file

@ -4495,21 +4495,21 @@
"message": "Playback time of audio attachment", "message": "Playback time of audio attachment",
"description": "Aria label for audio attachment's playback time slider" "description": "Aria label for audio attachment's playback time slider"
}, },
"MessageAudio--playbackRate1x": { "MessageAudio--playbackRate1": {
"message": "1x", "message": "1",
"description": "Button in the voice note message widget that shows the current playback rate of 1x (regular speed) and allows the user to toggle to the next rate" "description": "Button in the voice note message widget that shows the current playback rate of 1x (regular speed) and allows the user to toggle to the next rate. Don't include the 'x'."
}, },
"MessageAudio--playbackRate1p5x": { "MessageAudio--playbackRate1p5": {
"message": "1.5x", "message": "1.5",
"description": "Button in the voice note message widget that shows the current playback rate of 1.5x (%50 faster) and allows the user to toggle to the next rate" "description": "Button in the voice note message widget that shows the current playback rate of 1.5x (%50 faster) and allows the user to toggle to the next rate. Don't include the 'x'."
}, },
"MessageAudio--playbackRate2x": { "MessageAudio--playbackRate2": {
"message": "2x", "message": "2",
"description": "Button in the voice note message widget that shows the current playback rate of 2x (double speed) and allows the user to toggle to the next rate" "description": "Button in the voice note message widget that shows the current playback rate of 2x (double speed) and allows the user to toggle to the next rate. Don't include the 'x'."
}, },
"MessageAudio--playbackRatep5x": { "MessageAudio--playbackRatep5": {
"message": ".5x", "message": ".5",
"description": "Button in the voice note message widget that shows the current playback rate of .5x (half speed) and allows the user to toggle to the next rate" "description": "Button in the voice note message widget that shows the current playback rate of .5x (half speed) and allows the user to toggle to the next rate. Don't include the 'x'."
}, },
"emptyInboxMessage": { "emptyInboxMessage": {
"message": "Click the $composeIcon$ above and search for your contacts or groups to message.", "message": "Click the $composeIcon$ above and search for your contacts or groups to message.",

10
images/icons/v2/x-8.svg Normal file
View file

@ -0,0 +1,10 @@
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_6_2799)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M5.07699 3.99999L7.99998 1.07699L6.92299 0L4 2.923L1.07701 8.86908e-06L1.51392e-05 1.077L2.923 3.99999L0 6.92299L1.07699 7.99998L4 5.07698L6.92301 7.99999L8 6.923L5.07699 3.99999Z" fill="black"/>
</g>
<defs>
<clipPath id="clip0_6_2799">
<rect width="8" height="8" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 482 B

View file

@ -54,6 +54,7 @@ $audio-attachment-button-margin-small: 4px;
align-items: center; align-items: center;
justify-content: flex-start; justify-content: flex-start;
transition: width 100ms ease-out; transition: width 100ms ease-out;
width: 14px;
&:before { &:before {
content: ''; content: '';
@ -61,17 +62,10 @@ $audio-attachment-button-margin-small: 4px;
width: 6px; width: 6px;
height: 6px; height: 6px;
border-radius: 100%; border-radius: 100%;
transition: background 100ms ease-out;
} }
&--unplayed {
width: 14px;
}
&--played {
width: 0px;
}
.module-message__audio-attachment--incoming & { .module-message__audio-attachment--incoming & {
&--unplayed:before { &:before {
@include light-theme { @include light-theme {
background: $color-gray-60; background: $color-gray-60;
} }
@ -85,9 +79,6 @@ $audio-attachment-button-margin-small: 4px;
background: $color-white-alpha-80; background: $color-white-alpha-80;
} }
} }
&--played:before {
background: transparent;
}
} }
.module-message__audio-attachment__playback-rate-button { .module-message__audio-attachment__playback-rate-button {
@ -120,9 +111,33 @@ $audio-attachment-button-margin-small: 4px;
color: $color-white-alpha-80; color: $color-white-alpha-80;
background: $color-white-alpha-20; background: $color-white-alpha-20;
} }
&::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);
}
.module-message__audio-attachment--incoming & {
@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);
}
}
} }
.module-message__audio-attachment__button, .module-message__audio-attachment__play-button,
.module-message__audio-attachment__spinner { .module-message__audio-attachment__spinner {
@include button-reset; @include button-reset;
@ -195,7 +210,7 @@ $audio-attachment-button-margin-small: 4px;
outline: 0; outline: 0;
} }
.module-message__audio-attachment__button, .module-message__audio-attachment__play-button,
.module-message__audio-attachment__playback-rate-button, .module-message__audio-attachment__playback-rate-button,
.module-message__audio-attachment__spinner, .module-message__audio-attachment__spinner,
.module-message__audio-attachment__waveform { .module-message__audio-attachment__waveform {

View file

@ -163,7 +163,10 @@ const MessageAudioContainer: React.FC<AudioAttachmentProps> = ({
audio.play(); audio.play();
setPlaying(true); setPlaying(true);
} }
audio.currentTime = audio.duration * position;
if (!Number.isNaN(audio.duration)) {
audio.currentTime = audio.duration * position;
}
if (!Number.isNaN(audio.currentTime)) { if (!Number.isNaN(audio.currentTime)) {
setCurrentTime(audio.currentTime); setCurrentTime(audio.currentTime);
} }

View file

@ -4,6 +4,7 @@
import React, { useRef, useEffect, useState } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { animated, useSpring } from '@react-spring/web';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import type { AttachmentType } from '../../types/Attachment'; import type { AttachmentType } from '../../types/Attachment';
@ -35,7 +36,6 @@ export type OwnProps = Readonly<{
status?: MessageStatusType; status?: MessageStatusType;
textPending?: boolean; textPending?: boolean;
timestamp: number; timestamp: number;
buttonRef: React.RefObject<HTMLButtonElement>;
kickOffAttachmentDownload(): void; kickOffAttachmentDownload(): void;
onCorrupted(): void; onCorrupted(): void;
onFirstPlayed(): void; onFirstPlayed(): void;
@ -59,11 +59,13 @@ export type Props = OwnProps & DispatchProps;
type ButtonProps = { type ButtonProps = {
i18n: LocalizerType; i18n: LocalizerType;
buttonRef: React.RefObject<HTMLButtonElement>; variant: 'play' | 'playback-rate';
mod?: string;
mod: string;
label: string; label: string;
visible?: boolean;
onClick: () => void; onClick: () => void;
onMouseDown?: () => void;
onMouseUp?: () => void;
}; };
enum State { enum State {
@ -89,6 +91,15 @@ const BIG_INCREMENT = 5;
const PLAYBACK_RATES = [1, 1.5, 2, 0.5]; const PLAYBACK_RATES = [1, 1.5, 2, 0.5];
const SPRING_DEFAULTS = {
mass: 0.5,
tension: 350,
friction: 20,
velocity: 0.01,
};
const DOT_DIV_WIDTH = 14;
// Utils // Utils
const timeToText = (time: number): string => { const timeToText = (time: number): string => {
@ -107,8 +118,28 @@ const timeToText = (time: number): string => {
return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`; return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`;
}; };
/**
* Handles animations, key events, and stoping event propagation
* for play button and playback rate button
*/
const Button: React.FC<ButtonProps> = props => { const Button: React.FC<ButtonProps> = props => {
const { i18n, buttonRef, mod, label, onClick } = props; const {
i18n,
variant,
mod,
label,
children,
onClick,
visible = true,
} = props;
const [isDown, setIsDown] = useState(false);
const animProps = useSpring({
...SPRING_DEFAULTS,
from: isDown ? { scale: 1 } : { scale: 0 },
to: isDown ? { scale: 1.3 } : { scale: visible ? 1 : 0 },
});
// Clicking button toggle playback // Clicking button toggle playback
const onButtonClick = (event: React.MouseEvent) => { const onButtonClick = (event: React.MouseEvent) => {
event.stopPropagation(); event.stopPropagation();
@ -129,17 +160,59 @@ const Button: React.FC<ButtonProps> = props => {
}; };
return ( return (
<button <animated.div style={animProps}>
type="button" <button
ref={buttonRef} type="button"
className={classNames(
`${CSS_BASE}__${variant}-button`,
mod ? `${CSS_BASE}__${variant}-button--${mod}` : undefined
)}
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
onMouseDown={() => setIsDown(true)}
onMouseUp={() => setIsDown(false)}
onMouseLeave={() => setIsDown(false)}
tabIndex={0}
aria-label={i18n(label)}
>
{children}
</button>
</animated.div>
);
};
const PlayedDot = ({
played,
onHide,
}: {
played: boolean;
onHide: () => void;
}) => {
const start = played ? 1 : 0;
const end = played ? 0 : 1;
const [animProps] = useSpring(
{
...SPRING_DEFAULTS,
from: { scale: start, opacity: start, width: start },
to: { scale: end, opacity: end, width: end * DOT_DIV_WIDTH },
onRest: () => {
if (played) {
onHide();
}
},
},
[played]
);
return (
<animated.div
style={animProps}
aria-hidden="true"
className={classNames( className={classNames(
`${CSS_BASE}__button`, `${CSS_BASE}__dot`,
`${CSS_BASE}__button--${mod}` `${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
)} )}
onClick={onButtonClick}
onKeyDown={onButtonKeyDown}
tabIndex={0}
aria-label={i18n(label)}
/> />
); );
}; };
@ -178,7 +251,6 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
textPending, textPending,
timestamp, timestamp,
buttonRef,
kickOffAttachmentDownload, kickOffAttachmentDownload,
onCorrupted, onCorrupted,
onFirstPlayed, onFirstPlayed,
@ -193,6 +265,8 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
const isPlaying = active?.playing ?? false; const isPlaying = active?.playing ?? false;
const [isPlayedDotVisible, setIsPlayedDotVisible] = React.useState(!played);
// if it's playing, use the duration passed as props as it might // if it's playing, use the duration passed as props as it might
// change during loading/playback (?) // change during loading/playback (?)
// NOTE: Avoid division by zero // NOTE: Avoid division by zero
@ -431,7 +505,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
button = ( button = (
<Button <Button
i18n={i18n} i18n={i18n}
buttonRef={buttonRef} variant="play"
mod="download" mod="download"
label="MessageAudio--download" label="MessageAudio--download"
onClick={kickOffAttachmentDownload} onClick={kickOffAttachmentDownload}
@ -442,7 +516,7 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
button = ( button = (
<Button <Button
i18n={i18n} i18n={i18n}
buttonRef={buttonRef} variant="play"
mod={isPlaying ? 'pause' : 'play'} mod={isPlaying ? 'pause' : 'play'}
label={isPlaying ? 'MessageAudio--pause' : 'MessageAudio--play'} label={isPlaying ? 'MessageAudio--pause' : 'MessageAudio--play'}
onClick={toggleIsPlaying} onClick={toggleIsPlaying}
@ -460,10 +534,10 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
}; };
const playbackRateLabels: { [key: number]: string } = { const playbackRateLabels: { [key: number]: string } = {
1: i18n('MessageAudio--playbackRate1x'), 1: i18n('MessageAudio--playbackRate1'),
1.5: i18n('MessageAudio--playbackRate1p5x'), 1.5: i18n('MessageAudio--playbackRate1p5'),
2: i18n('MessageAudio--playbackRate2x'), 2: i18n('MessageAudio--playbackRate2'),
0.5: i18n('MessageAudio--playbackRatep5x'), 0.5: i18n('MessageAudio--playbackRatep5'),
}; };
const metadata = ( const metadata = (
@ -479,29 +553,26 @@ export const MessageAudio: React.FC<Props> = (props: Props) => {
</div> </div>
<div className={`${CSS_BASE}__controls`}> <div className={`${CSS_BASE}__controls`}>
<div <PlayedDot
aria-hidden="true" played={played}
className={classNames( onHide={() => setIsPlayedDotVisible(false)}
`${CSS_BASE}__dot`,
`${CSS_BASE}__dot--${played ? 'played' : 'unplayed'}`
)}
/> />
{active && active.playing && ( <Button
<button variant="playback-rate"
type="button" i18n={i18n}
className={classNames(`${CSS_BASE}__playback-rate-button`)} label={(active && playbackRateLabels[active.playbackRate]) ?? ''}
onClick={ev => { visible={isPlaying && (!played || (played && !isPlayedDotVisible))}
ev.stopPropagation(); onClick={() => {
if (active) {
setPlaybackRate( setPlaybackRate(
conversationId, conversationId,
nextPlaybackRate(active.playbackRate) nextPlaybackRate(active.playbackRate)
); );
}} }
tabIndex={0} }}
> >
{playbackRateLabels[active.playbackRate]} {active && playbackRateLabels[active.playbackRate]}
</button> </Button>
)}
</div> </div>
{!withContentBelow && !collapseMetadata && ( {!withContentBelow && !collapseMetadata && (

View file

@ -37,8 +37,6 @@ export type Props = {
textPending?: boolean; textPending?: boolean;
timestamp: number; timestamp: number;
buttonRef: React.RefObject<HTMLButtonElement>;
computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>; computePeaks(url: string, barCount: number): Promise<ComputePeaksResult>;
kickOffAttachmentDownload(): void; kickOffAttachmentDownload(): void;
onCorrupted(): void; onCorrupted(): void;

View file

@ -7,7 +7,10 @@ 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, 'computePeaks'>; type AudioAttachmentProps = Omit<
MessageAudioProps,
'computePeaks' | 'buttonRef'
>;
export function renderAudioAttachment( export function renderAudioAttachment(
props: AudioAttachmentProps props: AudioAttachmentProps