index.jsx 18 KB

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