Change design of audio player in web UI (#34520)
This commit is contained in:
parent
24c25ec4f5
commit
b4394ec129
26 changed files with 1476 additions and 1088 deletions
|
|
@ -1,588 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import { PureComponent } from 'react';
|
||||
|
||||
import { defineMessages, injectIntl } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { is } from 'immutable';
|
||||
|
||||
import { throttle, debounce } from 'lodash';
|
||||
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import PauseIcon from '@/material-icons/400-24px/pause.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
||||
|
||||
import { Blurhash } from '../../components/blurhash';
|
||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
||||
|
||||
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' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||
});
|
||||
|
||||
const TICK_SIZE = 10;
|
||||
const PADDING = 180;
|
||||
|
||||
class Audio extends PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
lang: PropTypes.string,
|
||||
poster: PropTypes.string,
|
||||
duration: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
editable: PropTypes.bool,
|
||||
fullscreen: PropTypes.bool,
|
||||
intl: PropTypes.object.isRequired,
|
||||
blurhash: PropTypes.string,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
onToggleVisibility: 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,
|
||||
matchedFilters: PropTypes.arrayOf(PropTypes.string),
|
||||
};
|
||||
|
||||
state = {
|
||||
width: this.props.width,
|
||||
currentTime: 0,
|
||||
buffer: 0,
|
||||
duration: null,
|
||||
paused: true,
|
||||
muted: false,
|
||||
volume: 1,
|
||||
dragging: false,
|
||||
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
||||
};
|
||||
|
||||
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.state.volume,
|
||||
muted: this.state.muted,
|
||||
currentTime: this.audio.currentTime,
|
||||
poster: this.props.poster,
|
||||
backgroundColor: this.props.backgroundColor,
|
||||
foregroundColor: this.props.foregroundColor,
|
||||
accentColor: this.props.accentColor,
|
||||
sensitive: this.props.sensitive,
|
||||
visible: this.props.visible,
|
||||
};
|
||||
}
|
||||
|
||||
_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.audio.volume = 1;
|
||||
this.audio.muted = false;
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
||||
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
||||
this.setState({ revealed: nextProps.visible });
|
||||
}
|
||||
}
|
||||
|
||||
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.state.volume === 0);
|
||||
|
||||
this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
toggleReveal = () => {
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
} else {
|
||||
this.setState({ revealed: !this.state.revealed });
|
||||
}
|
||||
};
|
||||
|
||||
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((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = this.state.muted ? 0 : 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 } = this.props;
|
||||
|
||||
if (currentTime) {
|
||||
this.audio.currentTime = currentTime;
|
||||
}
|
||||
|
||||
if (autoPlay) {
|
||||
this.togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
_initAudioContext () {
|
||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
||||
const context = new AudioContext();
|
||||
const source = context.createMediaElementSource(this.audio);
|
||||
const gainNode = context.createGain();
|
||||
|
||||
gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
|
||||
|
||||
this.visualizer.setAudioContext(context, source);
|
||||
source.connect(gainNode);
|
||||
gainNode.connect(context.destination);
|
||||
|
||||
this.audioContext = context;
|
||||
this.gainNode = gainNode;
|
||||
}
|
||||
|
||||
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) / 2 - PADDING * this._getScaleCoefficient());
|
||||
}
|
||||
|
||||
_getScaleCoefficient () {
|
||||
return (this.state.height || this.props.height) / 982;
|
||||
}
|
||||
|
||||
_getCX() {
|
||||
return Math.floor(this.state.width / 2);
|
||||
}
|
||||
|
||||
_getCY() {
|
||||
return Math.floor((this.state.height || this.props.height) / 2);
|
||||
}
|
||||
|
||||
_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, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
|
||||
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
|
||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
||||
const muted = this.state.muted || volume === 0;
|
||||
|
||||
return (
|
||||
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
|
||||
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
|
||||
{(revealed || editable) && <audio
|
||||
src={src}
|
||||
ref={this.setAudioRef}
|
||||
preload={autoPlay ? 'auto' : 'none'}
|
||||
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}
|
||||
lang={lang}
|
||||
/>
|
||||
|
||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
|
||||
|
||||
{(revealed || editable) && <img
|
||||
src={this.props.poster}
|
||||
alt=''
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
|
||||
aspectRatio: '1',
|
||||
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 id={paused ? 'play' : 'pause'} icon={paused ? PlayArrowIcon : PauseIcon} /></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 id={muted ? 'volume-off' : 'volume-up'} icon={muted ? VolumeOffIcon : VolumeUpIcon} /></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: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
||||
|
||||
<span
|
||||
className='video-player__volume__handle'
|
||||
tabIndex={0}
|
||||
style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className='video-player__time'>
|
||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{!editable && (
|
||||
<>
|
||||
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
|
||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
||||
<Icon id='download' icon={DownloadIcon} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default injectIntl(Audio);
|
||||
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
|
|
@ -0,0 +1,840 @@
|
|||
import { useEffect, useRef, useCallback, useState, useId } from 'react';
|
||||
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { useSpring, animated, config } from '@react-spring/web';
|
||||
|
||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
|
||||
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
|
||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||
import Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
|
||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||
import { Blurhash } from 'mastodon/components/blurhash';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||
import { formatTime, getPointerPosition } from 'mastodon/features/video';
|
||||
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
|
||||
import {
|
||||
displayMedia,
|
||||
useBlurhash,
|
||||
reduceMotion,
|
||||
} from 'mastodon/initial_state';
|
||||
import { playerSettings } from 'mastodon/settings';
|
||||
|
||||
const messages = defineMessages({
|
||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||
skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
|
||||
skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
|
||||
});
|
||||
|
||||
const persistVolume = (volume: number, muted: boolean) => {
|
||||
playerSettings.set('volume', volume);
|
||||
playerSettings.set('muted', muted);
|
||||
};
|
||||
|
||||
const restoreVolume = (audio: HTMLAudioElement) => {
|
||||
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||
|
||||
audio.volume = volume;
|
||||
audio.muted = muted;
|
||||
};
|
||||
|
||||
const HOVER_FADE_DELAY = 4000;
|
||||
|
||||
export const Audio: React.FC<{
|
||||
src: string;
|
||||
alt?: string;
|
||||
lang?: string;
|
||||
poster?: string;
|
||||
sensitive?: boolean;
|
||||
editable?: boolean;
|
||||
blurhash?: string;
|
||||
visible?: boolean;
|
||||
duration?: number;
|
||||
onToggleVisibility?: () => void;
|
||||
backgroundColor?: string;
|
||||
foregroundColor?: string;
|
||||
accentColor?: string;
|
||||
startTime?: number;
|
||||
startPlaying?: boolean;
|
||||
startVolume?: number;
|
||||
startMuted?: boolean;
|
||||
deployPictureInPicture?: (
|
||||
type: string,
|
||||
mediaProps: {
|
||||
src: string;
|
||||
muted: boolean;
|
||||
volume: number;
|
||||
currentTime: number;
|
||||
poster?: string;
|
||||
backgroundColor: string;
|
||||
foregroundColor: string;
|
||||
accentColor: string;
|
||||
},
|
||||
) => void;
|
||||
matchedFilters?: string[];
|
||||
}> = ({
|
||||
src,
|
||||
alt,
|
||||
lang,
|
||||
poster,
|
||||
duration,
|
||||
sensitive,
|
||||
editable,
|
||||
blurhash,
|
||||
visible,
|
||||
onToggleVisibility,
|
||||
backgroundColor = '#000000',
|
||||
foregroundColor = '#ffffff',
|
||||
accentColor = '#ffffff',
|
||||
startTime,
|
||||
startPlaying,
|
||||
startVolume,
|
||||
startMuted,
|
||||
deployPictureInPicture,
|
||||
matchedFilters,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [loadedDuration, setDuration] = useState(duration ?? 0);
|
||||
const [paused, setPaused] = useState(true);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [volume, setVolume] = useState(0.5);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
|
||||
const playerRef = useRef<HTMLDivElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const seekRef = useRef<HTMLDivElement>(null);
|
||||
const volumeRef = useRef<HTMLDivElement>(null);
|
||||
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
|
||||
audioRef,
|
||||
3,
|
||||
);
|
||||
const accessibilityId = useId();
|
||||
|
||||
const [style, spring] = useSpring(() => ({
|
||||
progress: '0%',
|
||||
buffer: '0%',
|
||||
volume: '0%',
|
||||
}));
|
||||
|
||||
const handleAudioRef = useCallback(
|
||||
(c: HTMLVideoElement | null) => {
|
||||
if (audioRef.current && !audioRef.current.paused && c === null) {
|
||||
deployPictureInPicture?.('audio', {
|
||||
src,
|
||||
poster,
|
||||
backgroundColor,
|
||||
foregroundColor,
|
||||
accentColor,
|
||||
currentTime: audioRef.current.currentTime,
|
||||
muted: audioRef.current.muted,
|
||||
volume: audioRef.current.volume,
|
||||
});
|
||||
}
|
||||
|
||||
audioRef.current = c;
|
||||
|
||||
if (audioRef.current) {
|
||||
restoreVolume(audioRef.current);
|
||||
setVolume(audioRef.current.volume);
|
||||
setMuted(audioRef.current.muted);
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
},
|
||||
[
|
||||
spring,
|
||||
setVolume,
|
||||
setMuted,
|
||||
src,
|
||||
poster,
|
||||
backgroundColor,
|
||||
accentColor,
|
||||
foregroundColor,
|
||||
deployPictureInPicture,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
audioRef.current.volume = volume;
|
||||
audioRef.current.muted = muted;
|
||||
}, [volume, muted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof visible !== 'undefined') {
|
||||
setRevealed(visible);
|
||||
} else {
|
||||
setRevealed(
|
||||
displayMedia === 'show_all' ||
|
||||
(displayMedia !== 'hide_all' && !sensitive),
|
||||
);
|
||||
}
|
||||
}, [visible, sensitive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed && audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
}
|
||||
}, [suspendAudio, revealed]);
|
||||
|
||||
useEffect(() => {
|
||||
let nextFrame: ReturnType<typeof requestAnimationFrame>;
|
||||
|
||||
const updateProgress = () => {
|
||||
nextFrame = requestAnimationFrame(() => {
|
||||
if (audioRef.current && audioRef.current.duration > 0) {
|
||||
void spring.start({
|
||||
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
config: config.stiff,
|
||||
});
|
||||
}
|
||||
|
||||
updateProgress();
|
||||
});
|
||||
};
|
||||
|
||||
updateProgress();
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(nextFrame);
|
||||
};
|
||||
}, [spring]);
|
||||
|
||||
const togglePlay = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (audioRef.current.paused) {
|
||||
resumeAudio();
|
||||
void audioRef.current.play();
|
||||
} else {
|
||||
audioRef.current.pause();
|
||||
suspendAudio();
|
||||
}
|
||||
}, [resumeAudio, suspendAudio]);
|
||||
|
||||
const handlePlay = useCallback(() => {
|
||||
setPaused(false);
|
||||
}, []);
|
||||
|
||||
const handlePause = useCallback(() => {
|
||||
setPaused(true);
|
||||
}, []);
|
||||
|
||||
const handleProgress = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lastTimeRange = audioRef.current.buffered.length - 1;
|
||||
|
||||
if (lastTimeRange > -1) {
|
||||
void spring.start({
|
||||
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
}
|
||||
}, [spring]);
|
||||
|
||||
const handleVolumeChange = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setVolume(audioRef.current.volume);
|
||||
setMuted(audioRef.current.muted);
|
||||
|
||||
void spring.start({
|
||||
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
|
||||
immediate: reduceMotion,
|
||||
});
|
||||
|
||||
persistVolume(audioRef.current.volume, audioRef.current.muted);
|
||||
}, [spring, setVolume, setMuted]);
|
||||
|
||||
const handleTimeUpdate = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentTime(audioRef.current.currentTime);
|
||||
}, [setCurrentTime]);
|
||||
|
||||
const toggleMute = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const effectivelyMuted =
|
||||
audioRef.current.muted || audioRef.current.volume === 0;
|
||||
|
||||
if (effectivelyMuted) {
|
||||
audioRef.current.muted = false;
|
||||
|
||||
if (audioRef.current.volume === 0) {
|
||||
audioRef.current.volume = 0.05;
|
||||
}
|
||||
} else {
|
||||
audioRef.current.muted = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleReveal = useCallback(() => {
|
||||
if (onToggleVisibility) {
|
||||
onToggleVisibility();
|
||||
} else {
|
||||
setRevealed((value) => !value);
|
||||
}
|
||||
}, [onToggleVisibility, setRevealed]);
|
||||
|
||||
const handleVolumeMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const handleVolumeMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleVolumeMouseMove, true);
|
||||
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
};
|
||||
|
||||
const handleVolumeMouseMove = (e: MouseEvent) => {
|
||||
if (!volumeRef.current || !audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x } = getPointerPosition(volumeRef.current, e);
|
||||
|
||||
if (!isNaN(x)) {
|
||||
audioRef.current.volume = x;
|
||||
audioRef.current.muted = x > 0 ? false : true;
|
||||
void spring.start({ volume: `${x * 100}%`, immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleVolumeMouseMove, true);
|
||||
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
|
||||
handleVolumeMouseMove(e.nativeEvent);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[spring],
|
||||
);
|
||||
|
||||
const handleSeekMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
const handleSeekMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleSeekMouseMove, true);
|
||||
document.removeEventListener('mouseup', handleSeekMouseUp, true);
|
||||
|
||||
setDragging(false);
|
||||
resumeAudio();
|
||||
void audioRef.current?.play();
|
||||
};
|
||||
|
||||
const handleSeekMouseMove = (e: MouseEvent) => {
|
||||
if (!seekRef.current || !audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { x } = getPointerPosition(seekRef.current, e);
|
||||
const newTime = audioRef.current.duration * x;
|
||||
|
||||
if (!isNaN(newTime)) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
void spring.start({ progress: `${x * 100}%`, immediate: true });
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleSeekMouseMove, true);
|
||||
document.addEventListener('mouseup', handleSeekMouseUp, true);
|
||||
|
||||
setDragging(true);
|
||||
audioRef.current?.pause();
|
||||
handleSeekMouseMove(e.nativeEvent);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
[setDragging, spring, resumeAudio],
|
||||
);
|
||||
|
||||
const handleMouseEnter = useCallback(() => {
|
||||
setHovered(true);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovered(false);
|
||||
}, HOVER_FADE_DELAY);
|
||||
}, [setHovered]);
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setHovered(true);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovered(false);
|
||||
}, HOVER_FADE_DELAY);
|
||||
}, [setHovered]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
setHovered(false);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
}, [setHovered]);
|
||||
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
setHovered(true);
|
||||
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setHovered(false);
|
||||
}, HOVER_FADE_DELAY);
|
||||
}, [setHovered]);
|
||||
|
||||
const handleLoadedData = useCallback(() => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDuration(audioRef.current.duration);
|
||||
|
||||
if (typeof startTime !== 'undefined') {
|
||||
audioRef.current.currentTime = startTime;
|
||||
}
|
||||
|
||||
if (typeof startVolume !== 'undefined') {
|
||||
audioRef.current.volume = startVolume;
|
||||
}
|
||||
|
||||
if (typeof startMuted !== 'undefined') {
|
||||
audioRef.current.muted = startMuted;
|
||||
}
|
||||
|
||||
if (startPlaying) {
|
||||
void audioRef.current.play();
|
||||
}
|
||||
}, [setDuration, startTime, startVolume, startMuted, startPlaying]);
|
||||
|
||||
const seekBy = (time: number) => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newTime = audioRef.current.currentTime + time;
|
||||
|
||||
if (!isNaN(newTime)) {
|
||||
audioRef.current.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
const handleAudioKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
// 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();
|
||||
}
|
||||
},
|
||||
[togglePlay],
|
||||
);
|
||||
|
||||
const handleSkipBackward = useCallback(() => {
|
||||
seekBy(-5);
|
||||
}, []);
|
||||
|
||||
const handleSkipForward = useCallback(() => {
|
||||
seekBy(5);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
const updateVolumeBy = (step: number) => {
|
||||
if (!audioRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newVolume = audioRef.current.volume + step;
|
||||
|
||||
if (!isNaN(newVolume)) {
|
||||
audioRef.current.volume = newVolume;
|
||||
audioRef.current.muted = newVolume > 0 ? false : true;
|
||||
}
|
||||
};
|
||||
|
||||
switch (e.key) {
|
||||
case 'k':
|
||||
case ' ':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleMute();
|
||||
break;
|
||||
case 'j':
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(-5);
|
||||
break;
|
||||
case 'l':
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(5);
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateVolumeBy(0.15);
|
||||
break;
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateVolumeBy(-0.15);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[togglePlay, toggleMute],
|
||||
);
|
||||
|
||||
const springForBand0 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand1 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
const springForBand2 = useSpring({
|
||||
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
|
||||
config: config.wobbly,
|
||||
});
|
||||
|
||||
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
|
||||
const effectivelyMuted = muted || volume === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames('audio-player', { inactive: !revealed })}
|
||||
ref={playerRef}
|
||||
style={
|
||||
{
|
||||
'--player-background-color': backgroundColor,
|
||||
'--player-foreground-color': foregroundColor,
|
||||
'--player-accent-color': accentColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
aria-label={alt}
|
||||
lang={lang}
|
||||
>
|
||||
{blurhash && (
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
dummy={!useBlurhash}
|
||||
/>
|
||||
)}
|
||||
|
||||
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
|
||||
src={src}
|
||||
ref={handleAudioRef}
|
||||
preload={startPlaying ? 'auto' : 'none'}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onProgress={handleProgress}
|
||||
onLoadedData={handleLoadedData}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
crossOrigin='anonymous'
|
||||
/>
|
||||
|
||||
<div
|
||||
className='video-player__seek'
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={progress}
|
||||
aria-valuemax={100}
|
||||
onMouseDown={handleSeekMouseDown}
|
||||
onKeyDownCapture={handleAudioKeyDown}
|
||||
ref={seekRef}
|
||||
role='slider'
|
||||
tabIndex={0}
|
||||
>
|
||||
<animated.div
|
||||
className='video-player__seek__buffer'
|
||||
style={{ width: style.buffer }}
|
||||
/>
|
||||
<animated.div
|
||||
className='video-player__seek__progress'
|
||||
style={{ width: style.progress }}
|
||||
/>
|
||||
|
||||
<animated.span
|
||||
className={classNames('video-player__seek__handle', {
|
||||
active: dragging,
|
||||
})}
|
||||
style={{ left: style.progress }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls'>
|
||||
<div className='audio-player__controls__play'>
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(messages.skipBackward)}
|
||||
aria-label={intl.formatMessage(messages.skipBackward)}
|
||||
className='player-button'
|
||||
onClick={handleSkipBackward}
|
||||
>
|
||||
<Icon id='' icon={Replay5Icon} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls__play'>
|
||||
<svg
|
||||
className='audio-player__visualizer'
|
||||
viewBox='0 0 124 124'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={57}
|
||||
cy={62.5}
|
||||
r={springForBand0.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={65}
|
||||
cy={57.5}
|
||||
r={springForBand1.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
<animated.circle
|
||||
opacity={0.5}
|
||||
cx={63}
|
||||
cy={66.5}
|
||||
r={springForBand2.r}
|
||||
fill='var(--player-accent-color)'
|
||||
/>
|
||||
|
||||
<g clipPath={`url(#${accessibilityId}-clip)`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill={`url(#${accessibilityId}-pattern)`}
|
||||
/>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
fill='var(--player-background-color'
|
||||
opacity={0.45}
|
||||
/>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<pattern
|
||||
id={`${accessibilityId}-pattern`}
|
||||
patternContentUnits='objectBoundingBox'
|
||||
width='1'
|
||||
height='1'
|
||||
>
|
||||
<use href={`#${accessibilityId}-image`} />
|
||||
</pattern>
|
||||
|
||||
<clipPath id={`${accessibilityId}-clip`}>
|
||||
<rect
|
||||
x={14}
|
||||
y={14}
|
||||
width={96}
|
||||
height={96}
|
||||
rx={48}
|
||||
fill='white'
|
||||
/>
|
||||
</clipPath>
|
||||
|
||||
<image
|
||||
id={`${accessibilityId}-image`}
|
||||
href={poster}
|
||||
width={1}
|
||||
height={1}
|
||||
preserveAspectRatio='none'
|
||||
/>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<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
|
||||
id={paused ? 'play' : 'pause'}
|
||||
icon={paused ? PlayArrowIcon : PauseIcon}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className='audio-player__controls__play'>
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(messages.skipForward)}
|
||||
aria-label={intl.formatMessage(messages.skipForward)}
|
||||
className='player-button'
|
||||
onClick={handleSkipForward}
|
||||
>
|
||||
<Icon id='' icon={Forward5Icon} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SpoilerButton
|
||||
hidden={revealed || editable}
|
||||
sensitive={sensitive ?? false}
|
||||
onClick={toggleReveal}
|
||||
matchedFilters={matchedFilters}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={classNames('video-player__controls', { active: hovered })}
|
||||
>
|
||||
<div className='video-player__buttons-bar'>
|
||||
<div className='video-player__buttons left'>
|
||||
<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
|
||||
id={muted ? 'volume-off' : 'volume-up'}
|
||||
icon={muted ? VolumeOffIcon : VolumeUpIcon}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className='video-player__volume active'
|
||||
ref={volumeRef}
|
||||
onMouseDown={handleVolumeMouseDown}
|
||||
role='slider'
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={effectivelyMuted ? 0 : volume * 100}
|
||||
aria-valuemax={100}
|
||||
tabIndex={0}
|
||||
>
|
||||
<animated.div
|
||||
className='video-player__volume__current'
|
||||
style={{ width: style.volume }}
|
||||
/>
|
||||
|
||||
<animated.span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
style={{ left: style.volume }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<span className='video-player__time'>
|
||||
<span className='video-player__time-current'>
|
||||
{formatTime(Math.floor(currentTime))}
|
||||
</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>
|
||||
{formatTime(Math.floor(loadedDuration))}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{!editable && (
|
||||
<>
|
||||
<button
|
||||
type='button'
|
||||
className='player-button'
|
||||
onClick={toggleReveal}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='media_gallery.hide'
|
||||
defaultMessage='Hide'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<a
|
||||
title={intl.formatMessage(messages.download)}
|
||||
aria-label={intl.formatMessage(messages.download)}
|
||||
className='video-player__download__icon player-button'
|
||||
href={src}
|
||||
download
|
||||
>
|
||||
<Icon id='download' icon={DownloadIcon} />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default Audio;
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
/*
|
||||
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
const hex2rgba = (hex, alpha = 1) => {
|
||||
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
};
|
||||
|
||||
export default class Visualizer {
|
||||
|
||||
constructor (tickSize) {
|
||||
this.tickSize = tickSize;
|
||||
}
|
||||
|
||||
setCanvas(canvas) {
|
||||
this.canvas = canvas;
|
||||
if (canvas) {
|
||||
this.context = canvas.getContext('2d');
|
||||
}
|
||||
}
|
||||
|
||||
setAudioContext(context, source) {
|
||||
const analyser = context.createAnalyser();
|
||||
|
||||
analyser.smoothingTimeConstant = 0.6;
|
||||
analyser.fftSize = 2048;
|
||||
|
||||
source.connect(analyser);
|
||||
|
||||
this.analyser = analyser;
|
||||
}
|
||||
|
||||
getTickPoints (count) {
|
||||
const coords = [];
|
||||
|
||||
for(let i = 0; i < count; i++) {
|
||||
const rad = Math.PI * 2 * i / count;
|
||||
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
|
||||
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
|
||||
const dx1 = Math.ceil(cx + x1);
|
||||
const dy1 = Math.ceil(cy + y1);
|
||||
const dx2 = Math.ceil(cx + x2);
|
||||
const dy2 = Math.ceil(cy + y2);
|
||||
|
||||
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
|
||||
|
||||
const lastColor = hex2rgba(mainColor, 0);
|
||||
|
||||
gradient.addColorStop(0, mainColor);
|
||||
gradient.addColorStop(0.6, mainColor);
|
||||
gradient.addColorStop(1, lastColor);
|
||||
|
||||
this.context.beginPath();
|
||||
this.context.strokeStyle = gradient;
|
||||
this.context.lineWidth = 2;
|
||||
this.context.moveTo(dx1, dy1);
|
||||
this.context.lineTo(dx2, dy2);
|
||||
this.context.stroke();
|
||||
}
|
||||
|
||||
getTicks (count, size, radius, scaleCoefficient) {
|
||||
const ticks = this.getTickPoints(count);
|
||||
const lesser = 200;
|
||||
const m = [];
|
||||
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
|
||||
const frequencyData = new Uint8Array(bufferLength);
|
||||
const allScales = [];
|
||||
|
||||
if (this.analyser) {
|
||||
this.analyser.getByteFrequencyData(frequencyData);
|
||||
}
|
||||
|
||||
ticks.forEach((tick, i) => {
|
||||
const coef = 1 - i / (ticks.length * 2.5);
|
||||
|
||||
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
|
||||
|
||||
if (delta < 0) {
|
||||
delta = 0;
|
||||
}
|
||||
|
||||
const k = radius / (radius - (size + delta));
|
||||
|
||||
const x1 = tick.x * (radius - size);
|
||||
const y1 = tick.y * (radius - size);
|
||||
const x2 = x1 * k;
|
||||
const y2 = y1 * k;
|
||||
|
||||
m.push({ x1, y1, x2, y2 });
|
||||
|
||||
if (i < 20) {
|
||||
let scale = delta / (200 * scaleCoefficient);
|
||||
scale = scale < 1 ? 1 : scale;
|
||||
allScales.push(scale);
|
||||
}
|
||||
});
|
||||
|
||||
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
|
||||
|
||||
return m.map(({ x1, y1, x2, y2 }) => ({
|
||||
x1: x1,
|
||||
y1: y1,
|
||||
x2: x2 * scale,
|
||||
y2: y2 * scale,
|
||||
}));
|
||||
}
|
||||
|
||||
clear (width, height) {
|
||||
this.context.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
draw (cx, cy, color, radius, coefficient) {
|
||||
this.context.save();
|
||||
|
||||
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
|
||||
|
||||
ticks.forEach(tick => {
|
||||
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
|
||||
});
|
||||
|
||||
this.context.restore();
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue