You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

572 lines
17 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { defineMessages, FormattedMessage, injectIntl } from 'react-intl';
  4. import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
  5. import Icon from 'mastodon/components/icon';
  6. import classNames from 'classnames';
  7. import { throttle, debounce } from 'lodash';
  8. import Visualizer from './visualizer';
  9. import { displayMedia, useBlurhash } from '../../initial_state';
  10. import Blurhash from '../../components/blurhash';
  11. import { is } from 'immutable';
  12. const messages = defineMessages({
  13. play: { id: 'video.play', defaultMessage: 'Play' },
  14. pause: { id: 'video.pause', defaultMessage: 'Pause' },
  15. mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
  16. unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
  17. download: { id: 'video.download', defaultMessage: 'Download file' },
  18. hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
  19. });
  20. const TICK_SIZE = 10;
  21. const PADDING = 180;
  22. class Audio extends React.PureComponent {
  23. static propTypes = {
  24. src: PropTypes.string.isRequired,
  25. alt: PropTypes.string,
  26. lang: PropTypes.string,
  27. poster: PropTypes.string,
  28. duration: PropTypes.number,
  29. width: PropTypes.number,
  30. height: PropTypes.number,
  31. sensitive: PropTypes.bool,
  32. editable: PropTypes.bool,
  33. fullscreen: PropTypes.bool,
  34. intl: PropTypes.object.isRequired,
  35. blurhash: PropTypes.string,
  36. cacheWidth: PropTypes.func,
  37. visible: PropTypes.bool,
  38. onToggleVisibility: PropTypes.func,
  39. backgroundColor: PropTypes.string,
  40. foregroundColor: PropTypes.string,
  41. accentColor: PropTypes.string,
  42. currentTime: PropTypes.number,
  43. autoPlay: PropTypes.bool,
  44. volume: PropTypes.number,
  45. muted: PropTypes.bool,
  46. deployPictureInPicture: PropTypes.func,
  47. };
  48. state = {
  49. width: this.props.width,
  50. currentTime: 0,
  51. buffer: 0,
  52. duration: null,
  53. paused: true,
  54. muted: false,
  55. volume: 1,
  56. dragging: false,
  57. revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
  58. };
  59. constructor (props) {
  60. super(props);
  61. this.visualizer = new Visualizer(TICK_SIZE);
  62. }
  63. setPlayerRef = c => {
  64. this.player = c;
  65. if (this.player) {
  66. this._setDimensions();
  67. }
  68. };
  69. _pack() {
  70. return {
  71. src: this.props.src,
  72. volume: this.state.volume,
  73. muted: this.state.muted,
  74. currentTime: this.audio.currentTime,
  75. poster: this.props.poster,
  76. backgroundColor: this.props.backgroundColor,
  77. foregroundColor: this.props.foregroundColor,
  78. accentColor: this.props.accentColor,
  79. sensitive: this.props.sensitive,
  80. visible: this.props.visible,
  81. };
  82. }
  83. _setDimensions () {
  84. const width = this.player.offsetWidth;
  85. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  86. if (this.props.cacheWidth) {
  87. this.props.cacheWidth(width);
  88. }
  89. this.setState({ width, height });
  90. }
  91. setSeekRef = c => {
  92. this.seek = c;
  93. };
  94. setVolumeRef = c => {
  95. this.volume = c;
  96. };
  97. setAudioRef = c => {
  98. this.audio = c;
  99. if (this.audio) {
  100. this.audio.volume = 1;
  101. this.audio.muted = false;
  102. }
  103. };
  104. setCanvasRef = c => {
  105. this.canvas = c;
  106. this.visualizer.setCanvas(c);
  107. };
  108. componentDidMount () {
  109. window.addEventListener('scroll', this.handleScroll);
  110. window.addEventListener('resize', this.handleResize, { passive: true });
  111. }
  112. componentDidUpdate (prevProps, prevState) {
  113. if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
  114. this._clear();
  115. this._draw();
  116. }
  117. }
  118. componentWillReceiveProps (nextProps) {
  119. if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
  120. this.setState({ revealed: nextProps.visible });
  121. }
  122. }
  123. componentWillUnmount () {
  124. window.removeEventListener('scroll', this.handleScroll);
  125. window.removeEventListener('resize', this.handleResize);
  126. if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
  127. this.props.deployPictureInPicture('audio', this._pack());
  128. }
  129. }
  130. togglePlay = () => {
  131. if (!this.audioContext) {
  132. this._initAudioContext();
  133. }
  134. if (this.state.paused) {
  135. this.setState({ paused: false }, () => this.audio.play());
  136. } else {
  137. this.setState({ paused: true }, () => this.audio.pause());
  138. }
  139. };
  140. handleResize = debounce(() => {
  141. if (this.player) {
  142. this._setDimensions();
  143. }
  144. }, 250, {
  145. trailing: true,
  146. });
  147. handlePlay = () => {
  148. this.setState({ paused: false });
  149. if (this.audioContext && this.audioContext.state === 'suspended') {
  150. this.audioContext.resume();
  151. }
  152. this._renderCanvas();
  153. };
  154. handlePause = () => {
  155. this.setState({ paused: true });
  156. if (this.audioContext) {
  157. this.audioContext.suspend();
  158. }
  159. };
  160. handleProgress = () => {
  161. const lastTimeRange = this.audio.buffered.length - 1;
  162. if (lastTimeRange > -1) {
  163. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  164. }
  165. };
  166. toggleMute = () => {
  167. const muted = !this.state.muted;
  168. this.setState({ muted }, () => {
  169. if (this.gainNode) {
  170. this.gainNode.gain.value = muted ? 0 : this.state.volume;
  171. }
  172. });
  173. };
  174. toggleReveal = () => {
  175. if (this.props.onToggleVisibility) {
  176. this.props.onToggleVisibility();
  177. } else {
  178. this.setState({ revealed: !this.state.revealed });
  179. }
  180. };
  181. handleVolumeMouseDown = e => {
  182. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  183. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  184. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  185. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  186. this.handleMouseVolSlide(e);
  187. e.preventDefault();
  188. e.stopPropagation();
  189. };
  190. handleVolumeMouseUp = () => {
  191. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  192. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  193. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  194. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  195. };
  196. handleMouseDown = e => {
  197. document.addEventListener('mousemove', this.handleMouseMove, true);
  198. document.addEventListener('mouseup', this.handleMouseUp, true);
  199. document.addEventListener('touchmove', this.handleMouseMove, true);
  200. document.addEventListener('touchend', this.handleMouseUp, true);
  201. this.setState({ dragging: true });
  202. this.audio.pause();
  203. this.handleMouseMove(e);
  204. e.preventDefault();
  205. e.stopPropagation();
  206. };
  207. handleMouseUp = () => {
  208. document.removeEventListener('mousemove', this.handleMouseMove, true);
  209. document.removeEventListener('mouseup', this.handleMouseUp, true);
  210. document.removeEventListener('touchmove', this.handleMouseMove, true);
  211. document.removeEventListener('touchend', this.handleMouseUp, true);
  212. this.setState({ dragging: false });
  213. this.audio.play();
  214. };
  215. handleMouseMove = throttle(e => {
  216. const { x } = getPointerPosition(this.seek, e);
  217. const currentTime = this.audio.duration * x;
  218. if (!isNaN(currentTime)) {
  219. this.setState({ currentTime }, () => {
  220. this.audio.currentTime = currentTime;
  221. });
  222. }
  223. }, 15);
  224. handleTimeUpdate = () => {
  225. this.setState({
  226. currentTime: this.audio.currentTime,
  227. duration: this.audio.duration,
  228. });
  229. };
  230. handleMouseVolSlide = throttle(e => {
  231. const { x } = getPointerPosition(this.volume, e);
  232. if(!isNaN(x)) {
  233. this.setState({ volume: x }, () => {
  234. if (this.gainNode) {
  235. this.gainNode.gain.value = this.state.muted ? 0 : x;
  236. }
  237. });
  238. }
  239. }, 15);
  240. handleScroll = throttle(() => {
  241. if (!this.canvas || !this.audio) {
  242. return;
  243. }
  244. const { top, height } = this.canvas.getBoundingClientRect();
  245. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  246. if (!this.state.paused && !inView) {
  247. this.audio.pause();
  248. if (this.props.deployPictureInPicture) {
  249. this.props.deployPictureInPicture('audio', this._pack());
  250. }
  251. this.setState({ paused: true });
  252. }
  253. }, 150, { trailing: true });
  254. handleMouseEnter = () => {
  255. this.setState({ hovered: true });
  256. };
  257. handleMouseLeave = () => {
  258. this.setState({ hovered: false });
  259. };
  260. handleLoadedData = () => {
  261. const { autoPlay, currentTime } = this.props;
  262. if (currentTime) {
  263. this.audio.currentTime = currentTime;
  264. }
  265. if (autoPlay) {
  266. this.togglePlay();
  267. }
  268. };
  269. _initAudioContext () {
  270. const AudioContext = window.AudioContext || window.webkitAudioContext;
  271. const context = new AudioContext();
  272. const source = context.createMediaElementSource(this.audio);
  273. const gainNode = context.createGain();
  274. gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
  275. this.visualizer.setAudioContext(context, source);
  276. source.connect(gainNode);
  277. gainNode.connect(context.destination);
  278. this.audioContext = context;
  279. this.gainNode = gainNode;
  280. }
  281. handleDownload = () => {
  282. fetch(this.props.src).then(res => res.blob()).then(blob => {
  283. const element = document.createElement('a');
  284. const objectURL = URL.createObjectURL(blob);
  285. element.setAttribute('href', objectURL);
  286. element.setAttribute('download', fileNameFromURL(this.props.src));
  287. document.body.appendChild(element);
  288. element.click();
  289. document.body.removeChild(element);
  290. URL.revokeObjectURL(objectURL);
  291. }).catch(err => {
  292. console.error(err);
  293. });
  294. };
  295. _renderCanvas () {
  296. requestAnimationFrame(() => {
  297. if (!this.audio) return;
  298. this.handleTimeUpdate();
  299. this._clear();
  300. this._draw();
  301. if (!this.state.paused) {
  302. this._renderCanvas();
  303. }
  304. });
  305. }
  306. _clear() {
  307. this.visualizer.clear(this.state.width, this.state.height);
  308. }
  309. _draw() {
  310. this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
  311. }
  312. _getRadius () {
  313. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  314. }
  315. _getScaleCoefficient () {
  316. return (this.state.height || this.props.height) / 982;
  317. }
  318. _getCX() {
  319. return Math.floor(this.state.width / 2);
  320. }
  321. _getCY() {
  322. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  323. }
  324. _getAccentColor () {
  325. return this.props.accentColor || '#ffffff';
  326. }
  327. _getBackgroundColor () {
  328. return this.props.backgroundColor || '#000000';
  329. }
  330. _getForegroundColor () {
  331. return this.props.foregroundColor || '#ffffff';
  332. }
  333. seekBy (time) {
  334. const currentTime = this.audio.currentTime + time;
  335. if (!isNaN(currentTime)) {
  336. this.setState({ currentTime }, () => {
  337. this.audio.currentTime = currentTime;
  338. });
  339. }
  340. }
  341. handleAudioKeyDown = e => {
  342. // On the audio element or the seek bar, we can safely use the space bar
  343. // for playback control because there are no buttons to press
  344. if (e.key === ' ') {
  345. e.preventDefault();
  346. e.stopPropagation();
  347. this.togglePlay();
  348. }
  349. };
  350. handleKeyDown = e => {
  351. switch(e.key) {
  352. case 'k':
  353. e.preventDefault();
  354. e.stopPropagation();
  355. this.togglePlay();
  356. break;
  357. case 'm':
  358. e.preventDefault();
  359. e.stopPropagation();
  360. this.toggleMute();
  361. break;
  362. case 'j':
  363. e.preventDefault();
  364. e.stopPropagation();
  365. this.seekBy(-10);
  366. break;
  367. case 'l':
  368. e.preventDefault();
  369. e.stopPropagation();
  370. this.seekBy(10);
  371. break;
  372. }
  373. };
  374. render () {
  375. const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash } = this.props;
  376. const { paused, muted, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
  377. const progress = Math.min((currentTime / duration) * 100, 100);
  378. let warning;
  379. if (sensitive) {
  380. warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
  381. } else {
  382. warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
  383. }
  384. return (
  385. <div className={classNames('audio-player', { editable, inactive: !revealed })} 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}>
  386. <Blurhash
  387. hash={blurhash}
  388. className={classNames('media-gallery__preview', {
  389. 'media-gallery__preview--hidden': revealed,
  390. })}
  391. dummy={!useBlurhash}
  392. />
  393. {(revealed || editable) && <audio
  394. src={src}
  395. ref={this.setAudioRef}
  396. preload={autoPlay ? 'auto' : 'none'}
  397. onPlay={this.handlePlay}
  398. onPause={this.handlePause}
  399. onProgress={this.handleProgress}
  400. onLoadedData={this.handleLoadedData}
  401. crossOrigin='anonymous'
  402. />}
  403. <canvas
  404. role='button'
  405. tabIndex='0'
  406. className='audio-player__canvas'
  407. width={this.state.width}
  408. height={this.state.height}
  409. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  410. ref={this.setCanvasRef}
  411. onClick={this.togglePlay}
  412. onKeyDown={this.handleAudioKeyDown}
  413. title={alt}
  414. aria-label={alt}
  415. lang={lang}
  416. />
  417. <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
  418. <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
  419. <span className='spoiler-button__overlay__label'>{warning}</span>
  420. </button>
  421. </div>
  422. {(revealed || editable) && <img
  423. src={this.props.poster}
  424. alt=''
  425. width={(this._getRadius() - TICK_SIZE) * 2}
  426. height={(this._getRadius() - TICK_SIZE) * 2}
  427. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  428. />}
  429. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  430. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  431. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  432. <span
  433. className={classNames('video-player__seek__handle', { active: dragging })}
  434. tabIndex='0'
  435. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  436. onKeyDown={this.handleAudioKeyDown}
  437. />
  438. </div>
  439. <div className='video-player__controls active'>
  440. <div className='video-player__buttons-bar'>
  441. <div className='video-player__buttons left'>
  442. <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'} fixedWidth /></button>
  443. <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'} fixedWidth /></button>
  444. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  445. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  446. <span
  447. className='video-player__volume__handle'
  448. tabIndex='0'
  449. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  450. />
  451. </div>
  452. <span className='video-player__time'>
  453. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  454. <span className='video-player__time-sep'>/</span>
  455. <span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
  456. </span>
  457. </div>
  458. <div className='video-player__buttons right'>
  459. {!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' fixedWidth /></button>}
  460. <a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
  461. <Icon id={'download'} fixedWidth />
  462. </a>
  463. </div>
  464. </div>
  465. </div>
  466. </div>
  467. );
  468. }
  469. }
  470. export default injectIntl(Audio);