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.

728 lines
19 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 { encode, decode } from 'blurhash';
  9. import { getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
  10. import { debounce } from 'lodash';
  11. const digitCharacters = [
  12. '0',
  13. '1',
  14. '2',
  15. '3',
  16. '4',
  17. '5',
  18. '6',
  19. '7',
  20. '8',
  21. '9',
  22. 'A',
  23. 'B',
  24. 'C',
  25. 'D',
  26. 'E',
  27. 'F',
  28. 'G',
  29. 'H',
  30. 'I',
  31. 'J',
  32. 'K',
  33. 'L',
  34. 'M',
  35. 'N',
  36. 'O',
  37. 'P',
  38. 'Q',
  39. 'R',
  40. 'S',
  41. 'T',
  42. 'U',
  43. 'V',
  44. 'W',
  45. 'X',
  46. 'Y',
  47. 'Z',
  48. 'a',
  49. 'b',
  50. 'c',
  51. 'd',
  52. 'e',
  53. 'f',
  54. 'g',
  55. 'h',
  56. 'i',
  57. 'j',
  58. 'k',
  59. 'l',
  60. 'm',
  61. 'n',
  62. 'o',
  63. 'p',
  64. 'q',
  65. 'r',
  66. 's',
  67. 't',
  68. 'u',
  69. 'v',
  70. 'w',
  71. 'x',
  72. 'y',
  73. 'z',
  74. '#',
  75. '$',
  76. '%',
  77. '*',
  78. '+',
  79. ',',
  80. '-',
  81. '.',
  82. ':',
  83. ';',
  84. '=',
  85. '?',
  86. '@',
  87. '[',
  88. ']',
  89. '^',
  90. '_',
  91. '{',
  92. '|',
  93. '}',
  94. '~',
  95. ];
  96. const decode83 = (str) => {
  97. let value = 0;
  98. let c, digit;
  99. for (let i = 0; i < str.length; i++) {
  100. c = str[i];
  101. digit = digitCharacters.indexOf(c);
  102. value = value * 83 + digit;
  103. }
  104. return value;
  105. };
  106. const decodeRGB = int => ({
  107. r: Math.max(0, (int >> 16)),
  108. g: Math.max(0, (int >> 8) & 255),
  109. b: Math.max(0, (int & 255)),
  110. });
  111. const luma = ({ r, g, b }) => 0.2126 * r + 0.7152 * g + 0.0722 * b;
  112. const adjustColor = ({ r, g, b }, lumaThreshold = 100) => {
  113. let delta;
  114. if (luma({ r, g, b }) >= lumaThreshold) {
  115. delta = -80;
  116. } else {
  117. delta = 80;
  118. }
  119. return {
  120. r: r + delta,
  121. g: g + delta,
  122. b: b + delta,
  123. };
  124. };
  125. const messages = defineMessages({
  126. play: { id: 'video.play', defaultMessage: 'Play' },
  127. pause: { id: 'video.pause', defaultMessage: 'Pause' },
  128. mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
  129. unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
  130. download: { id: 'video.download', defaultMessage: 'Download file' },
  131. });
  132. const TICK_SIZE = 10;
  133. const PADDING = 180;
  134. export default @injectIntl
  135. class Audio extends React.PureComponent {
  136. static propTypes = {
  137. src: PropTypes.string.isRequired,
  138. alt: PropTypes.string,
  139. poster: PropTypes.string,
  140. duration: PropTypes.number,
  141. width: PropTypes.number,
  142. height: PropTypes.number,
  143. editable: PropTypes.bool,
  144. fullscreen: PropTypes.bool,
  145. intl: PropTypes.object.isRequired,
  146. cacheWidth: PropTypes.func,
  147. blurhash: PropTypes.string,
  148. };
  149. state = {
  150. width: this.props.width,
  151. currentTime: 0,
  152. buffer: 0,
  153. duration: null,
  154. paused: true,
  155. muted: false,
  156. volume: 0.5,
  157. dragging: false,
  158. color: { r: 255, g: 255, b: 255 },
  159. };
  160. setPlayerRef = c => {
  161. this.player = c;
  162. if (this.player) {
  163. this._setDimensions();
  164. }
  165. }
  166. _setDimensions () {
  167. const width = this.player.offsetWidth;
  168. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  169. if (this.props.cacheWidth) {
  170. this.props.cacheWidth(width);
  171. }
  172. this.setState({ width, height });
  173. }
  174. setSeekRef = c => {
  175. this.seek = c;
  176. }
  177. setVolumeRef = c => {
  178. this.volume = c;
  179. }
  180. setAudioRef = c => {
  181. this.audio = c;
  182. if (this.audio) {
  183. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  184. }
  185. }
  186. setBlurhashCanvasRef = c => {
  187. this.blurhashCanvas = c;
  188. }
  189. setCanvasRef = c => {
  190. this.canvas = c;
  191. if (c) {
  192. this.canvasContext = c.getContext('2d');
  193. }
  194. }
  195. componentDidMount () {
  196. window.addEventListener('scroll', this.handleScroll);
  197. window.addEventListener('resize', this.handleResize, { passive: true });
  198. if (!this.props.blurhash) {
  199. const img = new Image();
  200. img.crossOrigin = 'anonymous';
  201. img.onload = () => this.handlePosterLoad(img);
  202. img.src = this.props.poster;
  203. } else {
  204. this._setColorScheme();
  205. this._decodeBlurhash();
  206. }
  207. }
  208. componentDidUpdate (prevProps, prevState) {
  209. if (prevProps.poster !== this.props.poster && !this.props.blurhash) {
  210. const img = new Image();
  211. img.crossOrigin = 'anonymous';
  212. img.onload = () => this.handlePosterLoad(img);
  213. img.src = this.props.poster;
  214. }
  215. if (prevState.blurhash !== this.state.blurhash || prevProps.blurhash !== this.props.blurhash) {
  216. this._setColorScheme();
  217. this._decodeBlurhash();
  218. }
  219. this._clear();
  220. this._draw();
  221. }
  222. _decodeBlurhash () {
  223. const context = this.blurhashCanvas.getContext('2d');
  224. const pixels = decode(this.props.blurhash || this.state.blurhash, 32, 32);
  225. const outputImageData = new ImageData(pixels, 32, 32);
  226. context.putImageData(outputImageData, 0, 0);
  227. }
  228. componentWillUnmount () {
  229. window.removeEventListener('scroll', this.handleScroll);
  230. window.removeEventListener('resize', this.handleResize);
  231. }
  232. togglePlay = () => {
  233. if (this.state.paused) {
  234. this.setState({ paused: false }, () => this.audio.play());
  235. } else {
  236. this.setState({ paused: true }, () => this.audio.pause());
  237. }
  238. }
  239. handleResize = debounce(() => {
  240. if (this.player) {
  241. this._setDimensions();
  242. }
  243. }, 250, {
  244. trailing: true,
  245. });
  246. handlePlay = () => {
  247. this.setState({ paused: false });
  248. if (this.canvas && !this.audioContext) {
  249. this._initAudioContext();
  250. }
  251. if (this.audioContext && this.audioContext.state === 'suspended') {
  252. this.audioContext.resume();
  253. }
  254. this._renderCanvas();
  255. }
  256. handlePause = () => {
  257. this.setState({ paused: true });
  258. if (this.audioContext) {
  259. this.audioContext.suspend();
  260. }
  261. }
  262. handleProgress = () => {
  263. const lastTimeRange = this.audio.buffered.length - 1;
  264. if (lastTimeRange > -1) {
  265. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  266. }
  267. }
  268. toggleMute = () => {
  269. const muted = !this.state.muted;
  270. this.setState({ muted }, () => {
  271. this.audio.muted = muted;
  272. });
  273. }
  274. handleVolumeMouseDown = e => {
  275. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  276. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  277. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  278. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  279. this.handleMouseVolSlide(e);
  280. e.preventDefault();
  281. e.stopPropagation();
  282. }
  283. handleVolumeMouseUp = () => {
  284. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  285. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  286. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  287. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  288. }
  289. handleMouseDown = e => {
  290. document.addEventListener('mousemove', this.handleMouseMove, true);
  291. document.addEventListener('mouseup', this.handleMouseUp, true);
  292. document.addEventListener('touchmove', this.handleMouseMove, true);
  293. document.addEventListener('touchend', this.handleMouseUp, true);
  294. this.setState({ dragging: true });
  295. this.audio.pause();
  296. this.handleMouseMove(e);
  297. e.preventDefault();
  298. e.stopPropagation();
  299. }
  300. handleMouseUp = () => {
  301. document.removeEventListener('mousemove', this.handleMouseMove, true);
  302. document.removeEventListener('mouseup', this.handleMouseUp, true);
  303. document.removeEventListener('touchmove', this.handleMouseMove, true);
  304. document.removeEventListener('touchend', this.handleMouseUp, true);
  305. this.setState({ dragging: false });
  306. this.audio.play();
  307. }
  308. handleMouseMove = throttle(e => {
  309. const { x } = getPointerPosition(this.seek, e);
  310. const currentTime = this.audio.duration * x;
  311. if (!isNaN(currentTime)) {
  312. this.setState({ currentTime }, () => {
  313. this.audio.currentTime = currentTime;
  314. });
  315. }
  316. }, 15);
  317. handleTimeUpdate = () => {
  318. this.setState({
  319. currentTime: this.audio.currentTime,
  320. duration: Math.floor(this.audio.duration),
  321. });
  322. }
  323. handleMouseVolSlide = throttle(e => {
  324. const { x } = getPointerPosition(this.volume, e);
  325. if(!isNaN(x)) {
  326. this.setState({ volume: x }, () => {
  327. this.audio.volume = x;
  328. });
  329. }
  330. }, 15);
  331. handleScroll = throttle(() => {
  332. if (!this.canvas || !this.audio) {
  333. return;
  334. }
  335. const { top, height } = this.canvas.getBoundingClientRect();
  336. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  337. if (!this.state.paused && !inView) {
  338. this.setState({ paused: true }, () => this.audio.pause());
  339. }
  340. }, 150, { trailing: true });
  341. handleMouseEnter = () => {
  342. this.setState({ hovered: true });
  343. }
  344. handleMouseLeave = () => {
  345. this.setState({ hovered: false });
  346. }
  347. _initAudioContext () {
  348. const context = new AudioContext();
  349. const analyser = context.createAnalyser();
  350. const source = context.createMediaElementSource(this.audio);
  351. analyser.smoothingTimeConstant = 0.6;
  352. analyser.fftSize = 2048;
  353. source.connect(analyser);
  354. source.connect(context.destination);
  355. this.audioContext = context;
  356. this.analyser = analyser;
  357. }
  358. handlePosterLoad = image => {
  359. const canvas = document.createElement('canvas');
  360. const context = canvas.getContext('2d');
  361. canvas.width = image.width;
  362. canvas.height = image.height;
  363. context.drawImage(image, 0, 0);
  364. const inputImageData = context.getImageData(0, 0, image.width, image.height);
  365. const blurhash = encode(inputImageData.data, image.width, image.height, 4, 4);
  366. this.setState({ blurhash });
  367. }
  368. _setColorScheme () {
  369. const blurhash = this.props.blurhash || this.state.blurhash;
  370. const averageColor = decodeRGB(decode83(blurhash.slice(2, 6)));
  371. this.setState({
  372. color: adjustColor(averageColor),
  373. darkText: luma(averageColor) >= 165,
  374. });
  375. }
  376. handleDownload = () => {
  377. fetch(this.props.src).then(res => res.blob()).then(blob => {
  378. const element = document.createElement('a');
  379. const objectURL = URL.createObjectURL(blob);
  380. element.setAttribute('href', objectURL);
  381. element.setAttribute('download', fileNameFromURL(this.props.src));
  382. document.body.appendChild(element);
  383. element.click();
  384. document.body.removeChild(element);
  385. URL.revokeObjectURL(objectURL);
  386. }).catch(err => {
  387. console.error(err);
  388. });
  389. }
  390. _renderCanvas () {
  391. requestAnimationFrame(() => {
  392. this.handleTimeUpdate();
  393. this._clear();
  394. this._draw();
  395. if (!this.state.paused) {
  396. this._renderCanvas();
  397. }
  398. });
  399. }
  400. _clear () {
  401. this.canvasContext.clearRect(0, 0, this.state.width, this.state.height);
  402. }
  403. _draw () {
  404. this.canvasContext.save();
  405. const ticks = this._getTicks(360 * this._getScaleCoefficient(), TICK_SIZE);
  406. ticks.forEach(tick => {
  407. this._drawTick(tick.x1, tick.y1, tick.x2, tick.y2);
  408. });
  409. this.canvasContext.restore();
  410. }
  411. _getRadius () {
  412. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  413. }
  414. _getScaleCoefficient () {
  415. return (this.state.height || this.props.height) / 982;
  416. }
  417. _getTicks (count, size, animationParams = [0, 90]) {
  418. const radius = this._getRadius();
  419. const ticks = this._getTickPoints(count);
  420. const lesser = 200;
  421. const m = [];
  422. const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
  423. const frequencyData = new Uint8Array(bufferLength);
  424. const allScales = [];
  425. const scaleCoefficient = this._getScaleCoefficient();
  426. if (this.analyser) {
  427. this.analyser.getByteFrequencyData(frequencyData);
  428. }
  429. ticks.forEach((tick, i) => {
  430. const coef = 1 - i / (ticks.length * 2.5);
  431. let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
  432. if (delta < 0) {
  433. delta = 0;
  434. }
  435. let k;
  436. if (animationParams[0] <= tick.angle && tick.angle <= animationParams[1]) {
  437. k = radius / (radius - this._getSize(tick.angle, animationParams[0], animationParams[1]) - delta);
  438. } else {
  439. k = radius / (radius - (size + delta));
  440. }
  441. const x1 = tick.x * (radius - size);
  442. const y1 = tick.y * (radius - size);
  443. const x2 = x1 * k;
  444. const y2 = y1 * k;
  445. m.push({ x1, y1, x2, y2 });
  446. if (i < 20) {
  447. let scale = delta / (200 * scaleCoefficient);
  448. scale = scale < 1 ? 1 : scale;
  449. allScales.push(scale);
  450. }
  451. });
  452. const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
  453. return m.map(({ x1, y1, x2, y2 }) => ({
  454. x1: x1,
  455. y1: y1,
  456. x2: x2 * scale,
  457. y2: y2 * scale,
  458. }));
  459. }
  460. _getSize (angle, l, r) {
  461. const scaleCoefficient = this._getScaleCoefficient();
  462. const maxTickSize = TICK_SIZE * 9 * scaleCoefficient;
  463. const m = (r - l) / 2;
  464. const x = (angle - l);
  465. let h;
  466. if (x === m) {
  467. return maxTickSize;
  468. }
  469. const d = Math.abs(m - x);
  470. const v = 40 * Math.sqrt(1 / d);
  471. if (v > maxTickSize) {
  472. h = maxTickSize;
  473. } else {
  474. h = Math.max(TICK_SIZE, v);
  475. }
  476. return h;
  477. }
  478. _getTickPoints (count) {
  479. const PI = 360;
  480. const coords = [];
  481. const step = PI / count;
  482. let rad;
  483. for(let deg = 0; deg < PI; deg += step) {
  484. rad = deg * Math.PI / (PI / 2);
  485. coords.push({ x: Math.cos(rad), y: -Math.sin(rad), angle: deg });
  486. }
  487. return coords;
  488. }
  489. _drawTick (x1, y1, x2, y2) {
  490. const cx = this._getCX();
  491. const cy = this._getCY();
  492. const dx1 = Math.ceil(cx + x1);
  493. const dy1 = Math.ceil(cy + y1);
  494. const dx2 = Math.ceil(cx + x2);
  495. const dy2 = Math.ceil(cy + y2);
  496. const gradient = this.canvasContext.createLinearGradient(dx1, dy1, dx2, dy2);
  497. const mainColor = `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`;
  498. const lastColor = `rgba(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b}, 0)`;
  499. gradient.addColorStop(0, mainColor);
  500. gradient.addColorStop(0.6, mainColor);
  501. gradient.addColorStop(1, lastColor);
  502. this.canvasContext.beginPath();
  503. this.canvasContext.strokeStyle = gradient;
  504. this.canvasContext.lineWidth = 2;
  505. this.canvasContext.moveTo(dx1, dy1);
  506. this.canvasContext.lineTo(dx2, dy2);
  507. this.canvasContext.stroke();
  508. }
  509. _getCX() {
  510. return Math.floor(this.state.width / 2);
  511. }
  512. _getCY() {
  513. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  514. }
  515. _getColor () {
  516. return `rgb(${this.state.color.r}, ${this.state.color.g}, ${this.state.color.b})`;
  517. }
  518. render () {
  519. const { src, intl, alt, editable } = this.props;
  520. const { paused, muted, volume, currentTime, duration, buffer, darkText, dragging } = this.state;
  521. const progress = (currentTime / duration) * 100;
  522. return (
  523. <div className={classNames('audio-player', { editable, 'with-light-background': darkText })} ref={this.setPlayerRef} style={{ width: '100%', height: this.props.fullscreen ? '100%' : (this.state.height || this.props.height) }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave}>
  524. <audio
  525. src={src}
  526. ref={this.setAudioRef}
  527. preload='none'
  528. onPlay={this.handlePlay}
  529. onPause={this.handlePause}
  530. onProgress={this.handleProgress}
  531. crossOrigin='anonymous'
  532. />
  533. <canvas
  534. className='audio-player__background'
  535. onClick={this.togglePlay}
  536. width='32'
  537. height='32'
  538. style={{ width: this.state.width, height: this.state.height, position: 'absolute', top: 0, left: 0 }}
  539. ref={this.setBlurhashCanvasRef}
  540. aria-label={alt}
  541. title={alt}
  542. role='button'
  543. tabIndex='0'
  544. />
  545. <canvas
  546. className='audio-player__canvas'
  547. width={this.state.width}
  548. height={this.state.height}
  549. style={{ width: '100%', position: 'absolute', top: 0, left: 0, pointerEvents: 'none' }}
  550. ref={this.setCanvasRef}
  551. />
  552. <img
  553. src={this.props.poster}
  554. alt=''
  555. width={(this._getRadius() - TICK_SIZE) * 2}
  556. height={(this._getRadius() - TICK_SIZE) * 2}
  557. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  558. />
  559. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  560. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  561. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getColor() }} />
  562. <span
  563. className={classNames('video-player__seek__handle', { active: dragging })}
  564. tabIndex='0'
  565. style={{ left: `${progress}%`, backgroundColor: this._getColor() }}
  566. />
  567. </div>
  568. <div className='video-player__controls active'>
  569. <div className='video-player__buttons-bar'>
  570. <div className='video-player__buttons left'>
  571. <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>
  572. <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>
  573. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  574. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getColor() }} />
  575. <span
  576. className={classNames('video-player__volume__handle')}
  577. tabIndex='0'
  578. style={{ left: `${volume * 100}%`, backgroundColor: this._getColor() }}
  579. />
  580. </div>
  581. <span className='video-player__time'>
  582. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  583. <span className='video-player__time-sep'>/</span>
  584. <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
  585. </span>
  586. </div>
  587. <div className='video-player__buttons right'>
  588. <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
  589. </div>
  590. </div>
  591. </div>
  592. </div>
  593. );
  594. }
  595. }