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

This commit is contained in:
Eugen Rochko 2025-05-02 18:15:00 +02:00 committed by GitHub
parent 24c25ec4f5
commit b4394ec129
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 1476 additions and 1088 deletions

View File

@ -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;
}

View File

@ -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);

View File

@ -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>
);
};

View File

@ -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')}

View File

@ -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';

View File

@ -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
}

View File

@ -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);

View 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;

View File

@ -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();
}
}

View File

@ -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),

View File

@ -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;

View File

@ -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))));

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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')}
/>

View File

@ -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);

View File

@ -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;

View File

@ -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';

View File

@ -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 }) => ({

View File

@ -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}

View 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;
};

View File

@ -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>} */

View 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

View 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

View File

@ -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;
}
}

View File

@ -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;