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 { throttle, debounce } from "lodash"; import { Blurhash } from "flavours/glitch/components/blurhash"; import { Icon } from "flavours/glitch/components/icon"; import { formatTime, getPointerPosition, fileNameFromURL } from "flavours/glitch/features/video"; import { displayMedia } from "flavours/glitch/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, useBlurhash: PropTypes.bool, }; 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 (width && width !== this.state.containerWidth) { 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 (this.player) { this._setDimensions(); } 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, useBlurhash } = 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) &&