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.

470 lines
14 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. import Visualizer from './visualizer';
  11. const messages = defineMessages({
  12. play: { id: 'video.play', defaultMessage: 'Play' },
  13. pause: { id: 'video.pause', defaultMessage: 'Pause' },
  14. mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
  15. unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
  16. download: { id: 'video.download', defaultMessage: 'Download file' },
  17. });
  18. const TICK_SIZE = 10;
  19. const PADDING = 180;
  20. export default @injectIntl
  21. class Audio extends React.PureComponent {
  22. static propTypes = {
  23. src: PropTypes.string.isRequired,
  24. alt: PropTypes.string,
  25. poster: PropTypes.string,
  26. duration: PropTypes.number,
  27. width: PropTypes.number,
  28. height: PropTypes.number,
  29. editable: PropTypes.bool,
  30. fullscreen: PropTypes.bool,
  31. intl: PropTypes.object.isRequired,
  32. cacheWidth: PropTypes.func,
  33. backgroundColor: PropTypes.string,
  34. foregroundColor: PropTypes.string,
  35. accentColor: PropTypes.string,
  36. currentTime: PropTypes.number,
  37. autoPlay: PropTypes.bool,
  38. volume: PropTypes.number,
  39. muted: PropTypes.bool,
  40. deployPictureInPicture: PropTypes.func,
  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. constructor (props) {
  53. super(props);
  54. this.visualizer = new Visualizer(TICK_SIZE);
  55. }
  56. setPlayerRef = c => {
  57. this.player = c;
  58. if (this.player) {
  59. this._setDimensions();
  60. }
  61. }
  62. _pack() {
  63. return {
  64. src: this.props.src,
  65. volume: this.audio.volume,
  66. muted: this.audio.muted,
  67. currentTime: this.audio.currentTime,
  68. poster: this.props.poster,
  69. backgroundColor: this.props.backgroundColor,
  70. foregroundColor: this.props.foregroundColor,
  71. accentColor: this.props.accentColor,
  72. };
  73. }
  74. _setDimensions () {
  75. const width = this.player.offsetWidth;
  76. const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
  77. if (this.props.cacheWidth) {
  78. this.props.cacheWidth(width);
  79. }
  80. this.setState({ width, height });
  81. }
  82. setSeekRef = c => {
  83. this.seek = c;
  84. }
  85. setVolumeRef = c => {
  86. this.volume = c;
  87. }
  88. setAudioRef = c => {
  89. this.audio = c;
  90. if (this.audio) {
  91. this.setState({ volume: this.audio.volume, muted: this.audio.muted });
  92. }
  93. }
  94. setCanvasRef = c => {
  95. this.canvas = c;
  96. this.visualizer.setCanvas(c);
  97. }
  98. componentDidMount () {
  99. window.addEventListener('scroll', this.handleScroll);
  100. window.addEventListener('resize', this.handleResize, { passive: true });
  101. }
  102. componentDidUpdate (prevProps, prevState) {
  103. if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
  104. this._clear();
  105. this._draw();
  106. }
  107. }
  108. componentWillUnmount () {
  109. window.removeEventListener('scroll', this.handleScroll);
  110. window.removeEventListener('resize', this.handleResize);
  111. if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
  112. this.props.deployPictureInPicture('audio', this._pack());
  113. }
  114. }
  115. togglePlay = () => {
  116. if (!this.audioContext) {
  117. this._initAudioContext();
  118. }
  119. if (this.state.paused) {
  120. this.setState({ paused: false }, () => this.audio.play());
  121. } else {
  122. this.setState({ paused: true }, () => this.audio.pause());
  123. }
  124. }
  125. handleResize = debounce(() => {
  126. if (this.player) {
  127. this._setDimensions();
  128. }
  129. }, 250, {
  130. trailing: true,
  131. });
  132. handlePlay = () => {
  133. this.setState({ paused: false });
  134. if (this.audioContext && this.audioContext.state === 'suspended') {
  135. this.audioContext.resume();
  136. }
  137. this._renderCanvas();
  138. }
  139. handlePause = () => {
  140. this.setState({ paused: true });
  141. if (this.audioContext) {
  142. this.audioContext.suspend();
  143. }
  144. }
  145. handleProgress = () => {
  146. const lastTimeRange = this.audio.buffered.length - 1;
  147. if (lastTimeRange > -1) {
  148. this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
  149. }
  150. }
  151. toggleMute = () => {
  152. const muted = !this.state.muted;
  153. this.setState({ muted }, () => {
  154. this.audio.muted = muted;
  155. });
  156. }
  157. handleVolumeMouseDown = e => {
  158. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  159. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  160. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  161. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  162. this.handleMouseVolSlide(e);
  163. e.preventDefault();
  164. e.stopPropagation();
  165. }
  166. handleVolumeMouseUp = () => {
  167. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  168. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  169. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  170. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  171. }
  172. handleMouseDown = e => {
  173. document.addEventListener('mousemove', this.handleMouseMove, true);
  174. document.addEventListener('mouseup', this.handleMouseUp, true);
  175. document.addEventListener('touchmove', this.handleMouseMove, true);
  176. document.addEventListener('touchend', this.handleMouseUp, true);
  177. this.setState({ dragging: true });
  178. this.audio.pause();
  179. this.handleMouseMove(e);
  180. e.preventDefault();
  181. e.stopPropagation();
  182. }
  183. handleMouseUp = () => {
  184. document.removeEventListener('mousemove', this.handleMouseMove, true);
  185. document.removeEventListener('mouseup', this.handleMouseUp, true);
  186. document.removeEventListener('touchmove', this.handleMouseMove, true);
  187. document.removeEventListener('touchend', this.handleMouseUp, true);
  188. this.setState({ dragging: false });
  189. this.audio.play();
  190. }
  191. handleMouseMove = throttle(e => {
  192. const { x } = getPointerPosition(this.seek, e);
  193. const currentTime = this.audio.duration * x;
  194. if (!isNaN(currentTime)) {
  195. this.setState({ currentTime }, () => {
  196. this.audio.currentTime = currentTime;
  197. });
  198. }
  199. }, 15);
  200. handleTimeUpdate = () => {
  201. this.setState({
  202. currentTime: this.audio.currentTime,
  203. duration: Math.floor(this.audio.duration),
  204. });
  205. }
  206. handleMouseVolSlide = throttle(e => {
  207. const { x } = getPointerPosition(this.volume, e);
  208. if(!isNaN(x)) {
  209. this.setState({ volume: x }, () => {
  210. this.audio.volume = x;
  211. });
  212. }
  213. }, 15);
  214. handleScroll = throttle(() => {
  215. if (!this.canvas || !this.audio) {
  216. return;
  217. }
  218. const { top, height } = this.canvas.getBoundingClientRect();
  219. const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
  220. if (!this.state.paused && !inView) {
  221. this.audio.pause();
  222. if (this.props.deployPictureInPicture) {
  223. this.props.deployPictureInPicture('audio', this._pack());
  224. }
  225. this.setState({ paused: true });
  226. }
  227. }, 150, { trailing: true });
  228. handleMouseEnter = () => {
  229. this.setState({ hovered: true });
  230. }
  231. handleMouseLeave = () => {
  232. this.setState({ hovered: false });
  233. }
  234. handleLoadedData = () => {
  235. const { autoPlay, currentTime, volume, muted } = this.props;
  236. if (currentTime) {
  237. this.audio.currentTime = currentTime;
  238. }
  239. if (volume !== undefined) {
  240. this.audio.volume = volume;
  241. }
  242. if (muted !== undefined) {
  243. this.audio.muted = muted;
  244. }
  245. if (autoPlay) {
  246. this.togglePlay();
  247. }
  248. }
  249. _initAudioContext () {
  250. const AudioContext = window.AudioContext || window.webkitAudioContext;
  251. const context = new AudioContext();
  252. const source = context.createMediaElementSource(this.audio);
  253. this.visualizer.setAudioContext(context, source);
  254. source.connect(context.destination);
  255. this.audioContext = context;
  256. }
  257. handleDownload = () => {
  258. fetch(this.props.src).then(res => res.blob()).then(blob => {
  259. const element = document.createElement('a');
  260. const objectURL = URL.createObjectURL(blob);
  261. element.setAttribute('href', objectURL);
  262. element.setAttribute('download', fileNameFromURL(this.props.src));
  263. document.body.appendChild(element);
  264. element.click();
  265. document.body.removeChild(element);
  266. URL.revokeObjectURL(objectURL);
  267. }).catch(err => {
  268. console.error(err);
  269. });
  270. }
  271. _renderCanvas () {
  272. requestAnimationFrame(() => {
  273. if (!this.audio) return;
  274. this.handleTimeUpdate();
  275. this._clear();
  276. this._draw();
  277. if (!this.state.paused) {
  278. this._renderCanvas();
  279. }
  280. });
  281. }
  282. _clear() {
  283. this.visualizer.clear(this.state.width, this.state.height);
  284. }
  285. _draw() {
  286. this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
  287. }
  288. _getRadius () {
  289. return parseInt(((this.state.height || this.props.height) - (PADDING * this._getScaleCoefficient()) * 2) / 2);
  290. }
  291. _getScaleCoefficient () {
  292. return (this.state.height || this.props.height) / 982;
  293. }
  294. _getCX() {
  295. return Math.floor(this.state.width / 2);
  296. }
  297. _getCY() {
  298. return Math.floor(this._getRadius() + (PADDING * this._getScaleCoefficient()));
  299. }
  300. _getAccentColor () {
  301. return this.props.accentColor || '#ffffff';
  302. }
  303. _getBackgroundColor () {
  304. return this.props.backgroundColor || '#000000';
  305. }
  306. _getForegroundColor () {
  307. return this.props.foregroundColor || '#ffffff';
  308. }
  309. render () {
  310. const { src, intl, alt, editable, autoPlay } = this.props;
  311. const { paused, muted, volume, currentTime, duration, buffer, dragging } = this.state;
  312. const progress = Math.min((currentTime / duration) * 100, 100);
  313. return (
  314. <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}>
  315. <audio
  316. src={src}
  317. ref={this.setAudioRef}
  318. preload={autoPlay ? 'auto' : 'none'}
  319. onPlay={this.handlePlay}
  320. onPause={this.handlePause}
  321. onProgress={this.handleProgress}
  322. onLoadedData={this.handleLoadedData}
  323. crossOrigin='anonymous'
  324. />
  325. <canvas
  326. role='button'
  327. className='audio-player__canvas'
  328. width={this.state.width}
  329. height={this.state.height}
  330. style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
  331. ref={this.setCanvasRef}
  332. onClick={this.togglePlay}
  333. title={alt}
  334. aria-label={alt}
  335. />
  336. <img
  337. src={this.props.poster}
  338. alt=''
  339. width={(this._getRadius() - TICK_SIZE) * 2}
  340. height={(this._getRadius() - TICK_SIZE) * 2}
  341. style={{ position: 'absolute', left: this._getCX(), top: this._getCY(), transform: 'translate(-50%, -50%)', borderRadius: '50%', pointerEvents: 'none' }}
  342. />
  343. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  344. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  345. <div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
  346. <span
  347. className={classNames('video-player__seek__handle', { active: dragging })}
  348. tabIndex='0'
  349. style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
  350. />
  351. </div>
  352. <div className='video-player__controls active'>
  353. <div className='video-player__buttons-bar'>
  354. <div className='video-player__buttons left'>
  355. <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>
  356. <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>
  357. <div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
  358. <div className='video-player__volume__current' style={{ width: `${volume * 100}%`, backgroundColor: this._getAccentColor() }} />
  359. <span
  360. className={classNames('video-player__volume__handle')}
  361. tabIndex='0'
  362. style={{ left: `${volume * 100}%`, backgroundColor: this._getAccentColor() }}
  363. />
  364. </div>
  365. <span className='video-player__time'>
  366. <span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
  367. <span className='video-player__time-sep'>/</span>
  368. <span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
  369. </span>
  370. </div>
  371. <div className='video-player__buttons right'>
  372. <button type='button' title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} onClick={this.handleDownload}><Icon id='download' fixedWidth /></button>
  373. </div>
  374. </div>
  375. </div>
  376. </div>
  377. );
  378. }
  379. }