The code powering m.abunchtell.com https://m.abunchtell.com
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.
 
 
 
 

505 рядки
16 KiB

  1. import React from 'react';
  2. import PropTypes from 'prop-types';
  3. import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
  4. import { fromJS, is } from 'immutable';
  5. import { throttle } from 'lodash';
  6. import classNames from 'classnames';
  7. import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
  8. import { displayMedia, useBlurhash } from '../../initial_state';
  9. import Icon from 'mastodon/components/icon';
  10. import { decode } from 'blurhash';
  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. hide: { id: 'video.hide', defaultMessage: 'Hide video' },
  17. expand: { id: 'video.expand', defaultMessage: 'Expand video' },
  18. close: { id: 'video.close', defaultMessage: 'Close video' },
  19. fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
  20. exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
  21. });
  22. export const formatTime = secondsNum => {
  23. let hours = Math.floor(secondsNum / 3600);
  24. let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
  25. let seconds = secondsNum - (hours * 3600) - (minutes * 60);
  26. if (hours < 10) hours = '0' + hours;
  27. if (minutes < 10) minutes = '0' + minutes;
  28. if (seconds < 10) seconds = '0' + seconds;
  29. return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
  30. };
  31. export const findElementPosition = el => {
  32. let box;
  33. if (el.getBoundingClientRect && el.parentNode) {
  34. box = el.getBoundingClientRect();
  35. }
  36. if (!box) {
  37. return {
  38. left: 0,
  39. top: 0,
  40. };
  41. }
  42. const docEl = document.documentElement;
  43. const body = document.body;
  44. const clientLeft = docEl.clientLeft || body.clientLeft || 0;
  45. const scrollLeft = window.pageXOffset || body.scrollLeft;
  46. const left = (box.left + scrollLeft) - clientLeft;
  47. const clientTop = docEl.clientTop || body.clientTop || 0;
  48. const scrollTop = window.pageYOffset || body.scrollTop;
  49. const top = (box.top + scrollTop) - clientTop;
  50. return {
  51. left: Math.round(left),
  52. top: Math.round(top),
  53. };
  54. };
  55. export const getPointerPosition = (el, event) => {
  56. const position = {};
  57. const box = findElementPosition(el);
  58. const boxW = el.offsetWidth;
  59. const boxH = el.offsetHeight;
  60. const boxY = box.top;
  61. const boxX = box.left;
  62. let pageY = event.pageY;
  63. let pageX = event.pageX;
  64. if (event.changedTouches) {
  65. pageX = event.changedTouches[0].pageX;
  66. pageY = event.changedTouches[0].pageY;
  67. }
  68. position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
  69. position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
  70. return position;
  71. };
  72. export default @injectIntl
  73. class Video extends React.PureComponent {
  74. static propTypes = {
  75. preview: PropTypes.string,
  76. src: PropTypes.string.isRequired,
  77. alt: PropTypes.string,
  78. width: PropTypes.number,
  79. height: PropTypes.number,
  80. sensitive: PropTypes.bool,
  81. startTime: PropTypes.number,
  82. onOpenVideo: PropTypes.func,
  83. onCloseVideo: PropTypes.func,
  84. detailed: PropTypes.bool,
  85. inline: PropTypes.bool,
  86. editable: PropTypes.bool,
  87. cacheWidth: PropTypes.func,
  88. visible: PropTypes.bool,
  89. onToggleVisibility: PropTypes.func,
  90. intl: PropTypes.object.isRequired,
  91. blurhash: PropTypes.string,
  92. link: PropTypes.node,
  93. };
  94. state = {
  95. currentTime: 0,
  96. duration: 0,
  97. volume: 0.5,
  98. paused: true,
  99. dragging: false,
  100. containerWidth: this.props.width,
  101. fullscreen: false,
  102. hovered: false,
  103. muted: false,
  104. revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
  105. };
  106. // hard coded in components.scss
  107. // any way to get ::before values programatically?
  108. volWidth = 50;
  109. volOffset = 70;
  110. volHandleOffset = v => {
  111. const offset = v * this.volWidth + this.volOffset;
  112. return (offset > 110) ? 110 : offset;
  113. }
  114. setPlayerRef = c => {
  115. this.player = c;
  116. if (c) {
  117. if (this.props.cacheWidth) this.props.cacheWidth(this.player.offsetWidth);
  118. this.setState({
  119. containerWidth: c.offsetWidth,
  120. });
  121. }
  122. }
  123. setVideoRef = c => {
  124. this.video = c;
  125. if (this.video) {
  126. this.setState({ volume: this.video.volume, muted: this.video.muted });
  127. }
  128. }
  129. setSeekRef = c => {
  130. this.seek = c;
  131. }
  132. setVolumeRef = c => {
  133. this.volume = c;
  134. }
  135. setCanvasRef = c => {
  136. this.canvas = c;
  137. }
  138. handleClickRoot = e => e.stopPropagation();
  139. handlePlay = () => {
  140. this.setState({ paused: false });
  141. }
  142. handlePause = () => {
  143. this.setState({ paused: true });
  144. }
  145. handleTimeUpdate = () => {
  146. this.setState({
  147. currentTime: Math.floor(this.video.currentTime),
  148. duration: Math.floor(this.video.duration),
  149. });
  150. }
  151. handleVolumeMouseDown = e => {
  152. document.addEventListener('mousemove', this.handleMouseVolSlide, true);
  153. document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
  154. document.addEventListener('touchmove', this.handleMouseVolSlide, true);
  155. document.addEventListener('touchend', this.handleVolumeMouseUp, true);
  156. this.handleMouseVolSlide(e);
  157. e.preventDefault();
  158. e.stopPropagation();
  159. }
  160. handleVolumeMouseUp = () => {
  161. document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
  162. document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
  163. document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
  164. document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
  165. }
  166. handleMouseVolSlide = throttle(e => {
  167. const rect = this.volume.getBoundingClientRect();
  168. const x = (e.clientX - rect.left) / this.volWidth; //x position within the element.
  169. if(!isNaN(x)) {
  170. var slideamt = x;
  171. if(x > 1) {
  172. slideamt = 1;
  173. } else if(x < 0) {
  174. slideamt = 0;
  175. }
  176. this.video.volume = slideamt;
  177. this.setState({ volume: slideamt });
  178. }
  179. }, 60);
  180. handleMouseDown = e => {
  181. document.addEventListener('mousemove', this.handleMouseMove, true);
  182. document.addEventListener('mouseup', this.handleMouseUp, true);
  183. document.addEventListener('touchmove', this.handleMouseMove, true);
  184. document.addEventListener('touchend', this.handleMouseUp, true);
  185. this.setState({ dragging: true });
  186. this.video.pause();
  187. this.handleMouseMove(e);
  188. e.preventDefault();
  189. e.stopPropagation();
  190. }
  191. handleMouseUp = () => {
  192. document.removeEventListener('mousemove', this.handleMouseMove, true);
  193. document.removeEventListener('mouseup', this.handleMouseUp, true);
  194. document.removeEventListener('touchmove', this.handleMouseMove, true);
  195. document.removeEventListener('touchend', this.handleMouseUp, true);
  196. this.setState({ dragging: false });
  197. this.video.play();
  198. }
  199. handleMouseMove = throttle(e => {
  200. const { x } = getPointerPosition(this.seek, e);
  201. const currentTime = Math.floor(this.video.duration * x);
  202. if (!isNaN(currentTime)) {
  203. this.video.currentTime = currentTime;
  204. this.setState({ currentTime });
  205. }
  206. }, 60);
  207. togglePlay = () => {
  208. if (this.state.paused) {
  209. this.video.play();
  210. } else {
  211. this.video.pause();
  212. }
  213. }
  214. toggleFullscreen = () => {
  215. if (isFullscreen()) {
  216. exitFullscreen();
  217. } else {
  218. requestFullscreen(this.player);
  219. }
  220. }
  221. componentDidMount () {
  222. document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
  223. document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
  224. document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
  225. document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
  226. if (this.props.blurhash) {
  227. this._decode();
  228. }
  229. }
  230. componentWillUnmount () {
  231. document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
  232. document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
  233. document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
  234. document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
  235. }
  236. componentWillReceiveProps (nextProps) {
  237. if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
  238. this.setState({ revealed: nextProps.visible });
  239. }
  240. }
  241. componentDidUpdate (prevProps, prevState) {
  242. if (prevState.revealed && !this.state.revealed && this.video) {
  243. this.video.pause();
  244. }
  245. if (prevProps.blurhash !== this.props.blurhash && this.props.blurhash) {
  246. this._decode();
  247. }
  248. }
  249. _decode () {
  250. if (!useBlurhash) return;
  251. const hash = this.props.blurhash;
  252. const pixels = decode(hash, 32, 32);
  253. if (pixels) {
  254. const ctx = this.canvas.getContext('2d');
  255. const imageData = new ImageData(pixels, 32, 32);
  256. ctx.putImageData(imageData, 0, 0);
  257. }
  258. }
  259. handleFullscreenChange = () => {
  260. this.setState({ fullscreen: isFullscreen() });
  261. }
  262. handleMouseEnter = () => {
  263. this.setState({ hovered: true });
  264. }
  265. handleMouseLeave = () => {
  266. this.setState({ hovered: false });
  267. }
  268. toggleMute = () => {
  269. this.video.muted = !this.video.muted;
  270. this.setState({ muted: this.video.muted });
  271. }
  272. toggleReveal = () => {
  273. if (this.props.onToggleVisibility) {
  274. this.props.onToggleVisibility();
  275. } else {
  276. this.setState({ revealed: !this.state.revealed });
  277. }
  278. }
  279. handleLoadedData = () => {
  280. if (this.props.startTime) {
  281. this.video.currentTime = this.props.startTime;
  282. this.video.play();
  283. }
  284. }
  285. handleProgress = () => {
  286. if (this.video.buffered.length > 0) {
  287. this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
  288. }
  289. }
  290. handleVolumeChange = () => {
  291. this.setState({ volume: this.video.volume, muted: this.video.muted });
  292. }
  293. handleOpenVideo = () => {
  294. const { src, preview, width, height, alt } = this.props;
  295. const media = fromJS({
  296. type: 'video',
  297. url: src,
  298. preview_url: preview,
  299. description: alt,
  300. width,
  301. height,
  302. });
  303. this.video.pause();
  304. this.props.onOpenVideo(media, this.video.currentTime);
  305. }
  306. handleCloseVideo = () => {
  307. this.video.pause();
  308. this.props.onCloseVideo();
  309. }
  310. render () {
  311. const { preview, src, inline, startTime, onOpenVideo, onCloseVideo, intl, alt, detailed, sensitive, link, editable } = this.props;
  312. const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
  313. const progress = (currentTime / duration) * 100;
  314. const volumeWidth = (muted) ? 0 : volume * this.volWidth;
  315. const volumeHandleLoc = (muted) ? this.volHandleOffset(0) : this.volHandleOffset(volume);
  316. const playerStyle = {};
  317. let { width, height } = this.props;
  318. if (inline && containerWidth) {
  319. width = containerWidth;
  320. height = containerWidth / (16/9);
  321. playerStyle.height = height;
  322. }
  323. let preload;
  324. if (startTime || fullscreen || dragging) {
  325. preload = 'auto';
  326. } else if (detailed) {
  327. preload = 'metadata';
  328. } else {
  329. preload = 'none';
  330. }
  331. let warning;
  332. if (sensitive) {
  333. warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
  334. } else {
  335. warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
  336. }
  337. return (
  338. <div
  339. role='menuitem'
  340. className={classNames('video-player', { inactive: !revealed, detailed, inline: inline && !fullscreen, fullscreen, editable })}
  341. style={playerStyle}
  342. ref={this.setPlayerRef}
  343. onMouseEnter={this.handleMouseEnter}
  344. onMouseLeave={this.handleMouseLeave}
  345. onClick={this.handleClickRoot}
  346. tabIndex={0}
  347. >
  348. <canvas width={32} height={32} ref={this.setCanvasRef} className={classNames('media-gallery__preview', { 'media-gallery__preview--hidden': revealed })} />
  349. {(revealed || editable) && <video
  350. ref={this.setVideoRef}
  351. src={src}
  352. poster={preview}
  353. preload={preload}
  354. loop
  355. role='button'
  356. tabIndex='0'
  357. aria-label={alt}
  358. title={alt}
  359. width={width}
  360. height={height}
  361. volume={volume}
  362. onClick={this.togglePlay}
  363. onPlay={this.handlePlay}
  364. onPause={this.handlePause}
  365. onTimeUpdate={this.handleTimeUpdate}
  366. onLoadedData={this.handleLoadedData}
  367. onProgress={this.handleProgress}
  368. onVolumeChange={this.handleVolumeChange}
  369. />}
  370. <div className={classNames('spoiler-button', { 'spoiler-button--hidden': revealed || editable })}>
  371. <button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
  372. <span className='spoiler-button__overlay__label'>{warning}</span>
  373. </button>
  374. </div>
  375. <div className={classNames('video-player__controls', { active: paused || hovered })}>
  376. <div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
  377. <div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
  378. <div className='video-player__seek__progress' style={{ width: `${progress}%` }} />
  379. <span
  380. className={classNames('video-player__seek__handle', { active: dragging })}
  381. tabIndex='0'
  382. style={{ left: `${progress}%` }}
  383. />
  384. </div>
  385. <div className='video-player__buttons-bar'>
  386. <div className='video-player__buttons left'>
  387. <button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
  388. <button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
  389. <div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
  390. <div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
  391. <span
  392. className={classNames('video-player__volume__handle')}
  393. tabIndex='0'
  394. style={{ left: `${volumeHandleLoc}px` }}
  395. />
  396. </div>
  397. {(detailed || fullscreen) && (
  398. <span>
  399. <span className='video-player__time-current'>{formatTime(currentTime)}</span>
  400. <span className='video-player__time-sep'>/</span>
  401. <span className='video-player__time-total'>{formatTime(duration)}</span>
  402. </span>
  403. )}
  404. {link && <span className='video-player__link'>{link}</span>}
  405. </div>
  406. <div className='video-player__buttons right'>
  407. {(!onCloseVideo && !editable) && <button type='button' aria-label={intl.formatMessage(messages.hide)} onClick={this.toggleReveal}><Icon id='eye-slash' fixedWidth /></button>}
  408. {(!fullscreen && onOpenVideo) && <button type='button' aria-label={intl.formatMessage(messages.expand)} onClick={this.handleOpenVideo}><Icon id='expand' fixedWidth /></button>}
  409. {onCloseVideo && <button type='button' aria-label={intl.formatMessage(messages.close)} onClick={this.handleCloseVideo}><Icon id='compress' fixedWidth /></button>}
  410. <button type='button' aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} fixedWidth /></button>
  411. </div>
  412. </div>
  413. </div>
  414. </div>
  415. );
  416. }
  417. }