import PropTypes from 'prop-types'; import { PureComponent } from 'react'; import { defineMessages, FormattedMessage, injectIntl } from 'react-intl'; import classNames from 'classnames'; import { is } from 'immutable'; import DownloadIcon from '@material-symbols/svg-600/outlined/download.svg?react'; import PauseIcon from '@material-symbols/svg-600/outlined/pause.svg?react'; import PlayArrowIcon from '@material-symbols/svg-600/outlined/play_arrow-fill.svg?react'; import VisibilityOffIcon from '@material-symbols/svg-600/outlined/visibility_off.svg?react'; import VolumeOffIcon from '@material-symbols/svg-600/outlined/volume_off-fill.svg?react'; import VolumeUpIcon from '@material-symbols/svg-600/outlined/volume_up-fill.svg?react'; import { throttle, debounce } from 'lodash'; import { Icon } from 'mastodon/components/icon'; 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 sound' }, unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' }, 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, }; 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 } = 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; let warning; if (sensitive) { warning = ; } else { warning = ; } return (
{(revealed || editable) &&