Change design of audio player in web UI (#34520)
This commit is contained in:
parent
24c25ec4f5
commit
b4394ec129
26 changed files with 1476 additions and 1088 deletions
|
@ -96,13 +96,19 @@ export const decode83 = (str: string) => {
|
||||||
return value;
|
return value;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const intToRGB = (int: number) => ({
|
export interface RGB {
|
||||||
|
r: number;
|
||||||
|
g: number;
|
||||||
|
b: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const intToRGB = (int: number): RGB => ({
|
||||||
r: Math.max(0, int >> 16),
|
r: Math.max(0, int >> 16),
|
||||||
g: Math.max(0, (int >> 8) & 255),
|
g: Math.max(0, (int >> 8) & 255),
|
||||||
b: Math.max(0, int & 255),
|
b: Math.max(0, int & 255),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAverageFromBlurhash = (blurhash: string) => {
|
export const getAverageFromBlurhash = (blurhash: string | null) => {
|
||||||
if (!blurhash) {
|
if (!blurhash) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { FormattedMessage } from 'react-intl';
|
|
||||||
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import CancelPresentationIcon from '@/material-icons/400-24px/cancel_presentation.svg?react';
|
|
||||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
|
|
||||||
class PictureInPicturePlaceholder extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
aspectRatio: PropTypes.string,
|
|
||||||
};
|
|
||||||
|
|
||||||
handleClick = () => {
|
|
||||||
const { dispatch } = this.props;
|
|
||||||
dispatch(removePictureInPicture());
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { aspectRatio } = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='picture-in-picture-placeholder' style={{ aspectRatio }} role='button' tabIndex={0} onClick={this.handleClick}>
|
|
||||||
<Icon id='window-restore' icon={CancelPresentationIcon} />
|
|
||||||
<FormattedMessage id='picture_in_picture.restore' defaultMessage='Put it back' />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect()(PictureInPicturePlaceholder);
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import PipExitIcon from '@/material-icons/400-24px/pip_exit.svg?react';
|
||||||
|
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
export const PictureInPicturePlaceholder: React.FC<{ aspectRatio: string }> = ({
|
||||||
|
aspectRatio,
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
dispatch(removePictureInPicture());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleClick();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleClick],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
|
||||||
|
className='picture-in-picture-placeholder'
|
||||||
|
style={{ aspectRatio }}
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={handleClick}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
>
|
||||||
|
<Icon id='' icon={PipExitIcon} />
|
||||||
|
<FormattedMessage
|
||||||
|
id='picture_in_picture.restore'
|
||||||
|
defaultMessage='Put it back'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -17,7 +17,7 @@ import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
|
||||||
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router';
|
import { withOptionalRouter, WithOptionalRouterPropTypes } from 'mastodon/utils/react_router';
|
||||||
|
|
||||||
import Card from '../features/status/components/card';
|
import Card from '../features/status/components/card';
|
||||||
|
@ -484,9 +484,6 @@ class Status extends ImmutablePureComponent {
|
||||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
width={this.props.cachedMediaWidth}
|
|
||||||
height={110}
|
|
||||||
cacheWidth={this.props.cacheMediaWidth}
|
|
||||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
blurhash={attachment.get('blurhash')}
|
blurhash={attachment.get('blurhash')}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
||||||
import MediaGallery from 'mastodon/components/media_gallery';
|
import MediaGallery from 'mastodon/components/media_gallery';
|
||||||
import ModalRoot from 'mastodon/components/modal_root';
|
import ModalRoot from 'mastodon/components/modal_root';
|
||||||
import { Poll } from 'mastodon/components/poll';
|
import { Poll } from 'mastodon/components/poll';
|
||||||
import Audio from 'mastodon/features/audio';
|
import { Audio } from 'mastodon/features/audio';
|
||||||
import Card from 'mastodon/features/status/components/card';
|
import Card from 'mastodon/features/status/components/card';
|
||||||
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
import MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
|
|
|
@ -27,7 +27,7 @@ import { Button } from 'mastodon/components/button';
|
||||||
import { GIFV } from 'mastodon/components/gifv';
|
import { GIFV } from 'mastodon/components/gifv';
|
||||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||||
import { Skeleton } from 'mastodon/components/skeleton';
|
import { Skeleton } from 'mastodon/components/skeleton';
|
||||||
import Audio from 'mastodon/features/audio';
|
import { Audio } from 'mastodon/features/audio';
|
||||||
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
import { CharacterCounter } from 'mastodon/features/compose/components/character_counter';
|
||||||
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
import { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||||
import { Video, getPointerPosition } from 'mastodon/features/video';
|
import { Video, getPointerPosition } from 'mastodon/features/video';
|
||||||
|
@ -212,11 +212,11 @@ const Preview: React.FC<{
|
||||||
return (
|
return (
|
||||||
<Audio
|
<Audio
|
||||||
src={media.get('url') as string}
|
src={media.get('url') as string}
|
||||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
|
||||||
poster={
|
poster={
|
||||||
(media.get('preview_url') as string | undefined) ??
|
(media.get('preview_url') as string | undefined) ??
|
||||||
account?.avatar_static
|
account?.avatar_static
|
||||||
}
|
}
|
||||||
|
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||||
backgroundColor={
|
backgroundColor={
|
||||||
media.getIn(['meta', 'colors', 'background']) as string
|
media.getIn(['meta', 'colors', 'background']) as string
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,588 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { PureComponent } from 'react';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
|
|
||||||
import { is } from 'immutable';
|
|
||||||
|
|
||||||
import { throttle, debounce } from 'lodash';
|
|
||||||
|
|
||||||
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
|
||||||
import PauseIcon from '@/material-icons/400-24px/pause.svg?react';
|
|
||||||
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
|
||||||
import VisibilityOffIcon from '@/material-icons/400-24px/visibility_off.svg?react';
|
|
||||||
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
|
||||||
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
|
||||||
import { Icon } from 'mastodon/components/icon';
|
|
||||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
|
||||||
import { formatTime, getPointerPosition, fileNameFromURL } from 'mastodon/features/video';
|
|
||||||
|
|
||||||
import { Blurhash } from '../../components/blurhash';
|
|
||||||
import { displayMedia, useBlurhash } from '../../initial_state';
|
|
||||||
|
|
||||||
import Visualizer from './visualizer';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
play: { id: 'video.play', defaultMessage: 'Play' },
|
|
||||||
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
|
||||||
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
|
||||||
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
|
||||||
download: { id: 'video.download', defaultMessage: 'Download file' },
|
|
||||||
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const TICK_SIZE = 10;
|
|
||||||
const PADDING = 180;
|
|
||||||
|
|
||||||
class Audio extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
src: PropTypes.string.isRequired,
|
|
||||||
alt: PropTypes.string,
|
|
||||||
lang: PropTypes.string,
|
|
||||||
poster: PropTypes.string,
|
|
||||||
duration: PropTypes.number,
|
|
||||||
width: PropTypes.number,
|
|
||||||
height: PropTypes.number,
|
|
||||||
sensitive: PropTypes.bool,
|
|
||||||
editable: PropTypes.bool,
|
|
||||||
fullscreen: PropTypes.bool,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
blurhash: PropTypes.string,
|
|
||||||
cacheWidth: PropTypes.func,
|
|
||||||
visible: PropTypes.bool,
|
|
||||||
onToggleVisibility: PropTypes.func,
|
|
||||||
backgroundColor: PropTypes.string,
|
|
||||||
foregroundColor: PropTypes.string,
|
|
||||||
accentColor: PropTypes.string,
|
|
||||||
currentTime: PropTypes.number,
|
|
||||||
autoPlay: PropTypes.bool,
|
|
||||||
volume: PropTypes.number,
|
|
||||||
muted: PropTypes.bool,
|
|
||||||
deployPictureInPicture: PropTypes.func,
|
|
||||||
matchedFilters: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
};
|
|
||||||
|
|
||||||
state = {
|
|
||||||
width: this.props.width,
|
|
||||||
currentTime: 0,
|
|
||||||
buffer: 0,
|
|
||||||
duration: null,
|
|
||||||
paused: true,
|
|
||||||
muted: false,
|
|
||||||
volume: 1,
|
|
||||||
dragging: false,
|
|
||||||
revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor (props) {
|
|
||||||
super(props);
|
|
||||||
this.visualizer = new Visualizer(TICK_SIZE);
|
|
||||||
}
|
|
||||||
|
|
||||||
setPlayerRef = c => {
|
|
||||||
this.player = c;
|
|
||||||
|
|
||||||
if (this.player) {
|
|
||||||
this._setDimensions();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_pack() {
|
|
||||||
return {
|
|
||||||
src: this.props.src,
|
|
||||||
volume: this.state.volume,
|
|
||||||
muted: this.state.muted,
|
|
||||||
currentTime: this.audio.currentTime,
|
|
||||||
poster: this.props.poster,
|
|
||||||
backgroundColor: this.props.backgroundColor,
|
|
||||||
foregroundColor: this.props.foregroundColor,
|
|
||||||
accentColor: this.props.accentColor,
|
|
||||||
sensitive: this.props.sensitive,
|
|
||||||
visible: this.props.visible,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
_setDimensions () {
|
|
||||||
const width = this.player.offsetWidth;
|
|
||||||
const height = this.props.fullscreen ? this.player.offsetHeight : (width / (16/9));
|
|
||||||
|
|
||||||
if (this.props.cacheWidth) {
|
|
||||||
this.props.cacheWidth(width);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ width, height });
|
|
||||||
}
|
|
||||||
|
|
||||||
setSeekRef = c => {
|
|
||||||
this.seek = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setVolumeRef = c => {
|
|
||||||
this.volume = c;
|
|
||||||
};
|
|
||||||
|
|
||||||
setAudioRef = c => {
|
|
||||||
this.audio = c;
|
|
||||||
|
|
||||||
if (this.audio) {
|
|
||||||
this.audio.volume = 1;
|
|
||||||
this.audio.muted = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
setCanvasRef = c => {
|
|
||||||
this.canvas = c;
|
|
||||||
|
|
||||||
this.visualizer.setCanvas(c);
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
window.addEventListener('scroll', this.handleScroll);
|
|
||||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate (prevProps, prevState) {
|
|
||||||
if (prevProps.src !== this.props.src || this.state.width !== prevState.width || this.state.height !== prevState.height || prevProps.accentColor !== this.props.accentColor) {
|
|
||||||
this._clear();
|
|
||||||
this._draw();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
UNSAFE_componentWillReceiveProps (nextProps) {
|
|
||||||
if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
|
|
||||||
this.setState({ revealed: nextProps.visible });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
window.removeEventListener('scroll', this.handleScroll);
|
|
||||||
window.removeEventListener('resize', this.handleResize);
|
|
||||||
|
|
||||||
if (!this.state.paused && this.audio && this.props.deployPictureInPicture) {
|
|
||||||
this.props.deployPictureInPicture('audio', this._pack());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
togglePlay = () => {
|
|
||||||
if (!this.audioContext) {
|
|
||||||
this._initAudioContext();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.paused) {
|
|
||||||
this.setState({ paused: false }, () => this.audio.play());
|
|
||||||
} else {
|
|
||||||
this.setState({ paused: true }, () => this.audio.pause());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleResize = debounce(() => {
|
|
||||||
if (this.player) {
|
|
||||||
this._setDimensions();
|
|
||||||
}
|
|
||||||
}, 250, {
|
|
||||||
trailing: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
handlePlay = () => {
|
|
||||||
this.setState({ paused: false });
|
|
||||||
|
|
||||||
if (this.audioContext && this.audioContext.state === 'suspended') {
|
|
||||||
this.audioContext.resume();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._renderCanvas();
|
|
||||||
};
|
|
||||||
|
|
||||||
handlePause = () => {
|
|
||||||
this.setState({ paused: true });
|
|
||||||
|
|
||||||
if (this.audioContext) {
|
|
||||||
this.audioContext.suspend();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleProgress = () => {
|
|
||||||
const lastTimeRange = this.audio.buffered.length - 1;
|
|
||||||
|
|
||||||
if (lastTimeRange > -1) {
|
|
||||||
this.setState({ buffer: Math.ceil(this.audio.buffered.end(lastTimeRange) / this.audio.duration * 100) });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleMute = () => {
|
|
||||||
const muted = !(this.state.muted || this.state.volume === 0);
|
|
||||||
|
|
||||||
this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
|
|
||||||
if (this.gainNode) {
|
|
||||||
this.gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
toggleReveal = () => {
|
|
||||||
if (this.props.onToggleVisibility) {
|
|
||||||
this.props.onToggleVisibility();
|
|
||||||
} else {
|
|
||||||
this.setState({ revealed: !this.state.revealed });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseDown = e => {
|
|
||||||
document.addEventListener('mousemove', this.handleMouseMove, true);
|
|
||||||
document.addEventListener('mouseup', this.handleMouseUp, true);
|
|
||||||
document.addEventListener('touchmove', this.handleMouseMove, true);
|
|
||||||
document.addEventListener('touchend', this.handleMouseUp, true);
|
|
||||||
|
|
||||||
this.setState({ dragging: true });
|
|
||||||
this.audio.pause();
|
|
||||||
this.handleMouseMove(e);
|
|
||||||
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseUp = () => {
|
|
||||||
document.removeEventListener('mousemove', this.handleMouseMove, true);
|
|
||||||
document.removeEventListener('mouseup', this.handleMouseUp, true);
|
|
||||||
document.removeEventListener('touchmove', this.handleMouseMove, true);
|
|
||||||
document.removeEventListener('touchend', this.handleMouseUp, true);
|
|
||||||
|
|
||||||
this.setState({ dragging: false });
|
|
||||||
this.audio.play();
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseMove = throttle(e => {
|
|
||||||
const { x } = getPointerPosition(this.seek, e);
|
|
||||||
const currentTime = this.audio.duration * x;
|
|
||||||
|
|
||||||
if (!isNaN(currentTime)) {
|
|
||||||
this.setState({ currentTime }, () => {
|
|
||||||
this.audio.currentTime = currentTime;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 15);
|
|
||||||
|
|
||||||
handleTimeUpdate = () => {
|
|
||||||
this.setState({
|
|
||||||
currentTime: this.audio.currentTime,
|
|
||||||
duration: this.audio.duration,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseVolSlide = throttle(e => {
|
|
||||||
const { x } = getPointerPosition(this.volume, e);
|
|
||||||
|
|
||||||
if(!isNaN(x)) {
|
|
||||||
this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
|
|
||||||
if (this.gainNode) {
|
|
||||||
this.gainNode.gain.value = this.state.muted ? 0 : x;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, 15);
|
|
||||||
|
|
||||||
handleScroll = throttle(() => {
|
|
||||||
if (!this.canvas || !this.audio) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { top, height } = this.canvas.getBoundingClientRect();
|
|
||||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
|
||||||
|
|
||||||
if (!this.state.paused && !inView) {
|
|
||||||
this.audio.pause();
|
|
||||||
|
|
||||||
if (this.props.deployPictureInPicture) {
|
|
||||||
this.props.deployPictureInPicture('audio', this._pack());
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setState({ paused: true });
|
|
||||||
}
|
|
||||||
}, 150, { trailing: true });
|
|
||||||
|
|
||||||
handleMouseEnter = () => {
|
|
||||||
this.setState({ hovered: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleMouseLeave = () => {
|
|
||||||
this.setState({ hovered: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
handleLoadedData = () => {
|
|
||||||
const { autoPlay, currentTime } = this.props;
|
|
||||||
|
|
||||||
if (currentTime) {
|
|
||||||
this.audio.currentTime = currentTime;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoPlay) {
|
|
||||||
this.togglePlay();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
_initAudioContext () {
|
|
||||||
const AudioContext = window.AudioContext || window.webkitAudioContext;
|
|
||||||
const context = new AudioContext();
|
|
||||||
const source = context.createMediaElementSource(this.audio);
|
|
||||||
const gainNode = context.createGain();
|
|
||||||
|
|
||||||
gainNode.gain.value = this.state.muted ? 0 : this.state.volume;
|
|
||||||
|
|
||||||
this.visualizer.setAudioContext(context, source);
|
|
||||||
source.connect(gainNode);
|
|
||||||
gainNode.connect(context.destination);
|
|
||||||
|
|
||||||
this.audioContext = context;
|
|
||||||
this.gainNode = gainNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleDownload = () => {
|
|
||||||
fetch(this.props.src).then(res => res.blob()).then(blob => {
|
|
||||||
const element = document.createElement('a');
|
|
||||||
const objectURL = URL.createObjectURL(blob);
|
|
||||||
|
|
||||||
element.setAttribute('href', objectURL);
|
|
||||||
element.setAttribute('download', fileNameFromURL(this.props.src));
|
|
||||||
|
|
||||||
document.body.appendChild(element);
|
|
||||||
element.click();
|
|
||||||
document.body.removeChild(element);
|
|
||||||
|
|
||||||
URL.revokeObjectURL(objectURL);
|
|
||||||
}).catch(err => {
|
|
||||||
console.error(err);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
_renderCanvas () {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
if (!this.audio) return;
|
|
||||||
|
|
||||||
this.handleTimeUpdate();
|
|
||||||
this._clear();
|
|
||||||
this._draw();
|
|
||||||
|
|
||||||
if (!this.state.paused) {
|
|
||||||
this._renderCanvas();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_clear() {
|
|
||||||
this.visualizer.clear(this.state.width, this.state.height);
|
|
||||||
}
|
|
||||||
|
|
||||||
_draw() {
|
|
||||||
this.visualizer.draw(this._getCX(), this._getCY(), this._getAccentColor(), this._getRadius(), this._getScaleCoefficient());
|
|
||||||
}
|
|
||||||
|
|
||||||
_getRadius () {
|
|
||||||
return parseInt((this.state.height || this.props.height) / 2 - PADDING * this._getScaleCoefficient());
|
|
||||||
}
|
|
||||||
|
|
||||||
_getScaleCoefficient () {
|
|
||||||
return (this.state.height || this.props.height) / 982;
|
|
||||||
}
|
|
||||||
|
|
||||||
_getCX() {
|
|
||||||
return Math.floor(this.state.width / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
_getCY() {
|
|
||||||
return Math.floor((this.state.height || this.props.height) / 2);
|
|
||||||
}
|
|
||||||
|
|
||||||
_getAccentColor () {
|
|
||||||
return this.props.accentColor || '#ffffff';
|
|
||||||
}
|
|
||||||
|
|
||||||
_getBackgroundColor () {
|
|
||||||
return this.props.backgroundColor || '#000000';
|
|
||||||
}
|
|
||||||
|
|
||||||
_getForegroundColor () {
|
|
||||||
return this.props.foregroundColor || '#ffffff';
|
|
||||||
}
|
|
||||||
|
|
||||||
seekBy (time) {
|
|
||||||
const currentTime = this.audio.currentTime + time;
|
|
||||||
|
|
||||||
if (!isNaN(currentTime)) {
|
|
||||||
this.setState({ currentTime }, () => {
|
|
||||||
this.audio.currentTime = currentTime;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleAudioKeyDown = e => {
|
|
||||||
// On the audio element or the seek bar, we can safely use the space bar
|
|
||||||
// for playback control because there are no buttons to press
|
|
||||||
|
|
||||||
if (e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.togglePlay();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleKeyDown = e => {
|
|
||||||
switch(e.key) {
|
|
||||||
case 'k':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.togglePlay();
|
|
||||||
break;
|
|
||||||
case 'm':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.toggleMute();
|
|
||||||
break;
|
|
||||||
case 'j':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.seekBy(-10);
|
|
||||||
break;
|
|
||||||
case 'l':
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
this.seekBy(10);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { src, intl, alt, lang, editable, autoPlay, sensitive, blurhash, matchedFilters } = this.props;
|
|
||||||
const { paused, volume, currentTime, duration, buffer, dragging, revealed } = this.state;
|
|
||||||
const progress = Math.min((currentTime / duration) * 100, 100);
|
|
||||||
const muted = this.state.muted || volume === 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames('audio-player', { editable, inactive: !revealed })} ref={this.setPlayerRef} style={{ backgroundColor: this._getBackgroundColor(), color: this._getForegroundColor(), aspectRatio: '16 / 9' }} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} tabIndex={0} onKeyDown={this.handleKeyDown}>
|
|
||||||
|
|
||||||
<Blurhash
|
|
||||||
hash={blurhash}
|
|
||||||
className={classNames('media-gallery__preview', {
|
|
||||||
'media-gallery__preview--hidden': revealed,
|
|
||||||
})}
|
|
||||||
dummy={!useBlurhash}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{(revealed || editable) && <audio
|
|
||||||
src={src}
|
|
||||||
ref={this.setAudioRef}
|
|
||||||
preload={autoPlay ? 'auto' : 'none'}
|
|
||||||
onPlay={this.handlePlay}
|
|
||||||
onPause={this.handlePause}
|
|
||||||
onProgress={this.handleProgress}
|
|
||||||
onLoadedData={this.handleLoadedData}
|
|
||||||
crossOrigin='anonymous'
|
|
||||||
/>}
|
|
||||||
|
|
||||||
<canvas
|
|
||||||
role='button'
|
|
||||||
tabIndex={0}
|
|
||||||
className='audio-player__canvas'
|
|
||||||
width={this.state.width}
|
|
||||||
height={this.state.height}
|
|
||||||
style={{ width: '100%', position: 'absolute', top: 0, left: 0 }}
|
|
||||||
ref={this.setCanvasRef}
|
|
||||||
onClick={this.togglePlay}
|
|
||||||
onKeyDown={this.handleAudioKeyDown}
|
|
||||||
title={alt}
|
|
||||||
aria-label={alt}
|
|
||||||
lang={lang}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
|
|
||||||
|
|
||||||
{(revealed || editable) && <img
|
|
||||||
src={this.props.poster}
|
|
||||||
alt=''
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
left: '50%',
|
|
||||||
top: '50%',
|
|
||||||
height: `calc(${(100 - 2 * 100 * PADDING / 982)}% - ${TICK_SIZE * 2}px)`,
|
|
||||||
aspectRatio: '1',
|
|
||||||
transform: 'translate(-50%, -50%)',
|
|
||||||
borderRadius: '50%',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
/>}
|
|
||||||
|
|
||||||
<div className='video-player__seek' onMouseDown={this.handleMouseDown} ref={this.setSeekRef}>
|
|
||||||
<div className='video-player__seek__buffer' style={{ width: `${buffer}%` }} />
|
|
||||||
<div className='video-player__seek__progress' style={{ width: `${progress}%`, backgroundColor: this._getAccentColor() }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={classNames('video-player__seek__handle', { active: dragging })}
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ left: `${progress}%`, backgroundColor: this._getAccentColor() }}
|
|
||||||
onKeyDown={this.handleAudioKeyDown}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='video-player__controls active'>
|
|
||||||
<div className='video-player__buttons-bar'>
|
|
||||||
<div className='video-player__buttons left'>
|
|
||||||
<button type='button' title={intl.formatMessage(paused ? messages.play : messages.pause)} aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} icon={paused ? PlayArrowIcon : PauseIcon} /></button>
|
|
||||||
<button type='button' title={intl.formatMessage(muted ? messages.unmute : messages.mute)} aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} icon={muted ? VolumeOffIcon : VolumeUpIcon} /></button>
|
|
||||||
|
|
||||||
<div className={classNames('video-player__volume', { active: this.state.hovered })} ref={this.setVolumeRef} onMouseDown={this.handleVolumeMouseDown}>
|
|
||||||
<div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }} />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className='video-player__volume__handle'
|
|
||||||
tabIndex={0}
|
|
||||||
style={{ left: `${muted ? 0 : volume * 100}%`, backgroundColor: this._getAccentColor() }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<span className='video-player__time'>
|
|
||||||
<span className='video-player__time-current'>{formatTime(Math.floor(currentTime))}</span>
|
|
||||||
<span className='video-player__time-sep'>/</span>
|
|
||||||
<span className='video-player__time-total'>{formatTime(Math.floor(this.state.duration || this.props.duration))}</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='video-player__buttons right'>
|
|
||||||
{!editable && (
|
|
||||||
<>
|
|
||||||
<button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon id='eye-slash' icon={VisibilityOffIcon} /></button>
|
|
||||||
<a title={intl.formatMessage(messages.download)} aria-label={intl.formatMessage(messages.download)} className='video-player__download__icon player-button' href={this.props.src} download>
|
|
||||||
<Icon id='download' icon={DownloadIcon} />
|
|
||||||
</a>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default injectIntl(Audio);
|
|
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
840
app/javascript/mastodon/features/audio/index.tsx
Normal file
|
@ -0,0 +1,840 @@
|
||||||
|
import { useEffect, useRef, useCallback, useState, useId } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import { useSpring, animated, config } from '@react-spring/web';
|
||||||
|
|
||||||
|
import DownloadIcon from '@/material-icons/400-24px/download.svg?react';
|
||||||
|
import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
|
||||||
|
import PauseIcon from '@/material-icons/400-24px/pause-fill.svg?react';
|
||||||
|
import PlayArrowIcon from '@/material-icons/400-24px/play_arrow-fill.svg?react';
|
||||||
|
import Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
|
||||||
|
import VolumeOffIcon from '@/material-icons/400-24px/volume_off-fill.svg?react';
|
||||||
|
import VolumeUpIcon from '@/material-icons/400-24px/volume_up-fill.svg?react';
|
||||||
|
import { Blurhash } from 'mastodon/components/blurhash';
|
||||||
|
import { Icon } from 'mastodon/components/icon';
|
||||||
|
import { SpoilerButton } from 'mastodon/components/spoiler_button';
|
||||||
|
import { formatTime, getPointerPosition } from 'mastodon/features/video';
|
||||||
|
import { useAudioVisualizer } from 'mastodon/hooks/useAudioVisualizer';
|
||||||
|
import {
|
||||||
|
displayMedia,
|
||||||
|
useBlurhash,
|
||||||
|
reduceMotion,
|
||||||
|
} from 'mastodon/initial_state';
|
||||||
|
import { playerSettings } from 'mastodon/settings';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
play: { id: 'video.play', defaultMessage: 'Play' },
|
||||||
|
pause: { id: 'video.pause', defaultMessage: 'Pause' },
|
||||||
|
mute: { id: 'video.mute', defaultMessage: 'Mute' },
|
||||||
|
unmute: { id: 'video.unmute', defaultMessage: 'Unmute' },
|
||||||
|
download: { id: 'video.download', defaultMessage: 'Download file' },
|
||||||
|
hide: { id: 'audio.hide', defaultMessage: 'Hide audio' },
|
||||||
|
skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
|
||||||
|
skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const persistVolume = (volume: number, muted: boolean) => {
|
||||||
|
playerSettings.set('volume', volume);
|
||||||
|
playerSettings.set('muted', muted);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restoreVolume = (audio: HTMLAudioElement) => {
|
||||||
|
const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
|
||||||
|
const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
|
||||||
|
|
||||||
|
audio.volume = volume;
|
||||||
|
audio.muted = muted;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOVER_FADE_DELAY = 4000;
|
||||||
|
|
||||||
|
export const Audio: React.FC<{
|
||||||
|
src: string;
|
||||||
|
alt?: string;
|
||||||
|
lang?: string;
|
||||||
|
poster?: string;
|
||||||
|
sensitive?: boolean;
|
||||||
|
editable?: boolean;
|
||||||
|
blurhash?: string;
|
||||||
|
visible?: boolean;
|
||||||
|
duration?: number;
|
||||||
|
onToggleVisibility?: () => void;
|
||||||
|
backgroundColor?: string;
|
||||||
|
foregroundColor?: string;
|
||||||
|
accentColor?: string;
|
||||||
|
startTime?: number;
|
||||||
|
startPlaying?: boolean;
|
||||||
|
startVolume?: number;
|
||||||
|
startMuted?: boolean;
|
||||||
|
deployPictureInPicture?: (
|
||||||
|
type: string,
|
||||||
|
mediaProps: {
|
||||||
|
src: string;
|
||||||
|
muted: boolean;
|
||||||
|
volume: number;
|
||||||
|
currentTime: number;
|
||||||
|
poster?: string;
|
||||||
|
backgroundColor: string;
|
||||||
|
foregroundColor: string;
|
||||||
|
accentColor: string;
|
||||||
|
},
|
||||||
|
) => void;
|
||||||
|
matchedFilters?: string[];
|
||||||
|
}> = ({
|
||||||
|
src,
|
||||||
|
alt,
|
||||||
|
lang,
|
||||||
|
poster,
|
||||||
|
duration,
|
||||||
|
sensitive,
|
||||||
|
editable,
|
||||||
|
blurhash,
|
||||||
|
visible,
|
||||||
|
onToggleVisibility,
|
||||||
|
backgroundColor = '#000000',
|
||||||
|
foregroundColor = '#ffffff',
|
||||||
|
accentColor = '#ffffff',
|
||||||
|
startTime,
|
||||||
|
startPlaying,
|
||||||
|
startVolume,
|
||||||
|
startMuted,
|
||||||
|
deployPictureInPicture,
|
||||||
|
matchedFilters,
|
||||||
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
|
const [loadedDuration, setDuration] = useState(duration ?? 0);
|
||||||
|
const [paused, setPaused] = useState(true);
|
||||||
|
const [muted, setMuted] = useState(false);
|
||||||
|
const [volume, setVolume] = useState(0.5);
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [revealed, setRevealed] = useState(false);
|
||||||
|
|
||||||
|
const playerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||||
|
const seekRef = useRef<HTMLDivElement>(null);
|
||||||
|
const volumeRef = useRef<HTMLDivElement>(null);
|
||||||
|
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>();
|
||||||
|
const [resumeAudio, suspendAudio, frequencyBands] = useAudioVisualizer(
|
||||||
|
audioRef,
|
||||||
|
3,
|
||||||
|
);
|
||||||
|
const accessibilityId = useId();
|
||||||
|
|
||||||
|
const [style, spring] = useSpring(() => ({
|
||||||
|
progress: '0%',
|
||||||
|
buffer: '0%',
|
||||||
|
volume: '0%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const handleAudioRef = useCallback(
|
||||||
|
(c: HTMLVideoElement | null) => {
|
||||||
|
if (audioRef.current && !audioRef.current.paused && c === null) {
|
||||||
|
deployPictureInPicture?.('audio', {
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
backgroundColor,
|
||||||
|
foregroundColor,
|
||||||
|
accentColor,
|
||||||
|
currentTime: audioRef.current.currentTime,
|
||||||
|
muted: audioRef.current.muted,
|
||||||
|
volume: audioRef.current.volume,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current = c;
|
||||||
|
|
||||||
|
if (audioRef.current) {
|
||||||
|
restoreVolume(audioRef.current);
|
||||||
|
setVolume(audioRef.current.volume);
|
||||||
|
setMuted(audioRef.current.muted);
|
||||||
|
void spring.start({
|
||||||
|
volume: `${audioRef.current.volume * 100}%`,
|
||||||
|
immediate: reduceMotion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
spring,
|
||||||
|
setVolume,
|
||||||
|
setMuted,
|
||||||
|
src,
|
||||||
|
poster,
|
||||||
|
backgroundColor,
|
||||||
|
accentColor,
|
||||||
|
foregroundColor,
|
||||||
|
deployPictureInPicture,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioRef.current.volume = volume;
|
||||||
|
audioRef.current.muted = muted;
|
||||||
|
}, [volume, muted]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof visible !== 'undefined') {
|
||||||
|
setRevealed(visible);
|
||||||
|
} else {
|
||||||
|
setRevealed(
|
||||||
|
displayMedia === 'show_all' ||
|
||||||
|
(displayMedia !== 'hide_all' && !sensitive),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [visible, sensitive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!revealed && audioRef.current) {
|
||||||
|
audioRef.current.pause();
|
||||||
|
suspendAudio();
|
||||||
|
}
|
||||||
|
}, [suspendAudio, revealed]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let nextFrame: ReturnType<typeof requestAnimationFrame>;
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
nextFrame = requestAnimationFrame(() => {
|
||||||
|
if (audioRef.current && audioRef.current.duration > 0) {
|
||||||
|
void spring.start({
|
||||||
|
progress: `${(audioRef.current.currentTime / audioRef.current.duration) * 100}%`,
|
||||||
|
immediate: reduceMotion,
|
||||||
|
config: config.stiff,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgress();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
updateProgress();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelAnimationFrame(nextFrame);
|
||||||
|
};
|
||||||
|
}, [spring]);
|
||||||
|
|
||||||
|
const togglePlay = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioRef.current.paused) {
|
||||||
|
resumeAudio();
|
||||||
|
void audioRef.current.play();
|
||||||
|
} else {
|
||||||
|
audioRef.current.pause();
|
||||||
|
suspendAudio();
|
||||||
|
}
|
||||||
|
}, [resumeAudio, suspendAudio]);
|
||||||
|
|
||||||
|
const handlePlay = useCallback(() => {
|
||||||
|
setPaused(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handlePause = useCallback(() => {
|
||||||
|
setPaused(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleProgress = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastTimeRange = audioRef.current.buffered.length - 1;
|
||||||
|
|
||||||
|
if (lastTimeRange > -1) {
|
||||||
|
void spring.start({
|
||||||
|
buffer: `${Math.ceil(audioRef.current.buffered.end(lastTimeRange) / audioRef.current.duration) * 100}%`,
|
||||||
|
immediate: reduceMotion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [spring]);
|
||||||
|
|
||||||
|
const handleVolumeChange = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(audioRef.current.volume);
|
||||||
|
setMuted(audioRef.current.muted);
|
||||||
|
|
||||||
|
void spring.start({
|
||||||
|
volume: `${audioRef.current.muted ? 0 : audioRef.current.volume * 100}%`,
|
||||||
|
immediate: reduceMotion,
|
||||||
|
});
|
||||||
|
|
||||||
|
persistVolume(audioRef.current.volume, audioRef.current.muted);
|
||||||
|
}, [spring, setVolume, setMuted]);
|
||||||
|
|
||||||
|
const handleTimeUpdate = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCurrentTime(audioRef.current.currentTime);
|
||||||
|
}, [setCurrentTime]);
|
||||||
|
|
||||||
|
const toggleMute = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectivelyMuted =
|
||||||
|
audioRef.current.muted || audioRef.current.volume === 0;
|
||||||
|
|
||||||
|
if (effectivelyMuted) {
|
||||||
|
audioRef.current.muted = false;
|
||||||
|
|
||||||
|
if (audioRef.current.volume === 0) {
|
||||||
|
audioRef.current.volume = 0.05;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audioRef.current.muted = true;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleReveal = useCallback(() => {
|
||||||
|
if (onToggleVisibility) {
|
||||||
|
onToggleVisibility();
|
||||||
|
} else {
|
||||||
|
setRevealed((value) => !value);
|
||||||
|
}
|
||||||
|
}, [onToggleVisibility, setRevealed]);
|
||||||
|
|
||||||
|
const handleVolumeMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const handleVolumeMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleVolumeMouseMove, true);
|
||||||
|
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVolumeMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!volumeRef.current || !audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x } = getPointerPosition(volumeRef.current, e);
|
||||||
|
|
||||||
|
if (!isNaN(x)) {
|
||||||
|
audioRef.current.volume = x;
|
||||||
|
audioRef.current.muted = x > 0 ? false : true;
|
||||||
|
void spring.start({ volume: `${x * 100}%`, immediate: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleVolumeMouseMove, true);
|
||||||
|
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
||||||
|
|
||||||
|
handleVolumeMouseMove(e.nativeEvent);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
[spring],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSeekMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
const handleSeekMouseUp = () => {
|
||||||
|
document.removeEventListener('mousemove', handleSeekMouseMove, true);
|
||||||
|
document.removeEventListener('mouseup', handleSeekMouseUp, true);
|
||||||
|
|
||||||
|
setDragging(false);
|
||||||
|
resumeAudio();
|
||||||
|
void audioRef.current?.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeekMouseMove = (e: MouseEvent) => {
|
||||||
|
if (!seekRef.current || !audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { x } = getPointerPosition(seekRef.current, e);
|
||||||
|
const newTime = audioRef.current.duration * x;
|
||||||
|
|
||||||
|
if (!isNaN(newTime)) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
void spring.start({ progress: `${x * 100}%`, immediate: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousemove', handleSeekMouseMove, true);
|
||||||
|
document.addEventListener('mouseup', handleSeekMouseUp, true);
|
||||||
|
|
||||||
|
setDragging(true);
|
||||||
|
audioRef.current?.pause();
|
||||||
|
handleSeekMouseMove(e.nativeEvent);
|
||||||
|
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
},
|
||||||
|
[setDragging, spring, resumeAudio],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMouseEnter = useCallback(() => {
|
||||||
|
setHovered(true);
|
||||||
|
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
setHovered(false);
|
||||||
|
}, HOVER_FADE_DELAY);
|
||||||
|
}, [setHovered]);
|
||||||
|
|
||||||
|
const handleMouseMove = useCallback(() => {
|
||||||
|
setHovered(true);
|
||||||
|
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
setHovered(false);
|
||||||
|
}, HOVER_FADE_DELAY);
|
||||||
|
}, [setHovered]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
setHovered(false);
|
||||||
|
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
}, [setHovered]);
|
||||||
|
|
||||||
|
const handleTouchEnd = useCallback(() => {
|
||||||
|
setHovered(true);
|
||||||
|
|
||||||
|
if (hoverTimeoutRef.current) {
|
||||||
|
clearTimeout(hoverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
hoverTimeoutRef.current = setTimeout(() => {
|
||||||
|
setHovered(false);
|
||||||
|
}, HOVER_FADE_DELAY);
|
||||||
|
}, [setHovered]);
|
||||||
|
|
||||||
|
const handleLoadedData = useCallback(() => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDuration(audioRef.current.duration);
|
||||||
|
|
||||||
|
if (typeof startTime !== 'undefined') {
|
||||||
|
audioRef.current.currentTime = startTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof startVolume !== 'undefined') {
|
||||||
|
audioRef.current.volume = startVolume;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof startMuted !== 'undefined') {
|
||||||
|
audioRef.current.muted = startMuted;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startPlaying) {
|
||||||
|
void audioRef.current.play();
|
||||||
|
}
|
||||||
|
}, [setDuration, startTime, startVolume, startMuted, startPlaying]);
|
||||||
|
|
||||||
|
const seekBy = (time: number) => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTime = audioRef.current.currentTime + time;
|
||||||
|
|
||||||
|
if (!isNaN(newTime)) {
|
||||||
|
audioRef.current.currentTime = newTime;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAudioKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
// On the audio element or the seek bar, we can safely use the space bar
|
||||||
|
// for playback control because there are no buttons to press
|
||||||
|
|
||||||
|
if (e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePlay();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[togglePlay],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSkipBackward = useCallback(() => {
|
||||||
|
seekBy(-5);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSkipForward = useCallback(() => {
|
||||||
|
seekBy(5);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: React.KeyboardEvent) => {
|
||||||
|
const updateVolumeBy = (step: number) => {
|
||||||
|
if (!audioRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newVolume = audioRef.current.volume + step;
|
||||||
|
|
||||||
|
if (!isNaN(newVolume)) {
|
||||||
|
audioRef.current.volume = newVolume;
|
||||||
|
audioRef.current.muted = newVolume > 0 ? false : true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'k':
|
||||||
|
case ' ':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
togglePlay();
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMute();
|
||||||
|
break;
|
||||||
|
case 'j':
|
||||||
|
case 'ArrowLeft':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
seekBy(-5);
|
||||||
|
break;
|
||||||
|
case 'l':
|
||||||
|
case 'ArrowRight':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
seekBy(5);
|
||||||
|
break;
|
||||||
|
case 'ArrowUp':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateVolumeBy(0.15);
|
||||||
|
break;
|
||||||
|
case 'ArrowDown':
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateVolumeBy(-0.15);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[togglePlay, toggleMute],
|
||||||
|
);
|
||||||
|
|
||||||
|
const springForBand0 = useSpring({
|
||||||
|
to: { r: 50 + (frequencyBands[0] ?? 0) * 10 },
|
||||||
|
config: config.wobbly,
|
||||||
|
});
|
||||||
|
const springForBand1 = useSpring({
|
||||||
|
to: { r: 50 + (frequencyBands[1] ?? 0) * 10 },
|
||||||
|
config: config.wobbly,
|
||||||
|
});
|
||||||
|
const springForBand2 = useSpring({
|
||||||
|
to: { r: 50 + (frequencyBands[2] ?? 0) * 10 },
|
||||||
|
config: config.wobbly,
|
||||||
|
});
|
||||||
|
|
||||||
|
const progress = Math.min((currentTime / loadedDuration) * 100, 100);
|
||||||
|
const effectivelyMuted = muted || volume === 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames('audio-player', { inactive: !revealed })}
|
||||||
|
ref={playerRef}
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
'--player-background-color': backgroundColor,
|
||||||
|
'--player-foreground-color': foregroundColor,
|
||||||
|
'--player-accent-color': accentColor,
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseMove={handleMouseMove}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
role='button'
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDownCapture={handleKeyDown}
|
||||||
|
aria-label={alt}
|
||||||
|
lang={lang}
|
||||||
|
>
|
||||||
|
{blurhash && (
|
||||||
|
<Blurhash
|
||||||
|
hash={blurhash}
|
||||||
|
className={classNames('media-gallery__preview', {
|
||||||
|
'media-gallery__preview--hidden': revealed,
|
||||||
|
})}
|
||||||
|
dummy={!useBlurhash}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<audio /* eslint-disable-line jsx-a11y/media-has-caption */
|
||||||
|
src={src}
|
||||||
|
ref={handleAudioRef}
|
||||||
|
preload={startPlaying ? 'auto' : 'none'}
|
||||||
|
onPlay={handlePlay}
|
||||||
|
onPause={handlePause}
|
||||||
|
onProgress={handleProgress}
|
||||||
|
onLoadedData={handleLoadedData}
|
||||||
|
onTimeUpdate={handleTimeUpdate}
|
||||||
|
onVolumeChange={handleVolumeChange}
|
||||||
|
crossOrigin='anonymous'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='video-player__seek'
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
aria-valuemax={100}
|
||||||
|
onMouseDown={handleSeekMouseDown}
|
||||||
|
onKeyDownCapture={handleAudioKeyDown}
|
||||||
|
ref={seekRef}
|
||||||
|
role='slider'
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
className='video-player__seek__buffer'
|
||||||
|
style={{ width: style.buffer }}
|
||||||
|
/>
|
||||||
|
<animated.div
|
||||||
|
className='video-player__seek__progress'
|
||||||
|
style={{ width: style.progress }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<animated.span
|
||||||
|
className={classNames('video-player__seek__handle', {
|
||||||
|
active: dragging,
|
||||||
|
})}
|
||||||
|
style={{ left: style.progress }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='audio-player__controls'>
|
||||||
|
<div className='audio-player__controls__play'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(messages.skipBackward)}
|
||||||
|
aria-label={intl.formatMessage(messages.skipBackward)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={handleSkipBackward}
|
||||||
|
>
|
||||||
|
<Icon id='' icon={Replay5Icon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='audio-player__controls__play'>
|
||||||
|
<svg
|
||||||
|
className='audio-player__visualizer'
|
||||||
|
viewBox='0 0 124 124'
|
||||||
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
|
>
|
||||||
|
<animated.circle
|
||||||
|
opacity={0.5}
|
||||||
|
cx={57}
|
||||||
|
cy={62.5}
|
||||||
|
r={springForBand0.r}
|
||||||
|
fill='var(--player-accent-color)'
|
||||||
|
/>
|
||||||
|
<animated.circle
|
||||||
|
opacity={0.5}
|
||||||
|
cx={65}
|
||||||
|
cy={57.5}
|
||||||
|
r={springForBand1.r}
|
||||||
|
fill='var(--player-accent-color)'
|
||||||
|
/>
|
||||||
|
<animated.circle
|
||||||
|
opacity={0.5}
|
||||||
|
cx={63}
|
||||||
|
cy={66.5}
|
||||||
|
r={springForBand2.r}
|
||||||
|
fill='var(--player-accent-color)'
|
||||||
|
/>
|
||||||
|
|
||||||
|
<g clipPath={`url(#${accessibilityId}-clip)`}>
|
||||||
|
<rect
|
||||||
|
x={14}
|
||||||
|
y={14}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
fill={`url(#${accessibilityId}-pattern)`}
|
||||||
|
/>
|
||||||
|
<rect
|
||||||
|
x={14}
|
||||||
|
y={14}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
fill='var(--player-background-color'
|
||||||
|
opacity={0.45}
|
||||||
|
/>
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<defs>
|
||||||
|
<pattern
|
||||||
|
id={`${accessibilityId}-pattern`}
|
||||||
|
patternContentUnits='objectBoundingBox'
|
||||||
|
width='1'
|
||||||
|
height='1'
|
||||||
|
>
|
||||||
|
<use href={`#${accessibilityId}-image`} />
|
||||||
|
</pattern>
|
||||||
|
|
||||||
|
<clipPath id={`${accessibilityId}-clip`}>
|
||||||
|
<rect
|
||||||
|
x={14}
|
||||||
|
y={14}
|
||||||
|
width={96}
|
||||||
|
height={96}
|
||||||
|
rx={48}
|
||||||
|
fill='white'
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
|
|
||||||
|
<image
|
||||||
|
id={`${accessibilityId}-image`}
|
||||||
|
href={poster}
|
||||||
|
width={1}
|
||||||
|
height={1}
|
||||||
|
preserveAspectRatio='none'
|
||||||
|
/>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(paused ? messages.play : messages.pause)}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
paused ? messages.play : messages.pause,
|
||||||
|
)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={togglePlay}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id={paused ? 'play' : 'pause'}
|
||||||
|
icon={paused ? PlayArrowIcon : PauseIcon}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='audio-player__controls__play'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(messages.skipForward)}
|
||||||
|
aria-label={intl.formatMessage(messages.skipForward)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={handleSkipForward}
|
||||||
|
>
|
||||||
|
<Icon id='' icon={Forward5Icon} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SpoilerButton
|
||||||
|
hidden={revealed || editable}
|
||||||
|
sensitive={sensitive ?? false}
|
||||||
|
onClick={toggleReveal}
|
||||||
|
matchedFilters={matchedFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames('video-player__controls', { active: hovered })}
|
||||||
|
>
|
||||||
|
<div className='video-player__buttons-bar'>
|
||||||
|
<div className='video-player__buttons left'>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
title={intl.formatMessage(
|
||||||
|
muted ? messages.unmute : messages.mute,
|
||||||
|
)}
|
||||||
|
aria-label={intl.formatMessage(
|
||||||
|
muted ? messages.unmute : messages.mute,
|
||||||
|
)}
|
||||||
|
className='player-button'
|
||||||
|
onClick={toggleMute}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
id={muted ? 'volume-off' : 'volume-up'}
|
||||||
|
icon={muted ? VolumeOffIcon : VolumeUpIcon}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className='video-player__volume active'
|
||||||
|
ref={volumeRef}
|
||||||
|
onMouseDown={handleVolumeMouseDown}
|
||||||
|
role='slider'
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuenow={effectivelyMuted ? 0 : volume * 100}
|
||||||
|
aria-valuemax={100}
|
||||||
|
tabIndex={0}
|
||||||
|
>
|
||||||
|
<animated.div
|
||||||
|
className='video-player__volume__current'
|
||||||
|
style={{ width: style.volume }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<animated.span
|
||||||
|
className={classNames('video-player__volume__handle')}
|
||||||
|
style={{ left: style.volume }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className='video-player__time'>
|
||||||
|
<span className='video-player__time-current'>
|
||||||
|
{formatTime(Math.floor(currentTime))}
|
||||||
|
</span>
|
||||||
|
<span className='video-player__time-sep'>/</span>
|
||||||
|
<span className='video-player__time-total'>
|
||||||
|
{formatTime(Math.floor(loadedDuration))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='video-player__buttons right'>
|
||||||
|
{!editable && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
className='player-button'
|
||||||
|
onClick={toggleReveal}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id='media_gallery.hide'
|
||||||
|
defaultMessage='Hide'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a
|
||||||
|
title={intl.formatMessage(messages.download)}
|
||||||
|
aria-label={intl.formatMessage(messages.download)}
|
||||||
|
className='video-player__download__icon player-button'
|
||||||
|
href={src}
|
||||||
|
download
|
||||||
|
>
|
||||||
|
<Icon id='download' icon={DownloadIcon} />
|
||||||
|
</a>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default Audio;
|
|
@ -1,136 +0,0 @@
|
||||||
/*
|
|
||||||
Copyright (c) 2020 by Alex Permyakov (https://codepen.io/alexdevp/pen/RNELPV)
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const hex2rgba = (hex, alpha = 1) => {
|
|
||||||
const [r, g, b] = hex.match(/\w\w/g).map(x => parseInt(x, 16));
|
|
||||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class Visualizer {
|
|
||||||
|
|
||||||
constructor (tickSize) {
|
|
||||||
this.tickSize = tickSize;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCanvas(canvas) {
|
|
||||||
this.canvas = canvas;
|
|
||||||
if (canvas) {
|
|
||||||
this.context = canvas.getContext('2d');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setAudioContext(context, source) {
|
|
||||||
const analyser = context.createAnalyser();
|
|
||||||
|
|
||||||
analyser.smoothingTimeConstant = 0.6;
|
|
||||||
analyser.fftSize = 2048;
|
|
||||||
|
|
||||||
source.connect(analyser);
|
|
||||||
|
|
||||||
this.analyser = analyser;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTickPoints (count) {
|
|
||||||
const coords = [];
|
|
||||||
|
|
||||||
for(let i = 0; i < count; i++) {
|
|
||||||
const rad = Math.PI * 2 * i / count;
|
|
||||||
coords.push({ x: Math.cos(rad), y: -Math.sin(rad) });
|
|
||||||
}
|
|
||||||
|
|
||||||
return coords;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTick (cx, cy, mainColor, x1, y1, x2, y2) {
|
|
||||||
const dx1 = Math.ceil(cx + x1);
|
|
||||||
const dy1 = Math.ceil(cy + y1);
|
|
||||||
const dx2 = Math.ceil(cx + x2);
|
|
||||||
const dy2 = Math.ceil(cy + y2);
|
|
||||||
|
|
||||||
const gradient = this.context.createLinearGradient(dx1, dy1, dx2, dy2);
|
|
||||||
|
|
||||||
const lastColor = hex2rgba(mainColor, 0);
|
|
||||||
|
|
||||||
gradient.addColorStop(0, mainColor);
|
|
||||||
gradient.addColorStop(0.6, mainColor);
|
|
||||||
gradient.addColorStop(1, lastColor);
|
|
||||||
|
|
||||||
this.context.beginPath();
|
|
||||||
this.context.strokeStyle = gradient;
|
|
||||||
this.context.lineWidth = 2;
|
|
||||||
this.context.moveTo(dx1, dy1);
|
|
||||||
this.context.lineTo(dx2, dy2);
|
|
||||||
this.context.stroke();
|
|
||||||
}
|
|
||||||
|
|
||||||
getTicks (count, size, radius, scaleCoefficient) {
|
|
||||||
const ticks = this.getTickPoints(count);
|
|
||||||
const lesser = 200;
|
|
||||||
const m = [];
|
|
||||||
const bufferLength = this.analyser ? this.analyser.frequencyBinCount : 0;
|
|
||||||
const frequencyData = new Uint8Array(bufferLength);
|
|
||||||
const allScales = [];
|
|
||||||
|
|
||||||
if (this.analyser) {
|
|
||||||
this.analyser.getByteFrequencyData(frequencyData);
|
|
||||||
}
|
|
||||||
|
|
||||||
ticks.forEach((tick, i) => {
|
|
||||||
const coef = 1 - i / (ticks.length * 2.5);
|
|
||||||
|
|
||||||
let delta = ((frequencyData[i] || 0) - lesser * coef) * scaleCoefficient;
|
|
||||||
|
|
||||||
if (delta < 0) {
|
|
||||||
delta = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
const k = radius / (radius - (size + delta));
|
|
||||||
|
|
||||||
const x1 = tick.x * (radius - size);
|
|
||||||
const y1 = tick.y * (radius - size);
|
|
||||||
const x2 = x1 * k;
|
|
||||||
const y2 = y1 * k;
|
|
||||||
|
|
||||||
m.push({ x1, y1, x2, y2 });
|
|
||||||
|
|
||||||
if (i < 20) {
|
|
||||||
let scale = delta / (200 * scaleCoefficient);
|
|
||||||
scale = scale < 1 ? 1 : scale;
|
|
||||||
allScales.push(scale);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const scale = allScales.reduce((pv, cv) => pv + cv, 0) / allScales.length;
|
|
||||||
|
|
||||||
return m.map(({ x1, y1, x2, y2 }) => ({
|
|
||||||
x1: x1,
|
|
||||||
y1: y1,
|
|
||||||
x2: x2 * scale,
|
|
||||||
y2: y2 * scale,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
clear (width, height) {
|
|
||||||
this.context.clearRect(0, 0, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
draw (cx, cy, color, radius, coefficient) {
|
|
||||||
this.context.save();
|
|
||||||
|
|
||||||
const ticks = this.getTicks(parseInt(360 * coefficient), this.tickSize, radius, coefficient);
|
|
||||||
|
|
||||||
ticks.forEach(tick => {
|
|
||||||
this.drawTick(cx, cy, color, tick.x1, tick.y1, tick.x2, tick.y2);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.context.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
|
@ -13,7 +13,6 @@ import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||||
import { DisplayName } from 'mastodon/components/display_name';
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import type { Status } from 'mastodon/models/status';
|
|
||||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||||
|
@ -27,9 +26,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
||||||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const status = useAppSelector(
|
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
const account = useAppSelector((state) =>
|
const account = useAppSelector((state) =>
|
||||||
state.accounts.get(status?.get('account') as string),
|
state.accounts.get(status?.get('account') as string),
|
||||||
|
|
|
@ -6,7 +6,6 @@ import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?re
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
import ReplyIcon from '@/material-icons/400-24px/reply-fill.svg?react';
|
||||||
import { me } from 'mastodon/initial_state';
|
import { me } from 'mastodon/initial_state';
|
||||||
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
|
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
|
||||||
import type { Status } from 'mastodon/models/status';
|
|
||||||
import { useAppSelector } from 'mastodon/store';
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
import type { LabelRenderer } from './notification_group_with_status';
|
import type { LabelRenderer } from './notification_group_with_status';
|
||||||
|
@ -40,7 +39,7 @@ export const NotificationMention: React.FC<{
|
||||||
}> = ({ notification, unread }) => {
|
}> = ({ notification, unread }) => {
|
||||||
const [isDirect, isReply] = useAppSelector((state) => {
|
const [isDirect, isReply] = useAppSelector((state) => {
|
||||||
const status = notification.statusId
|
const status = notification.statusId
|
||||||
? (state.statuses.get(notification.statusId) as Status | undefined)
|
? state.statuses.get(notification.statusId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!status) return [false, false] as const;
|
if (!status) return [false, false] as const;
|
||||||
|
|
|
@ -1,195 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import { defineMessages, injectIntl } from 'react-intl';
|
|
||||||
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import { withRouter } from 'react-router-dom';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
|
||||||
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
|
||||||
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
|
||||||
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
|
||||||
import StarIcon from '@/material-icons/400-24px/star.svg?react';
|
|
||||||
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
|
||||||
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
|
||||||
import { replyCompose } from 'mastodon/actions/compose';
|
|
||||||
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
|
||||||
import { openModal } from 'mastodon/actions/modal';
|
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
|
||||||
import { identityContextPropShape, withIdentity } from 'mastodon/identity_context';
|
|
||||||
import { me } from 'mastodon/initial_state';
|
|
||||||
import { makeGetStatus } from 'mastodon/selectors';
|
|
||||||
import { WithRouterPropTypes } from 'mastodon/utils/react_router';
|
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
|
||||||
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
|
||||||
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
|
||||||
reblog_private: { id: 'status.reblog_private', defaultMessage: 'Boost with original visibility' },
|
|
||||||
cancel_reblog_private: { id: 'status.cancel_reblog_private', defaultMessage: 'Unboost' },
|
|
||||||
cannot_reblog: { id: 'status.cannot_reblog', defaultMessage: 'This post cannot be boosted' },
|
|
||||||
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
|
||||||
removeFavourite: { id: 'status.remove_favourite', defaultMessage: 'Remove from favorites' },
|
|
||||||
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
|
||||||
});
|
|
||||||
|
|
||||||
const makeMapStateToProps = () => {
|
|
||||||
const getStatus = makeGetStatus();
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
|
||||||
status: getStatus(state, { id: statusId }),
|
|
||||||
askReplyConfirmation: state.getIn(['compose', 'text']).trim().length !== 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
return mapStateToProps;
|
|
||||||
};
|
|
||||||
|
|
||||||
class Footer extends ImmutablePureComponent {
|
|
||||||
static propTypes = {
|
|
||||||
identity: identityContextPropShape,
|
|
||||||
statusId: PropTypes.string.isRequired,
|
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
dispatch: PropTypes.func.isRequired,
|
|
||||||
askReplyConfirmation: PropTypes.bool,
|
|
||||||
withOpenButton: PropTypes.bool,
|
|
||||||
onClose: PropTypes.func,
|
|
||||||
...WithRouterPropTypes,
|
|
||||||
};
|
|
||||||
|
|
||||||
_performReply = () => {
|
|
||||||
const { dispatch, status, onClose } = this.props;
|
|
||||||
|
|
||||||
if (onClose) {
|
|
||||||
onClose(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch(replyCompose(status));
|
|
||||||
};
|
|
||||||
|
|
||||||
handleReplyClick = () => {
|
|
||||||
const { dispatch, askReplyConfirmation, status, onClose } = this.props;
|
|
||||||
const { signedIn } = this.props.identity;
|
|
||||||
|
|
||||||
if (signedIn) {
|
|
||||||
if (askReplyConfirmation) {
|
|
||||||
onClose(true);
|
|
||||||
dispatch(openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }));
|
|
||||||
} else {
|
|
||||||
this._performReply();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
type: 'reply',
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleFavouriteClick = () => {
|
|
||||||
const { dispatch, status } = this.props;
|
|
||||||
const { signedIn } = this.props.identity;
|
|
||||||
|
|
||||||
if (signedIn) {
|
|
||||||
dispatch(toggleFavourite(status.get('id')));
|
|
||||||
} else {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
type: 'favourite',
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleReblogClick = e => {
|
|
||||||
const { dispatch, status } = this.props;
|
|
||||||
const { signedIn } = this.props.identity;
|
|
||||||
|
|
||||||
if (signedIn) {
|
|
||||||
dispatch(toggleReblog(status.get('id'), e && e.shiftKey));
|
|
||||||
} else {
|
|
||||||
dispatch(openModal({
|
|
||||||
modalType: 'INTERACTION',
|
|
||||||
modalProps: {
|
|
||||||
type: 'reblog',
|
|
||||||
accountId: status.getIn(['account', 'id']),
|
|
||||||
url: status.get('uri'),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
handleOpenClick = e => {
|
|
||||||
if (e.button !== 0 || !history) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status, onClose } = this.props;
|
|
||||||
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.props.history.push(`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { status, intl, withOpenButton } = this.props;
|
|
||||||
|
|
||||||
const publicStatus = ['public', 'unlisted'].includes(status.get('visibility'));
|
|
||||||
const reblogPrivate = status.getIn(['account', 'id']) === me && status.get('visibility') === 'private';
|
|
||||||
|
|
||||||
let replyIcon, replyIconComponent, replyTitle;
|
|
||||||
|
|
||||||
if (status.get('in_reply_to_id', null) === null) {
|
|
||||||
replyIcon = 'reply';
|
|
||||||
replyIconComponent = ReplyIcon;
|
|
||||||
replyTitle = intl.formatMessage(messages.reply);
|
|
||||||
} else {
|
|
||||||
replyIcon = 'reply-all';
|
|
||||||
replyIconComponent = ReplyAllIcon;
|
|
||||||
replyTitle = intl.formatMessage(messages.replyAll);
|
|
||||||
}
|
|
||||||
|
|
||||||
let reblogTitle, reblogIconComponent;
|
|
||||||
|
|
||||||
if (status.get('reblogged')) {
|
|
||||||
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
|
||||||
reblogIconComponent = publicStatus ? RepeatIcon : RepeatPrivateIcon;
|
|
||||||
} else if (publicStatus) {
|
|
||||||
reblogTitle = intl.formatMessage(messages.reblog);
|
|
||||||
reblogIconComponent = RepeatIcon;
|
|
||||||
} else if (reblogPrivate) {
|
|
||||||
reblogTitle = intl.formatMessage(messages.reblog_private);
|
|
||||||
reblogIconComponent = RepeatPrivateIcon;
|
|
||||||
} else {
|
|
||||||
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
|
||||||
reblogIconComponent = RepeatDisabledIcon;
|
|
||||||
}
|
|
||||||
|
|
||||||
const favouriteTitle = intl.formatMessage(status.get('favourited') ? messages.removeFavourite : messages.favourite);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='picture-in-picture__footer'>
|
|
||||||
<IconButton className='status__action-bar-button' title={replyTitle} icon={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? 'reply' : replyIcon} iconComponent={status.get('in_reply_to_account_id') === status.getIn(['account', 'id']) ? ReplyIcon : replyIconComponent} onClick={this.handleReplyClick} counter={status.get('replies_count')} />
|
|
||||||
<IconButton className={classNames('status__action-bar-button', { reblogPrivate })} disabled={!publicStatus && !reblogPrivate} active={status.get('reblogged')} title={reblogTitle} icon='retweet' iconComponent={reblogIconComponent} onClick={this.handleReblogClick} counter={status.get('reblogs_count')} />
|
|
||||||
<IconButton className='status__action-bar-button star-icon' animate active={status.get('favourited')} title={favouriteTitle} icon='star' iconComponent={StarIcon} onClick={this.handleFavouriteClick} counter={status.get('favourites_count')} />
|
|
||||||
{withOpenButton && <IconButton className='status__action-bar-button' title={intl.formatMessage(messages.open)} icon='external-link' iconComponent={OpenInNewIcon} onClick={this.handleOpenClick} href={`/@${status.getIn(['account', 'acct'])}/${status.get('id')}`} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(makeMapStateToProps)(withIdentity(withRouter(injectIntl(Footer))));
|
|
|
@ -0,0 +1,255 @@
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { defineMessages, useIntl } from 'react-intl';
|
||||||
|
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
import OpenInNewIcon from '@/material-icons/400-24px/open_in_new.svg?react';
|
||||||
|
import RepeatIcon from '@/material-icons/400-24px/repeat.svg?react';
|
||||||
|
import ReplyIcon from '@/material-icons/400-24px/reply.svg?react';
|
||||||
|
import ReplyAllIcon from '@/material-icons/400-24px/reply_all.svg?react';
|
||||||
|
import StarIcon from '@/material-icons/400-24px/star-fill.svg?react';
|
||||||
|
import StarBorderIcon from '@/material-icons/400-24px/star.svg?react';
|
||||||
|
import RepeatActiveIcon from '@/svg-icons/repeat_active.svg?react';
|
||||||
|
import RepeatDisabledIcon from '@/svg-icons/repeat_disabled.svg?react';
|
||||||
|
import RepeatPrivateIcon from '@/svg-icons/repeat_private.svg?react';
|
||||||
|
import RepeatPrivateActiveIcon from '@/svg-icons/repeat_private_active.svg?react';
|
||||||
|
import { replyCompose } from 'mastodon/actions/compose';
|
||||||
|
import { toggleReblog, toggleFavourite } from 'mastodon/actions/interactions';
|
||||||
|
import { openModal } from 'mastodon/actions/modal';
|
||||||
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
|
import { useIdentity } from 'mastodon/identity_context';
|
||||||
|
import { me } from 'mastodon/initial_state';
|
||||||
|
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
reply: { id: 'status.reply', defaultMessage: 'Reply' },
|
||||||
|
replyAll: { id: 'status.replyAll', defaultMessage: 'Reply to thread' },
|
||||||
|
reblog: { id: 'status.reblog', defaultMessage: 'Boost' },
|
||||||
|
reblog_private: {
|
||||||
|
id: 'status.reblog_private',
|
||||||
|
defaultMessage: 'Boost with original visibility',
|
||||||
|
},
|
||||||
|
cancel_reblog_private: {
|
||||||
|
id: 'status.cancel_reblog_private',
|
||||||
|
defaultMessage: 'Unboost',
|
||||||
|
},
|
||||||
|
cannot_reblog: {
|
||||||
|
id: 'status.cannot_reblog',
|
||||||
|
defaultMessage: 'This post cannot be boosted',
|
||||||
|
},
|
||||||
|
favourite: { id: 'status.favourite', defaultMessage: 'Favorite' },
|
||||||
|
removeFavourite: {
|
||||||
|
id: 'status.remove_favourite',
|
||||||
|
defaultMessage: 'Remove from favorites',
|
||||||
|
},
|
||||||
|
open: { id: 'status.open', defaultMessage: 'Expand this status' },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Footer: React.FC<{
|
||||||
|
statusId: string;
|
||||||
|
withOpenButton?: boolean;
|
||||||
|
onClose: (arg0?: boolean) => void;
|
||||||
|
}> = ({ statusId, withOpenButton, onClose }) => {
|
||||||
|
const { signedIn } = useIdentity();
|
||||||
|
const intl = useIntl();
|
||||||
|
const history = useHistory();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||||
|
const accountId = status?.get('account') as string | undefined;
|
||||||
|
const account = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId) : undefined,
|
||||||
|
);
|
||||||
|
const askReplyConfirmation = useAppSelector(
|
||||||
|
(state) => (state.compose.get('text') as string).trim().length !== 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleReplyClick = useCallback(() => {
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedIn) {
|
||||||
|
onClose(true);
|
||||||
|
|
||||||
|
if (askReplyConfirmation) {
|
||||||
|
dispatch(
|
||||||
|
openModal({ modalType: 'CONFIRM_REPLY', modalProps: { status } }),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
dispatch(replyCompose(status));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'reply',
|
||||||
|
accountId: status.getIn(['account', 'id']),
|
||||||
|
url: status.get('uri'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [dispatch, status, signedIn, askReplyConfirmation, onClose]);
|
||||||
|
|
||||||
|
const handleFavouriteClick = useCallback(() => {
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedIn) {
|
||||||
|
dispatch(toggleFavourite(status.get('id')));
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'favourite',
|
||||||
|
accountId: status.getIn(['account', 'id']),
|
||||||
|
url: status.get('uri'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [dispatch, status, signedIn]);
|
||||||
|
|
||||||
|
const handleReblogClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (!status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signedIn) {
|
||||||
|
dispatch(toggleReblog(status.get('id'), e.shiftKey));
|
||||||
|
} else {
|
||||||
|
dispatch(
|
||||||
|
openModal({
|
||||||
|
modalType: 'INTERACTION',
|
||||||
|
modalProps: {
|
||||||
|
type: 'reblog',
|
||||||
|
accountId: status.getIn(['account', 'id']),
|
||||||
|
url: status.get('uri'),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, status, signedIn],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOpenClick = useCallback(
|
||||||
|
(e: React.MouseEvent) => {
|
||||||
|
if (e.button !== 0 || !status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
history.push(`/@${account?.acct}/${status.get('id') as string}`);
|
||||||
|
},
|
||||||
|
[history, status, account, onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!status) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicStatus = ['public', 'unlisted'].includes(
|
||||||
|
status.get('visibility') as string,
|
||||||
|
);
|
||||||
|
const reblogPrivate =
|
||||||
|
status.getIn(['account', 'id']) === me &&
|
||||||
|
status.get('visibility') === 'private';
|
||||||
|
|
||||||
|
let replyIcon, replyIconComponent, replyTitle;
|
||||||
|
|
||||||
|
if (status.get('in_reply_to_id', null) === null) {
|
||||||
|
replyIcon = 'reply';
|
||||||
|
replyIconComponent = ReplyIcon;
|
||||||
|
replyTitle = intl.formatMessage(messages.reply);
|
||||||
|
} else {
|
||||||
|
replyIcon = 'reply-all';
|
||||||
|
replyIconComponent = ReplyAllIcon;
|
||||||
|
replyTitle = intl.formatMessage(messages.replyAll);
|
||||||
|
}
|
||||||
|
|
||||||
|
let reblogTitle, reblogIconComponent;
|
||||||
|
|
||||||
|
if (status.get('reblogged')) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.cancel_reblog_private);
|
||||||
|
reblogIconComponent = publicStatus
|
||||||
|
? RepeatActiveIcon
|
||||||
|
: RepeatPrivateActiveIcon;
|
||||||
|
} else if (publicStatus) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.reblog);
|
||||||
|
reblogIconComponent = RepeatIcon;
|
||||||
|
} else if (reblogPrivate) {
|
||||||
|
reblogTitle = intl.formatMessage(messages.reblog_private);
|
||||||
|
reblogIconComponent = RepeatPrivateIcon;
|
||||||
|
} else {
|
||||||
|
reblogTitle = intl.formatMessage(messages.cannot_reblog);
|
||||||
|
reblogIconComponent = RepeatDisabledIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
const favouriteTitle = intl.formatMessage(
|
||||||
|
status.get('favourited') ? messages.removeFavourite : messages.favourite,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='picture-in-picture__footer'>
|
||||||
|
<IconButton
|
||||||
|
className='status__action-bar-button'
|
||||||
|
title={replyTitle}
|
||||||
|
icon={
|
||||||
|
status.get('in_reply_to_account_id') ===
|
||||||
|
status.getIn(['account', 'id'])
|
||||||
|
? 'reply'
|
||||||
|
: replyIcon
|
||||||
|
}
|
||||||
|
iconComponent={
|
||||||
|
status.get('in_reply_to_account_id') ===
|
||||||
|
status.getIn(['account', 'id'])
|
||||||
|
? ReplyIcon
|
||||||
|
: replyIconComponent
|
||||||
|
}
|
||||||
|
onClick={handleReplyClick}
|
||||||
|
counter={status.get('replies_count') as number}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className={classNames('status__action-bar-button', { reblogPrivate })}
|
||||||
|
disabled={!publicStatus && !reblogPrivate}
|
||||||
|
active={status.get('reblogged') as boolean}
|
||||||
|
title={reblogTitle}
|
||||||
|
icon='retweet'
|
||||||
|
iconComponent={reblogIconComponent}
|
||||||
|
onClick={handleReblogClick}
|
||||||
|
counter={status.get('reblogs_count') as number}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
className='status__action-bar-button star-icon'
|
||||||
|
animate
|
||||||
|
active={status.get('favourited') as boolean}
|
||||||
|
title={favouriteTitle}
|
||||||
|
icon='star'
|
||||||
|
iconComponent={status.get('favourited') ? StarIcon : StarBorderIcon}
|
||||||
|
onClick={handleFavouriteClick}
|
||||||
|
counter={status.get('favourites_count') as number}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{withOpenButton && (
|
||||||
|
<IconButton
|
||||||
|
className='status__action-bar-button'
|
||||||
|
title={intl.formatMessage(messages.open)}
|
||||||
|
icon='external-link'
|
||||||
|
iconComponent={OpenInNewIcon}
|
||||||
|
onClick={handleOpenClick}
|
||||||
|
href={`/@${account?.acct}/${status.get('id') as string}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
|
@ -1,11 +1,11 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
|
||||||
import Audio from 'mastodon/features/audio';
|
import { Audio } from 'mastodon/features/audio';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
||||||
|
|
||||||
import Footer from './components/footer';
|
import { Footer } from './components/footer';
|
||||||
import { Header } from './components/header';
|
import { Header } from './components/header';
|
||||||
|
|
||||||
export const PictureInPicture: React.FC = () => {
|
export const PictureInPicture: React.FC = () => {
|
||||||
|
@ -58,14 +58,14 @@ export const PictureInPicture: React.FC = () => {
|
||||||
player = (
|
player = (
|
||||||
<Audio
|
<Audio
|
||||||
src={src}
|
src={src}
|
||||||
currentTime={currentTime}
|
startTime={currentTime}
|
||||||
volume={volume}
|
startVolume={volume}
|
||||||
muted={muted}
|
startMuted={muted}
|
||||||
|
startPlaying
|
||||||
poster={poster}
|
poster={poster}
|
||||||
backgroundColor={backgroundColor}
|
backgroundColor={backgroundColor}
|
||||||
foregroundColor={foregroundColor}
|
foregroundColor={foregroundColor}
|
||||||
accentColor={accentColor}
|
accentColor={accentColor}
|
||||||
autoPlay
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -76,7 +76,7 @@ export const PictureInPicture: React.FC = () => {
|
||||||
|
|
||||||
{player}
|
{player}
|
||||||
|
|
||||||
<Footer statusId={statusId} />
|
<Footer statusId={statusId} onClose={handleClose} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -13,7 +13,9 @@ import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||||
|
import { Avatar } from 'mastodon/components/avatar';
|
||||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||||
|
import { DisplayName } from 'mastodon/components/display_name';
|
||||||
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
|
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
|
||||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||||
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
import { FormattedDateWrapper } from 'mastodon/components/formatted_date';
|
||||||
|
@ -21,17 +23,14 @@ import type { StatusLike } from 'mastodon/components/hashtag_bar';
|
||||||
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
import { getHashtagBarForStatus } from 'mastodon/components/hashtag_bar';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconLogo } from 'mastodon/components/logo';
|
import { IconLogo } from 'mastodon/components/logo';
|
||||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
|
import MediaGallery from 'mastodon/components/media_gallery';
|
||||||
|
import { PictureInPicturePlaceholder } from 'mastodon/components/picture_in_picture_placeholder';
|
||||||
|
import StatusContent from 'mastodon/components/status_content';
|
||||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
|
||||||
|
import { Audio } from 'mastodon/features/audio';
|
||||||
|
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
|
|
||||||
import { Avatar } from '../../../components/avatar';
|
|
||||||
import { DisplayName } from '../../../components/display_name';
|
|
||||||
import MediaGallery from '../../../components/media_gallery';
|
|
||||||
import StatusContent from '../../../components/status_content';
|
|
||||||
import Audio from '../../audio';
|
|
||||||
import scheduleIdleTask from '../../ui/util/schedule_idle_task';
|
|
||||||
|
|
||||||
import Card from './card';
|
import Card from './card';
|
||||||
|
|
||||||
interface VideoModalOptions {
|
interface VideoModalOptions {
|
||||||
|
@ -189,18 +188,17 @@ export const DetailedStatus: React.FC<{
|
||||||
src={attachment.get('url')}
|
src={attachment.get('url')}
|
||||||
alt={description}
|
alt={description}
|
||||||
lang={language}
|
lang={language}
|
||||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
|
||||||
poster={
|
poster={
|
||||||
attachment.get('preview_url') ||
|
attachment.get('preview_url') ||
|
||||||
status.getIn(['account', 'avatar_static'])
|
status.getIn(['account', 'avatar_static'])
|
||||||
}
|
}
|
||||||
|
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||||
sensitive={status.get('sensitive')}
|
sensitive={status.get('sensitive')}
|
||||||
visible={showMedia}
|
visible={showMedia}
|
||||||
blurhash={attachment.get('blurhash')}
|
blurhash={attachment.get('blurhash')}
|
||||||
height={150}
|
|
||||||
onToggleVisibility={onToggleMediaVisibility}
|
onToggleVisibility={onToggleMediaVisibility}
|
||||||
matchedFilters={status.get('matched_media_filters')}
|
matchedFilters={status.get('matched_media_filters')}
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -1,74 +0,0 @@
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
|
|
||||||
import ImmutablePropTypes from 'react-immutable-proptypes';
|
|
||||||
import ImmutablePureComponent from 'react-immutable-pure-component';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
|
|
||||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
|
||||||
import Audio from 'mastodon/features/audio';
|
|
||||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
|
||||||
status: state.getIn(['statuses', statusId]),
|
|
||||||
accountStaticAvatar: state.getIn(['accounts', state.getIn(['statuses', statusId, 'account']), 'avatar_static']),
|
|
||||||
});
|
|
||||||
|
|
||||||
class AudioModal extends ImmutablePureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
|
||||||
media: ImmutablePropTypes.map.isRequired,
|
|
||||||
statusId: PropTypes.string.isRequired,
|
|
||||||
status: ImmutablePropTypes.map.isRequired,
|
|
||||||
accountStaticAvatar: PropTypes.string.isRequired,
|
|
||||||
options: PropTypes.shape({
|
|
||||||
autoPlay: PropTypes.bool,
|
|
||||||
}),
|
|
||||||
onClose: PropTypes.func.isRequired,
|
|
||||||
onChangeBackgroundColor: PropTypes.func.isRequired,
|
|
||||||
};
|
|
||||||
|
|
||||||
componentDidMount () {
|
|
||||||
const { media, onChangeBackgroundColor } = this.props;
|
|
||||||
|
|
||||||
const backgroundColor = getAverageFromBlurhash(media.get('blurhash'));
|
|
||||||
|
|
||||||
onChangeBackgroundColor(backgroundColor || { r: 255, g: 255, b: 255 });
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount () {
|
|
||||||
this.props.onChangeBackgroundColor(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
render () {
|
|
||||||
const { media, status, accountStaticAvatar, onClose } = this.props;
|
|
||||||
const options = this.props.options || {};
|
|
||||||
const language = status.getIn(['translation', 'language']) || status.get('language');
|
|
||||||
const description = media.getIn(['translation', 'description']) || media.get('description');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='modal-root__modal audio-modal'>
|
|
||||||
<div className='audio-modal__container'>
|
|
||||||
<Audio
|
|
||||||
src={media.get('url')}
|
|
||||||
alt={description}
|
|
||||||
lang={language}
|
|
||||||
duration={media.getIn(['meta', 'original', 'duration'], 0)}
|
|
||||||
height={150}
|
|
||||||
poster={media.get('preview_url') || accountStaticAvatar}
|
|
||||||
backgroundColor={media.getIn(['meta', 'colors', 'background'])}
|
|
||||||
foregroundColor={media.getIn(['meta', 'colors', 'foreground'])}
|
|
||||||
accentColor={media.getIn(['meta', 'colors', 'accent'])}
|
|
||||||
autoPlay={options.autoPlay}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className='media-modal__overlay'>
|
|
||||||
{status && <Footer statusId={status.get('id')} withOpenButton onClose={onClose} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(mapStateToProps, null, null, { forwardRef: true })(AudioModal);
|
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||||
|
import type { RGB } from 'mastodon/blurhash';
|
||||||
|
import { Audio } from 'mastodon/features/audio';
|
||||||
|
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
|
import type { MediaAttachment } from 'mastodon/models/media_attachment';
|
||||||
|
import { useAppSelector } from 'mastodon/store';
|
||||||
|
|
||||||
|
const AudioModal: React.FC<{
|
||||||
|
media: MediaAttachment;
|
||||||
|
statusId: string;
|
||||||
|
options: {
|
||||||
|
autoPlay: boolean;
|
||||||
|
};
|
||||||
|
onClose: () => void;
|
||||||
|
onChangeBackgroundColor: (color: RGB | null) => void;
|
||||||
|
}> = ({ media, statusId, options, onClose, onChangeBackgroundColor }) => {
|
||||||
|
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||||
|
const accountId = status?.get('account') as string | undefined;
|
||||||
|
const accountStaticAvatar = useAppSelector((state) =>
|
||||||
|
accountId ? state.accounts.get(accountId)?.avatar_static : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const backgroundColor = getAverageFromBlurhash(
|
||||||
|
media.get('blurhash') as string | null,
|
||||||
|
);
|
||||||
|
|
||||||
|
onChangeBackgroundColor(backgroundColor ?? { r: 255, g: 255, b: 255 });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onChangeBackgroundColor(null);
|
||||||
|
};
|
||||||
|
}, [media, onChangeBackgroundColor]);
|
||||||
|
|
||||||
|
const language = (status?.getIn(['translation', 'language']) ??
|
||||||
|
status?.get('language')) as string;
|
||||||
|
const description = (media.getIn(['translation', 'description']) ??
|
||||||
|
media.get('description')) as string;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='modal-root__modal audio-modal'>
|
||||||
|
<div className='audio-modal__container'>
|
||||||
|
<Audio
|
||||||
|
src={media.get('url') as string}
|
||||||
|
alt={description}
|
||||||
|
lang={language}
|
||||||
|
poster={
|
||||||
|
(media.get('preview_url') as string | null) ?? accountStaticAvatar
|
||||||
|
}
|
||||||
|
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||||
|
backgroundColor={
|
||||||
|
media.getIn(['meta', 'colors', 'background']) as string
|
||||||
|
}
|
||||||
|
foregroundColor={
|
||||||
|
media.getIn(['meta', 'colors', 'foreground']) as string
|
||||||
|
}
|
||||||
|
accentColor={media.getIn(['meta', 'colors', 'accent']) as string}
|
||||||
|
startPlaying={options.autoPlay}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='media-modal__overlay'>
|
||||||
|
{status && (
|
||||||
|
<Footer
|
||||||
|
statusId={status.get('id') as string}
|
||||||
|
withOpenButton
|
||||||
|
onClose={onClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/no-default-export
|
||||||
|
export default AudioModal;
|
|
@ -18,7 +18,7 @@ import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||||
import { GIFV } from 'mastodon/components/gifv';
|
import { GIFV } from 'mastodon/components/gifv';
|
||||||
import { Icon } from 'mastodon/components/icon';
|
import { Icon } from 'mastodon/components/icon';
|
||||||
import { IconButton } from 'mastodon/components/icon_button';
|
import { IconButton } from 'mastodon/components/icon_button';
|
||||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
import { disableSwiping } from 'mastodon/initial_state';
|
import { disableSwiping } from 'mastodon/initial_state';
|
||||||
|
|
||||||
|
|
|
@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
|
|
||||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
import { getAverageFromBlurhash } from 'mastodon/blurhash';
|
||||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
|
import { Footer } from 'mastodon/features/picture_in_picture/components/footer';
|
||||||
import { Video } from 'mastodon/features/video';
|
import { Video } from 'mastodon/features/video';
|
||||||
|
|
||||||
const mapStateToProps = (state, { statusId }) => ({
|
const mapStateToProps = (state, { statusId }) => ({
|
||||||
|
|
|
@ -806,7 +806,7 @@ export const Video: React.FC<{
|
||||||
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
|
// The outer wrapper is necessary to avoid reflowing the layout when going into full screen
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
|
||||||
role='menuitem'
|
role='menuitem'
|
||||||
className={classNames('video-player', {
|
className={classNames('video-player', {
|
||||||
inactive: !revealed,
|
inactive: !revealed,
|
||||||
|
@ -820,7 +820,7 @@ export const Video: React.FC<{
|
||||||
onMouseMove={handleMouseMove}
|
onMouseMove={handleMouseMove}
|
||||||
onMouseLeave={handleMouseLeave}
|
onMouseLeave={handleMouseLeave}
|
||||||
onClick={handleClickRoot}
|
onClick={handleClickRoot}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDownCapture={handleKeyDown}
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
{blurhash && (
|
{blurhash && (
|
||||||
|
@ -845,7 +845,7 @@ export const Video: React.FC<{
|
||||||
title={alt}
|
title={alt}
|
||||||
lang={lang}
|
lang={lang}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onKeyDown={handleVideoKeyDown}
|
onKeyDownCapture={handleVideoKeyDown}
|
||||||
onPlay={handlePlay}
|
onPlay={handlePlay}
|
||||||
onPause={handlePause}
|
onPause={handlePause}
|
||||||
onLoadedData={handleLoadedData}
|
onLoadedData={handleLoadedData}
|
||||||
|
|
112
app/javascript/mastodon/hooks/useAudioVisualizer.ts
Normal file
112
app/javascript/mastodon/hooks/useAudioVisualizer.ts
Normal file
|
@ -0,0 +1,112 @@
|
||||||
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
|
||||||
|
const normalizeFrequencies = (arr: Float32Array): number[] => {
|
||||||
|
return new Array(...arr).map((value: number) => {
|
||||||
|
if (value === -Infinity) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.sqrt(1 - (Math.max(-100, Math.min(-10, value)) * -1) / 100);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAudioVisualizer = (
|
||||||
|
ref: React.MutableRefObject<HTMLAudioElement | null>,
|
||||||
|
numBands: number,
|
||||||
|
) => {
|
||||||
|
const audioContextRef = useRef<AudioContext>();
|
||||||
|
const sourceRef = useRef<MediaElementAudioSourceNode>();
|
||||||
|
const analyzerRef = useRef<AnalyserNode>();
|
||||||
|
|
||||||
|
const [frequencyBands, setFrequencyBands] = useState<number[]>(
|
||||||
|
new Array(numBands).fill(0),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!audioContextRef.current) {
|
||||||
|
audioContextRef.current = new AudioContext();
|
||||||
|
analyzerRef.current = audioContextRef.current.createAnalyser();
|
||||||
|
analyzerRef.current.smoothingTimeConstant = 0.6;
|
||||||
|
analyzerRef.current.fftSize = 2048;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
void audioContextRef.current.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (
|
||||||
|
audioContextRef.current &&
|
||||||
|
analyzerRef.current &&
|
||||||
|
!sourceRef.current &&
|
||||||
|
ref.current
|
||||||
|
) {
|
||||||
|
sourceRef.current = audioContextRef.current.createMediaElementSource(
|
||||||
|
ref.current,
|
||||||
|
);
|
||||||
|
sourceRef.current.connect(analyzerRef.current);
|
||||||
|
sourceRef.current.connect(audioContextRef.current.destination);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (sourceRef.current) {
|
||||||
|
sourceRef.current.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [ref]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = sourceRef.current;
|
||||||
|
const analyzer = analyzerRef.current;
|
||||||
|
const context = audioContextRef.current;
|
||||||
|
|
||||||
|
if (!source || !analyzer || !context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bufferLength = analyzer.frequencyBinCount;
|
||||||
|
const frequencyData = new Float32Array(bufferLength);
|
||||||
|
|
||||||
|
const updateProgress = () => {
|
||||||
|
analyzer.getFloatFrequencyData(frequencyData);
|
||||||
|
|
||||||
|
const normalizedFrequencies = normalizeFrequencies(
|
||||||
|
frequencyData.slice(100, 600),
|
||||||
|
);
|
||||||
|
const bands: number[] = [];
|
||||||
|
const chunkSize = Math.ceil(normalizedFrequencies.length / numBands);
|
||||||
|
|
||||||
|
for (let i = 0; i < numBands; i++) {
|
||||||
|
const sum = normalizedFrequencies
|
||||||
|
.slice(i * chunkSize, (i + 1) * chunkSize)
|
||||||
|
.reduce((sum, cur) => sum + cur, 0);
|
||||||
|
bands.push(sum / chunkSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFrequencyBands(bands);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateInterval = setInterval(updateProgress, 15);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearInterval(updateInterval);
|
||||||
|
};
|
||||||
|
}, [numBands]);
|
||||||
|
|
||||||
|
const resume = useCallback(() => {
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
void audioContextRef.current.resume();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const suspend = useCallback(() => {
|
||||||
|
if (audioContextRef.current) {
|
||||||
|
void audioContextRef.current.suspend();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return [resume, suspend, frequencyBands] as const;
|
||||||
|
};
|
|
@ -64,7 +64,8 @@ const statusTranslateUndo = (state, id) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
/** @type {ImmutableMap<string, ImmutableMap<string, any>>} */
|
|
||||||
|
/** @type {ImmutableMap<string, import('mastodon/models/status').Status>} */
|
||||||
const initialState = ImmutableMap();
|
const initialState = ImmutableMap();
|
||||||
|
|
||||||
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
/** @type {import('@reduxjs/toolkit').Reducer<typeof initialState>} */
|
||||||
|
|
1
app/javascript/material-icons/400-24px/pip_exit-fill.svg
Normal file
1
app/javascript/material-icons/400-24px/pip_exit-fill.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="m683-300 57-57-124-123h104v-80H480v240h80v-103l123 123ZM80-600v-200h280v200H80Zm0 80h360v-280h360q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160q-33 0-56.5-23.5T80-240v-280Z"/></svg>
|
After Width: | Height: | Size: 286 B |
1
app/javascript/material-icons/400-24px/pip_exit.svg
Normal file
1
app/javascript/material-icons/400-24px/pip_exit.svg
Normal file
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M160-160q-33 0-56.5-23.5T80-240v-280h80v280h640v-480H440v-80h360q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H160Zm523-140 57-57-124-123h104v-80H480v240h80v-103l123 123ZM80-600v-200h280v200H80Zm400 120Z"/></svg>
|
After Width: | Height: | Size: 313 B |
|
@ -6961,15 +6961,69 @@ a.status-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--background-color);
|
background: var(--player-background-color, var(--background-color));
|
||||||
|
color: var(--player-foreground-color);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding-bottom: 44px;
|
padding-bottom: 44px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
outline: 1px solid var(--media-outline-color);
|
outline: 1px solid var(--media-outline-color);
|
||||||
outline-offset: -1px;
|
outline-offset: -1px;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
container: audio-player / inline-size;
|
||||||
|
|
||||||
|
&__controls {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr 1fr;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
&__play {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
.player-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
inset-inline-start: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
filter: var(--overlay-icon-shadow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-button {
|
||||||
|
display: inline-block;
|
||||||
|
outline: 0;
|
||||||
|
padding: 5px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--player-foreground-color);
|
||||||
|
opacity: 0.75;
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__visualizer {
|
||||||
|
max-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
&.inactive {
|
&.inactive {
|
||||||
audio,
|
.video-player__seek,
|
||||||
|
.audio-player__controls,
|
||||||
.video-player__controls {
|
.video-player__controls {
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
@ -6986,6 +7040,13 @@ a.status-card {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.video-player__seek__progress,
|
||||||
|
.video-player__seek__handle,
|
||||||
|
.video-player__volume__current,
|
||||||
|
.video-player__volume__handle {
|
||||||
|
background-color: var(--player-accent-color);
|
||||||
|
}
|
||||||
|
|
||||||
.video-player__buttons button,
|
.video-player__buttons button,
|
||||||
.video-player__buttons a {
|
.video-player__buttons a {
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
|
@ -7005,6 +7066,13 @@ a.status-card {
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container audio-player (max-width: 400px) {
|
||||||
|
.video-player__time,
|
||||||
|
.player-button.video-player__download__icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.video-player__seek::before,
|
.video-player__seek::before,
|
||||||
.video-player__seek__buffer,
|
.video-player__seek__buffer,
|
||||||
.video-player__seek__progress {
|
.video-player__seek__progress {
|
||||||
|
@ -7072,10 +7140,12 @@ a.status-card {
|
||||||
);
|
);
|
||||||
padding: 0 15px;
|
padding: 0 15px;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
transition: opacity 0.1s ease;
|
transition: opacity 0.1s ease;
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7161,6 +7231,7 @@ a.status-card {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 0;
|
border: 0;
|
||||||
color: rgba($white, 0.75);
|
color: rgba($white, 0.75);
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
&:active,
|
&:active,
|
||||||
&:hover,
|
&:hover,
|
||||||
|
@ -8486,23 +8557,33 @@ noscript {
|
||||||
bottom: 20px;
|
bottom: 20px;
|
||||||
inset-inline-end: 20px;
|
inset-inline-end: 20px;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
|
box-shadow: var(--dropdown-shadow);
|
||||||
|
|
||||||
&__footer {
|
&__footer {
|
||||||
border-radius: 0 0 4px 4px;
|
border-radius: 0 0 4px 4px;
|
||||||
background: lighten($ui-base-color, 4%);
|
background: var(--modal-background-variant-color);
|
||||||
padding: 10px;
|
backdrop-filter: var(--background-filter);
|
||||||
padding-top: 12px;
|
border: 1px solid var(--modal-border-color);
|
||||||
|
border-top: 0;
|
||||||
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__header {
|
&__header {
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
background: lighten($ui-base-color, 4%);
|
background: var(--modal-background-variant-color);
|
||||||
padding: 10px;
|
backdrop-filter: var(--background-filter);
|
||||||
|
border: 1px solid var(--modal-border-color);
|
||||||
|
border-bottom: 0;
|
||||||
|
padding: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
&__account {
|
&__account {
|
||||||
display: flex;
|
display: flex;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
@ -8510,7 +8591,7 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.account__avatar {
|
.account__avatar {
|
||||||
margin-inline-end: 10px;
|
margin-inline-end: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.display-name {
|
.display-name {
|
||||||
|
@ -8537,30 +8618,36 @@ noscript {
|
||||||
}
|
}
|
||||||
|
|
||||||
.picture-in-picture-placeholder {
|
.picture-in-picture-placeholder {
|
||||||
|
border-radius: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 2px dashed var(--background-border-color);
|
border: 1px dashed var(--background-border-color);
|
||||||
background: $base-shadow-color;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
margin-top: 10px;
|
margin-top: 16px;
|
||||||
font-size: 16px;
|
font-size: 15px;
|
||||||
|
line-height: 21px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
color: $darker-text-color;
|
color: $dark-text-color;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 24px;
|
width: 48px;
|
||||||
height: 24px;
|
height: 48px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover,
|
&:hover,
|
||||||
&:focus,
|
&:active,
|
||||||
&:active {
|
&:focus {
|
||||||
border-color: lighten($ui-base-color, 12%);
|
color: $darker-text-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: $ui-button-focus-outline;
|
||||||
|
border-color: transparent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
--on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.5)};
|
--on-surface-color: #{color.adjust($ui-base-color, $alpha: -0.5)};
|
||||||
--avatar-border-radius: 8px;
|
--avatar-border-radius: 8px;
|
||||||
--media-outline-color: #{rgba(#fcf8ff, 0.15)};
|
--media-outline-color: #{rgba(#fcf8ff, 0.15)};
|
||||||
--overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.25)});
|
--overlay-icon-shadow: drop-shadow(0 0 8px #{rgba($base-shadow-color, 0.35)});
|
||||||
--error-background-color: #{darken($error-red, 16%)};
|
--error-background-color: #{darken($error-red, 16%)};
|
||||||
--error-active-background-color: #{darken($error-red, 12%)};
|
--error-active-background-color: #{darken($error-red, 12%)};
|
||||||
--on-error-color: #fff;
|
--on-error-color: #fff;
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue