diff --git a/app/javascript/mastodon/containers/media_container.jsx b/app/javascript/mastodon/containers/media_container.jsx
index d18602e3b5..f5f38d8902 100644
--- a/app/javascript/mastodon/containers/media_container.jsx
+++ b/app/javascript/mastodon/containers/media_container.jsx
@@ -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';
diff --git a/app/javascript/mastodon/features/alt_text_modal/index.tsx b/app/javascript/mastodon/features/alt_text_modal/index.tsx
index 80c4f36105..8c5e552eb8 100644
--- a/app/javascript/mastodon/features/alt_text_modal/index.tsx
+++ b/app/javascript/mastodon/features/alt_text_modal/index.tsx
@@ -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}
/>
);
diff --git a/app/javascript/mastodon/features/audio/index.jsx b/app/javascript/mastodon/features/audio/index.jsx
index dc48756906..53ce3f0bdb 100644
--- a/app/javascript/mastodon/features/audio/index.jsx
+++ b/app/javascript/mastodon/features/audio/index.jsx
@@ -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' },
});
diff --git a/app/javascript/mastodon/features/picture_in_picture/index.tsx b/app/javascript/mastodon/features/picture_in_picture/index.tsx
index 51b72f9725..9bae1b5545 100644
--- a/app/javascript/mastodon/features/picture_in_picture/index.tsx
+++ b/app/javascript/mastodon/features/picture_in_picture/index.tsx
@@ -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 = (
);
diff --git a/app/javascript/mastodon/features/status/components/detailed_status.tsx b/app/javascript/mastodon/features/status/components/detailed_status.tsx
index 1291c26159..0e6ee8c1ea 100644
--- a/app/javascript/mastodon/features/status/components/detailed_status.tsx
+++ b/app/javascript/mastodon/features/status/components/detailed_status.tsx
@@ -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}
diff --git a/app/javascript/mastodon/features/ui/components/media_modal.jsx b/app/javascript/mastodon/features/ui/components/media_modal.jsx
index 9312805b5c..9eb08ce9c3 100644
--- a/app/javascript/mastodon/features/ui/components/media_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/media_modal.jsx
@@ -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}
diff --git a/app/javascript/mastodon/features/ui/components/video_modal.jsx b/app/javascript/mastodon/features/ui/components/video_modal.jsx
index 4fc3ee1728..ed58d642a4 100644
--- a/app/javascript/mastodon/features/ui/components/video_modal.jsx
+++ b/app/javascript/mastodon/features/ui/components/video_modal.jsx
@@ -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
diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.js b/app/javascript/mastodon/features/ui/util/fullscreen.js
deleted file mode 100644
index cf5d0cf98d..0000000000
--- a/app/javascript/mastodon/features/ui/util/fullscreen.js
+++ /dev/null
@@ -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);
- }
-};
diff --git a/app/javascript/mastodon/features/ui/util/fullscreen.ts b/app/javascript/mastodon/features/ui/util/fullscreen.ts
new file mode 100644
index 0000000000..796b9e6515
--- /dev/null
+++ b/app/javascript/mastodon/features/ui/util/fullscreen.ts
@@ -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
+ }
+};
diff --git a/app/javascript/mastodon/features/video/components/hotkey_indicator.tsx b/app/javascript/mastodon/features/video/components/hotkey_indicator.tsx
new file mode 100644
index 0000000000..1abbb96fc0
--- /dev/null
+++ b/app/javascript/mastodon/features/video/components/hotkey_indicator.tsx
@@ -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) => (
+
+
+
+ {intl.formatMessage(item.label)}
+
+
+ ))}
+ >
+ );
+};
diff --git a/app/javascript/mastodon/features/video/index.jsx b/app/javascript/mastodon/features/video/index.jsx
deleted file mode 100644
index 7459b94a92..0000000000
--- a/app/javascript/mastodon/features/video/index.jsx
+++ /dev/null
@@ -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 (
-
-
-
-
- {(revealed || editable) &&
}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {(detailed || fullscreen) && (
-
- {formatTime(Math.floor(currentTime))}
- /
- {formatTime(Math.floor(duration))}
-
- )}
-
-
-
- {(!onCloseVideo && !editable && !fullscreen && !this.props.alwaysVisible) && }
- {(!fullscreen && onOpenVideo) && }
- {onCloseVideo && }
-
-
-
-
-
-
- );
- }
-
-}
-
-export default injectIntl(Video);
diff --git a/app/javascript/mastodon/features/video/index.tsx b/app/javascript/mastodon/features/video/index.tsx
new file mode 100644
index 0000000000..99ca1a96da
--- /dev/null
+++ b/app/javascript/mastodon/features/video/index.tsx
@@ -0,0 +1,1022 @@
+import { useEffect, useCallback, useRef, useState } from 'react';
+
+import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
+
+import classNames from 'classnames';
+
+import { useSpring, animated, config } from '@react-spring/web';
+import { throttle } from 'lodash';
+
+import Forward5Icon from '@/material-icons/400-24px/forward_5-fill.svg?react';
+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-fill.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 Replay5Icon from '@/material-icons/400-24px/replay_5-fill.svg?react';
+import VolumeDownIcon from '@/material-icons/400-24px/volume_down-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 {
+ isFullscreen,
+ requestFullscreen,
+ exitFullscreen,
+ attachFullscreenListener,
+ detachFullscreenListener,
+} from 'mastodon/features/ui/util/fullscreen';
+import {
+ displayMedia,
+ useBlurhash,
+ reduceMotion,
+} from 'mastodon/initial_state';
+import { playerSettings } from 'mastodon/settings';
+
+import { HotkeyIndicator } from './components/hotkey_indicator';
+import type { HotkeyEvent } from './components/hotkey_indicator';
+
+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' },
+ 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',
+ },
+ volumeUp: { id: 'video.volume_up', defaultMessage: 'Volume up' },
+ volumeDown: { id: 'video.volume_down', defaultMessage: 'Volume down' },
+ skipForward: { id: 'video.skip_forward', defaultMessage: 'Skip forward' },
+ skipBackward: { id: 'video.skip_backward', defaultMessage: 'Skip backward' },
+});
+
+const DOUBLE_CLICK_THRESHOLD = 250;
+
+export const formatTime = (secondsNum: number) => {
+ const hours = Math.floor(secondsNum / 3600);
+ const minutes = Math.floor((secondsNum - hours * 3600) / 60);
+ const seconds = secondsNum - hours * 3600 - minutes * 60;
+
+ const formattedHours = `${hours < 10 ? '0' : ''}${hours}`;
+ const formattedMinutes = `${minutes < 10 ? '0' : ''}${minutes}`;
+ const formattedSeconds = `${seconds < 10 ? '0' : ''}${seconds}`;
+
+ return (
+ (formattedHours === '00' ? '' : `${formattedHours}:`) +
+ `${formattedMinutes}:${formattedSeconds}`
+ );
+};
+
+export const findElementPosition = (el: HTMLElement) => {
+ const box = el.getBoundingClientRect();
+ const docEl = document.documentElement;
+ const body = document.body;
+
+ const clientLeft = docEl.clientLeft || body.clientLeft || 0;
+ const scrollLeft = window.scrollX || body.scrollLeft;
+ const left = box.left + scrollLeft - clientLeft;
+
+ const clientTop = docEl.clientTop || body.clientTop || 0;
+ const scrollTop = window.scrollY || body.scrollTop;
+ const top = box.top + scrollTop - clientTop;
+
+ return {
+ left: Math.round(left),
+ top: Math.round(top),
+ };
+};
+
+export const getPointerPosition = (
+ el: HTMLElement | null,
+ event: MouseEvent,
+) => {
+ if (!el) {
+ return {
+ y: 0,
+ x: 0,
+ };
+ }
+
+ const box = findElementPosition(el);
+ const boxW = el.offsetWidth;
+ const boxH = el.offsetHeight;
+ const boxY = box.top;
+ const boxX = box.left;
+
+ const { pageY, pageX } = event;
+
+ return {
+ y: Math.max(0, Math.min(1, (pageY - boxY) / boxH)),
+ x: Math.max(0, Math.min(1, (pageX - boxX) / boxW)),
+ };
+};
+
+export const fileNameFromURL = (str: string) => {
+ const url = new URL(str);
+ const pathname = url.pathname;
+ const index = pathname.lastIndexOf('/');
+
+ return pathname.slice(index + 1);
+};
+
+const frameRateAsNumber = (frameRate: string): number => {
+ if (frameRate.includes('/')) {
+ return frameRate
+ .split('/')
+ .map((c) => parseInt(c))
+ .reduce((p, c) => p / c);
+ }
+
+ return parseInt(frameRate);
+};
+
+const persistVolume = (volume: number, muted: boolean) => {
+ playerSettings.set('volume', volume);
+ playerSettings.set('muted', muted);
+};
+
+const restoreVolume = (video: HTMLVideoElement) => {
+ const volume = (playerSettings.get('volume') as number | undefined) ?? 0.5;
+ const muted = (playerSettings.get('muted') as boolean | undefined) ?? false;
+
+ video.volume = volume;
+ video.muted = muted;
+};
+
+let hotkeyEventId = 0;
+
+const registerHotkeyEvent = (
+ setHotkeyEvents: React.Dispatch>,
+ event: Omit,
+) => {
+ setHotkeyEvents(() => [{ key: hotkeyEventId++, ...event }]);
+};
+
+export const Video: React.FC<{
+ preview?: string;
+ frameRate?: string;
+ aspectRatio?: string;
+ src: string;
+ alt?: string;
+ lang?: string;
+ sensitive?: boolean;
+ onOpenVideo?: (options: {
+ startTime: number;
+ autoPlay: boolean;
+ defaultVolume: number;
+ }) => void;
+ onCloseVideo?: () => void;
+ detailed?: boolean;
+ editable?: boolean;
+ alwaysVisible?: boolean;
+ visible?: boolean;
+ onToggleVisibility?: () => void;
+ deployPictureInPicture?: (
+ type: string,
+ mediaProps: {
+ src: string;
+ muted: boolean;
+ volume: number;
+ currentTime: number;
+ },
+ ) => void;
+ blurhash?: string;
+ startPlaying?: boolean;
+ startTime?: number;
+ startVolume?: number;
+ startMuted?: boolean;
+ matchedFilters?: string[];
+}> = ({
+ preview,
+ frameRate = '25',
+ aspectRatio,
+ src,
+ alt = '',
+ lang,
+ sensitive,
+ onOpenVideo,
+ onCloseVideo,
+ detailed,
+ editable,
+ alwaysVisible,
+ visible,
+ onToggleVisibility,
+ deployPictureInPicture,
+ blurhash,
+ startPlaying,
+ startTime,
+ startVolume,
+ startMuted,
+ matchedFilters,
+}) => {
+ const intl = useIntl();
+ const [currentTime, setCurrentTime] = useState(0);
+ const [duration, setDuration] = useState(0);
+ const [volume, setVolume] = useState(0.5);
+ const [paused, setPaused] = useState(true);
+ const [dragging, setDragging] = useState(false);
+ const [fullscreen, setFullscreen] = useState(false);
+ const [hovered, setHovered] = useState(false);
+ const [muted, setMuted] = useState(false);
+ const [revealed, setRevealed] = useState(false);
+ const [hotkeyEvents, setHotkeyEvents] = useState([]);
+
+ const playerRef = useRef(null);
+ const videoRef = useRef(null);
+ const seekRef = useRef(null);
+ const volumeRef = useRef(null);
+ const doubleClickTimeoutRef = useRef | null>();
+
+ const [style, api] = useSpring(() => ({
+ progress: '0%',
+ buffer: '0%',
+ volume: '0%',
+ }));
+
+ const handleVideoRef = useCallback(
+ (c: HTMLVideoElement | null) => {
+ if (videoRef.current && !videoRef.current.paused && c === null) {
+ deployPictureInPicture?.('video', {
+ src: src,
+ currentTime: videoRef.current.currentTime,
+ muted: videoRef.current.muted,
+ volume: videoRef.current.volume,
+ });
+ }
+
+ videoRef.current = c;
+
+ if (videoRef.current) {
+ restoreVolume(videoRef.current);
+ setVolume(videoRef.current.volume);
+ setMuted(videoRef.current.muted);
+ void api.start({
+ volume: `${videoRef.current.volume * 100}%`,
+ immediate: reduceMotion,
+ });
+ }
+ },
+ [api, setVolume, setMuted, src, deployPictureInPicture],
+ );
+
+ const togglePlay = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ if (videoRef.current.paused) {
+ void videoRef.current.play();
+ } else {
+ videoRef.current.pause();
+ }
+ }, []);
+
+ const toggleFullscreen = useCallback(() => {
+ if (isFullscreen()) {
+ exitFullscreen();
+ } else {
+ requestFullscreen(playerRef.current);
+ }
+ }, []);
+
+ const toggleMute = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ const effectivelyMuted =
+ videoRef.current.muted || videoRef.current.volume === 0;
+
+ if (effectivelyMuted) {
+ videoRef.current.muted = false;
+
+ if (videoRef.current.volume === 0) {
+ videoRef.current.volume = 0.05;
+ }
+ } else {
+ videoRef.current.muted = true;
+ }
+ }, []);
+
+ const handleClickRoot = useCallback((e: React.MouseEvent) => {
+ // Stop clicks within the video player e.g. closing parent modal
+ e.stopPropagation();
+ }, []);
+
+ const handleClick = useCallback(() => {
+ if (!doubleClickTimeoutRef.current) {
+ doubleClickTimeoutRef.current = setTimeout(() => {
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: videoRef.current?.paused ? PlayArrowIcon : PauseIcon,
+ label: videoRef.current?.paused ? messages.play : messages.pause,
+ });
+ togglePlay();
+ doubleClickTimeoutRef.current = null;
+ }, DOUBLE_CLICK_THRESHOLD);
+ } else {
+ clearTimeout(doubleClickTimeoutRef.current);
+ doubleClickTimeoutRef.current = null;
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: isFullscreen() ? FullscreenExitIcon : FullscreenIcon,
+ label: isFullscreen() ? messages.exit_fullscreen : messages.fullscreen,
+ });
+ toggleFullscreen();
+ }
+ }, [setHotkeyEvents, togglePlay, toggleFullscreen]);
+
+ const handlePlay = useCallback(() => {
+ setPaused(false);
+ }, [setPaused]);
+
+ const handlePause = useCallback(() => {
+ setPaused(true);
+ }, [setPaused]);
+
+ useEffect(() => {
+ let nextFrame: ReturnType;
+
+ const updateProgress = () => {
+ nextFrame = requestAnimationFrame(() => {
+ if (videoRef.current) {
+ void api.start({
+ progress: `${(videoRef.current.currentTime / videoRef.current.duration) * 100}%`,
+ immediate: reduceMotion,
+ config: config.stiff,
+ });
+ }
+
+ updateProgress();
+ });
+ };
+
+ updateProgress();
+
+ return () => {
+ cancelAnimationFrame(nextFrame);
+ };
+ }, [api]);
+
+ useEffect(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ videoRef.current.volume = volume;
+ videoRef.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 && videoRef.current) {
+ videoRef.current.pause();
+ }
+ }, [revealed]);
+
+ useEffect(() => {
+ const handleFullscreenChange = () => {
+ setFullscreen(isFullscreen());
+ };
+
+ const handleScroll = throttle(
+ () => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ const { top, height } = videoRef.current.getBoundingClientRect();
+ const inView =
+ top <=
+ (window.innerHeight || document.documentElement.clientHeight) &&
+ top + height >= 0;
+
+ if (!videoRef.current.paused && !inView) {
+ videoRef.current.pause();
+
+ deployPictureInPicture?.('video', {
+ src: src,
+ currentTime: videoRef.current.currentTime,
+ muted: videoRef.current.muted,
+ volume: videoRef.current.volume,
+ });
+ }
+ },
+ 150,
+ { trailing: true },
+ );
+
+ attachFullscreenListener(handleFullscreenChange);
+ window.addEventListener('scroll', handleScroll);
+
+ return () => {
+ window.removeEventListener('scroll', handleScroll);
+ detachFullscreenListener(handleFullscreenChange);
+ };
+ }, [setPaused, setFullscreen, src, deployPictureInPicture]);
+
+ const handleTimeUpdate = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ setCurrentTime(videoRef.current.currentTime);
+ }, [setCurrentTime]);
+
+ 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 || !videoRef.current) {
+ return;
+ }
+
+ const { x } = getPointerPosition(volumeRef.current, e);
+
+ if (!isNaN(x)) {
+ videoRef.current.volume = x;
+ videoRef.current.muted = x > 0 ? false : true;
+ void api.start({ volume: `${x * 100}%`, immediate: true });
+ }
+ };
+
+ document.addEventListener('mousemove', handleVolumeMouseMove, true);
+ document.addEventListener('mouseup', handleVolumeMouseUp, true);
+
+ handleVolumeMouseMove(e.nativeEvent);
+
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ [api],
+ );
+
+ const handleSeekMouseDown = useCallback(
+ (e: React.MouseEvent) => {
+ const handleSeekMouseUp = () => {
+ document.removeEventListener('mousemove', handleSeekMouseMove, true);
+ document.removeEventListener('mouseup', handleSeekMouseUp, true);
+
+ setDragging(false);
+ void videoRef.current?.play();
+ };
+
+ const handleSeekMouseMove = (e: MouseEvent) => {
+ if (!seekRef.current || !videoRef.current) {
+ return;
+ }
+
+ const { x } = getPointerPosition(seekRef.current, e);
+ const newTime = videoRef.current.duration * x;
+
+ if (!isNaN(newTime)) {
+ videoRef.current.currentTime = newTime;
+ void api.start({ progress: `${x * 100}%`, immediate: true });
+ }
+ };
+
+ document.addEventListener('mousemove', handleSeekMouseMove, true);
+ document.addEventListener('mouseup', handleSeekMouseUp, true);
+
+ setDragging(true);
+ videoRef.current?.pause();
+ handleSeekMouseMove(e.nativeEvent);
+
+ e.preventDefault();
+ e.stopPropagation();
+ },
+ [setDragging, api],
+ );
+
+ const seekBy = (time: number) => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ const newTime = videoRef.current.currentTime + time;
+
+ if (!isNaN(newTime)) {
+ videoRef.current.currentTime = newTime;
+ }
+ };
+
+ const updateVolumeBy = (step: number) => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ const newVolume = videoRef.current.volume + step;
+
+ if (!isNaN(newVolume)) {
+ videoRef.current.volume = newVolume;
+ videoRef.current.muted = newVolume > 0 ? false : true;
+ }
+ };
+
+ const handleVideoKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ // 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();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: videoRef.current?.paused ? PlayArrowIcon : PauseIcon,
+ label: videoRef.current?.paused ? messages.play : messages.pause,
+ });
+ togglePlay();
+ }
+ },
+ [setHotkeyEvents, togglePlay],
+ );
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ const frameTime = 1 / frameRateAsNumber(frameRate);
+
+ switch (e.key) {
+ case 'k':
+ case ' ':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: videoRef.current?.paused ? PlayArrowIcon : PauseIcon,
+ label: videoRef.current?.paused ? messages.play : messages.pause,
+ });
+ togglePlay();
+ break;
+ case 'm':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: videoRef.current?.muted ? VolumeUpIcon : VolumeOffIcon,
+ label: videoRef.current?.muted ? messages.unmute : messages.mute,
+ });
+ toggleMute();
+ break;
+ case 'f':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: isFullscreen() ? FullscreenExitIcon : FullscreenIcon,
+ label: isFullscreen()
+ ? messages.exit_fullscreen
+ : messages.fullscreen,
+ });
+ toggleFullscreen();
+ break;
+ case 'j':
+ case 'ArrowLeft':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: Replay5Icon,
+ label: messages.skipBackward,
+ });
+ seekBy(-5);
+ break;
+ case 'l':
+ case 'ArrowRight':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: Forward5Icon,
+ label: messages.skipForward,
+ });
+ seekBy(5);
+ break;
+ case ',':
+ e.preventDefault();
+ e.stopPropagation();
+ seekBy(-frameTime);
+ break;
+ case '.':
+ e.preventDefault();
+ e.stopPropagation();
+ seekBy(frameTime);
+ break;
+ case 'ArrowUp':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: VolumeUpIcon,
+ label: messages.volumeUp,
+ });
+ updateVolumeBy(0.15);
+ break;
+ case 'ArrowDown':
+ e.preventDefault();
+ e.stopPropagation();
+ registerHotkeyEvent(setHotkeyEvents, {
+ icon: VolumeDownIcon,
+ label: messages.volumeDown,
+ });
+ updateVolumeBy(-0.15);
+ break;
+ }
+
+ // If we are in fullscreen mode, we don't want any hotkeys
+ // interacting with the UI that's not visible
+
+ if (fullscreen) {
+ e.preventDefault();
+ e.stopPropagation();
+
+ if (e.key === 'Escape') {
+ setHotkeyEvents((events) => [
+ ...events,
+ {
+ key: hotkeyEventId++,
+ icon: FullscreenExitIcon,
+ label: messages.exit_fullscreen,
+ },
+ ]);
+ exitFullscreen();
+ }
+ }
+ },
+ [
+ setHotkeyEvents,
+ togglePlay,
+ toggleFullscreen,
+ toggleMute,
+ fullscreen,
+ frameRate,
+ ],
+ );
+
+ const handleMouseEnter = useCallback(() => {
+ setHovered(true);
+ }, [setHovered]);
+
+ const handleMouseLeave = useCallback(() => {
+ setHovered(false);
+ }, [setHovered]);
+
+ const toggleReveal = useCallback(() => {
+ if (onToggleVisibility) {
+ onToggleVisibility();
+ } else {
+ setRevealed((value) => !value);
+ }
+ }, [setRevealed, onToggleVisibility]);
+
+ const handleLoadedData = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ setDuration(videoRef.current.duration);
+
+ if (typeof startTime !== 'undefined') {
+ videoRef.current.currentTime = startTime;
+ }
+
+ if (typeof startVolume !== 'undefined') {
+ videoRef.current.volume = startVolume;
+ }
+
+ if (typeof startMuted !== 'undefined') {
+ videoRef.current.muted = startMuted;
+ }
+
+ if (startPlaying) {
+ void videoRef.current.play();
+ }
+ }, [setDuration, startTime, startVolume, startMuted, startPlaying]);
+
+ const handleProgress = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ const lastTimeRange = videoRef.current.buffered.length - 1;
+
+ if (lastTimeRange > -1) {
+ void api.start({
+ buffer: `${Math.ceil(videoRef.current.buffered.end(lastTimeRange) / videoRef.current.duration) * 100}%`,
+ immediate: reduceMotion,
+ });
+ }
+ }, [api]);
+
+ const handleVolumeChange = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ setVolume(videoRef.current.volume);
+ setMuted(videoRef.current.muted);
+
+ void api.start({
+ volume: `${videoRef.current.muted ? 0 : videoRef.current.volume * 100}%`,
+ immediate: reduceMotion,
+ });
+
+ persistVolume(videoRef.current.volume, videoRef.current.muted);
+ }, [api, setVolume, setMuted]);
+
+ const handleOpenVideo = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ const wasPaused = videoRef.current.paused;
+
+ videoRef.current.pause();
+
+ onOpenVideo?.({
+ startTime: videoRef.current.currentTime,
+ autoPlay: !wasPaused,
+ defaultVolume: videoRef.current.volume,
+ });
+ }, [onOpenVideo]);
+
+ const handleCloseVideo = useCallback(() => {
+ if (!videoRef.current) {
+ return;
+ }
+
+ videoRef.current.pause();
+
+ onCloseVideo?.();
+ }, [onCloseVideo]);
+
+ const handleHotkeyEventDismiss = useCallback(
+ ({ key }: HotkeyEvent) => {
+ setHotkeyEvents((events) => events.filter((e) => e.key !== key));
+ },
+ [setHotkeyEvents],
+ );
+
+ const progress = Math.min((currentTime / duration) * 100, 100);
+ const effectivelyMuted = muted || volume === 0;
+
+ let preload;
+
+ if (startTime || 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 (
+
+
+ {blurhash && (
+
+ )}
+
+ {(revealed || editable) && (
+
+ )}
+
+
+
+
+
+ {!onCloseVideo &&
+ !editable &&
+ !fullscreen &&
+ !alwaysVisible &&
+ revealed && (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {(detailed || fullscreen) && (
+
+
+ {formatTime(Math.floor(currentTime))}
+
+ /
+
+ {formatTime(Math.floor(duration))}
+
+
+ )}
+
+
+
+ {!fullscreen && onOpenVideo && (
+
+ )}
+ {onCloseVideo && (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+// eslint-disable-next-line import/no-default-export
+export default Video;
diff --git a/app/javascript/mastodon/locales/en.json b/app/javascript/mastodon/locales/en.json
index 5a6e40cd8a..424983840e 100644
--- a/app/javascript/mastodon/locales/en.json
+++ b/app/javascript/mastodon/locales/en.json
@@ -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"
}
diff --git a/app/javascript/material-icons/400-24px/forward_5-fill.svg b/app/javascript/material-icons/400-24px/forward_5-fill.svg
new file mode 100644
index 0000000000..bc0119a640
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/forward_5-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/forward_5.svg b/app/javascript/material-icons/400-24px/forward_5.svg
new file mode 100644
index 0000000000..bc0119a640
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/forward_5.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/public-fill.svg b/app/javascript/material-icons/400-24px/public-fill.svg
index 1e9e79de4d..104f26e133 100644
--- a/app/javascript/material-icons/400-24px/public-fill.svg
+++ b/app/javascript/material-icons/400-24px/public-fill.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/public.svg b/app/javascript/material-icons/400-24px/public.svg
index 1e9e79de4d..104f26e133 100644
--- a/app/javascript/material-icons/400-24px/public.svg
+++ b/app/javascript/material-icons/400-24px/public.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/replay_5-fill.svg b/app/javascript/material-icons/400-24px/replay_5-fill.svg
new file mode 100644
index 0000000000..c0c259829e
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/replay_5-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/replay_5.svg b/app/javascript/material-icons/400-24px/replay_5.svg
new file mode 100644
index 0000000000..c0c259829e
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/replay_5.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/volume_down-fill.svg b/app/javascript/material-icons/400-24px/volume_down-fill.svg
new file mode 100644
index 0000000000..26ec2b09ab
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/volume_down-fill.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/material-icons/400-24px/volume_down.svg b/app/javascript/material-icons/400-24px/volume_down.svg
new file mode 100644
index 0000000000..a3fbc41002
--- /dev/null
+++ b/app/javascript/material-icons/400-24px/volume_down.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/javascript/styles/mastodon/components.scss b/app/javascript/styles/mastodon/components.scss
index b4a31de811..553935d53b 100644
--- a/app/javascript/styles/mastodon/components.scss
+++ b/app/javascript/styles/mastodon/components.scss
@@ -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 {