Refactor <Video> to TypeScript (#34284)
				
					
				
			@@ -11,7 +11,7 @@ import Poll from 'mastodon/components/poll';
 | 
			
		||||
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';
 | 
			
		||||
import { Video } from 'mastodon/features/video';
 | 
			
		||||
import { IntlProvider } from 'mastodon/locales';
 | 
			
		||||
import { getScrollbarWidth } from 'mastodon/utils/scrollbar';
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -30,7 +30,7 @@ import { Skeleton } from 'mastodon/components/skeleton';
 | 
			
		||||
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';
 | 
			
		||||
import { Video, getPointerPosition } from 'mastodon/features/video';
 | 
			
		||||
import { me } from 'mastodon/initial_state';
 | 
			
		||||
import type { MediaAttachment } from 'mastodon/models/media_attachment';
 | 
			
		||||
import { useAppSelector, useAppDispatch } from 'mastodon/store';
 | 
			
		||||
@@ -134,17 +134,7 @@ const Preview: React.FC<{
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const { x, y } = getPointerPosition(nodeRef.current, e);
 | 
			
		||||
      setDragging(true);
 | 
			
		||||
      draggingRef.current = true;
 | 
			
		||||
      onPositionChange([x, y]);
 | 
			
		||||
    },
 | 
			
		||||
    [setDragging, onPositionChange],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleTouchStart = useCallback(
 | 
			
		||||
    (e: React.TouchEvent) => {
 | 
			
		||||
      const { x, y } = getPointerPosition(nodeRef.current, e);
 | 
			
		||||
      const { x, y } = getPointerPosition(nodeRef.current, e.nativeEvent);
 | 
			
		||||
      setDragging(true);
 | 
			
		||||
      draggingRef.current = true;
 | 
			
		||||
      onPositionChange([x, y]);
 | 
			
		||||
@@ -165,28 +155,12 @@ const Preview: React.FC<{
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleTouchEnd = () => {
 | 
			
		||||
      setDragging(false);
 | 
			
		||||
      draggingRef.current = false;
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    const handleTouchMove = (e: TouchEvent) => {
 | 
			
		||||
      if (draggingRef.current) {
 | 
			
		||||
        const { x, y } = getPointerPosition(nodeRef.current, e);
 | 
			
		||||
        onPositionChange([x, y]);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    document.addEventListener('mouseup', handleMouseUp);
 | 
			
		||||
    document.addEventListener('mousemove', handleMouseMove);
 | 
			
		||||
    document.addEventListener('touchend', handleTouchEnd);
 | 
			
		||||
    document.addEventListener('touchmove', handleTouchMove);
 | 
			
		||||
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener('mouseup', handleMouseUp);
 | 
			
		||||
      document.removeEventListener('mousemove', handleMouseMove);
 | 
			
		||||
      document.removeEventListener('touchend', handleTouchEnd);
 | 
			
		||||
      document.removeEventListener('touchmove', handleTouchMove);
 | 
			
		||||
    };
 | 
			
		||||
  }, [setDragging, onPositionChange]);
 | 
			
		||||
 | 
			
		||||
@@ -204,7 +178,6 @@ const Preview: React.FC<{
 | 
			
		||||
          alt=''
 | 
			
		||||
          role='presentation'
 | 
			
		||||
          onMouseDown={handleMouseDown}
 | 
			
		||||
          onTouchStart={handleTouchStart}
 | 
			
		||||
        />
 | 
			
		||||
        <div
 | 
			
		||||
          className='focal-point__reticle'
 | 
			
		||||
@@ -220,7 +193,6 @@ const Preview: React.FC<{
 | 
			
		||||
          src={media.get('url') as string}
 | 
			
		||||
          alt=''
 | 
			
		||||
          onMouseDown={handleMouseDown}
 | 
			
		||||
          onTouchStart={handleTouchStart}
 | 
			
		||||
        />
 | 
			
		||||
        <div
 | 
			
		||||
          className='focal-point__reticle'
 | 
			
		||||
@@ -233,10 +205,10 @@ const Preview: React.FC<{
 | 
			
		||||
      <Video
 | 
			
		||||
        preview={media.get('preview_url') as string}
 | 
			
		||||
        frameRate={media.getIn(['meta', 'original', 'frame_rate']) as string}
 | 
			
		||||
        aspectRatio={`${media.getIn(['meta', 'original', 'width']) as number} / ${media.getIn(['meta', 'original', 'height']) as number}`}
 | 
			
		||||
        blurhash={media.get('blurhash') as string}
 | 
			
		||||
        src={media.get('url') as string}
 | 
			
		||||
        detailed
 | 
			
		||||
        inline
 | 
			
		||||
        editable
 | 
			
		||||
      />
 | 
			
		||||
    );
 | 
			
		||||
 
 | 
			
		||||
@@ -27,8 +27,8 @@ 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 sound' },
 | 
			
		||||
  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
 | 
			
		||||
  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' },
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
 | 
			
		||||
 | 
			
		||||
import { removePictureInPicture } from 'mastodon/actions/picture_in_picture';
 | 
			
		||||
import Audio from 'mastodon/features/audio';
 | 
			
		||||
import Video from 'mastodon/features/video';
 | 
			
		||||
import { Video } from 'mastodon/features/video';
 | 
			
		||||
import { useAppDispatch, useAppSelector } from 'mastodon/store/typed_functions';
 | 
			
		||||
 | 
			
		||||
import Footer from './components/footer';
 | 
			
		||||
@@ -35,6 +35,10 @@ export const PictureInPicture: React.FC = () => {
 | 
			
		||||
    accentColor,
 | 
			
		||||
  } = pipState;
 | 
			
		||||
 | 
			
		||||
  if (!src) {
 | 
			
		||||
    return null;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  let player;
 | 
			
		||||
 | 
			
		||||
  switch (type) {
 | 
			
		||||
@@ -42,11 +46,10 @@ export const PictureInPicture: React.FC = () => {
 | 
			
		||||
      player = (
 | 
			
		||||
        <Video
 | 
			
		||||
          src={src}
 | 
			
		||||
          currentTime={currentTime}
 | 
			
		||||
          volume={volume}
 | 
			
		||||
          muted={muted}
 | 
			
		||||
          autoPlay
 | 
			
		||||
          inline
 | 
			
		||||
          startTime={currentTime}
 | 
			
		||||
          startVolume={volume}
 | 
			
		||||
          startMuted={muted}
 | 
			
		||||
          startPlaying
 | 
			
		||||
          alwaysVisible
 | 
			
		||||
        />
 | 
			
		||||
      );
 | 
			
		||||
 
 | 
			
		||||
@@ -23,6 +23,7 @@ import { Icon } from 'mastodon/components/icon';
 | 
			
		||||
import { IconLogo } from 'mastodon/components/logo';
 | 
			
		||||
import PictureInPicturePlaceholder from 'mastodon/components/picture_in_picture_placeholder';
 | 
			
		||||
import { VisibilityIcon } from 'mastodon/components/visibility_icon';
 | 
			
		||||
import { Video } from 'mastodon/features/video';
 | 
			
		||||
 | 
			
		||||
import { Avatar } from '../../../components/avatar';
 | 
			
		||||
import { DisplayName } from '../../../components/display_name';
 | 
			
		||||
@@ -30,7 +31,6 @@ 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 Video from '../../video';
 | 
			
		||||
 | 
			
		||||
import Card from './card';
 | 
			
		||||
 | 
			
		||||
@@ -38,7 +38,6 @@ interface VideoModalOptions {
 | 
			
		||||
  startTime: number;
 | 
			
		||||
  autoPlay?: boolean;
 | 
			
		||||
  defaultVolume: number;
 | 
			
		||||
  componentIndex: number;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const DetailedStatus: React.FC<{
 | 
			
		||||
@@ -221,8 +220,6 @@ export const DetailedStatus: React.FC<{
 | 
			
		||||
          src={attachment.get('url')}
 | 
			
		||||
          alt={description}
 | 
			
		||||
          lang={language}
 | 
			
		||||
          width={300}
 | 
			
		||||
          height={150}
 | 
			
		||||
          onOpenVideo={handleOpenVideo}
 | 
			
		||||
          sensitive={status.get('sensitive')}
 | 
			
		||||
          visible={showMedia}
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,7 @@ 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 Video from 'mastodon/features/video';
 | 
			
		||||
import { Video } from 'mastodon/features/video';
 | 
			
		||||
import { disableSwiping } from 'mastodon/initial_state';
 | 
			
		||||
 | 
			
		||||
import { ZoomableImage } from './zoomable_image';
 | 
			
		||||
@@ -205,9 +205,9 @@ class MediaModal extends ImmutablePureComponent {
 | 
			
		||||
            height={image.get('height')}
 | 
			
		||||
            frameRate={image.getIn(['meta', 'original', 'frame_rate'])}
 | 
			
		||||
            aspectRatio={`${image.getIn(['meta', 'original', 'width'])} / ${image.getIn(['meta', 'original', 'height'])}`}
 | 
			
		||||
            currentTime={currentTime || 0}
 | 
			
		||||
            autoPlay={autoPlay || false}
 | 
			
		||||
            volume={volume || 1}
 | 
			
		||||
            startTime={currentTime || 0}
 | 
			
		||||
            startPlaying={autoPlay || false}
 | 
			
		||||
            startVolume={volume || 1}
 | 
			
		||||
            onCloseVideo={onClose}
 | 
			
		||||
            detailed
 | 
			
		||||
            alt={description}
 | 
			
		||||
 
 | 
			
		||||
@@ -6,7 +6,7 @@ import { connect } from 'react-redux';
 | 
			
		||||
 | 
			
		||||
import { getAverageFromBlurhash } from 'mastodon/blurhash';
 | 
			
		||||
import Footer from 'mastodon/features/picture_in_picture/components/footer';
 | 
			
		||||
import Video from 'mastodon/features/video';
 | 
			
		||||
import { Video } from 'mastodon/features/video';
 | 
			
		||||
 | 
			
		||||
const mapStateToProps = (state, { statusId }) => ({
 | 
			
		||||
  status: state.getIn(['statuses', statusId]),
 | 
			
		||||
@@ -56,9 +56,9 @@ class VideoModal extends ImmutablePureComponent {
 | 
			
		||||
            aspectRatio={`${media.getIn(['meta', 'original', 'width'])} / ${media.getIn(['meta', 'original', 'height'])}`}
 | 
			
		||||
            blurhash={media.get('blurhash')}
 | 
			
		||||
            src={media.get('url')}
 | 
			
		||||
            currentTime={options.startTime}
 | 
			
		||||
            autoPlay={options.autoPlay}
 | 
			
		||||
            volume={options.defaultVolume}
 | 
			
		||||
            startTime={options.startTime}
 | 
			
		||||
            startPlaying={options.autoPlay}
 | 
			
		||||
            startVolume={options.defaultVolume}
 | 
			
		||||
            onCloseVideo={onClose}
 | 
			
		||||
            autoFocus
 | 
			
		||||
            detailed
 | 
			
		||||
 
 | 
			
		||||
@@ -1,46 +0,0 @@
 | 
			
		||||
// APIs for normalizing fullscreen operations. Note that Edge uses
 | 
			
		||||
// the WebKit-prefixed APIs currently (as of Edge 16).
 | 
			
		||||
 | 
			
		||||
export const isFullscreen = () => document.fullscreenElement ||
 | 
			
		||||
  document.webkitFullscreenElement ||
 | 
			
		||||
  document.mozFullScreenElement;
 | 
			
		||||
 | 
			
		||||
export const exitFullscreen = () => {
 | 
			
		||||
  if (document.exitFullscreen) {
 | 
			
		||||
    document.exitFullscreen();
 | 
			
		||||
  } else if (document.webkitExitFullscreen) {
 | 
			
		||||
    document.webkitExitFullscreen();
 | 
			
		||||
  } else if (document.mozCancelFullScreen) {
 | 
			
		||||
    document.mozCancelFullScreen();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const requestFullscreen = el => {
 | 
			
		||||
  if (el.requestFullscreen) {
 | 
			
		||||
    el.requestFullscreen();
 | 
			
		||||
  } else if (el.webkitRequestFullscreen) {
 | 
			
		||||
    el.webkitRequestFullscreen();
 | 
			
		||||
  } else if (el.mozRequestFullScreen) {
 | 
			
		||||
    el.mozRequestFullScreen();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const attachFullscreenListener = (listener) => {
 | 
			
		||||
  if ('onfullscreenchange' in document) {
 | 
			
		||||
    document.addEventListener('fullscreenchange', listener);
 | 
			
		||||
  } else if ('onwebkitfullscreenchange' in document) {
 | 
			
		||||
    document.addEventListener('webkitfullscreenchange', listener);
 | 
			
		||||
  } else if ('onmozfullscreenchange' in document) {
 | 
			
		||||
    document.addEventListener('mozfullscreenchange', listener);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const detachFullscreenListener = (listener) => {
 | 
			
		||||
  if ('onfullscreenchange' in document) {
 | 
			
		||||
    document.removeEventListener('fullscreenchange', listener);
 | 
			
		||||
  } else if ('onwebkitfullscreenchange' in document) {
 | 
			
		||||
    document.removeEventListener('webkitfullscreenchange', listener);
 | 
			
		||||
  } else if ('onmozfullscreenchange' in document) {
 | 
			
		||||
    document.removeEventListener('mozfullscreenchange', listener);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										80
									
								
								app/javascript/mastodon/features/ui/util/fullscreen.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -0,0 +1,80 @@
 | 
			
		||||
// APIs for normalizing fullscreen operations. Note that Edge uses
 | 
			
		||||
// the WebKit-prefixed APIs currently (as of Edge 16).
 | 
			
		||||
 | 
			
		||||
interface DocumentWithFullscreen extends Document {
 | 
			
		||||
  mozFullScreenElement?: Element;
 | 
			
		||||
  webkitFullscreenElement?: Element;
 | 
			
		||||
  mozCancelFullScreen?: () => void;
 | 
			
		||||
  webkitExitFullscreen?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
interface HTMLElementWithFullscreen extends HTMLElement {
 | 
			
		||||
  mozRequestFullScreen?: () => void;
 | 
			
		||||
  webkitRequestFullscreen?: () => void;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const isFullscreen = () => {
 | 
			
		||||
  const d = document as DocumentWithFullscreen;
 | 
			
		||||
 | 
			
		||||
  return !!(
 | 
			
		||||
    d.fullscreenElement ??
 | 
			
		||||
    d.webkitFullscreenElement ??
 | 
			
		||||
    d.mozFullScreenElement
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const exitFullscreen = () => {
 | 
			
		||||
  const d = document as DocumentWithFullscreen;
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
  if (d.exitFullscreen) {
 | 
			
		||||
    void d.exitFullscreen();
 | 
			
		||||
  } else if (d.webkitExitFullscreen) {
 | 
			
		||||
    d.webkitExitFullscreen();
 | 
			
		||||
  } else if (d.mozCancelFullScreen) {
 | 
			
		||||
    d.mozCancelFullScreen();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const requestFullscreen = (el: HTMLElementWithFullscreen | null) => {
 | 
			
		||||
  if (!el) {
 | 
			
		||||
    return;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
 | 
			
		||||
  if (el.requestFullscreen) {
 | 
			
		||||
    void el.requestFullscreen();
 | 
			
		||||
  } else if (el.webkitRequestFullscreen) {
 | 
			
		||||
    el.webkitRequestFullscreen();
 | 
			
		||||
  } else if (el.mozRequestFullScreen) {
 | 
			
		||||
    el.mozRequestFullScreen();
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const attachFullscreenListener = (listener: () => void) => {
 | 
			
		||||
  const d = document as DocumentWithFullscreen;
 | 
			
		||||
 | 
			
		||||
  if ('onfullscreenchange' in d) {
 | 
			
		||||
    d.addEventListener('fullscreenchange', listener);
 | 
			
		||||
  } else if ('onwebkitfullscreenchange' in d) {
 | 
			
		||||
    // @ts-expect-error This is valid on some browsers
 | 
			
		||||
    d.addEventListener('webkitfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
 | 
			
		||||
  } else if ('onmozfullscreenchange' in d) {
 | 
			
		||||
    // @ts-expect-error This is valid on some browsers
 | 
			
		||||
    d.addEventListener('mozfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const detachFullscreenListener = (listener: () => void) => {
 | 
			
		||||
  const d = document as DocumentWithFullscreen;
 | 
			
		||||
 | 
			
		||||
  if ('onfullscreenchange' in d) {
 | 
			
		||||
    d.removeEventListener('fullscreenchange', listener);
 | 
			
		||||
  } else if ('onwebkitfullscreenchange' in d) {
 | 
			
		||||
    // @ts-expect-error This is valid on some browsers
 | 
			
		||||
    d.removeEventListener('webkitfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
 | 
			
		||||
  } else if ('onmozfullscreenchange' in d) {
 | 
			
		||||
    // @ts-expect-error This is valid on some browsers
 | 
			
		||||
    d.removeEventListener('mozfullscreenchange', listener); // eslint-disable-line @typescript-eslint/no-unsafe-call
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@@ -0,0 +1,43 @@
 | 
			
		||||
import { useIntl } from 'react-intl';
 | 
			
		||||
import type { MessageDescriptor } from 'react-intl';
 | 
			
		||||
 | 
			
		||||
import { useTransition, animated } from '@react-spring/web';
 | 
			
		||||
 | 
			
		||||
import { Icon } from 'mastodon/components/icon';
 | 
			
		||||
import type { IconProp } from 'mastodon/components/icon';
 | 
			
		||||
 | 
			
		||||
export interface HotkeyEvent {
 | 
			
		||||
  key: number;
 | 
			
		||||
  icon: IconProp;
 | 
			
		||||
  label: MessageDescriptor;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const HotkeyIndicator: React.FC<{
 | 
			
		||||
  events: HotkeyEvent[];
 | 
			
		||||
  onDismiss: (e: HotkeyEvent) => void;
 | 
			
		||||
}> = ({ events, onDismiss }) => {
 | 
			
		||||
  const intl = useIntl();
 | 
			
		||||
 | 
			
		||||
  const transitions = useTransition(events, {
 | 
			
		||||
    from: { opacity: 0 },
 | 
			
		||||
    keys: (item) => item.key,
 | 
			
		||||
    enter: [{ opacity: 1 }],
 | 
			
		||||
    leave: [{ opacity: 0 }],
 | 
			
		||||
    onRest: (_result, _ctrl, item) => {
 | 
			
		||||
      onDismiss(item);
 | 
			
		||||
    },
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {transitions((style, item) => (
 | 
			
		||||
        <animated.div className='video-player__hotkey-indicator' style={style}>
 | 
			
		||||
          <Icon id='' icon={item.icon} />
 | 
			
		||||
          <span className='video-player__hotkey-indicator__label'>
 | 
			
		||||
            {intl.formatMessage(item.label)}
 | 
			
		||||
          </span>
 | 
			
		||||
        </animated.div>
 | 
			
		||||
      ))}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
};
 | 
			
		||||
@@ -1,650 +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 } from 'lodash';
 | 
			
		||||
 | 
			
		||||
import FullscreenIcon from '@/material-icons/400-24px/fullscreen.svg?react';
 | 
			
		||||
import FullscreenExitIcon from '@/material-icons/400-24px/fullscreen_exit.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 RectangleIcon from '@/material-icons/400-24px/rectangle.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 { Blurhash } from 'mastodon/components/blurhash';
 | 
			
		||||
import { Icon }  from 'mastodon/components/icon';
 | 
			
		||||
import { SpoilerButton } from 'mastodon/components/spoiler_button';
 | 
			
		||||
import { playerSettings } from 'mastodon/settings';
 | 
			
		||||
 | 
			
		||||
import { displayMedia, useBlurhash } from '../../initial_state';
 | 
			
		||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
 | 
			
		||||
 | 
			
		||||
const messages = defineMessages({
 | 
			
		||||
  play: { id: 'video.play', defaultMessage: 'Play' },
 | 
			
		||||
  pause: { id: 'video.pause', defaultMessage: 'Pause' },
 | 
			
		||||
  mute: { id: 'video.mute', defaultMessage: 'Mute sound' },
 | 
			
		||||
  unmute: { id: 'video.unmute', defaultMessage: 'Unmute sound' },
 | 
			
		||||
  hide: { id: 'video.hide', defaultMessage: 'Hide video' },
 | 
			
		||||
  expand: { id: 'video.expand', defaultMessage: 'Expand video' },
 | 
			
		||||
  close: { id: 'video.close', defaultMessage: 'Close video' },
 | 
			
		||||
  fullscreen: { id: 'video.fullscreen', defaultMessage: 'Full screen' },
 | 
			
		||||
  exit_fullscreen: { id: 'video.exit_fullscreen', defaultMessage: 'Exit full screen' },
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
export const formatTime = secondsNum => {
 | 
			
		||||
  let hours   = Math.floor(secondsNum / 3600);
 | 
			
		||||
  let minutes = Math.floor((secondsNum - (hours * 3600)) / 60);
 | 
			
		||||
  let seconds = secondsNum - (hours * 3600) - (minutes * 60);
 | 
			
		||||
 | 
			
		||||
  if (hours   < 10) hours   = '0' + hours;
 | 
			
		||||
  if (minutes < 10) minutes = '0' + minutes;
 | 
			
		||||
  if (seconds < 10) seconds = '0' + seconds;
 | 
			
		||||
 | 
			
		||||
  return (hours === '00' ? '' : `${hours}:`) + `${minutes}:${seconds}`;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const findElementPosition = el => {
 | 
			
		||||
  let box;
 | 
			
		||||
 | 
			
		||||
  if (el.getBoundingClientRect && el.parentNode) {
 | 
			
		||||
    box = el.getBoundingClientRect();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  if (!box) {
 | 
			
		||||
    return {
 | 
			
		||||
      left: 0,
 | 
			
		||||
      top: 0,
 | 
			
		||||
    };
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  const docEl = document.documentElement;
 | 
			
		||||
  const body  = document.body;
 | 
			
		||||
 | 
			
		||||
  const clientLeft = docEl.clientLeft || body.clientLeft || 0;
 | 
			
		||||
  const scrollLeft = window.pageXOffset || body.scrollLeft;
 | 
			
		||||
  const left       = (box.left + scrollLeft) - clientLeft;
 | 
			
		||||
 | 
			
		||||
  const clientTop = docEl.clientTop || body.clientTop || 0;
 | 
			
		||||
  const scrollTop = window.pageYOffset || body.scrollTop;
 | 
			
		||||
  const top       = (box.top + scrollTop) - clientTop;
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    left: Math.round(left),
 | 
			
		||||
    top: Math.round(top),
 | 
			
		||||
  };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const getPointerPosition = (el, event) => {
 | 
			
		||||
  const position = {};
 | 
			
		||||
  const box = findElementPosition(el);
 | 
			
		||||
  const boxW = el.offsetWidth;
 | 
			
		||||
  const boxH = el.offsetHeight;
 | 
			
		||||
  const boxY = box.top;
 | 
			
		||||
  const boxX = box.left;
 | 
			
		||||
 | 
			
		||||
  let pageY = event.pageY;
 | 
			
		||||
  let pageX = event.pageX;
 | 
			
		||||
 | 
			
		||||
  if (event.changedTouches) {
 | 
			
		||||
    pageX = event.changedTouches[0].pageX;
 | 
			
		||||
    pageY = event.changedTouches[0].pageY;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  position.y = Math.max(0, Math.min(1, (pageY - boxY) / boxH));
 | 
			
		||||
  position.x = Math.max(0, Math.min(1, (pageX - boxX) / boxW));
 | 
			
		||||
 | 
			
		||||
  return position;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const fileNameFromURL = str => {
 | 
			
		||||
  const url      = new URL(str);
 | 
			
		||||
  const pathname = url.pathname;
 | 
			
		||||
  const index    = pathname.lastIndexOf('/');
 | 
			
		||||
 | 
			
		||||
  return pathname.slice(index + 1);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
class Video extends PureComponent {
 | 
			
		||||
 | 
			
		||||
  static propTypes = {
 | 
			
		||||
    preview: PropTypes.string,
 | 
			
		||||
    frameRate: PropTypes.string,
 | 
			
		||||
    aspectRatio: PropTypes.string,
 | 
			
		||||
    src: PropTypes.string.isRequired,
 | 
			
		||||
    alt: PropTypes.string,
 | 
			
		||||
    lang: PropTypes.string,
 | 
			
		||||
    sensitive: PropTypes.bool,
 | 
			
		||||
    currentTime: PropTypes.number,
 | 
			
		||||
    onOpenVideo: PropTypes.func,
 | 
			
		||||
    onCloseVideo: PropTypes.func,
 | 
			
		||||
    detailed: PropTypes.bool,
 | 
			
		||||
    editable: PropTypes.bool,
 | 
			
		||||
    alwaysVisible: PropTypes.bool,
 | 
			
		||||
    visible: PropTypes.bool,
 | 
			
		||||
    onToggleVisibility: PropTypes.func,
 | 
			
		||||
    deployPictureInPicture: PropTypes.func,
 | 
			
		||||
    intl: PropTypes.object.isRequired,
 | 
			
		||||
    blurhash: PropTypes.string,
 | 
			
		||||
    autoPlay: PropTypes.bool,
 | 
			
		||||
    volume: PropTypes.number,
 | 
			
		||||
    muted: PropTypes.bool,
 | 
			
		||||
    componentIndex: PropTypes.number,
 | 
			
		||||
    autoFocus: PropTypes.bool,
 | 
			
		||||
    matchedFilters: PropTypes.arrayOf(PropTypes.string),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  static defaultProps = {
 | 
			
		||||
    frameRate: '25',
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  state = {
 | 
			
		||||
    currentTime: 0,
 | 
			
		||||
    duration: 0,
 | 
			
		||||
    volume: 0.5,
 | 
			
		||||
    paused: true,
 | 
			
		||||
    dragging: false,
 | 
			
		||||
    fullscreen: false,
 | 
			
		||||
    hovered: false,
 | 
			
		||||
    muted: false,
 | 
			
		||||
    revealed: this.props.visible !== undefined ? this.props.visible : (displayMedia !== 'hide_all' && !this.props.sensitive || displayMedia === 'show_all'),
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setPlayerRef = c => {
 | 
			
		||||
    this.player = c;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setVideoRef = c => {
 | 
			
		||||
    this.video = c;
 | 
			
		||||
 | 
			
		||||
    if (this.video) {
 | 
			
		||||
      this.setState({ volume: this.video.volume, muted: this.video.muted });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setSeekRef = c => {
 | 
			
		||||
    this.seek = c;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  setVolumeRef = c => {
 | 
			
		||||
    this.volume = c;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleClickRoot = e => e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
  handlePlay = () => {
 | 
			
		||||
    this.setState({ paused: false });
 | 
			
		||||
    this._updateTime();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handlePause = () => {
 | 
			
		||||
    this.setState({ paused: true });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  _updateTime () {
 | 
			
		||||
    requestAnimationFrame(() => {
 | 
			
		||||
      if (!this.video) return;
 | 
			
		||||
 | 
			
		||||
      this.handleTimeUpdate();
 | 
			
		||||
 | 
			
		||||
      if (!this.state.paused) {
 | 
			
		||||
        this._updateTime();
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleTimeUpdate = () => {
 | 
			
		||||
    this.setState({
 | 
			
		||||
      currentTime: this.video.currentTime,
 | 
			
		||||
      duration:this.video.duration,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  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);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleMouseVolSlide = throttle(e => {
 | 
			
		||||
    const { x } = getPointerPosition(this.volume, e);
 | 
			
		||||
 | 
			
		||||
    if(!isNaN(x)) {
 | 
			
		||||
      this.setState((state) => ({ volume: x, muted: state.muted && x === 0 }), () => {
 | 
			
		||||
        this._syncVideoToVolumeState(x);
 | 
			
		||||
        this._saveVolumeState(x);
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }, 15);
 | 
			
		||||
 | 
			
		||||
  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.video.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.video.play();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleMouseMove = throttle(e => {
 | 
			
		||||
    const { x } = getPointerPosition(this.seek, e);
 | 
			
		||||
    const currentTime = this.video.duration * x;
 | 
			
		||||
 | 
			
		||||
    if (!isNaN(currentTime)) {
 | 
			
		||||
      this.setState({ currentTime }, () => {
 | 
			
		||||
        this.video.currentTime = currentTime;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }, 15);
 | 
			
		||||
 | 
			
		||||
  seekBy (time) {
 | 
			
		||||
    const currentTime = this.video.currentTime + time;
 | 
			
		||||
 | 
			
		||||
    if (!isNaN(currentTime)) {
 | 
			
		||||
      this.setState({ currentTime }, () => {
 | 
			
		||||
        this.video.currentTime = currentTime;
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleVideoKeyDown = e => {
 | 
			
		||||
    // On the video 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 => {
 | 
			
		||||
    const frameTime = 1 / this.getFrameRate();
 | 
			
		||||
 | 
			
		||||
    switch(e.key) {
 | 
			
		||||
    case 'k':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.togglePlay();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'm':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleMute();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'f':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.toggleFullscreen();
 | 
			
		||||
      break;
 | 
			
		||||
    case 'j':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-10);
 | 
			
		||||
      break;
 | 
			
		||||
    case 'l':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(10);
 | 
			
		||||
      break;
 | 
			
		||||
    case ',':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(-frameTime);
 | 
			
		||||
      break;
 | 
			
		||||
    case '.':
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      this.seekBy(frameTime);
 | 
			
		||||
      break;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // If we are in fullscreen mode, we don't want any hotkeys
 | 
			
		||||
    // interacting with the UI that's not visible
 | 
			
		||||
 | 
			
		||||
    if (this.state.fullscreen) {
 | 
			
		||||
      e.preventDefault();
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
 | 
			
		||||
      if (e.key === 'Escape') {
 | 
			
		||||
        exitFullscreen();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  togglePlay = () => {
 | 
			
		||||
    if (this.state.paused) {
 | 
			
		||||
      this.setState({ paused: false }, () => this.video.play());
 | 
			
		||||
    } else {
 | 
			
		||||
      this.setState({ paused: true }, () => this.video.pause());
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  toggleFullscreen = () => {
 | 
			
		||||
    if (isFullscreen()) {
 | 
			
		||||
      exitFullscreen();
 | 
			
		||||
    } else {
 | 
			
		||||
      requestFullscreen(this.player);
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  componentDidMount () {
 | 
			
		||||
    document.addEventListener('fullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.addEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.addEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.addEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
			
		||||
 | 
			
		||||
    window.addEventListener('scroll', this.handleScroll);
 | 
			
		||||
 | 
			
		||||
    this._syncVideoFromLocalStorage();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentWillUnmount () {
 | 
			
		||||
    window.removeEventListener('scroll', this.handleScroll);
 | 
			
		||||
 | 
			
		||||
    document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
 | 
			
		||||
    document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
 | 
			
		||||
 | 
			
		||||
    if (!this.state.paused && this.video && this.props.deployPictureInPicture) {
 | 
			
		||||
      this.props.deployPictureInPicture('video', {
 | 
			
		||||
        src: this.props.src,
 | 
			
		||||
        currentTime: this.video.currentTime,
 | 
			
		||||
        muted: this.video.muted,
 | 
			
		||||
        volume: this.video.volume,
 | 
			
		||||
      });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  UNSAFE_componentWillReceiveProps (nextProps) {
 | 
			
		||||
    if (!is(nextProps.visible, this.props.visible) && nextProps.visible !== undefined) {
 | 
			
		||||
      this.setState({ revealed: nextProps.visible });
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  componentDidUpdate (prevProps, prevState) {
 | 
			
		||||
    if (prevState.revealed && !this.state.revealed && this.video) {
 | 
			
		||||
      this.video.pause();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  handleScroll = throttle(() => {
 | 
			
		||||
    if (!this.video) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    const { top, height } = this.video.getBoundingClientRect();
 | 
			
		||||
    const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
 | 
			
		||||
 | 
			
		||||
    if (!this.state.paused && !inView) {
 | 
			
		||||
      this.video.pause();
 | 
			
		||||
 | 
			
		||||
      if (this.props.deployPictureInPicture) {
 | 
			
		||||
        this.props.deployPictureInPicture('video', {
 | 
			
		||||
          src: this.props.src,
 | 
			
		||||
          currentTime: this.video.currentTime,
 | 
			
		||||
          muted: this.video.muted,
 | 
			
		||||
          volume: this.video.volume,
 | 
			
		||||
        });
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      this.setState({ paused: true });
 | 
			
		||||
    }
 | 
			
		||||
  }, 150, { trailing: true });
 | 
			
		||||
 | 
			
		||||
  handleFullscreenChange = () => {
 | 
			
		||||
    this.setState({ fullscreen: isFullscreen() });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleMouseEnter = () => {
 | 
			
		||||
    this.setState({ hovered: true });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleMouseLeave = () => {
 | 
			
		||||
    this.setState({ hovered: false });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  toggleMute = () => {
 | 
			
		||||
    const muted = !(this.video.muted || this.state.volume === 0);
 | 
			
		||||
 | 
			
		||||
    this.setState((state) => ({ muted, volume: Math.max(state.volume || 0.5, 0.05) }), () => {
 | 
			
		||||
      this._syncVideoToVolumeState();
 | 
			
		||||
      this._saveVolumeState();
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  _syncVideoToVolumeState = (volume = null, muted = null) => {
 | 
			
		||||
    if (!this.video) {
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    this.video.volume = volume ?? this.state.volume;
 | 
			
		||||
    this.video.muted = muted ?? this.state.muted;
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  _saveVolumeState = (volume = null, muted = null) => {
 | 
			
		||||
    playerSettings.set('volume', volume ?? this.state.volume);
 | 
			
		||||
    playerSettings.set('muted', muted ?? this.state.muted);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  _syncVideoFromLocalStorage = () => {
 | 
			
		||||
    this.setState({ volume: playerSettings.get('volume') ?? 0.5, muted: playerSettings.get('muted') ?? false }, () => {
 | 
			
		||||
      this._syncVideoToVolumeState();
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  toggleReveal = () => {
 | 
			
		||||
    if (this.props.onToggleVisibility) {
 | 
			
		||||
      this.props.onToggleVisibility();
 | 
			
		||||
    } else {
 | 
			
		||||
      this.setState({ revealed: !this.state.revealed });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleLoadedData = () => {
 | 
			
		||||
    const { currentTime, volume, muted, autoPlay } = this.props;
 | 
			
		||||
 | 
			
		||||
    if (currentTime) {
 | 
			
		||||
      this.video.currentTime = currentTime;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (volume !== undefined) {
 | 
			
		||||
      this.video.volume = volume;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (muted !== undefined) {
 | 
			
		||||
      this.video.muted = muted;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (autoPlay) {
 | 
			
		||||
      this.video.play();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleProgress = () => {
 | 
			
		||||
    const lastTimeRange = this.video.buffered.length - 1;
 | 
			
		||||
 | 
			
		||||
    if (lastTimeRange > -1) {
 | 
			
		||||
      this.setState({ buffer: Math.ceil(this.video.buffered.end(lastTimeRange) / this.video.duration * 100) });
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleVolumeChange = () => {
 | 
			
		||||
    this.setState({ volume: this.video.volume, muted: this.video.muted });
 | 
			
		||||
    this._saveVolumeState(this.video.volume, this.video.muted);
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleOpenVideo = () => {
 | 
			
		||||
    this.video.pause();
 | 
			
		||||
 | 
			
		||||
    this.props.onOpenVideo(this.props.lang, {
 | 
			
		||||
      startTime: this.video.currentTime,
 | 
			
		||||
      autoPlay: !this.state.paused,
 | 
			
		||||
      defaultVolume: this.state.volume,
 | 
			
		||||
      componentIndex: this.props.componentIndex,
 | 
			
		||||
    });
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  handleCloseVideo = () => {
 | 
			
		||||
    this.video.pause();
 | 
			
		||||
    this.props.onCloseVideo();
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  getFrameRate () {
 | 
			
		||||
    if (this.props.frameRate && isNaN(this.props.frameRate)) {
 | 
			
		||||
      // The frame rate is returned as a fraction string so we
 | 
			
		||||
      // need to convert it to a number
 | 
			
		||||
 | 
			
		||||
      return this.props.frameRate.split('/').reduce((p, c) => p / c);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return this.props.frameRate;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  render () {
 | 
			
		||||
    const { preview, src, aspectRatio, onOpenVideo, onCloseVideo, intl, alt, lang, detailed, sensitive, editable, blurhash, autoFocus, matchedFilters } = this.props;
 | 
			
		||||
    const { currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, revealed } = this.state;
 | 
			
		||||
    const progress = Math.min((currentTime / duration) * 100, 100);
 | 
			
		||||
    const muted = this.state.muted || volume === 0;
 | 
			
		||||
 | 
			
		||||
    let preload;
 | 
			
		||||
 | 
			
		||||
    if (this.props.currentTime || fullscreen || dragging) {
 | 
			
		||||
      preload = 'auto';
 | 
			
		||||
    } else if (detailed) {
 | 
			
		||||
      preload = 'metadata';
 | 
			
		||||
    } else {
 | 
			
		||||
      preload = 'none';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // The outer wrapper is necessary to avoid reflowing the layout when going into full screen
 | 
			
		||||
    return (
 | 
			
		||||
      <div style={{ aspectRatio }}>
 | 
			
		||||
        <div
 | 
			
		||||
          role='menuitem'
 | 
			
		||||
          className={classNames('video-player', { inactive: !revealed, detailed, fullscreen, editable })}
 | 
			
		||||
          style={{ aspectRatio }}
 | 
			
		||||
          ref={this.setPlayerRef}
 | 
			
		||||
          onMouseEnter={this.handleMouseEnter}
 | 
			
		||||
          onMouseLeave={this.handleMouseLeave}
 | 
			
		||||
          onClick={this.handleClickRoot}
 | 
			
		||||
          onKeyDown={this.handleKeyDown}
 | 
			
		||||
          tabIndex={0}
 | 
			
		||||
        >
 | 
			
		||||
          <Blurhash
 | 
			
		||||
            hash={blurhash}
 | 
			
		||||
            className={classNames('media-gallery__preview', {
 | 
			
		||||
              'media-gallery__preview--hidden': revealed,
 | 
			
		||||
            })}
 | 
			
		||||
            dummy={!useBlurhash}
 | 
			
		||||
          />
 | 
			
		||||
 | 
			
		||||
          {(revealed || editable) && <video
 | 
			
		||||
            ref={this.setVideoRef}
 | 
			
		||||
            src={src}
 | 
			
		||||
            poster={preview}
 | 
			
		||||
            preload={preload}
 | 
			
		||||
            role='button'
 | 
			
		||||
            tabIndex={0}
 | 
			
		||||
            aria-label={alt}
 | 
			
		||||
            title={alt}
 | 
			
		||||
            lang={lang}
 | 
			
		||||
            onClick={this.togglePlay}
 | 
			
		||||
            onKeyDown={this.handleVideoKeyDown}
 | 
			
		||||
            onPlay={this.handlePlay}
 | 
			
		||||
            onPause={this.handlePause}
 | 
			
		||||
            onLoadedData={this.handleLoadedData}
 | 
			
		||||
            onProgress={this.handleProgress}
 | 
			
		||||
            onVolumeChange={this.handleVolumeChange}
 | 
			
		||||
            style={{ width: '100%' }}
 | 
			
		||||
          />}
 | 
			
		||||
 | 
			
		||||
          <SpoilerButton hidden={revealed || editable} sensitive={sensitive} onClick={this.toggleReveal} matchedFilters={matchedFilters} />
 | 
			
		||||
 | 
			
		||||
          <div className={classNames('video-player__controls', { active: paused || hovered })}>
 | 
			
		||||
            <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}%` }} />
 | 
			
		||||
 | 
			
		||||
              <span
 | 
			
		||||
                className={classNames('video-player__seek__handle', { active: dragging })}
 | 
			
		||||
                tabIndex={0}
 | 
			
		||||
                style={{ left: `${progress}%` }}
 | 
			
		||||
                onKeyDown={this.handleVideoKeyDown}
 | 
			
		||||
              />
 | 
			
		||||
            </div>
 | 
			
		||||
 | 
			
		||||
            <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} autoFocus={autoFocus}><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 })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>
 | 
			
		||||
                  <div className='video-player__volume__current' style={{ width: `${muted ? 0 : volume * 100}%` }} />
 | 
			
		||||
 | 
			
		||||
                  <span
 | 
			
		||||
                    className={classNames('video-player__volume__handle')}
 | 
			
		||||
                    tabIndex={0}
 | 
			
		||||
                    style={{ left: `${muted ? 0 : volume * 100}%` }}
 | 
			
		||||
                  />
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                {(detailed || fullscreen) && (
 | 
			
		||||
                  <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(duration))}</span>
 | 
			
		||||
                  </span>
 | 
			
		||||
                )}
 | 
			
		||||
              </div>
 | 
			
		||||
 | 
			
		||||
              <div className='video-player__buttons right'>
 | 
			
		||||
                {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && <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>}
 | 
			
		||||
                {(!fullscreen && onOpenVideo) && <button type='button' title={intl.formatMessage(messages.expand)} aria-label={intl.formatMessage(messages.expand)} className='player-button' onClick={this.handleOpenVideo}><Icon id='expand' icon={RectangleIcon} /></button>}
 | 
			
		||||
                {onCloseVideo && <button type='button' title={intl.formatMessage(messages.close)} aria-label={intl.formatMessage(messages.close)} className='player-button' onClick={this.handleCloseVideo}><Icon id='compress' icon={FullscreenExitIcon} /></button>}
 | 
			
		||||
                <button type='button' title={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} aria-label={intl.formatMessage(fullscreen ? messages.exit_fullscreen : messages.fullscreen)} className='player-button' onClick={this.toggleFullscreen}><Icon id={fullscreen ? 'compress' : 'arrows-alt'} icon={fullscreen ? FullscreenExitIcon : FullscreenIcon} /></button>
 | 
			
		||||
              </div>
 | 
			
		||||
            </div>
 | 
			
		||||
          </div>
 | 
			
		||||
        </div>
 | 
			
		||||
      </div>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export default injectIntl(Video);
 | 
			
		||||
							
								
								
									
										1022
									
								
								app/javascript/mastodon/features/video/index.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						@@ -905,8 +905,12 @@
 | 
			
		||||
  "video.expand": "Expand video",
 | 
			
		||||
  "video.fullscreen": "Full screen",
 | 
			
		||||
  "video.hide": "Hide video",
 | 
			
		||||
  "video.mute": "Mute sound",
 | 
			
		||||
  "video.mute": "Mute",
 | 
			
		||||
  "video.pause": "Pause",
 | 
			
		||||
  "video.play": "Play",
 | 
			
		||||
  "video.unmute": "Unmute sound"
 | 
			
		||||
  "video.skip_backward": "Skip backward",
 | 
			
		||||
  "video.skip_forward": "Skip forward",
 | 
			
		||||
  "video.unmute": "Unmute",
 | 
			
		||||
  "video.volume_down": "Volume down",
 | 
			
		||||
  "video.volume_up": "Volume up"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-440q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-800h6l-62-62 56-58 160 160-160 160-56-58 62-62h-6q-117 0-198.5 81.5T200-440q0 117 81.5 198.5T480-160q117 0 198.5-81.5T760-440h80q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-80ZM380-320v-60h120v-40H380v-140h180v60H440v40h80q17 0 28.5 11.5T560-420v60q0 17-11.5 28.5T520-320H380Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 487 B  | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/forward_5.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="M480-80q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-440q0-75 28.5-140.5t77-114q48.5-48.5 114-77T480-800h6l-62-62 56-58 160 160-160 160-56-58 62-62h-6q-117 0-198.5 81.5T200-440q0 117 81.5 198.5T480-160q117 0 198.5-81.5T760-440h80q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-80ZM380-320v-60h120v-40H380v-140h180v60H440v40h80q17 0 28.5 11.5T560-420v60q0 17-11.5 28.5T520-320H380Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 487 B  | 
@@ -1 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-40-82v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q20-22 36-47.5t26.5-53q10.5-27.5 16-56.5t5.5-59q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z"/></svg>
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-40-82v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q41-45 62.5-100.5T800-480q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 583 B After Width: | Height: | Size: 561 B  | 
@@ -1 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-40-82v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q20-22 36-47.5t26.5-53q10.5-27.5 16-56.5t5.5-59q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z"/></svg>
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80Zm-40-82v-78q-33 0-56.5-23.5T360-320v-40L168-552q-3 18-5.5 36t-2.5 36q0 121 79.5 212T440-162Zm276-102q41-45 62.5-100.5T800-480q0-98-54.5-179T600-776v16q0 33-23.5 56.5T520-680h-80v80q0 17-11.5 28.5T400-560h-80v80h240q17 0 28.5 11.5T600-440v120h40q26 0 47 15.5t29 40.5Z"/></svg>
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 583 B After Width: | Height: | Size: 561 B  | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/replay_5-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="M480-80q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-440h80q0 117 81.5 198.5T480-160q117 0 198.5-81.5T760-440q0-117-81.5-198.5T480-720h-6l62 62-56 58-160-160 160-160 56 58-62 62h6q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-440q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-80ZM380-320v-60h120v-40H380v-140h180v60H440v40h80q17 0 28.5 11.5T560-420v60q0 17-11.5 28.5T520-320H380Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 485 B  | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/replay_5.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="M480-80q-75 0-140.5-28.5t-114-77q-48.5-48.5-77-114T120-440h80q0 117 81.5 198.5T480-160q117 0 198.5-81.5T760-440q0-117-81.5-198.5T480-720h-6l62 62-56 58-160-160 160-160 56 58-62 62h6q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-440q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-80ZM380-320v-60h120v-40H380v-140h180v60H440v40h80q17 0 28.5 11.5T560-420v60q0 17-11.5 28.5T520-320H380Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 485 B  | 
@@ -0,0 +1 @@
 | 
			
		||||
<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 -960 960 960" width="24"><path d="M200-360v-240h160l200-200v640L360-360H200Zm440 40v-322q45 21 72.5 65t27.5 97q0 53-27.5 96T640-320Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 204 B  | 
							
								
								
									
										1
									
								
								app/javascript/material-icons/400-24px/volume_down.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="M200-360v-240h160l200-200v640L360-360H200Zm440 40v-322q45 21 72.5 65t27.5 97q0 53-27.5 96T640-320ZM480-606l-86 86H280v80h114l86 86v-252ZM380-480Z"/></svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 251 B  | 
@@ -7106,6 +7106,15 @@ a.status-card {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  .media-gallery__actions {
 | 
			
		||||
    opacity: 0;
 | 
			
		||||
    transition: opacity 0.1s ease;
 | 
			
		||||
 | 
			
		||||
    &.active {
 | 
			
		||||
      opacity: 1;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.inactive {
 | 
			
		||||
    video,
 | 
			
		||||
    .video-player__controls {
 | 
			
		||||
@@ -7256,7 +7265,7 @@ a.status-card {
 | 
			
		||||
      inset-inline-start: 0;
 | 
			
		||||
      top: 50%;
 | 
			
		||||
      transform: translate(0, -50%);
 | 
			
		||||
      background: lighten($ui-highlight-color, 8%);
 | 
			
		||||
      background: $white;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__handle {
 | 
			
		||||
@@ -7269,7 +7278,7 @@ a.status-card {
 | 
			
		||||
      inset-inline-start: 0;
 | 
			
		||||
      margin-inline-start: -6px;
 | 
			
		||||
      transform: translate(0, -50%);
 | 
			
		||||
      background: lighten($ui-highlight-color, 8%);
 | 
			
		||||
      background: $white;
 | 
			
		||||
      box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
 | 
			
		||||
      opacity: 0;
 | 
			
		||||
 | 
			
		||||
@@ -7323,7 +7332,7 @@ a.status-card {
 | 
			
		||||
      height: 4px;
 | 
			
		||||
      border-radius: 4px;
 | 
			
		||||
      top: 14px;
 | 
			
		||||
      background: lighten($ui-highlight-color, 8%);
 | 
			
		||||
      background: $white;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    &__buffer {
 | 
			
		||||
@@ -7339,7 +7348,7 @@ a.status-card {
 | 
			
		||||
      height: 12px;
 | 
			
		||||
      top: 10px;
 | 
			
		||||
      margin-inline-start: -6px;
 | 
			
		||||
      background: lighten($ui-highlight-color, 8%);
 | 
			
		||||
      background: $white;
 | 
			
		||||
      box-shadow: 1px 2px 6px rgba($base-shadow-color, 0.2);
 | 
			
		||||
 | 
			
		||||
      .no-reduce-motion & {
 | 
			
		||||
@@ -7348,6 +7357,7 @@ a.status-card {
 | 
			
		||||
 | 
			
		||||
      &.active {
 | 
			
		||||
        opacity: 1;
 | 
			
		||||
        cursor: grabbing;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@@ -7358,6 +7368,28 @@ a.status-card {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &__hotkey-indicator {
 | 
			
		||||
    position: absolute;
 | 
			
		||||
    top: 50%;
 | 
			
		||||
    inset-inline-start: 50%;
 | 
			
		||||
    transform: translate(-50%, -50%);
 | 
			
		||||
    background: rgba($base-shadow-color, 0.45);
 | 
			
		||||
    backdrop-filter: var(--background-filter);
 | 
			
		||||
    color: $white;
 | 
			
		||||
    border-radius: 8px;
 | 
			
		||||
    padding: 16px 24px;
 | 
			
		||||
    display: flex;
 | 
			
		||||
    flex-direction: column;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    justify-content: center;
 | 
			
		||||
    gap: 8px;
 | 
			
		||||
 | 
			
		||||
    &__label {
 | 
			
		||||
      font-size: 15px;
 | 
			
		||||
      font-weight: 500;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &.detailed,
 | 
			
		||||
  &.fullscreen {
 | 
			
		||||
    .video-player__buttons {
 | 
			
		||||
 
 | 
			
		||||