Skip to content

Commit 88570d9

Browse files
Gargronhiyuki2578
authored andcommitted
Add audio player (mastodon#11644)
1 parent 132ddf2 commit 88570d9

12 files changed

Lines changed: 337 additions & 15 deletions

File tree

app/javascript/mastodon/components/status.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import AttachmentList from './attachment_list';
1212
import Card from '../features/status/components/card';
1313
import { injectIntl, FormattedMessage } from 'react-intl';
1414
import ImmutablePureComponent from 'react-immutable-pure-component';
15-
import { MediaGallery, Video } from '../features/ui/util/async-components';
15+
import { MediaGallery, Video, Audio } from '../features/ui/util/async-components';
1616
import { HotKeys } from 'react-hotkeys';
1717
import classNames from 'classnames';
1818
import Icon from 'mastodon/components/icon';
@@ -199,11 +199,15 @@ class Status extends ImmutablePureComponent {
199199
};
200200

201201
renderLoadingMediaGallery () {
202-
return <div className='media_gallery' style={{ height: '110px' }} />;
202+
return <div className='media-gallery' style={{ height: '110px' }} />;
203203
}
204204

205205
renderLoadingVideoPlayer () {
206-
return <div className='media-spoiler-video' style={{ height: '110px' }} />;
206+
return <div className='video-player' style={{ height: '110px' }} />;
207+
}
208+
209+
renderLoadingAudioPlayer () {
210+
return <div className='audio-player' style={{ height: '110px' }} />;
207211
}
208212

209213
handleOpenVideo = (media, startTime) => {
@@ -348,7 +352,22 @@ class Status extends ImmutablePureComponent {
348352
media={status.get('media_attachments')}
349353
/>
350354
);
351-
} else if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
355+
} else if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
356+
const attachment = status.getIn(['media_attachments', 0]);
357+
358+
media = (
359+
<Bundle fetchComponent={Audio} loading={this.renderLoadingAudioPlayer} >
360+
{Component => (
361+
<Component
362+
src={attachment.get('url')}
363+
alt={attachment.get('description')}
364+
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
365+
height={110}
366+
/>
367+
)}
368+
</Bundle>
369+
);
370+
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
352371
const attachment = status.getIn(['media_attachments', 0]);
353372

354373
media = (

app/javascript/mastodon/containers/media_container.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Video from '../features/video';
88
import Card from '../features/status/components/card';
99
import Poll from 'mastodon/components/poll';
1010
import Hashtag from 'mastodon/components/hashtag';
11+
import Audio from 'mastodon/features/audio';
1112
import ModalRoot from '../components/modal_root';
1213
import { getScrollbarWidth } from '../features/ui/components/modal_root';
1314
import MediaModal from '../features/ui/components/media_modal';
@@ -16,7 +17,7 @@ import { List as ImmutableList, fromJS } from 'immutable';
1617
const { localeData, messages } = getLocale();
1718
addLocaleData(localeData);
1819

19-
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag };
20+
const MEDIA_COMPONENTS = { MediaGallery, Video, Card, Poll, Hashtag, Audio };
2021

