Add audio player (#11644)

pull/1208/head
Eugen Rochko 2019-08-23 22:38:02 +02:00 committed by GitHub
parent 73ca0bb925
commit 4190e31626
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 337 additions and 15 deletions

View File

@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import { injectIntl, FormattedMessage } from 'react-intl'; import { injectIntl, FormattedMessage } from 'react-intl';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import { MediaGallery, Video } from '../features/ui/util/async-components'; import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
}; };
renderLoadingMediaGallery () { renderLoadingMediaGallery () {
return <div className='media_gallery' style={{ height: '110px' }} />; return <div className='media-gallery' style={{ height: '110px' }} />;
} }
renderLoadingVideoPlayer () { renderLoadingVideoPlayer () {
return <div className='media-spoiler-video' style={{ height: '110px' }} />; return <div className='video-player' style={{ height: '110px' }} />;
}
renderLoadingAudioPlayer () {
return <div className='audio-player' style={{ height: '110px' }} />;
} }
handleOpenVideo = (media, startTime) => { handleOpenVideo = (media, startTime) => {
@ -348,7 +352,22 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
/> />
); );
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { } else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
{Component => (
<Component
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
height={110}
/>
)}
</Bundle>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (

View File

@ -8,6 +8,7 @@ import Video from '../features/video';
import Card from '../features/status/components/card'; import Card from '../features/status/components/card';
import Poll from 'mastodon/components/poll'; import Poll from 'mastodon/components/poll';
import Hashtag from 'mastodon/components/hashtag'; import Hashtag from 'mastodon/components/hashtag';
import Audio from 'mastodon/features/audio';
import ModalRoot from '../components/modal_root'; import ModalRoot from '../components/modal_root';
import { getScrollbarWidth } from '../features/ui/components/modal_root'; import { getScrollbarWidth } from '../features/ui/components/modal_root';
import MediaModal from '../features/ui/components/media_modal'; import MediaModal from '../features/ui/components/media_modal';
@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
const { localeData, messages } = getLocale(); const { localeData, messages } = getLocale();
addLocaleData(localeData); addLocaleData(localeData);
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
export default class MediaContainer extends PureComponent { export default class MediaContainer extends PureComponent {

View File

@ -0,0 +1,219 @@
import React from 'react';
import PropTypes from 'prop-types';
import WaveSurfer from 'wavesurfer.js';
import { defineMessages, injectIntl } from 'react-intl';
import { formatTime } from 'mastodon/features/video';
import Icon from 'mastodon/components/icon';
import classNames from 'classnames';
import { throttle } from 'lodash';
const messages = defineMessages({
play: { id: 'video.play', defaultMessage: 'Play' },
pause: { id: 'video.pause', defaultMessage: 'Pause' },
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
});
const arrayOf = (length, fill) => (new Array(length)).fill(fill);
export default @injectIntl
class Audio extends React.PureComponent {
static propTypes = {
src: PropTypes.string.isRequired,
alt: PropTypes.string,
duration: PropTypes.number,
height: PropTypes.number,
preload: PropTypes.bool,
editable: PropTypes.bool,
intl: PropTypes.object.isRequired,
};
state = {
currentTime: 0,
duration: null,
paused: true,
muted: false,
volume: 0.5,
};
// hard coded in components.scss
// any way to get ::before values programatically?
volWidth = 50;
volOffset = 70;
volHandleOffset = v => {
const offset = v * this.volWidth + this.volOffset;
return (offset > 110) ? 110 : offset;
}
setVolumeRef = c => {
this.volume = c;
}
setWaveformRef = c => {
this.waveform = c;
}
componentDidMount () {
if (this.waveform) {
this._updateWaveform();
}
}
componentDidUpdate (prevProps) {
if (this.waveform && prevProps.src !== this.props.src) {
this._updateWaveform();
}
}
componentWillUnmount () {
if (this.wavesurfer) {
this.wavesurfer.destroy();
this.wavesurfer = null;
}
}
_updateWaveform () {
const { src, height, duration, preload } = this.props;
const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
if (this.wavesurfer) {
this.wavesurfer.destroy();
}
const wavesurfer = WaveSurfer.create({
container: this.waveform,
height,
barWidth: 3,
cursorWidth: 0,
progressColor,
waveColor,
forceDecode: true,
});
wavesurfer.setVolume(this.state.volume);
if (preload) {
wavesurfer.load(src);
} else {
wavesurfer.load(src, arrayOf(1, 0.5), null, duration);
}
wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
wavesurfer.on('pause', () => this.setState({ paused: true }));
wavesurfer.on('play', () => this.setState({ paused: false }));
wavesurfer.on('volume', volume => this.setState({ volume }));
wavesurfer.on('mute', muted => this.setState({ muted }));
this.wavesurfer = wavesurfer;
}
togglePlay = () => {
if (this.state.paused) {
if (!this.props.preload) {
this.wavesurfer.createBackend();
this.wavesurfer.createPeakCache();
this.wavesurfer.load(this.props.src);
}
this.wavesurfer.play();
} else {
this.wavesurfer.pause();
}
}
toggleMute = () => {
this.wavesurfer.setMute(!this.state.muted);
}
handleVolumeMouseDown = e => {
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
this.handleMouseVolSlide(e);
e.preventDefault();
e.stopPropagation();
}
handleVolumeMouseUp = () => {
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
}
handleMouseVolSlide = throttle(e => {
const rect = this.volume.getBoundingClientRect();
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
if(!isNaN(x)) {
let slideamt = x;
if (x > 1) {
slideamt = 1;
} else if(x < 0) {
slideamt = 0;
}
this.wavesurfer.setVolume(slideamt);
}
}, 60);
render () {
const { height, intl, alt, editable } = this.props;
const { paused, muted, volume, currentTime } = this.state;
const volumeWidth = muted ? 0 : volume * this.volWidth;
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
return (
<div className={classNames('audio-player', { editable })}>
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
<div
className='audio-player__waveform'
aria-label={alt}
title={alt}
style={{ height }}
ref={this.setWaveformRef}
/>
<div className='video-player__controls active'>
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
<span
className={classNames('video-player__volume__handle')}
tabIndex='0'
style={{ left: `${volumeHandleLoc}px` }}
/>
</div>
<span>
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
<span className='video-player__time-sep'>/</span>
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
</span>
</div>
</div>
</div>
</div>
);
}
}

View File

@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
import Card from './card'; import Card from './card';
import ImmutablePureComponent from 'react-immutable-pure-component'; import ImmutablePureComponent from 'react-immutable-pure-component';
import Video from '../../video'; import Video from '../../video';
import Audio from '../../audio';
import scheduleIdleTask from '../../ui/util/schedule_idle_task'; import scheduleIdleTask from '../../ui/util/schedule_idle_task';
import classNames from 'classnames'; import classNames from 'classnames';
import Icon from 'mastodon/components/icon'; import Icon from 'mastodon/components/icon';
@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
} }
if (status.get('media_attachments').size > 0) { if (status.get('media_attachments').size > 0) {
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) { if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
const attachment = status.getIn(['media_attachments', 0]);
media = (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
/>
);
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (

View File

@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'mastodon/components/icon_button'; import IconButton from 'mastodon/components/icon_button';
import Button from 'mastodon/components/button'; import Button from 'mastodon/components/button';
import Video from 'mastodon/features/video'; import Video from 'mastodon/features/video';
import Audio from 'mastodon/features/audio';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import UploadProgress from 'mastodon/features/compose/components/upload_progress'; import UploadProgress from 'mastodon/features/compose/components/upload_progress';
import CharacterCounter from 'mastodon/features/compose/components/character_counter'; import CharacterCounter from 'mastodon/features/compose/components/character_counter';
@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
</div> </div>
)} )}
{['audio', 'video'].includes(media.get('type')) && ( {media.get('type') === 'video' && (
<Video <Video
preview={media.get('preview_url')} preview={media.get('preview_url')}
blurhash={media.get('blurhash')} blurhash={media.get('blurhash')}
src={media.get('url')} src={media.get('url')}
detailed detailed
inline
editable
/>
)}
{media.get('type') === 'audio' && (
<Audio
src={media.get('url')}
duration={media.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
editable editable
/> />
)} )}

View File

@ -137,3 +137,7 @@ export function Search () {
export function Tesseract () { export function Tesseract () {
return import(/*webpackChunkName: "tesseract" */'tesseract.js'); return import(/*webpackChunkName: "tesseract" */'tesseract.js');
} }
export function Audio () {
return import(/* webpackChunkName: "features/audio" */'../../audio');
}

View File

@ -21,7 +21,7 @@ const messages = defineMessages({
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' }, exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
}); });
const formatTime = secondsNum => { export const formatTime = secondsNum => {
let hours = Math.floor(secondsNum / 3600); let hours = Math.floor(secondsNum / 3600);
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60); let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
let seconds = secondsNum - (hours * 3600) - (minutes * 60); let seconds = secondsNum - (hours * 3600) - (minutes * 60);

View File

@ -948,7 +948,8 @@
opacity: 1; opacity: 1;
animation: fade 150ms linear; animation: fade 150ms linear;
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
@ -1043,7 +1044,8 @@
white-space: normal; white-space: normal;
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
max-width: 250px; max-width: 250px;
} }
@ -1154,7 +1156,8 @@
} }
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
} }
@ -2130,7 +2133,8 @@ a.account__display-name {
padding: 15px; padding: 15px;
.media-gallery, .media-gallery,
.video-player { .video-player,
.audio-player {
margin-top: 15px; margin-top: 15px;
} }
} }
@ -2172,7 +2176,8 @@ a.account__display-name {
.media-gallery, .media-gallery,
&__action-bar, &__action-bar,
.video-player { .video-player,
.audio-player {
margin-top: 10px; margin-top: 10px;
} }
} }
@ -5043,15 +5048,50 @@ a.status-card.compact:hover {
} }
.audio-player {
box-sizing: border-box;
position: relative;
background: darken($ui-base-color, 8%);
border-radius: 4px;
padding-bottom: 44px;
&.editable {
border-radius: 0;
height: 100%;
}
&__waveform {
padding: 15px 0;
}
&__progress-placeholder {
background-color: rgba(lighten($ui-highlight-color, 8%), 0.5);
}
&__wave-placeholder {
background-color: lighten($ui-base-color, 16%);
}
.video-player__controls {
padding: 0 15px;
padding-top: 10px;
background: darken($ui-base-color, 8%);
border-top: 1px solid lighten($ui-base-color, 4%);
border-radius: 0 0 4px 4px;
}
}
.video-player { .video-player {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
background: $base-shadow-color; background: $base-shadow-color;
max-width: 100%; max-width: 100%;
border-radius: 4px; border-radius: 4px;
box-sizing: border-box;
&.editable { &.editable {
border-radius: 0; border-radius: 0;
height: 100% !important;
} }
&:focus { &:focus {

View File

@ -27,10 +27,14 @@
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
- if !status.media_attachments.empty? - if !status.media_attachments.empty?
- if status.media_attachments.first.audio_or_video? - if status.media_attachments.first.video?
- video = status.media_attachments.first - video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 670, height: 380, detailed: true, inline: true, alt: video.description do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 130, alt: audio.description, preload: true, duration: audio.meta.dig(:original, :duration) do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else - else
= react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = react_component :media_gallery, height: 380, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, standalone: true, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, 'reduceMotion': current_account&.user&.setting_reduce_motion, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }

View File

@ -31,10 +31,14 @@
= render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay } = render partial: 'statuses/poll', locals: { status: status, poll: status.preloadable_poll, autoplay: autoplay }
- if !status.media_attachments.empty? - if !status.media_attachments.empty?
- if status.media_attachments.first.audio_or_video? - if status.media_attachments.first.video?
- video = status.media_attachments.first - video = status.media_attachments.first
= react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do = react_component :video, src: video.file.url(:original), preview: video.file.url(:small), blurhash: video.blurhash, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, width: 610, height: 343, inline: true, alt: video.description do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- elsif status.media_attachments.first.audio?
- audio = status.media_attachments.first
= react_component :audio, src: audio.file.url(:original), height: 110, alt: audio.description, duration: audio.meta.dig(:original, :duration) do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }
- else - else
= react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do = react_component :media_gallery, height: 343, sensitive: !current_account&.user&.show_all_media? && status.sensitive? || current_account&.user&.hide_all_media?, 'autoPlayGif': current_account&.user&.setting_auto_play_gif || autoplay, media: status.media_attachments.map { |a| ActiveModelSerializers::SerializableResource.new(a, serializer: REST::MediaAttachmentSerializer).as_json } do
= render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments } = render partial: 'statuses/attachment_list', locals: { attachments: status.media_attachments }

