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.

555 lines
16 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { defineMessages, 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. const hex2rgba = (hex, alpha = 1) => {
  11. const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
  12. return `rgba(${r}, ${g}, ${b}, ${alpha})`;
  13. };
  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. });
  21. // Some parts of the canvas rendering code in this file have been adopted from
  22. // https://codepen.io/alexdevp/full/RNELPV by Alex Permyakov
  23. const TICK_SIZE = 10;
  24. const PADDING = 180;
  25. export default @injectIntl
  26. class Audio extends React.PureComponent {
  27. static propTypes = {
  28. src: PropTypes.string.isRequired,
  29. alt: PropTypes.string,
  30. poster: PropTypes.string,
  31. duration: PropTypes.number,
  32. width: PropTypes.number,
  33. height: PropTypes.number,
  34. editable: PropTypes.bool,
  35. fullscreen: PropTypes.bool,
  36. intl: PropTypes.object.isRequired,
  37. cacheWidth: PropTypes.func,
  38. backgroundColor: PropTypes.string,
  39. foregroundColor: PropTypes.string,
  40. accentColor: PropTypes.string,
  41. };
  42. state = {
  43. width: this.props.width,
  44. currentTime: 0,
  45. buffer: 0,
  46. duration: null,
  47. paused: true,
  48. muted: false,
  49. volume: 0.5,
  50. dragging: false,
  51. };
  52. setPlayerRef = c => {
  53. this.player = c;
  54. if (this.player) {
  55. this._setDimensions();
  56. }
  57. }
  58. _setDimensions () {
  59. const width = this.player.offsetWidth;
  60. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  61. if (this.props.cacheWidth) {
  62. this.props.cacheWidth(width);
  63. }
  64. this.setState({ width, height });
  65. }
  66. setSeekRef = c => {
  67. this.seek = c;
  68. }
  69. setVolumeRef = c => {
  70. this.volume = c;
  71. }
  72. setAudioRef = c => {
  73. this.audio = c;
  74. if (this.audio) {
  75. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  76. }
  77. }
  78. setCanvasRef = c => {
  79. this.canvas = c;
  80. if (c) {
  81. this.canvasContext = c.getContext('2d');
  82. }
  83. }
  84. componentDidMount () {
  85. window.addEventListener('scroll', this.handleScroll);
  86. window.addEventListener('resize', this.handleResize, { passive: true });
  87. }
  88. componentDidUpdate (prevProps, prevState) {
  89. if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
  90. this._clear();
  91. this._draw();
  92. }
  93. }
  94. componentWillUnmount () {
  95. window.removeEventListener('scroll', this.handleScroll);
  96. window.removeEventListener('resize', this.handleResize);
  97. }
  98. togglePlay = () => {
  99. if (this.state.paused) {
  100. this.setState({ paused: false }, () => this.audio.play());
  101. } else {
  102. this.setState({ paused: true }, () => this.audio.pause());
  103. }
  104. }
  105. handleResize = debounce(() => {
  106. if (this.player) {
  107. this._setDimensions();
  108. }
  109. }, 250, {
  110. trailing: true,
  111. });
  112. handlePlay = () => {
  113. this.setState({ paused: false });
  114. if (this.canvas && !this.audioContext) {
  115. this._initAudioContext();
  116. }
  117. if (this.audioContext && this.audioContext.state === 'suspended') {
  118. this.audioContext.resume();
  119. }
  120. this._renderCanvas();
  121. }
  122. handlePause = () => {
  123. this.setState({ paused: true });
  124. if (this.audioContext) {
  125. this.audioContext.suspend();
  126. }
  127. }
  128. handleProgress = () => {
  129. const lastTimeRange = this.audio.buffered.length - 1;
  130. if (lastTimeRange > -1) {
  131. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  132. }
  133. }
  134. toggleMute = () => {
  135. const muted = !this.state.muted;
  136. this.setState({ muted }, () => {
  137. this.audio.muted = muted;
  138. });
  139. }
  140. handleVolumeMouseDown = e => {
  141. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  142. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  143. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  144. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  145. this.handleMouseVolSlide(e);
  146. e.preventDefault();
  147. e.stopPropagation();
  148. }
  149. handleVolumeMouseUp = () => {
  150. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  151. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  152. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  153. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  154. }
  155. handleMouseDown = e => {
  156. document.addEventListener('mousemove', this.handleMouseMove, true);
  157. document.addEventListener('mouseup', this.handleMouseUp, true);
  158. document.addEventListener('touchmove', this.handleMouseMove, true);
  159. document.addEventListener('touchend', this.handleMouseUp, true);
  160. this.setState({ dragging: true });
  161. this.audio.pause();
  162. this.handleMouseMove(e);
  163. e.preventDefault();
  164. e.stopPropagation();
  165. }
  166. handleMouseUp = () => {
  167. document.removeEventListener('mousemove', this.handleMouseMove, true);
  168. document.removeEventListener('mouseup', this.handleMouseUp, true);
  169. document.removeEventListener('touchmove', this.handleMouseMove, true);
  170. document.removeEventListener('touchend', this.handleMouseUp, true);
  171. this.setState({ dragging: false });
  172. this.audio.play();
  173. }
  174. handleMouseMove = throttle(e => {
  175. const { x } = getPointerPosition(this.seek, e);
  176. const currentTime = this.audio.duration * x;
  177. if (!isNaN(currentTime)) {
  178. this.setState({ currentTime }, () => {
  179. this.audio.currentTime = currentTime;
  180. });
  181. }
  182. }, 15);
  183. handleTimeUpdate = () => {
  184. this.setState({
  185. currentTime: this.audio.currentTime,
  186. duration: Math.floor(this.audio.duration),
  187. });
  188. }
  189. handleMouseVolSlide = throttle(e => {
  190. const { x } = getPointerPosition(this.volume, e);
  191. if(!isNaN(x)) {
  192. this.setState({ volume: x }, () => {
  193. this.audio.volume = x;
  194. });
  195. }
  196. }, 15);
  197. handleScroll = throttle(() => {
  198. if (!this.canvas || !this.audio) {
  199. return;
  200. }
  201. const { top, height } = this.canvas.getBoundingClientRect();
  202. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  203. if (!this.state.paused && !inView) {
  204. this.setState({ paused: true }, () => this.audio.pause());
  205. }
  206. }, 150, { trailing: true });
  207. handleMouseEnter = () => {
  208. this.setState({ hovered: true });
  209. }
  210. handleMouseLeave = () => {
  211. this.setState({ hovered: false });
  212. }
  213. _initAudioContext () {
  214. const context = new AudioContext();
  215. const analyser = context.createAnalyser();
  216. const source = context.createMediaElementSource(this.audio);
  217. analyser.smoothingTimeConstant = 0.6;
  218. analyser.fftSize = 2048;
  219. source.connect(analyser);
  220. source.connect(context.destination);
  221. this.audioContext = context;
  222. this.analyser = analyser;
  223. }
  224. handleDownload = () => {
  225. fetch(this.props.src).then(res => res.blob()).then(blob => {
  226. const element = document.createElement('a');
  227. const objectURL = URL.createObjectURL(blob);
  228. element.setAttribute('href', objectURL);
  229. element.setAttribute('download', fileNameFromURL(this.props.src));
  230. document.body.appendChild(element);
  231. element.click();
  232. document.body.removeChild(element);
  233. URL.revokeObjectURL(objectURL);
  234. }).catch(err => {
  235. console.error(err);
  236. });
  237. }
  238. _renderCanvas () {
  239. requestAnimationFrame(() => {
  240. this.handleTimeUpdate();
  241. this._clear();
  242. this._draw();
  243. if (!this.state.paused) {
  244. this._renderCanvas();
  245. }
  246. });
  247. }
  248. _clear () {
  249. this.canvasContext.clearRect(0, 0, this.state.width, this.state.height);
  250. }
  251. _draw () {
  252. this.canvasContext.save();
  253. const ticks = this._getTicks(360 * this._getScaleCoefficient(), TICK_SIZE);
  254. ticks.forEach(tick => {
  255. this._drawTick(tick.x1, tick.y1, tick.x2, tick.y2);
  256. });
  257. this.canvasContext.restore();
  258. }
  259. _getRadius () {
  260. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  261. }
  262. _getScaleCoefficient () {
  263. return (this.state.height || this.props.height) / 982;
  264. }
  265. _getTicks (count, size, animationParams = [0, 90]) {
  266. const radius = this._getRadius();
  267. const ticks = this._getTickPoints(count);
  268. const lesser = 200;
  269. const m = [];
  270. const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
  271. const frequencyData = new Uint8Array(bufferLength);
  272. const allScales = [];
  273. const scaleCoefficient = this._getScaleCoefficient();
  274. if (this.analyser) {
  275. this.analyser.getByteFrequencyData(frequencyData);
  276. }
  277. ticks.forEach((tick, i) => {
  278. const coef = 1 - i / (ticks.length * 2.5);
  279. let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
  280. if (delta < 0) {
  281. delta = 0;
  282. }
  283. let k;
  284. if (animationParams[0] <= tick.angle && tick.angle <= animationParams[1]) {
  285. k = radius / (radius - this._getSize(tick.angle, animationParams[0], animationParams[1]) - delta);
  286. } else {
  287. k = radius / (radius - (size + delta));
  288. }
  289. const x1 = tick.x * (radius - size);
  290. const y1 = tick.y * (radius - size);
  291. const x2 = x1 * k;
  292. const y2 = y1 * k;
  293. m.push({ x1, y1, x2, y2 });
  294. if (i < 20) {
  295. let scale = delta / (200 * scaleCoefficient);
  296. scale = scale < 1 ? 1 : scale;
  297. allScales.push(scale);
  298. }
  299. });
  300. const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
  301. return m.map(({ x1, y1, x2, y2 }) => ({
  302. x1: x1,
  303. y1: y1,
  304. x2: x2 * scale,
  305. y2: y2 * scale,
  306. }));
  307. }
  308. _getSize (angle, l, r) {
  309. const scaleCoefficient = this._getScaleCoefficient();
  310. const maxTickSize = TICK_SIZE * 9 * scaleCoefficient;
  311. const m = (r - l) / 2;
  312. const x = (angle - l);
  313. let h;
  314. if (x === m) {
  315. return maxTickSize;
  316. }
  317. const d = Math.abs(m - x);
  318. const v = 40 * Math.sqrt(1 / d);
  319. if (v > maxTickSize) {
  320. h = maxTickSize;
  321. } else {
  322. h = Math.max(TICK_SIZE, v);
  323. }
  324. return h;
  325. }
  326. _getTickPoints (count) {
  327. const PI = 360;
  328. const coords = [];
  329. const step = PI / count;
  330. let rad;
  331. for(let deg = 0; deg < PI; deg += step) {
  332. rad = deg * Math.PI / (PI / 2);
  333. coords.push({ x: Math.cos(rad), y: -Math.sin(rad), angle: deg });
  334. }
  335. return coords;
  336. }
  337. _drawTick (x1, y1, x2, y2) {
  338. const cx = this._getCX();
  339. const cy = this._getCY();
  340. const dx1 = Math.ceil(cx + x1);
  341. const dy1 = Math.ceil(cy + y1);
  342. const dx2 = Math.ceil(cx + x2);
  343. const dy2 = Math.ceil(cy + y2);
  344. const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
  345. const mainColor = this._getAccentColor();
  346. const lastColor = hex2rgba(mainColor, 0);
  347. gradient.addColorStop(0, mainColor);
  348. gradient.addColorStop(0.6, mainColor);
  349. gradient.addColorStop(1, lastColor);
  350. this.canvasContext.beginPath();
  351. this.canvasContext.strokeStyle = gradient;
  352. this.canvasContext.lineWidth = 2;
  353. this.canvasContext.moveTo(dx1, dy1);
  354. this.canvasContext.lineTo(dx2, dy2);
  355. this.canvasContext.stroke();
  356. }
  357. _getCX() {
  358. return Math.floor(this.state.width / 2);
  359. }
  360. _getCY() {
  361. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  362. }
  363. _getAccentColor () {
  364. return this.props.accentColor || '#ffffff';
  365. }
  366. _getBackgroundColor () {
  367. return this.props.backgroundColor || '#000000';
  368. }
  369. _getForegroundColor () {
  370. return this.props.foregroundColor || '#ffffff';
  371. }
  372. render () {
  373. const { src, intl, alt, editable } = this.props;
  374. const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
  375. const progress = (currentTime / duration) * 100;
  376. return (
  377. <div className={classNames('audio-player', { editable })} 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}>
  378. <audio
  379. src={src}
  380. ref={this.setAudioRef}
  381. preload='none'
  382. onPlay={this.handlePlay}
  383. onPause={this.handlePause}
  384. onProgress={this.handleProgress}
  385. crossOrigin='anonymous'
  386. />
  387. <canvas
  388. role='button'
  389. className='audio-player__canvas'
  390. width={this.state.width}
  391. height={this.state.height}
  392. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  393. ref={this.setCanvasRef}
  394. onClick={this.togglePlay}
  395. title={alt}
  396. aria-label={alt}
  397. />
  398. <img
  399. src={this.props.poster}
  400. alt=''
  401. width={(this._getRadius() - TICK_SIZE) * 2}
  402. height={(this._getRadius() - TICK_SIZE) * 2}
  403. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  404. />
  405. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  406. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  407. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  408. <span
  409. className={classNames('video-player__seek__handle', { active: dragging })}
  410. tabIndex='0'
  411. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  412. />
  413. </div>
  414. <div className='video-player__controls active'>
  415. <div className='video-player__buttons-bar'>
  416. <div className='video-player__buttons left'>
  417. <button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
  418. <button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
  419. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  420. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  421. <span
  422. className={classNames('video-player__volume__handle')}
  423. tabIndex='0'
  424. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  425. />
  426. </div>
  427. <span className='video-player__time'>
  428. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  429. <span className='video-player__time-sep'>/</span>
  430. <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
  431. </span>
  432. </div>
  433. <div className='video-player__buttons right'>
  434. <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
  435. </div>
  436. </div>
  437. </div>
  438. </div>
  439. );
  440. }
  441. }