2122
export default class MediaContainer extends PureComponent {
2223

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import WaveSurfer from 'wavesurfer.js';
4+
import { defineMessages, injectIntl } from 'react-intl';
5+
import { formatTime } from 'mastodon/features/video';
6+
import Icon from 'mastodon/components/icon';
7+
import classNames from 'classnames';
8+
import { throttle } from 'lodash';
9+
10+
const messages = defineMessages({
11+
play: { id: 'video.play', defaultMessage: 'Play' },
12+
pause: { id: 'video.pause', defaultMessage: 'Pause' },
13+
mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
14+
unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
15+
});
16+
17+
const arrayOf = (length, fill) => (new Array(length)).fill(fill);
18+
19+
export default @injectIntl
20+
class Audio extends React.PureComponent {
21+
22+
static propTypes = {
23+
src: PropTypes.string.isRequired,
24+
alt: PropTypes.string,
25+
duration: PropTypes.number,
26+
height: PropTypes.number,
27+
preload: PropTypes.bool,
28+
editable: PropTypes.bool,
29+
intl: PropTypes.object.isRequired,
30+
};
31+
32+
state = {
33+
currentTime: 0,
34+
duration: null,
35+
paused: true,
36+
muted: false,
37+
volume: 0.5,
38+
};
39+
40+
// hard coded in components.scss
41+
// any way to get ::before values programatically?
42+
43+
volWidth = 50;
44+
45+
volOffset = 70;
46+
47+
volHandleOffset = v => {
48+
const offset = v * this.volWidth + this.volOffset;
49+
return (offset > 110) ? 110 : offset;
50+
}
51+
52+
setVolumeRef = c => {
53+
this.volume = c;
54+
}
55+
56+
setWaveformRef = c => {
57+
this.waveform = c;
58+
}
59+
60+
componentDidMount () {
61+
if (this.waveform) {
62+
this._updateWaveform();
63+
}
64+
}
65+
66+
componentDidUpdate (prevProps) {
67+
if (this.waveform && prevProps.src !== this.props.src) {
68+
this._updateWaveform();
69+
}
70+
}
71+
72+
componentWillUnmount () {
73+
if (this.wavesurfer) {
74+
this.wavesurfer.destroy();
75+
this.wavesurfer = null;
76+
}
77+
}
78+
79+
_updateWaveform () {
80+
const { src, height, duration, preload } = this.props;
81+
82+
const progressColor = window.getComputedStyle(document.querySelector('.audio-player__progress-placeholder')).getPropertyValue('background-color');
83+
const waveColor = window.getComputedStyle(document.querySelector('.audio-player__wave-placeholder')).getPropertyValue('background-color');
84+
85+
if (this.wavesurfer) {
86+
this.wavesurfer.destroy();
87+
}
88+
89+
const wavesurfer = WaveSurfer.create({
90+
container: this.waveform,
91+
height,
92+
barWidth: 3,
93+
cursorWidth: 0,
94+
progressColor,
95+
waveColor,
96+
forceDecode: true,
97+
});
98+
99+
wavesurfer.setVolume(this.state.volume);
100+
101+
if (preload) {
102+
wavesurfer.load(src);
103+
} else {
104+
wavesurfer.load(src, arrayOf(1, 0.5), null, duration);
105+
}
106+
107+
wavesurfer.on('ready', () => this.setState({ duration: Math.floor(wavesurfer.getDuration()) }));
108+
wavesurfer.on('audioprocess', () => this.setState({ currentTime: Math.floor(wavesurfer.getCurrentTime()) }));
109+
wavesurfer.on('pause', () => this.setState({ paused: true }));
110+
wavesurfer.on('play', () => this.setState({ paused: false }));
111+
wavesurfer.on('volume', volume => this.setState({ volume }));
112+
wavesurfer.on('mute', muted => this.setState({ muted }));
113+
114+
this.wavesurfer = wavesurfer;
115+
}
116+
117+
togglePlay = () => {
118+
if (this.state.paused) {
119+
if (!this.props.preload) {
120+
this.wavesurfer.createBackend();
121+
this.wavesurfer.createPeakCache();
122+
this.wavesurfer.load(this.props.src);
123+
}
124+
125+
this.wavesurfer.play();
126+
} else {
127+
this.wavesurfer.pause();
128+
}
129+
}
130+
131+
toggleMute = () => {
132+
this.wavesurfer.setMute(!this.state.muted);
133+
}
134+
135+
handleVolumeMouseDown = e => {
136+
document.addEventListener('mousemove', this.handleMouseVolSlide, true);
137+
document.addEventListener('mouseup', this.handleVolumeMouseUp, true);
138+
document.addEventListener('touchmove', this.handleMouseVolSlide, true);
139+
document.addEventListener('touchend', this.handleVolumeMouseUp, true);
140+
141+
this.handleMouseVolSlide(e);
142+
143+
e.preventDefault();
144+
e.stopPropagation();
145+
}
146+
147+
handleVolumeMouseUp = () => {
148+
document.removeEventListener('mousemove', this.handleMouseVolSlide, true);
149+
document.removeEventListener('mouseup', this.handleVolumeMouseUp, true);
150+
document.removeEventListener('touchmove', this.handleMouseVolSlide, true);
151+
document.removeEventListener('touchend', this.handleVolumeMouseUp, true);
152+
}
153+
154+
handleMouseVolSlide = throttle(e => {
155+
const rect = this.volume.getBoundingClientRect();
156+
const x = (e.clientX - rect.left) / this.volWidth; // x position within the element.
157+
158+
if(!isNaN(x)) {
159+
let slideamt = x;
160+
161+
if (x > 1) {
162+
slideamt = 1;
163+
} else if(x < 0) {
164+
slideamt = 0;
165+
}
166+
167+
this.wavesurfer.setVolume(slideamt);
168+
}
169+
}, 60);
170+
171+
render () {
172+
const { height, intl, alt, editable } = this.props;
173+
const { paused, muted, volume, currentTime } = this.state;
174+
175+
const volumeWidth = muted ? 0 : volume * this.volWidth;
176+
const volumeHandleLoc = muted ? this.volHandleOffset(0) : this.volHandleOffset(volume);
177+
178+
return (
179+
<div className={classNames('audio-player', { editable })}>
180+
<div className='audio-player__progress-placeholder' style={{ display: 'none' }} />
181+
<div className='audio-player__wave-placeholder' style={{ display: 'none' }} />
182+
183+
<div
184+
className='audio-player__waveform'
185+
aria-label={alt}
186+
title={alt}
187+
style={{ height }}
188+
ref={this.setWaveformRef}
189+
/>
190+
191+
<div className='video-player__controls active'>
192+
<div className='video-player__buttons-bar'>
193+
<div className='video-player__buttons left'>
194+
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
195+
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
196+
197+
<div className='video-player__volume' onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
198+
<div className='video-player__volume__current' style={{ width: `${volumeWidth}px` }} />
199+
200+
<span
201+
className={classNames('video-player__volume__handle')}
202+
tabIndex='0'
203+
style={{ left: `${volumeHandleLoc}px` }}
204+
/>
205+
</div>
206+
207+
<span>
208+
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
209+
<span className='video-player__time-sep'>/</span>
210+
<span className='video-player__time-total'>{formatTime(this.state.duration || Math.floor(this.props.duration))}</span>
211+
</span>
212+
</div>
213+
</div>
214+
</div>
215+
</div>
216+
);
217+
}
218+
219+
}

app/javascript/mastodon/features/status/components/detailed_status.js

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { FormattedDate, FormattedNumber } from 'react-intl';
1010
import Card from './card';
1111
import ImmutablePureComponent from 'react-immutable-pure-component';
1212
import Video from '../../video';
13+
import Audio from '../../audio';
1314
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
1415
import classNames from 'classnames';
1516
import Icon from 'mastodon/components/icon';
@@ -107,7 +108,19 @@ export default class DetailedStatus extends ImmutablePureComponent {
107108
}
108109

109110
if (status.get('media_attachments').size > 0) {
110-
if (['video', 'audio'].includes(status.getIn(['media_attachments', 0, 'type']))) {
111+
if (status.getIn(['media_attachments', 0, 'type']) === 'audio') {
112+
const attachment = status.getIn(['media_attachments', 0]);
113+
114+
media = (
115+
<Audio
116+
src={attachment.get('url')}
117+
alt={attachment.get('description')}
118+
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
119+
height={150}
120+
preload
121+
/>
122+
);
123+
} else if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
111124
const attachment = status.getIn(['media_attachments', 0]);
112125

113126
media = (

app/javascript/mastodon/features/ui/components/focal_point_modal.js

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { FormattedMessage, defineMessages, injectIntl } from 'react-intl';
1010
import IconButton from 'mastodon/components/icon_button';
1111
import Button from 'mastodon/components/button';
1212
import Video from 'mastodon/features/video';
13+
import Audio from 'mastodon/features/audio';
1314
import Textarea from 'react-textarea-autosize';
1415
import UploadProgress from 'mastodon/features/compose/components/upload_progress';
1516
import CharacterCounter from 'mastodon/features/compose/components/character_counter';
@@ -244,12 +245,23 @@ class FocalPointModal extends ImmutablePureComponent {
244245
</div>
245246
)}
246247

247-
{['audio', 'video'].includes(media.get('type')) && (
248+
{media.get('type') === 'video' && (
248249
<Video
249250
preview={media.get('preview_url')}
250251
blurhash={media.get('blurhash')}
251252
src={media.get('url')}
252253
detailed
254+
inline
255+
editable
256+
/>
257+
)}
258+
259+
{media.get('type') === 'audio' && (
260+
<Audio
261+
src={media.get('url')}
262+
duration={media.getIn(['meta', 'original', 'duration'], 0)}
263+
height={150}
264+
preload
253265
editable
254266
/>
255267
)}

app/javascript/mastodon/features/ui/util/async-components.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,3 +137,7 @@ export function Search () {
137137
export function Tesseract () {
138138
return import(/*webpackChunkName: "tesseract" */'tesseract.js');
139139
}
140+
141+
export function Audio () {
142+
return import(/* webpackChunkName: "features/audio" */'../../audio');
143+
}

app/javascript/mastodon/features/video/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const messages = defineMessages({
2121
exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
2222
});
2323

24-
const formatTime = secondsNum => {
24+
export const formatTime = secondsNum => {
2525
let hours = Math.floor(secondsNum / 3600);
2626
let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
2727
let seconds = secondsNum - (hours * 3600) - (minutes * 60);

0 commit comments

Comments
 (0)