mirror of
https://github.com/mastodon/mastodon.git
synced 2025-05-03 18:26:12 +00:00
Change design of audio player in web UI (#34520)
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / Libvips tests (3.2) (push) Blocked by required conditions
Ruby Testing / Libvips tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Some checks are pending
Check i18n / check-i18n (push) Waiting to run
CodeQL / Analyze (javascript) (push) Waiting to run
CodeQL / Analyze (ruby) (push) Waiting to run
Check formatting / lint (push) Waiting to run
CSS Linting / lint (push) Waiting to run
JavaScript Linting / lint (push) Waiting to run
JavaScript Testing / test (push) Waiting to run
Ruby Testing / build (production) (push) Waiting to run
Ruby Testing / build (test) (push) Waiting to run
Ruby Testing / test (.ruby-version) (push) Blocked by required conditions
Ruby Testing / test (3.2) (push) Blocked by required conditions
Ruby Testing / test (3.3) (push) Blocked by required conditions
Ruby Testing / Libvips tests (.ruby-version) (push) Blocked by required conditions
Ruby Testing / Libvips tests (3.2) (push) Blocked by required conditions
Ruby Testing / Libvips tests (3.3) (push) Blocked by required conditions
Ruby Testing / End to End testing (.ruby-version) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.2) (push) Blocked by required conditions
Ruby Testing / End to End testing (3.3) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, docker.elastic.co/elasticsearch/elasticsearch:8.10.2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (.ruby-version, opensearchproject/opensearch:2) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.2, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
Ruby Testing / Elastic Search integration testing (3.3, docker.elastic.co/elasticsearch/elasticsearch:7.17.13) (push) Blocked by required conditions
This commit is contained in:
parent
24c25ec4f5
commit
b4394ec129
|
@ -96,13 +96,19 @@ export const decode83 = (str: string) => {
|
|||
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),
|
||||
g: Math.max(0, (int >> 8) & 255),
|
||||
b: Math.max(0, int & 255),
|
||||
});
|
||||
|
||||
export const getAverageFromBlurhash = (blurhash: string) => {
|
||||
export const getAverageFromBlurhash = (blurhash: string | null) => {
|
||||
if (!blurhash) {
|
||||
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 { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
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 Card from '../features/status/components/card';
|
||||
|
@ -484,9 +484,6 @@ class Status extends ImmutablePureComponent {
|
|||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
width={this.props.cachedMediaWidth}
|
||||
height={110}
|
||||
cacheWidth={this.props.cacheMediaWidth}
|
||||
deployPictureInPicture={pictureInPicture.get('available') ? this.handleDeployPictureInPicture : undefined}
|
||||
sensitive={status.get('sensitive')}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
|
|
|
@ -8,7 +8,7 @@ import { ImmutableHashtag as Hashtag } from 'mastodon/components/hashtag';
|
|||
import MediaGallery from 'mastodon/components/media_gallery';
|
||||
import ModalRoot from 'mastodon/components/modal_root';
|
||||
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 MediaModal from 'mastodon/features/ui/components/media_modal';
|
||||
import { Video } from 'mastodon/features/video';
|
||||
|
|
|
@ -27,7 +27,7 @@ import { Button } from 'mastodon/components/button';
|
|||
import { GIFV } from 'mastodon/components/gifv';
|
||||
import { LoadingIndicator } from 'mastodon/components/loading_indicator';
|
||||
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 { Tesseract as fetchTesseract } from 'mastodon/features/ui/util/async-components';
|
||||
import { Video, getPointerPosition } from 'mastodon/features/video';
|
||||
|
@ -212,11 +212,11 @@ const Preview: React.FC<{
|
|||
return (
|
||||
<Audio
|
||||
src={media.get('url') as string}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||
poster={
|
||||
(media.get('preview_url') as string | undefined) ??
|
||||
account?.avatar_static
|
||||
}
|
||||
duration={media.getIn(['meta', 'original', 'duration'], 0) as number}
|
||||
backgroundColor={
|
||||
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 { DisplayName } from 'mastodon/components/display_name';
|
||||
import { Icon } from 'mastodon/components/icon';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
|
||||
|
||||
import { EmbeddedStatusContent } from './embedded_status_content';
|
||||
|
@ -27,9 +26,7 @@ export const EmbeddedStatus: React.FC<{ statusId: string }> = ({
|
|||
const clickCoordinatesRef = useRef<[number, number] | null>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const status = useAppSelector(
|
||||
(state) => state.statuses.get(statusId) as Status | undefined,
|
||||
);
|
||||
const status = useAppSelector((state) => state.statuses.get(statusId));
|
||||
|
||||
const account = useAppSelector((state) =>
|
||||
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 { me } from 'mastodon/initial_state';
|
||||
import type { NotificationGroupMention } from 'mastodon/models/notification_group';
|
||||
import type { Status } from 'mastodon/models/status';
|
||||
import { useAppSelector } from 'mastodon/store';
|
||||
|
||||
import type { LabelRenderer } from './notification_group_with_status';
|
||||
|
@ -40,7 +39,7 @@ export const NotificationMention: React.FC<{
|
|||
}> = ({ notification, unread }) => {
|
||||
const [isDirect, isReply] = useAppSelector((state) => {
|
||||
const status = notification.statusId
|
||||
? (state.statuses.get(notification.statusId) as Status | undefined)
|
||||
? state.statuses.get(notification.statusId)
|
||||
: undefined;
|
||||
|
||||
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 { 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 { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
|
||||
|
||||
import Footer from './components/footer';
|
||||
import { Footer } from './components/footer';
|
||||
import { Header } from './components/header';
|
||||
|
||||
export const PictureInPicture: React.FC = () => {
|
||||
|
@ -58,14 +58,14 @@ export const PictureInPicture: React.FC = () => {
|
|||
player = (
|
||||
<Audio
|
||||
src={src}
|
||||
currentTime={currentTime}
|
||||
volume={volume}
|
||||
muted={muted}
|
||||
startTime={currentTime}
|
||||
startVolume={volume}
|
||||
startMuted={muted}
|
||||
startPlaying
|
||||
poster={poster}
|
||||
backgroundColor={backgroundColor}
|
||||
foregroundColor={foregroundColor}
|
||||
accentColor={accentColor}
|
||||
autoPlay
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
@ -76,7 +76,7 @@ export const PictureInPicture: React.FC = () => {
|
|||
|
||||
{player}
|
||||
|
||||
<Footer statusId={statusId} />
|
||||
<Footer statusId={statusId} onClose={handleClose} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -13,7 +13,9 @@ import { Link } from 'react-router-dom';
|
|||
|
||||
import AlternateEmailIcon from '@/material-icons/400-24px/alternate_email.svg?react';
|
||||
import { AnimatedNumber } from 'mastodon/components/animated_number';
|
||||
import { Avatar } from 'mastodon/components/avatar';
|
||||
import { ContentWarning } from 'mastodon/components/content_warning';
|
||||
import { DisplayName } from 'mastodon/components/display_name';
|
||||
import { EditedTimestamp } from 'mastodon/components/edited_timestamp';
|
||||
import { FilterWarning } from 'mastodon/components/filter_warning';
|
||||
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 { Icon } from 'mastodon/components/icon';
|
||||
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 { Audio } from 'mastodon/features/audio';
|
||||
import scheduleIdleTask from 'mastodon/features/ui/util/schedule_idle_task';
|
||||
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';
|
||||
|
||||
interface VideoModalOptions {
|
||||
|
@ -189,18 +188,17 @@ export const DetailedStatus: React.FC<{
|
|||
src={attachment.get('url')}
|
||||
alt={description}
|
||||
lang={language}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
poster={
|
||||
attachment.get('preview_url') ||
|
||||
status.getIn(['account', 'avatar_static'])
|
||||
}
|
||||
duration={attachment.getIn(['meta', 'original', 'duration'], 0)}
|
||||
backgroundColor={attachment.getIn(['meta', 'colors', 'background'])}
|
||||
foregroundColor={attachment.getIn(['meta', 'colors', 'foreground'])}
|
||||
accentColor={attachment.getIn(['meta', 'colors', 'accent'])}
|
||||
sensitive={status.get('sensitive')}
|
||||
visible={showMedia}
|
||||
blurhash={attachment.get('blurhash')}
|
||||
height={150}
|
||||
onToggleVisibility={onToggleMediaVisibility}
|
||||
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 { Icon } from 'mastodon/components/icon';
|
||||
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 { disableSwiping } from 'mastodon/initial_state';
|
||||
|
||||
|
|
|
@ -5,7 +5,7 @@ import ImmutablePureComponent from 'react-immutable-pure-component';
|
|||
import { connect } from 'react-redux';
|
||||
|
||||
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';
|
||||
|
||||
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
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
<div /* eslint-disable-line jsx-a11y/click-events-have-key-events */
|
||||
role='menuitem'
|
||||
className={classNames('video-player', {
|
||||
inactive: !revealed,
|
||||
|
@ -820,7 +820,7 @@ export const Video: React.FC<{
|
|||
onMouseMove={handleMouseMove}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClickRoot}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
{blurhash && (
|
||||
|
@ -845,7 +845,7 @@ export const Video: React.FC<{
|
|||
title={alt}
|
||||
lang={lang}
|
||||
onClick={handleClick}
|
||||
onKeyDown={handleVideoKeyDown}
|
||||
onKeyDownCapture={handleVideoKeyDown}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
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();
|
||||
|
||||
/** @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;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
background: var(--background-color);
|
||||
background: var(--player-background-color, var(--background-color));
|
||||
color: var(--player-foreground-color);
|
||||
border-radius: 8px;
|
||||
padding-bottom: 44px;
|
||||
width: 100%;
|
||||
outline: 1px solid var(--media-outline-color);
|
||||
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 {
|
||||
audio,
|
||||
.video-player__seek,
|
||||
.audio-player__controls,
|
||||
.video-player__controls {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
@ -6986,6 +7040,13 @@ a.status-card {
|
|||
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 a {
|
||||
color: currentColor;
|
||||
|
@ -7005,6 +7066,13 @@ a.status-card {
|
|||
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__buffer,
|
||||
.video-player__seek__progress {
|
||||
|
@ -7072,10 +7140,12 @@ a.status-card {
|
|||
);
|
||||
padding: 0 15px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.1s ease;
|
||||
|
||||
&.active {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -7161,6 +7231,7 @@ a.status-card {
|
|||
background: transparent;
|
||||
border: 0;
|
||||
color: rgba($white, 0.75);
|
||||
font-weight: 500;
|
||||
|
||||
&:active,
|
||||
&:hover,
|
||||
|
@ -8486,23 +8557,33 @@ noscript {
|
|||
bottom: 20px;
|
||||
inset-inline-end: 20px;
|
||||
width: 300px;
|
||||
box-shadow: var(--dropdown-shadow);
|
||||
|
||||
&__footer {
|
||||
border-radius: 0 0 4px 4px;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
padding: 10px;
|
||||
padding-top: 12px;
|
||||
background: var(--modal-background-variant-color);
|
||||
backdrop-filter: var(--background-filter);
|
||||
border: 1px solid var(--modal-border-color);
|
||||
border-top: 0;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__header {
|
||||
border-radius: 4px 4px 0 0;
|
||||
background: lighten($ui-base-color, 4%);
|
||||
padding: 10px;
|
||||
background: var(--modal-background-variant-color);
|
||||
backdrop-filter: var(--background-filter);
|
||||
border: 1px solid var(--modal-border-color);
|
||||
border-bottom: 0;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.icon-button {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
&__account {
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
|
@ -8510,7 +8591,7 @@ noscript {
|
|||
}
|
||||
|
||||
.account__avatar {
|
||||
margin-inline-end: 10px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
.display-name {
|
||||
|
@ -8537,30 +8618,36 @@ noscript {
|
|||
}
|
||||
|
||||
.picture-in-picture-placeholder {
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--background-border-color);
|
||||
background: $base-shadow-color;
|
||||
border: 1px dashed var(--background-border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: 10px;
|
||||
font-size: 16px;
|
||||
margin-top: 16px;
|
||||
font-size: 15px;
|
||||
line-height: 21px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
color: $darker-text-color;
|
||||
color: $dark-text-color;
|
||||
aspect-ratio: 16 / 9;
|
||||
|
||||
.icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-bottom: 10px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
border-color: lighten($ui-base-color, 12%);
|
||||
&:active,
|
||||
&:focus {
|
||||
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)};
|
||||
--avatar-border-radius: 8px;
|
||||
--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-active-background-color: #{darken($error-red, 12%)};
|
||||
--on-error-color: #fff;
|
||||
|
|
Loading…
Reference in New Issue
Block a user