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