[Glitch] Add audio player

Port 4190e31626 to glitch-soc

Signed-off-by: Thibaut Girka <thib@sitedethib.com>
This commit is contained in:
Eugen Rochko 2019-08-23 22:38:02 +02:00 committed by Thibaut Girka
parent 04de74c992
commit 3aeaf9b897
10 changed files with 325 additions and 15 deletions

View file

@ -10,7 +10,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 'flavours/glitch/util/async-components'; import { MediaGallery, Video, Audio } from 'flavours/glitch/util/async-components';
import { HotKeys } from 'react-hotkeys'; import { HotKeys } from 'react-hotkeys';
import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container'; import NotificationOverlayContainer from 'flavours/glitch/features/notifications/containers/overlay_container';
import classNames from 'classnames'; import classNames from 'classnames';
@ -443,11 +443,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' }} />;
} }
render () { render () {
@ -561,7 +565,23 @@ class Status extends ImmutablePureComponent {
media={status.get('media_attachments')} media={status.get('media_attachments')}
/> />
); );
} else if (['video', 'audio'].includes(attachments.getIn([0, 'type']))) { } else if (attachments.getIn([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>
);
mediaIcon = 'music';
} else if (attachments.getIn([0, 'type']) === 'video') {
const attachment = status.getIn(['media_attachments', 0]); const attachment = status.getIn(['media_attachments', 0]);
media = ( media = (
@ -584,7 +604,7 @@ class Status extends ImmutablePureComponent {
/>)} />)}
</Bundle> </Bundle>
); );
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music'; mediaIcon = 'video-camera';
} else { // Media type is 'image' or 'gifv' } else { // Media type is 'image' or 'gifv'
media = ( media = (
<Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}> <Bundle fetchComponent={MediaGallery} loading={this.renderLoadingMediaGallery}>

View file

@ -7,6 +7,7 @@ import MediaGallery from 'flavours/glitch/components/media_gallery';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import Card from 'flavours/glitch/features/status/components/card'; import Card from 'flavours/glitch/features/status/components/card';
import Poll from 'flavours/glitch/components/poll'; import Poll from 'flavours/glitch/components/poll';
import Audio from 'flavours/glitch/features/audio';
import ModalRoot from 'flavours/glitch/components/modal_root'; import ModalRoot from 'flavours/glitch/components/modal_root';
import MediaModal from 'flavours/glitch/features/ui/components/media_modal'; import MediaModal from 'flavours/glitch/features/ui/components/media_modal';
import { List as ImmutableList, fromJS } from 'immutable'; import { List as ImmutableList, fromJS } from 'immutable';
@ -14,7 +15,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 }; const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, 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 'flavours/glitch/features/video';
import Icon from 'flavours/glitch/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 icon={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon icon={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

@ -11,6 +11,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 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import Audio from 'flavours/glitch/features/audio';
import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon'; import VisibilityIcon from 'flavours/glitch/components/status_visibility_icon';
import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task'; import scheduleIdleTask from 'flavours/glitch/util/schedule_idle_task';
import classNames from 'classnames'; import classNames from 'classnames';
@ -131,7 +132,20 @@ export default class DetailedStatus extends ImmutablePureComponent {
} else if (status.get('media_attachments').size > 0) { } else if (status.get('media_attachments').size > 0) {
if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) { if (status.get('media_attachments').some(item => item.get('type') === 'unknown')) {
media = <AttachmentList media={status.get('media_attachments')} />; media = <AttachmentList 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 = (
<Audio
src={attachment.get('url')}
alt={attachment.get('description')}
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
height={150}
preload
/>
);
mediaIcon = 'music';
} 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 = (
<Video <Video
@ -150,7 +164,7 @@ export default class DetailedStatus extends ImmutablePureComponent {
onToggleVisibility={this.props.onToggleMediaVisibility} onToggleVisibility={this.props.onToggleMediaVisibility}
/> />
); );
mediaIcon = attachment.get('type') === 'video' ? 'video-camera' : 'music'; mediaIcon = 'video-camera';
} else { } else {
media = ( media = (
<MediaGallery <MediaGallery

View file

@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
import IconButton from 'flavours/glitch/components/icon_button'; import IconButton from 'flavours/glitch/components/icon_button';
import Button from 'flavours/glitch/components/button'; import Button from 'flavours/glitch/components/button';
import Video from 'flavours/glitch/features/video'; import Video from 'flavours/glitch/features/video';
import Audio from 'flavours/glitch/features/audio';
import Textarea from 'react-textarea-autosize'; import Textarea from 'react-textarea-autosize';
import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress'; import UploadProgress from 'flavours/glitch/features/compose/components/upload_progress';
import CharacterCounter from 'flavours/glitch/features/compose/components/character_counter'; import CharacterCounter from 'flavours/glitch/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

@ -20,7 +20,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

@ -333,15 +333,50 @@
} }
.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

@ -107,7 +107,8 @@
padding: 15px; padding: 15px;
.media-gallery, .media-gallery,
.video-player { .video-player,
.audio-player {
margin-top: 15px; margin-top: 15px;
} }
} }
@ -131,7 +132,8 @@
.media-gallery, .media-gallery,
&__action-bar, &__action-bar,
.video-player { .video-player,
.audio-player {
margin-top: 10px; margin-top: 10px;
} }
} }

View file

@ -263,7 +263,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;
} }
@ -453,7 +454,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;
} }
@ -561,7 +563,8 @@
} }
} }
.video-player { .video-player,
.audio-player {
margin-top: 8px; margin-top: 8px;
} }
} }

View file

@ -138,6 +138,10 @@ export function Video () {
return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video'); return import(/* webpackChunkName: "flavours/glitch/async/video" */'flavours/glitch/features/video');
} }
export function Audio () {
return import(/* webpackChunkName: "features/glitch/async/audio" */'flavours/glitch/features/audio');
}
export function EmbedModal () { export function EmbedModal () {
return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal'); return import(/* webpackChunkName: "flavours/glitch/async/embed_modal" */'flavours/glitch/features/ui/components/embed_modal');
} }