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.

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