Merge remote-tracking branch 'origin/develop' into media-modal-tsx

environments/review-media-moda-e1w8yz/deployments/1144
Alex Gleason 2 years ago
commit def1c58835
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7

@ -1,12 +1,32 @@
import classNames from 'clsx';
type ButtonThemes = 'primary' | 'secondary' | 'tertiary' | 'accent' | 'danger' | 'transparent' | 'outline'
type ButtonSizes = 'sm' | 'md' | 'lg'
const themes = {
primary:
'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300',
secondary:
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200',
tertiary:
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
};
const sizes = {
xs: 'px-3 py-1 text-xs',
sm: 'px-3 py-1.5 text-xs leading-4',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
type ButtonSizes = keyof typeof sizes
type ButtonThemes = keyof typeof themes
type IButtonStyles = {
theme: ButtonThemes,
block: boolean,
disabled: boolean,
theme: ButtonThemes
block: boolean
disabled: boolean
size: ButtonSizes
}
@ -17,26 +37,6 @@ const useButtonStyles = ({
disabled,
size,
}: IButtonStyles) => {
const themes = {
primary:
'bg-primary-500 hover:bg-primary-400 dark:hover:bg-primary-600 border-transparent focus:bg-primary-500 text-gray-100 focus:ring-primary-300',
secondary:
'border-transparent bg-primary-100 dark:bg-primary-800 hover:bg-primary-50 dark:hover:bg-primary-700 focus:bg-primary-100 dark:focus:bg-primary-800 text-primary-500 dark:text-primary-200',
tertiary:
'bg-transparent border-gray-400 dark:border-gray-800 hover:border-primary-300 dark:hover:border-primary-700 focus:border-primary-500 text-gray-900 dark:text-gray-100 focus:ring-primary-500',
accent: 'border-transparent bg-secondary-500 hover:bg-secondary-400 focus:bg-secondary-500 text-gray-100 focus:ring-secondary-300',
danger: 'border-transparent bg-danger-100 dark:bg-danger-900 text-danger-600 dark:text-danger-200 hover:bg-danger-600 hover:text-gray-100 dark:hover:text-gray-100 dark:hover:bg-danger-500 focus:bg-danger-800 dark:focus:bg-danger-600',
transparent: 'border-transparent text-gray-800 backdrop-blur-sm bg-white/75 hover:bg-white/80',
outline: 'border-gray-100 border-2 bg-transparent text-gray-100 hover:bg-white/10',
};
const sizes = {
xs: 'px-3 py-1 text-xs',
sm: 'px-3 py-1.5 text-xs leading-4',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
const buttonStyle = classNames({
'inline-flex items-center border font-medium rounded-full focus:outline-none focus:ring-2 focus:ring-offset-2 appearance-none transition-all': true,
'select-none disabled:opacity-75 disabled:cursor-default': disabled,

@ -18,13 +18,13 @@ const messages = defineMessages({
interface ICard {
/** The type of card. */
variant?: 'default' | 'rounded',
variant?: 'default' | 'rounded'
/** Card size preset. */
size?: 'md' | 'lg' | 'xl',
size?: keyof typeof sizes
/** Extra classnames for the <div> element. */
className?: string,
className?: string
/** Elements inside the card. */
children: React.ReactNode,
children: React.ReactNode
}
/** An opaque backdrop to hold a collection of related elements. */

@ -17,7 +17,7 @@ const alignItemsOptions = {
};
const spaces = {
'0.5': 'space-x-0.5',
[0.5]: 'space-x-0.5',
1: 'space-x-1',
1.5: 'space-x-1.5',
2: 'space-x-2',
@ -29,21 +29,21 @@ const spaces = {
interface IHStack {
/** Vertical alignment of children. */
alignItems?: 'top' | 'bottom' | 'center' | 'start',
alignItems?: keyof typeof alignItemsOptions
/** Extra class names on the <div> element. */
className?: string,
className?: string
/** Children */
children?: React.ReactNode,
children?: React.ReactNode
/** Horizontal alignment of children. */
justifyContent?: 'between' | 'center' | 'start' | 'end' | 'around',
justifyContent?: keyof typeof justifyContentOptions
/** Size of the gap between elements. */
space?: 0.5 | 1 | 1.5 | 2 | 3 | 4 | 6 | 8,
space?: keyof typeof spaces
/** Whether to let the flexbox grow. */
grow?: boolean,
grow?: boolean
/** Extra CSS styles for the <div> */
style?: React.CSSProperties
/** Whether to let the flexbox wrap onto multiple lines. */
wrap?: boolean,
wrap?: boolean
}
/** Horizontal row of child elements. */

@ -10,8 +10,6 @@ const messages = defineMessages({
confirm: { id: 'confirmations.delete.confirm', defaultMessage: 'Delete' },
});
type Widths = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
const widths = {
xs: 'max-w-xs',
sm: 'max-w-sm',
@ -51,7 +49,7 @@ interface IModal {
skipFocus?: boolean,
/** Title text for the modal. */
title?: React.ReactNode,
width?: Widths,
width?: keyof typeof widths,
}
/** Displays a modal dialog box. */

@ -1,13 +1,11 @@
import classNames from 'clsx';
import React from 'react';
type SIZES = 0 | 0.5 | 1 | 1.5 | 2 | 3 | 4 | 5 | 10
const spaces = {
0: 'space-y-0',
'0.5': 'space-y-0.5',
[0.5]: 'space-y-0.5',
1: 'space-y-1',
'1.5': 'space-y-1.5',
[1.5]: 'space-y-1.5',
2: 'space-y-2',
3: 'space-y-3',
4: 'space-y-4',
@ -25,15 +23,15 @@ const alignItemsOptions = {
interface IStack extends React.HTMLAttributes<HTMLDivElement> {
/** Size of the gap between elements. */
space?: SIZES,
space?: keyof typeof spaces
/** Horizontal alignment of children. */
alignItems?: 'center',
alignItems?: 'center'
/** Vertical alignment of children. */
justifyContent?: 'center',
justifyContent?: 'center'
/** Extra class names on the <div> element. */
className?: string,
className?: string
/** Whether to let the flexbox grow. */
grow?: boolean,
grow?: boolean
}
/** Vertical stack of child elements. */

@ -1,16 +1,6 @@
import classNames from 'clsx';
import React from 'react';
type Themes = 'default' | 'danger' | 'primary' | 'muted' | 'subtle' | 'success' | 'inherit' | 'white'
type Weights = 'normal' | 'medium' | 'semibold' | 'bold'
export type Sizes = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl'
type Alignments = 'left' | 'center' | 'right'
type TrackingSizes = 'normal' | 'wide'
type TransformProperties = 'uppercase' | 'normal'
type Families = 'sans' | 'mono'
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
type Directions = 'ltr' | 'rtl'
const themes = {
default: 'text-gray-900 dark:text-gray-100',
danger: 'text-danger-600',
@ -60,15 +50,19 @@ const families = {
mono: 'font-mono',
};
export type Sizes = keyof typeof sizes
type Tags = 'abbr' | 'p' | 'span' | 'pre' | 'time' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'label'
type Directions = 'ltr' | 'rtl'
interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'dangerouslySetInnerHTML'> {
/** How to align the text. */
align?: Alignments,
align?: keyof typeof alignments,
/** Extra class names for the outer element. */
className?: string,
/** Text direction. */
direction?: Directions,
/** Typeface of the text. */
family?: Families,
family?: keyof typeof families,
/** The "for" attribute specifies which form element a label is bound to. */
htmlFor?: string,
/** Font size of the text. */
@ -76,15 +70,15 @@ interface IText extends Pick<React.HTMLAttributes<HTMLParagraphElement>, 'danger
/** HTML element name of the outer element. */
tag?: Tags,
/** Theme for the text. */
theme?: Themes,
theme?: keyof typeof themes,
/** Letter-spacing of the text. */
tracking?: TrackingSizes,
tracking?: keyof typeof trackingSizes,
/** Transform (eg uppercase) for the text. */
transform?: TransformProperties,
transform?: keyof typeof transformProperties,
/** Whether to truncate the text if its container is too small. */
truncate?: boolean,
/** Font weight of the text. */
weight?: Weights,
weight?: keyof typeof weights,
/** Tooltip title. */
title?: string,
}

@ -1,535 +0,0 @@
import classNames from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import PropTypes from 'prop-types';
import React from 'react';
import { defineMessages, injectIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { formatTime, getPointerPosition, fileNameFromURL } from 'soapbox/features/video';
import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
download: { id: 'video.download', defaultMessage: 'Download file' },
});
const TICK_SIZE = 10;
const PADDING = 180;
export default @injectIntl
class Audio extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
poster: PropTypes.string,
duration: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
editable: PropTypes.bool,
fullscreen: PropTypes.bool,
intl: PropTypes.object.isRequired,
cacheWidth: PropTypes.func,
backgroundColor: PropTypes.string,
foregroundColor: PropTypes.string,
accentColor: PropTypes.string,
currentTime: PropTypes.number,
autoPlay: PropTypes.bool,
volume: PropTypes.number,
muted: PropTypes.bool,
deployPictureInPicture: PropTypes.func,
};
state = {
width: this.props.width,
currentTime: 0,
buffer: 0,
duration: null,
paused: true,
muted: false,
volume: 0.5,
dragging: false,
};
constructor(props) {
super(props);
this.visualizer = new Visualizer(TICK_SIZE);
}
setPlayerRef = c => {
this.player = c;
if (this.player) {
this._setDimensions();
}
}
_pack() {
return {
src: this.props.src,
volume: this.audio.volume,
muted: this.audio.muted,
currentTime: this.audio.currentTime,
poster: this.props.poster,
backgroundColor: this.props.backgroundColor,
foregroundColor: this.props.foregroundColor,
accentColor: this.props.accentColor,
};
}
_setDimensions() {
const width = this.player.offsetWidth;
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16 / 9));
if (this.props.cacheWidth) {
this.props.cacheWidth(width);
}
this.setState({ width, height });
}
setSeekRef = c => {
this.seek = c;
}
setVolumeRef = c => {
this.volume = c;
}
setAudioRef = c => {
this.audio = c;
if (this.audio) {
this.setState({ volume: this.audio.volume, muted: this.audio.muted });
}
}
setCanvasRef = c => {
this.canvas = c;
this.visualizer.setCanvas(c);
}
componentDidMount() {
window.addEventListener('scroll', this.handleScroll);
window.addEventListener('resize', this.handleResize, { passive: true });
}
componentDidUpdate(prevProps, prevState) {
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
this._clear();
this._draw();
}
}
componentWillUnmount() {
window.removeEventListener('scroll', this.handleScroll);
window.removeEventListener('resize', this.handleResize);
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
}
togglePlay = () => {
if (!this.audioContext) {
this._initAudioContext();
}
if (this.state.paused) {
this.setState({ paused: false }, () => this.audio.play());
} else {
this.setState({ paused: true }, () => this.audio.pause());
}
}
handleResize = debounce(() => {
if (this.player) {
this._setDimensions();
}
}, 250, {
trailing: true,
});
handlePlay = () => {
this.setState({ paused: false });
if (this.audioContext && this.audioContext.state === 'suspended') {
this.audioContext.resume();
}
this._renderCanvas();
}
handlePause = () => {
this.setState({ paused: true });
if (this.audioContext) {
this.audioContext.suspend();
}
}
handleProgress = () => {
const lastTimeRange = this.audio.buffered.length - 1;
if (lastTimeRange > -1) {
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
}
}
toggleMute = () => {
const muted = !this.state.muted;
this.setState({ muted }, () => {
this.audio.muted = muted;
});
}
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
this.handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
}
handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
}
handleMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseMove, true);
document.addEventListener('mouseup', this.handleMouseUp, true);
document.addEventListener('touchmove', this.handleMouseMove, true);
document.addEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: true });
this.audio.pause();
this.handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
}
handleMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseMove, true);
document.removeEventListener('mouseup', this.handleMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseMove, true);
document.removeEventListener('touchend', this.handleMouseUp, true);
this.setState({ dragging: false });
this.audio.play();
}
handleMouseMove = throttle(e => {
const { x } = getPointerPosition(this.seek, e);
const currentTime = this.audio.duration * x;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}, 15);
handleTimeUpdate = () => {
this.setState({
currentTime: this.audio.currentTime,
duration: this.audio.duration,
});
}
handleMouseVolSlide = throttle(e => {
const { x } = getPointerPosition(this.volume, e);
if (!isNaN(x)) {
this.setState({ volume: x }, () => {
this.audio.volume = x;
});
}
}, 15);
handleScroll = throttle(() => {
if (!this.canvas || !this.audio) {
return;
}
const { top, height } = this.canvas.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!this.state.paused && !inView) {
this.audio.pause();
if (this.props.deployPictureInPicture) {
this.props.deployPictureInPicture('audio', this._pack());
}
this.setState({ paused: true });
}
}, 150, { trailing: true });
handleMouseEnter = () => {
this.setState({ hovered: true });
}
handleMouseLeave = () => {
this.setState({ hovered: false });
}
handleLoadedData = () => {
const { autoPlay, currentTime, volume, muted } = this.props;
this.setState({ duration: this.audio.duration });
if (currentTime) {
this.audio.currentTime = currentTime;
}
if (volume !== undefined) {
this.audio.volume = volume;
}
if (muted !== undefined) {
this.audio.muted = muted;
}
if (autoPlay) {
this.togglePlay();
}
}
_initAudioContext() {
// eslint-disable-next-line compat/compat
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
const source = context.createMediaElementSource(this.audio);
this.visualizer.setAudioContext(context, source);
source.connect(context.destination);
this.audioContext = context;
}
handleDownload = () => {
fetch(this.props.src).then(res => res.blob()).then(blob => {
const element = document.createElement('a');
const objectURL = URL.createObjectURL(blob);
element.setAttribute('href', objectURL);
element.setAttribute('download', fileNameFromURL(this.props.src));
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
URL.revokeObjectURL(objectURL);
}).catch(err => {
console.error(err);
});
}
_renderCanvas() {
requestAnimationFrame(() => {
if (!this.audio) return;
this.handleTimeUpdate();
this._clear();
this._draw();
if (!this.state.paused) {
this._renderCanvas();
}
});
}
_clear() {
this.visualizer.clear(this.state.width, this.state.height);
}
_draw() {
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
}
_getRadius() {
return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
}
_getScaleCoefficient() {
return (this.state.height || this.props.height) / 982;
}
_getCX() {
return Math.floor(this.state.width / 2) || null;
}
_getCY() {
return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient())) || null;
}
_getAccentColor() {
return this.props.accentColor || '#ffffff';
}
_getBackgroundColor() {
return this.props.backgroundColor || '#000000';
}
_getForegroundColor() {
return this.props.foregroundColor || '#ffffff';
}
seekBy(time) {
const currentTime = this.audio.currentTime + time;
if (!isNaN(currentTime)) {
this.setState({ currentTime }, () => {
this.audio.currentTime = currentTime;
});
}
}
handleAudioKeyDown = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
this.togglePlay();
}
}
handleKeyDown = e => {
switch (e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
this.togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
this.toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
this.seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
this.seekBy(10);
break;
}
}
render() {
const { src, intl, alt, editable } = this.props;
const { paused, muted, volume, currentTime, buffer, dragging } = this.state;
const duration = this.state.duration || this.props.duration;
const progress = Math.min((currentTime / duration) * 100, 100);
return (
<div className={classNames('audio-player', { editable })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex='0' onKeyDown={this.handleKeyDown}>
<audio
src={src}
ref={this.setAudioRef}
preload='auto'
onPlay={this.handlePlay}
onPause={this.handlePause}
onProgress={this.handleProgress}
onLoadedData={this.handleLoadedData}
crossOrigin='anonymous'
/>
<canvas
role='button'
tabIndex='0'
className='audio-player__canvas'
width={this.state.width}
height={this.state.height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={this.setCanvasRef}
onClick={this.togglePlay}
onKeyDown={this.handleAudioKeyDown}
title={alt}
aria-label={alt}
/>
{this.props.poster && <img
src={this.props.poster}
alt=''
width={(this._getRadius() - TICK_SIZE) * 2 || null}
height={(this._getRadius() - TICK_SIZE) * 2 || null}
style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
/>}
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex='0'
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
onKeyDown={this.handleAudioKeyDown}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} /></button>
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
<div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
<span
className='video-player__volume__handle'
tabIndex='0'
style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
/>
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
{duration && (<>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(Math.floor(duration))}</span>
</>)}
</span>
</div>
<div className='video-player__buttons right'>
<a
title={intl.formatMessage(messages.download)}
aria-label={intl.formatMessage(messages.download)}
className='video-player__download__icon player-button'
href={this.props.src}
download
target='_blank'
>
<Icon src={require('@tabler/icons/download.svg')} />
</a>
</div>
</div>
</div>
</div>
);
}
}

