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