Animated voice-note controls and used image x for playback rate
This commit is contained in:
parent
f9453c64dd
commit
b4c9c3051b
7 changed files with 168 additions and 68 deletions
|
@ -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
10
images/icons/v2/x-8.svg
Normal 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 |
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 && (
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in a new issue