@ -0,0 +1,583 @@
import classNames from 'clsx';
import debounce from 'lodash/debounce';
import throttle from 'lodash/throttle';
import React, { useEffect, useRef, useState } from 'react';
import { defineMessages, useIntl } from 'react-intl';
import Icon from 'soapbox/components/icon';
import { formatTime, getPointerPosition } from 'soapbox/features/video';
import Visualizer from './visualizer';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
download: { id: 'video.download', defaultMessage: 'Download file' },
});
const TICK_SIZE = 10;
const PADDING = 180;
interface IAudio {
src: string,
alt?: string,
poster?: string,
duration?: number,
width?: number,
height?: number,
editable?: boolean,
fullscreen?: boolean,
cacheWidth?: (width: number) => void,
backgroundColor?: string,
foregroundColor?: string,
accentColor?: string,
currentTime?: number,
autoPlay?: boolean,
volume?: number,
muted?: boolean,
deployPictureInPicture?: (type: string, opts: Record<string, any>) => void,
}
const Audio: React.FC<IAudio> = (props) => {
const {
src,
alt = '',
poster,
accentColor,
backgroundColor,
foregroundColor,
cacheWidth,
fullscreen,
autoPlay,
editable,
deployPictureInPicture = false,
} = props;
const intl = useIntl();
const [width, setWidth] = useState<number | undefined>(props.width);
const [height, setHeight] = useState<number | undefined>(props.height);
const [currentTime, setCurrentTime] = useState(0);
const [buffer, setBuffer] = useState(0);
const [duration, setDuration] = useState<number | undefined>(undefined);
const [paused, setPaused] = useState(true);
const [muted, setMuted] = useState(false);
const [volume, setVolume] = useState(0.5);
const [dragging, setDragging] = useState(false);
const [hovered, setHovered] = useState(false);
const visualizer = useRef<Visualizer>(new Visualizer(TICK_SIZE));
const audioContext = useRef<AudioContext | null>(null);
const player = useRef<HTMLDivElement>(null);
const audio = useRef<HTMLAudioElement>(null);
const seek = useRef<HTMLDivElement>(null);
const slider = useRef<HTMLDivElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const _pack = () => ({
src: props.src,
volume: audio.current?.volume,
muted: audio.current?.muted,
currentTime: audio.current?.currentTime,
poster: props.poster,
backgroundColor: props.backgroundColor,
foregroundColor: props.foregroundColor,
accentColor: props.accentColor,
});
const _setDimensions = () => {
if (player.current) {
const width = player.current.offsetWidth;
const height = fullscreen ? player.current.offsetHeight : (width / (16 / 9));
if (cacheWidth) {
cacheWidth(width);
}
setWidth(width);
setHeight(height);
}
};
const togglePlay = () => {
if (!audioContext.current) {
_initAudioContext();
}
if (paused) {
audio.current?.play();
} else {
audio.current?.pause();
}
setPaused(!paused);
};
const handleResize = debounce(() => {
if (player.current) {
_setDimensions();
}
}, 250, {
trailing: true,
});
const handlePlay = () => {
setPaused(false);
if (audioContext.current?.state === 'suspended') {
audioContext.current?.resume();
}
_renderCanvas();
};
const handlePause = () => {
setPaused(true);
audioContext.current?.suspend();
};
const handleProgress = () => {
if (audio.current) {
const lastTimeRange = audio.current.buffered.length - 1;
if (lastTimeRange > -1) {
setBuffer(Math.ceil(audio.current.buffered.end(lastTimeRange) / audio.current.duration * 100));
}
}
};
const toggleMute = () => {
const nextMuted = !muted;
setMuted(nextMuted);
if (audio.current) {
audio.current.muted = nextMuted;
}
};
const handleVolumeMouseDown: React.MouseEventHandler = e => {
document.addEventListener('mousemove', handleMouseVolSlide, true);
document.addEventListener('mouseup', handleVolumeMouseUp, true);
document.addEventListener('touchmove', handleMouseVolSlide, true);
document.addEventListener('touchend', handleVolumeMouseUp, true);
handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
};
const handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', handleMouseVolSlide, true);
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
document.removeEventListener('touchmove', handleMouseVolSlide, true);
document.removeEventListener('touchend', handleVolumeMouseUp, true);
};
const handleMouseDown: React.MouseEventHandler = e => {
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('mouseup', handleMouseUp, true);
document.addEventListener('touchmove', handleMouseMove, true);
document.addEventListener('touchend', handleMouseUp, true);
setDragging(true);
audio.current?.pause();
handleMouseMove(e);
e.preventDefault();
e.stopPropagation();
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('mouseup', handleMouseUp, true);
document.removeEventListener('touchmove', handleMouseMove, true);
document.removeEventListener('touchend', handleMouseUp, true);
setDragging(false);
audio.current?.play();
};
const handleMouseMove = throttle((e) => {
if (audio.current && seek.current) {
const { x } = getPointerPosition(seek.current, e);
const currentTime = audio.current.duration * x;
if (!isNaN(currentTime)) {
setCurrentTime(currentTime);
audio.current.currentTime = currentTime;
}
}
}, 15);
const handleTimeUpdate = () => {
if (audio.current) {
setCurrentTime(audio.current.currentTime);
setDuration(audio.current.duration);
}
};
const handleMouseVolSlide = throttle(e => {
if (audio.current && slider.current) {
const { x } = getPointerPosition(slider.current, e);
if (!isNaN(x)) {
setVolume(x);
audio.current.volume = x;
}
}
}, 15);
const handleScroll = throttle(() => {
if (!canvas.current || !audio.current) {
return;
}
const { top, height } = canvas.current.getBoundingClientRect();
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
if (!paused && !inView) {
audio.current.pause();
if (deployPictureInPicture) {
deployPictureInPicture('audio', _pack());
}
setPaused(true);
}
}, 150, { trailing: true });
const handleMouseEnter = () => {
setHovered(true);
};
const handleMouseLeave = () => {
setHovered(false);
};
const handleLoadedData = () => {
if (audio.current) {
setDuration(audio.current.duration);
if (currentTime) {
audio.current.currentTime = currentTime;
}
if (volume !== undefined) {
audio.current.volume = volume;
}
if (muted !== undefined) {
audio.current.muted = muted;
}
if (autoPlay) {
togglePlay();
}
}
};
const _initAudioContext = () => {
if (audio.current) {
// @ts-ignore
// eslint-disable-next-line compat/compat
const AudioContext = window.AudioContext || window.webkitAudioContext;
const context = new AudioContext();
const source = context.createMediaElementSource(audio.current);
visualizer.current.setAudioContext(context, source);
source.connect(context.destination);
audioContext.current = context;
}
};
const _renderCanvas = () => {
requestAnimationFrame(() => {
if (!audio.current) return;
handleTimeUpdate();
_clear();
_draw();
if (!paused) {
_renderCanvas();
}
});
};
const _clear = () => {
visualizer.current?.clear(width || 0, height || 0);
};
const _draw = () => {
visualizer.current?.draw(_getCX(), _getCY(), _getAccentColor(), _getRadius(), _getScaleCoefficient());
};
const _getRadius = (): number => {
return ((height || props.height || 0) - (PADDING * _getScaleCoefficient()) * 2) / 2;
};
const _getScaleCoefficient = (): number => {
return (height || props.height || 0) / 982;
};
const _getCX = (): number => {
return Math.floor((width || 0) / 2);
};
const _getCY = (): number => {
return Math.floor(_getRadius() + (PADDING * _getScaleCoefficient()));
};
const _getAccentColor = (): string => {
return accentColor || '#ffffff';
};
const _getBackgroundColor = (): string => {
return backgroundColor || '#000000';
};
const _getForegroundColor = (): string => {
return foregroundColor || '#ffffff';
};
const seekBy = (time: number) => {
if (audio.current) {
const currentTime = audio.current.currentTime + time;
if (!isNaN(currentTime)) {
setCurrentTime(currentTime);
audio.current.currentTime = currentTime;
}
}
};
const handleAudioKeyDown: React.KeyboardEventHandler = e => {
// On the audio element or the seek bar, we can safely use the space bar
// for playback control because there are no buttons to press
if (e.key === ' ') {
e.preventDefault();
e.stopPropagation();
togglePlay();
}
};
const handleKeyDown: React.KeyboardEventHandler = e => {
switch (e.key) {
case 'k':
e.preventDefault();
e.stopPropagation();
togglePlay();
break;
case 'm':
e.preventDefault();
e.stopPropagation();
toggleMute();
break;
case 'j':
e.preventDefault();
e.stopPropagation();
seekBy(-10);
break;
case 'l':
e.preventDefault();
e.stopPropagation();
seekBy(10);
break;
}
};
const getDuration = () => duration || props.duration || 0;
const progress = Math.min((currentTime / getDuration()) * 100, 100);
useEffect(() => {
if (player.current) {
_setDimensions();
}
}, [player.current]);
useEffect(() => {
if (audio.current) {
setVolume(audio.current.volume);
setMuted(audio.current.muted);
}
}, [audio.current]);
useEffect(() => {
if (canvas.current && visualizer.current) {
visualizer.current.setCanvas(canvas.current);
}
}, [canvas.current, visualizer.current]);
useEffect(() => {
window.addEventListener('scroll', handleScroll);
window.addEventListener('resize', handleResize, { passive: true });
return () => {
window.removeEventListener('scroll', handleScroll);
window.removeEventListener('resize', handleResize);
if (!paused && audio.current && deployPictureInPicture) {
deployPictureInPicture('audio', _pack());
}
};
}, []);
useEffect(() => {
_clear();
_draw();
}, [src, width, height, accentColor]);
return (
<div
className={classNames('audio-player', { editable })}
ref={player}
style={{
backgroundColor: _getBackgroundColor(),
color: _getForegroundColor(),
width: '100%',
height: fullscreen ? '100%' : (height || props.height),
}}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
tabIndex={0}
onKeyDown={handleKeyDown}
>
<audio
src={src}
ref={audio}
preload='auto'
onPlay={handlePlay}
onPause={handlePause}
onProgress={handleProgress}
onLoadedData={handleLoadedData}
crossOrigin='anonymous'
/>
<canvas
role='button'
tabIndex={0}
className='audio-player__canvas'
width={width}
height={height}
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
ref={canvas}
onClick={togglePlay}
onKeyDown={handleAudioKeyDown}
title={alt}
aria-label={alt}
/>
{poster && (
<img
src={poster}
alt=''
width={(_getRadius() - TICK_SIZE) * 2}
height={(_getRadius() - TICK_SIZE) * 2}
style={{
position: 'absolute',
left: _getCX(),
top: _getCY(),
transform: 'translate(-50%, -50%)',
borderRadius: '50%',
pointerEvents: 'none',
}}
/>
)}
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
<div
className='video-player__seek__progress'
style={{ width: `${progress}%`, backgroundColor: _getAccentColor() }}
/>
<span
className={classNames('video-player__seek__handle', { active: dragging })}
tabIndex={0}
style={{ left: `${progress}%`, backgroundColor: _getAccentColor() }}
onKeyDown={handleAudioKeyDown}
/>
</div>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button
type='button'
title={intl.formatMessage(paused ? messages.play : messages.pause)}
aria-label={intl.formatMessage(paused ? messages.play : messages.pause)}
className='player-button'
onClick={togglePlay}
>
<Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} />
</button>
<button
type='button'
title={intl.formatMessage(muted ? messages.unmute : messages.mute)}
aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)}
className='player-button'
onClick={toggleMute}
>
<Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} />
</button>
<div
className={classNames('video-player__volume', { active: hovered })}
ref={slider}
onMouseDown={handleVolumeMouseDown}
>
<div
className='video-player__volume__current'
style={{
width: `${volume * 100}%`,
backgroundColor: _getAccentColor(),
}}
/>
<span
className='video-player__volume__handle'
tabIndex={0}
style={{ left: `${volume * 100}%`, backgroundColor: _getAccentColor() }}
/>
</div>
<span className='video-player__time'>
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
{getDuration() && (<>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(Math.floor(getDuration()))}</span>
</>)}
</span>
</div>
<div className='video-player__buttons right'>
<a
title={intl.formatMessage(messages.download)}
aria-label={intl.formatMessage(messages.download)}
className='video-player__download__icon player-button'
href={src}
download
target='_blank'
>
<Icon src={require('@tabler/icons/download.svg')} />
</a>
</div>
</div>
</div>
</div>
);
};
export default Audio;

@ -20,6 +20,10 @@ const messages = defineMessages({
id: 'registrations.success',
defaultMessage: 'Welcome to {siteTitle}!',
},
usernameHint: {
id: 'registrations.username.hint',
defaultMessage: 'May only contain A-Z, 0-9, and underscores',
},
usernameTaken: {
id: 'registrations.unprocessable_entity',
defaultMessage: 'This username has already been taken.',
@ -104,7 +108,7 @@ const Registration = () => {
<div className='sm:pt-10 sm:w-2/3 md:w-1/2 mx-auto space-y-4'>
<Form onSubmit={handleSubmit}>
<FormGroup labelText='Your username'>
<FormGroup labelText='Your username' hintText={intl.formatMessage(messages.usernameHint)}>
<Input
name='username'
type='text'
@ -112,6 +116,7 @@ const Registration = () => {
onChange={handleInputChange}
required
icon={require('@tabler/icons/at.svg')}
placeholder='LibertyForAll'
/>
</FormGroup>

Loading…
Cancel
Save