index.js 17 KB

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