View File

@ -160,6 +160,7 @@
"throng": "^4.0.0", "throng": "^4.0.0",
"tiny-queue": "^0.2.1", "tiny-queue": "^0.2.1",
"uuid": "^3.1.0", "uuid": "^3.1.0",
"wavesurfer.js": "^3.0.0",
"webpack": "^4.35.3", "webpack": "^4.35.3",
"webpack-assets-manifest": "^3.1.1", "webpack-assets-manifest": "^3.1.1",
"webpack-bundle-analyzer": "^3.3.2", "webpack-bundle-analyzer": "^3.3.2",

View File

@ -10466,6 +10466,11 @@ watchpack@^1.5.0:
graceful-fs "^4.1.2" graceful-fs "^4.1.2"
neo-async "^2.5.0" neo-async "^2.5.0"
wavesurfer.js@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/wavesurfer.js/-/wavesurfer.js-3.0.0.tgz#35f36d76d59c749dca453cf4e10ee0ec49f454f8"
integrity sha512-DANu206c6gb9pSUbYFevsSiXMy8+Ri+CNtqm0UsouUdsn9fVQRtYs8uxzBtXK+rUPlIc6FlO54DU8uWeW3lDzw==
wbuf@^1.1.0, wbuf@^1.7.3: wbuf@^1.1.0, wbuf@^1.7.3:
version "1.7.3" version "1.7.3"
resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df"