Merge remote-tracking branch 'soapbox/develop' into events-
This commit is contained in:
@ -8,6 +8,8 @@ import RegistrationForm from 'soapbox/features/auth_login/components/registratio
|
||||
import { useAppDispatch, useAppSelector, useFeatures, useSoapboxConfig } from 'soapbox/hooks';
|
||||
import { capitalize } from 'soapbox/utils/strings';
|
||||
|
||||
import './instance-description.css';
|
||||
|
||||
const LandingPage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
14
app/soapbox/features/landing_page/instance-description.css
Normal file
14
app/soapbox/features/landing_page/instance-description.css
Normal file
@ -0,0 +1,14 @@
|
||||
/* Instance HTML from the API. */
|
||||
.instance-description a {
|
||||
@apply underline;
|
||||
}
|
||||
|
||||
.instance-description b,
|
||||
.instance-description strong {
|
||||
@apply font-bold;
|
||||
}
|
||||
|
||||
.instance-description i,
|
||||
.instance-description em {
|
||||
@apply italic;
|
||||
}
|
||||
@ -6,16 +6,19 @@ import ReactSwipeableViews from 'react-swipeable-views';
|
||||
import { endOnboarding } from 'soapbox/actions/onboarding';
|
||||
import LandingGradient from 'soapbox/components/landing-gradient';
|
||||
import { HStack } from 'soapbox/components/ui';
|
||||
import { useFeatures } from 'soapbox/hooks';
|
||||
|
||||
import AvatarSelectionStep from './steps/avatar-selection-step';
|
||||
import BioStep from './steps/bio-step';
|
||||
import CompletedStep from './steps/completed-step';
|
||||
import CoverPhotoSelectionStep from './steps/cover-photo-selection-step';
|
||||
import DisplayNameStep from './steps/display-name-step';
|
||||
import FediverseStep from './steps/fediverse-step';
|
||||
import SuggestedAccountsStep from './steps/suggested-accounts-step';
|
||||
|
||||
const OnboardingWizard = () => {
|
||||
const dispatch = useDispatch();
|
||||
const features = useFeatures();
|
||||
|
||||
const [currentStep, setCurrentStep] = React.useState<number>(0);
|
||||
|
||||
@ -41,9 +44,14 @@ const OnboardingWizard = () => {
|
||||
<BioStep onNext={handleNextStep} />,
|
||||
<CoverPhotoSelectionStep onNext={handleNextStep} />,
|
||||
<SuggestedAccountsStep onNext={handleNextStep} />,
|
||||
<CompletedStep onComplete={handleComplete} />,
|
||||
];
|
||||
|
||||
if (features.federating){
|
||||
steps.push(<FediverseStep onNext={handleNextStep} />);
|
||||
}
|
||||
|
||||
steps.push(<CompletedStep onComplete={handleComplete} />);
|
||||
|
||||
const handleKeyUp = ({ key }: KeyboardEvent): void => {
|
||||
switch (key) {
|
||||
case 'ArrowLeft':
|
||||
|
||||
88
app/soapbox/features/onboarding/steps/fediverse-step.tsx
Normal file
88
app/soapbox/features/onboarding/steps/fediverse-step.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import * as React from 'react';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
|
||||
import Account from 'soapbox/components/account';
|
||||
import { Button, Card, CardBody, Icon, Stack, Text } from 'soapbox/components/ui';
|
||||
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
|
||||
|
||||
import type { Account as AccountEntity } from 'soapbox/types/entities';
|
||||
|
||||
const FediverseStep = ({ onNext }: { onNext: () => void }) => {
|
||||
const siteTitle = useAppSelector((state) => state.instance.title);
|
||||
const account = useOwnAccount() as AccountEntity;
|
||||
|
||||
return (
|
||||
<Card variant='rounded' size='xl'>
|
||||
<CardBody>
|
||||
<Stack space={2}>
|
||||
<Icon strokeWidth={1} src={require('@tabler/icons/affiliate.svg')} className='w-16 h-16 mx-auto text-primary-600 dark:text-primary-400' />
|
||||
|
||||
<Text size='2xl' weight='bold'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.title'
|
||||
defaultMessage='{siteTitle} is just one part of the Fediverse'
|
||||
values={{
|
||||
siteTitle,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Stack space={4}>
|
||||
<div className='border-b border-gray-200 dark:border-gray-800 border-solid pb-2 sm:pb-5'>
|
||||
<Stack space={4}>
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.message'
|
||||
defaultMessage='The Fediverse is a social network made up of thousands of diverse and independently-run social media sites (aka "servers"). You can follow users — and like, repost, and reply to posts — from most other Fediverse servers, because they can communicate with {siteTitle}.'
|
||||
values={{
|
||||
siteTitle,
|
||||
}}
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.trailer'
|
||||
defaultMessage='Because it is distributed and anyone can run their own server, the Fediverse is resilient and open. If you choose to join another server or set up your own, you can interact with the same people and continue on the same social graph.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</div>
|
||||
|
||||
<div className='bg-primary-50 dark:bg-gray-800 rounded-lg text-center p-4'>
|
||||
<Account account={account} />
|
||||
</div>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.its_you'
|
||||
defaultMessage='This is you! Other people can follow you from other servers by using your full @-handle.'
|
||||
/>
|
||||
</Text>
|
||||
|
||||
<Text theme='muted'>
|
||||
<FormattedMessage
|
||||
id='onboarding.fediverse.other_instances'
|
||||
defaultMessage='When browsing your timeline, pay attention to the full username after the second @ symbol to know which server a post is from.'
|
||||
/>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<div className='pt-10 sm:w-2/3 md:w-1/2 mx-auto'>
|
||||
<Stack justifyContent='center' space={2}>
|
||||
<Button
|
||||
block
|
||||
theme='primary'
|
||||
onClick={onNext}
|
||||
>
|
||||
<FormattedMessage id='onboarding.fediverse.next' defaultMessage='Next' />
|
||||
</Button>
|
||||
</Stack>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FediverseStep;
|
||||
@ -27,10 +27,8 @@ const ComposeModal: React.FC<IComposeModal> = ({ onClose }) => {
|
||||
|
||||
const { id: statusId, privacy, in_reply_to: inReplyTo, quote } = compose!;
|
||||
|
||||
const hasComposeContent = checkComposeContent(compose);
|
||||
|
||||
const onClickClose = () => {
|
||||
if (hasComposeContent) {
|
||||
if (checkComposeContent(compose)) {
|
||||
dispatch(openModal('CONFIRM', {
|
||||
icon: require('@tabler/icons/trash.svg'),
|
||||
heading: statusId
|
||||
|
||||
@ -1,31 +1,43 @@
|
||||
// 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 isFullscreen = (): boolean => {
|
||||
return Boolean(
|
||||
document.fullscreenElement ||
|
||||
// @ts-ignore
|
||||
document.webkitFullscreenElement ||
|
||||
// @ts-ignore
|
||||
document.mozFullScreenElement,
|
||||
);
|
||||
};
|
||||
|
||||
export const exitFullscreen = () => {
|
||||
export const exitFullscreen = (): void => {
|
||||
if (document.exitFullscreen) {
|
||||
document.exitFullscreen();
|
||||
} else if (document.webkitExitFullscreen) {
|
||||
} else if ('webkitExitFullscreen' in document) {
|
||||
// @ts-ignore
|
||||
document.webkitExitFullscreen();
|
||||
} else if (document.mozCancelFullScreen) {
|
||||
} else if ('mozCancelFullScreen' in document) {
|
||||
// @ts-ignore
|
||||
document.mozCancelFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const requestFullscreen = el => {
|
||||
export const requestFullscreen = (el: Element): void => {
|
||||
if (el.requestFullscreen) {
|
||||
el.requestFullscreen();
|
||||
} else if (el.webkitRequestFullscreen) {
|
||||
} else if ('webkitRequestFullscreen' in el) {
|
||||
// @ts-ignore
|
||||
el.webkitRequestFullscreen();
|
||||
} else if (el.mozRequestFullScreen) {
|
||||
} else if ('mozRequestFullScreen' in el) {
|
||||
// @ts-ignore
|
||||
el.mozRequestFullScreen();
|
||||
}
|
||||
};
|
||||
|
||||
export const attachFullscreenListener = (listener) => {
|
||||
type FullscreenListener = (this: Document, ev: Event) => void;
|
||||
|
||||
export const attachFullscreenListener = (listener: FullscreenListener): void => {
|
||||
if ('onfullscreenchange' in document) {
|
||||
document.addEventListener('fullscreenchange', listener);
|
||||
} else if ('onwebkitfullscreenchange' in document) {
|
||||
@ -35,7 +47,7 @@ export const attachFullscreenListener = (listener) => {
|
||||
}
|
||||
};
|
||||
|
||||
export const detachFullscreenListener = (listener) => {
|
||||
export const detachFullscreenListener = (listener: FullscreenListener): void => {
|
||||
if ('onfullscreenchange' in document) {
|
||||
document.removeEventListener('fullscreenchange', listener);
|
||||
} else if ('onwebkitfullscreenchange' in document) {
|
||||
@ -1,625 +0,0 @@
|
||||
import classNames from 'clsx';
|
||||
import { fromJS, is } from 'immutable';
|
||||
import debounce from 'lodash/debounce';
|
||||
import throttle from 'lodash/throttle';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { defineMessages, injectIntl, FormattedMessage } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
|
||||
import { getSettings } from 'soapbox/actions/settings';
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media_aspect_ratio';
|
||||
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
const DEFAULT_HEIGHT = 300;
|
||||
|
||||
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.substring(index + 1);
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
displayMedia: getSettings(state).get('displayMedia'),
|
||||
});
|
||||
|
||||
export default @connect(mapStateToProps)
|
||||
@injectIntl
|
||||
class Video extends React.PureComponent {
|
||||
|
||||
static propTypes = {
|
||||
preview: PropTypes.string,
|
||||
src: PropTypes.string.isRequired,
|
||||
alt: PropTypes.string,
|
||||
width: PropTypes.number,
|
||||
height: PropTypes.number,
|
||||
sensitive: PropTypes.bool,
|
||||
startTime: PropTypes.number,
|
||||
onOpenVideo: PropTypes.func,
|
||||
onCloseVideo: PropTypes.func,
|
||||
detailed: PropTypes.bool,
|
||||
inline: PropTypes.bool,
|
||||
cacheWidth: PropTypes.func,
|
||||
visible: PropTypes.bool,
|
||||
onToggleVisibility: PropTypes.func,
|
||||
intl: PropTypes.object.isRequired,
|
||||
blurhash: PropTypes.string,
|
||||
link: PropTypes.node,
|
||||
aspectRatio: PropTypes.number,
|
||||
displayMedia: PropTypes.string,
|
||||
};
|
||||
|
||||
state = {
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
volume: 0.5,
|
||||
paused: true,
|
||||
dragging: false,
|
||||
containerWidth: this.props.width,
|
||||
fullscreen: false,
|
||||
hovered: false,
|
||||
muted: false,
|
||||
revealed: this.props.visible !== undefined ? this.props.visible : (this.props.displayMedia !== 'hide_all' && !this.props.sensitive || this.props.displayMedia === 'show_all'),
|
||||
};
|
||||
|
||||
setPlayerRef = c => {
|
||||
this.player = c;
|
||||
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}
|
||||
|
||||
_setDimensions() {
|
||||
const width = this.player.offsetWidth;
|
||||
|
||||
if (this.props.cacheWidth) {
|
||||
this.props.cacheWidth(width);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
containerWidth: width,
|
||||
});
|
||||
}
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
handlePause = () => {
|
||||
this.setState({ paused: true });
|
||||
}
|
||||
|
||||
handleTimeUpdate = () => {
|
||||
this.setState({
|
||||
currentTime: Math.floor(this.video.currentTime),
|
||||
duration: Math.floor(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)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if (x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
this.video.volume = slideamt;
|
||||
this.setState({ volume: slideamt });
|
||||
}
|
||||
}, 60);
|
||||
|
||||
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 = Math.floor(this.video.duration * x);
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
this.video.currentTime = currentTime;
|
||||
this.setState({ currentTime });
|
||||
}
|
||||
}, 60);
|
||||
|
||||
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 / 25;
|
||||
|
||||
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 = (e) => {
|
||||
if (e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
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);
|
||||
window.addEventListener('resize', this.handleResize, { passive: true });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('scroll', this.handleScroll);
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
|
||||
document.removeEventListener('fullscreenchange', this.handleFullscreenChange, true);
|
||||
document.removeEventListener('webkitfullscreenchange', this.handleFullscreenChange, true);
|
||||
document.removeEventListener('mozfullscreenchange', this.handleFullscreenChange, true);
|
||||
document.removeEventListener('MSFullscreenChange', this.handleFullscreenChange, true);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const { visible } = this.props;
|
||||
|
||||
if (!is(visible, prevProps.visible) && visible !== undefined) {
|
||||
this.setState({ revealed: visible });
|
||||
}
|
||||
|
||||
if (prevState.revealed && !this.state.revealed && this.video) {
|
||||
this.video.pause();
|
||||
}
|
||||
}
|
||||
|
||||
handleResize = debounce(() => {
|
||||
if (this.player) {
|
||||
this._setDimensions();
|
||||
}
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
});
|
||||
|
||||
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.setState({ paused: true }, () => this.video.pause());
|
||||
}
|
||||
}, 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.setState({ muted }, () => {
|
||||
this.video.muted = muted;
|
||||
});
|
||||
}
|
||||
|
||||
toggleReveal = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (this.props.onToggleVisibility) {
|
||||
this.props.onToggleVisibility();
|
||||
} else {
|
||||
this.setState({ revealed: !this.state.revealed });
|
||||
}
|
||||
}
|
||||
|
||||
handleLoadedData = () => {
|
||||
if (this.props.startTime) {
|
||||
this.video.currentTime = this.props.startTime;
|
||||
this.video.play();
|
||||
}
|
||||
}
|
||||
|
||||
handleProgress = () => {
|
||||
if (this.video.buffered.length > 0) {
|
||||
this.setState({ buffer: this.video.buffered.end(0) / this.video.duration * 100 });
|
||||
}
|
||||
}
|
||||
|
||||
handleVolumeChange = () => {
|
||||
this.setState({ volume: this.video.volume, muted: this.video.muted });
|
||||
}
|
||||
|
||||
handleOpenVideo = () => {
|
||||
const { src, preview, width, height, alt } = this.props;
|
||||
|
||||
const media = fromJS({
|
||||
type: 'video',
|
||||
url: src,
|
||||
preview_url: preview,
|
||||
description: alt,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
|
||||
this.video.pause();
|
||||
this.props.onOpenVideo(media, this.video.currentTime);
|
||||
}
|
||||
|
||||
handleCloseVideo = () => {
|
||||
this.video.pause();
|
||||
this.props.onCloseVideo();
|
||||
}
|
||||
|
||||
getPreload = () => {
|
||||
const { startTime, detailed } = this.props;
|
||||
const { dragging, fullscreen } = this.state;
|
||||
|
||||
if (startTime || fullscreen || dragging) {
|
||||
return 'auto';
|
||||
} else if (detailed) {
|
||||
return 'metadata';
|
||||
} else {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { src, inline, onCloseVideo, intl, alt, detailed, sensitive, link, aspectRatio, blurhash } = this.props;
|
||||
const { containerWidth, currentTime, duration, volume, buffer, dragging, paused, fullscreen, hovered, muted, revealed } = this.state;
|
||||
const progress = (currentTime / duration) * 100;
|
||||
const playerStyle = {};
|
||||
|
||||
let { width, height } = this.props;
|
||||
|
||||
if (inline && containerWidth) {
|
||||
width = containerWidth;
|
||||
const minSize = containerWidth / (16 / 9);
|
||||
|
||||
if (isPanoramic(aspectRatio)) {
|
||||
height = Math.max(Math.floor(containerWidth / maximumAspectRatio), minSize);
|
||||
} else if (isPortrait(aspectRatio)) {
|
||||
height = Math.max(Math.floor(containerWidth / minimumAspectRatio), minSize);
|
||||
} else {
|
||||
height = Math.floor(containerWidth / aspectRatio);
|
||||
}
|
||||
|
||||
playerStyle.height = height || DEFAULT_HEIGHT;
|
||||
}
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role='menuitem'
|
||||
className={classNames('video-player', { 'video-player--inactive': !revealed, detailed, 'video-player--inline': inline && !fullscreen, fullscreen })}
|
||||
style={playerStyle}
|
||||
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,
|
||||
})}
|
||||
/>
|
||||
|
||||
{revealed && <video
|
||||
ref={this.setVideoRef}
|
||||
src={src}
|
||||
// preload={this.getPreload()}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex='0'
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
width={width}
|
||||
height={height || DEFAULT_HEIGHT}
|
||||
volume={volume}
|
||||
onClick={this.togglePlay}
|
||||
onKeyDown={this.handleVideoKeyDown}
|
||||
onPlay={this.handlePlay}
|
||||
onPause={this.handlePause}
|
||||
onTimeUpdate={this.handleTimeUpdate}
|
||||
onLoadedData={this.handleLoadedData}
|
||||
onProgress={this.handleProgress}
|
||||
onVolumeChange={this.handleVolumeChange}
|
||||
/>}
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': !sensitive || revealed })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={this.toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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={detailed}><Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} /></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 src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} /></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: `${volume * 100}%` }} />
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex='0'
|
||||
style={{ left: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detailed || fullscreen) && (
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{link && <span className='video-player__link'>{link}</span>}
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{(sensitive && !onCloseVideo) && <button type='button' title={intl.formatMessage(messages.hide)} aria-label={intl.formatMessage(messages.hide)} className='player-button' onClick={this.toggleReveal}><Icon src={require('@tabler/icons/eye-off.svg')} /></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 src={fullscreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')} /></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
636
app/soapbox/features/video/index.tsx
Normal file
636
app/soapbox/features/video/index.tsx
Normal file
@ -0,0 +1,636 @@
|
||||
import classNames from 'clsx';
|
||||
import debounce from 'lodash/debounce';
|
||||
import throttle from 'lodash/throttle';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { defineMessages, useIntl, FormattedMessage } from 'react-intl';
|
||||
|
||||
import Blurhash from 'soapbox/components/blurhash';
|
||||
import Icon from 'soapbox/components/icon';
|
||||
import { useSettings } from 'soapbox/hooks';
|
||||
import { isPanoramic, isPortrait, minimumAspectRatio, maximumAspectRatio } from 'soapbox/utils/media_aspect_ratio';
|
||||
|
||||
import { isFullscreen, requestFullscreen, exitFullscreen } from '../ui/util/fullscreen';
|
||||
|
||||
import type { Attachment } from 'soapbox/types/entities';
|
||||
|
||||
const DEFAULT_HEIGHT = 300;
|
||||
|
||||
type Position = { x: number, y: number };
|
||||
|
||||
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: number): string => {
|
||||
let hours: number | string = Math.floor(secondsNum / 3600);
|
||||
let minutes: number | string = Math.floor((secondsNum - (hours * 3600)) / 60);
|
||||
let seconds: number | string = 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: HTMLElement) => {
|
||||
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: HTMLElement, event: MouseEvent & TouchEvent): 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;
|
||||
}
|
||||
|
||||
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.substring(index + 1);
|
||||
};
|
||||
|
||||
interface IVideo {
|
||||
preview?: string,
|
||||
src: string,
|
||||
alt?: string,
|
||||
width?: number,
|
||||
height?: number,
|
||||
sensitive?: boolean,
|
||||
startTime?: number,
|
||||
onOpenVideo?: (attachment: Attachment, time: number) => void,
|
||||
onCloseVideo?: () => void,
|
||||
detailed?: boolean,
|
||||
inline?: boolean,
|
||||
cacheWidth?: (width: number) => void,
|
||||
visible?: boolean,
|
||||
onToggleVisibility?: () => void,
|
||||
blurhash?: string,
|
||||
link?: React.ReactNode,
|
||||
aspectRatio?: number,
|
||||
displayMedia?: string,
|
||||
}
|
||||
|
||||
const Video: React.FC<IVideo> = ({
|
||||
width,
|
||||
visible = false,
|
||||
sensitive = false,
|
||||
detailed = false,
|
||||
cacheWidth,
|
||||
onToggleVisibility,
|
||||
startTime,
|
||||
src,
|
||||
height,
|
||||
alt,
|
||||
onCloseVideo,
|
||||
inline,
|
||||
aspectRatio = 16 / 9,
|
||||
link,
|
||||
blurhash,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const settings = useSettings();
|
||||
const displayMedia = settings.get('displayMedia') as string | undefined;
|
||||
|
||||
const player = useRef<HTMLDivElement>(null);
|
||||
const video = useRef<HTMLVideoElement>(null);
|
||||
const seek = useRef<HTMLDivElement>(null);
|
||||
const slider = useRef<HTMLDivElement>(null);
|
||||
|
||||
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 [containerWidth, setContainerWidth] = useState(width);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [revealed, setRevealed] = useState<boolean>(visible !== undefined ? visible : (displayMedia !== 'hide_all' && !sensitive || displayMedia === 'show_all'));
|
||||
const [buffer, setBuffer] = useState(0);
|
||||
|
||||
const setDimensions = () => {
|
||||
if (player.current) {
|
||||
const { offsetWidth } = player.current;
|
||||
|
||||
if (cacheWidth) {
|
||||
cacheWidth(offsetWidth);
|
||||
}
|
||||
|
||||
setContainerWidth(offsetWidth);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setDimensions();
|
||||
}, [player.current]);
|
||||
|
||||
useEffect(() => {
|
||||
if (video.current) {
|
||||
setVolume(video.current.volume);
|
||||
setMuted(video.current.muted);
|
||||
}
|
||||
}, [video.current]);
|
||||
|
||||
const handleClickRoot: React.MouseEventHandler = e => e.stopPropagation();
|
||||
|
||||
const handlePlay = () => {
|
||||
setPaused(false);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setPaused(true);
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
if (video.current) {
|
||||
setCurrentTime(Math.floor(video.current.currentTime));
|
||||
setDuration(Math.floor(video.current.duration));
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeMouseDown: React.MouseEventHandler = e => {
|
||||
document.addEventListener('mousemove', handleMouseVolSlide, true);
|
||||
document.addEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
document.addEventListener('touchmove', handleMouseVolSlide, true);
|
||||
document.addEventListener('touchend', handleVolumeMouseUp, true);
|
||||
|
||||
handleMouseVolSlide(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleVolumeMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseVolSlide, true);
|
||||
document.removeEventListener('mouseup', handleVolumeMouseUp, true);
|
||||
document.removeEventListener('touchmove', handleMouseVolSlide, true);
|
||||
document.removeEventListener('touchend', handleVolumeMouseUp, true);
|
||||
};
|
||||
|
||||
const handleMouseVolSlide = throttle(e => {
|
||||
if (slider.current) {
|
||||
const { x } = getPointerPosition(slider.current, e);
|
||||
|
||||
if (!isNaN(x)) {
|
||||
let slideamt = x;
|
||||
|
||||
if (x > 1) {
|
||||
slideamt = 1;
|
||||
} else if (x < 0) {
|
||||
slideamt = 0;
|
||||
}
|
||||
|
||||
if (video.current) {
|
||||
video.current.volume = slideamt;
|
||||
}
|
||||
|
||||
setVolume(slideamt);
|
||||
}
|
||||
}
|
||||
}, 60);
|
||||
|
||||
const handleMouseDown: React.MouseEventHandler = e => {
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('mouseup', handleMouseUp, true);
|
||||
document.addEventListener('touchmove', handleMouseMove, true);
|
||||
document.addEventListener('touchend', handleMouseUp, true);
|
||||
|
||||
setDragging(true);
|
||||
video.current?.pause();
|
||||
handleMouseMove(e);
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove, true);
|
||||
document.removeEventListener('mouseup', handleMouseUp, true);
|
||||
document.removeEventListener('touchmove', handleMouseMove, true);
|
||||
document.removeEventListener('touchend', handleMouseUp, true);
|
||||
|
||||
setDragging(false);
|
||||
video.current?.play();
|
||||
};
|
||||
|
||||
const handleMouseMove = throttle(e => {
|
||||
if (seek.current && video.current) {
|
||||
const { x } = getPointerPosition(seek.current, e);
|
||||
const currentTime = Math.floor(video.current.duration * x);
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
video.current.currentTime = currentTime;
|
||||
setCurrentTime(currentTime);
|
||||
}
|
||||
}
|
||||
}, 60);
|
||||
|
||||
const seekBy = (time: number) => {
|
||||
if (video.current) {
|
||||
const currentTime = video.current.currentTime + time;
|
||||
|
||||
if (!isNaN(currentTime)) {
|
||||
setCurrentTime(currentTime);
|
||||
video.current.currentTime = currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoKeyDown: React.KeyboardEventHandler = 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();
|
||||
togglePlay();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown: React.KeyboardEventHandler = e => {
|
||||
const frameTime = 1 / 25;
|
||||
|
||||
switch (e.key) {
|
||||
case 'k':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
togglePlay();
|
||||
break;
|
||||
case 'm':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleMute();
|
||||
break;
|
||||
case 'f':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
toggleFullscreen();
|
||||
break;
|
||||
case 'j':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(-10);
|
||||
break;
|
||||
case 'l':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(10);
|
||||
break;
|
||||
case ',':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(-frameTime);
|
||||
break;
|
||||
case '.':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
seekBy(frameTime);
|
||||
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') {
|
||||
exitFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const togglePlay = (e?: React.MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
setPaused(!paused);
|
||||
|
||||
if (paused) {
|
||||
video.current?.play();
|
||||
} else {
|
||||
video.current?.pause();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
if (isFullscreen()) {
|
||||
exitFullscreen();
|
||||
} else if (player.current) {
|
||||
requestFullscreen(player.current);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = useCallback(debounce(() => {
|
||||
setDimensions();
|
||||
}, 250, {
|
||||
trailing: true,
|
||||
}), [player.current, cacheWidth]);
|
||||
|
||||
const handleScroll = useCallback(throttle(() => {
|
||||
if (!video.current) return;
|
||||
|
||||
const { top, height } = video.current.getBoundingClientRect();
|
||||
const inView = (top <= (window.innerHeight || document.documentElement.clientHeight)) && (top + height >= 0);
|
||||
|
||||
if (!paused && !inView) {
|
||||
setPaused(true);
|
||||
video.current.pause();
|
||||
}
|
||||
}, 150, { trailing: true }), [video.current, paused]);
|
||||
|
||||
const handleFullscreenChange = useCallback(() => {
|
||||
setFullscreen(isFullscreen());
|
||||
}, []);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
setHovered(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
setHovered(false);
|
||||
};
|
||||
|
||||
const toggleMute = () => {
|
||||
if (video.current) {
|
||||
const muted = !video.current.muted;
|
||||
setMuted(!muted);
|
||||
video.current.muted = muted;
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReveal: React.MouseEventHandler = (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
if (onToggleVisibility) {
|
||||
onToggleVisibility();
|
||||
} else {
|
||||
setRevealed(!revealed);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
if (video.current && startTime) {
|
||||
video.current.currentTime = startTime;
|
||||
video.current.play();
|
||||
}
|
||||
};
|
||||
|
||||
const handleProgress = () => {
|
||||
if (video.current && video.current.buffered.length > 0) {
|
||||
setBuffer(video.current.buffered.end(0) / video.current.duration * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVolumeChange = () => {
|
||||
if (video.current) {
|
||||
setVolume(video.current.volume);
|
||||
setMuted(video.current.muted);
|
||||
}
|
||||
};
|
||||
|
||||
const progress = (currentTime / duration) * 100;
|
||||
const playerStyle: React.CSSProperties = {};
|
||||
|
||||
if (inline && containerWidth) {
|
||||
width = containerWidth;
|
||||
const minSize = containerWidth / (16 / 9);
|
||||
|
||||
if (isPanoramic(aspectRatio)) {
|
||||
height = Math.max(Math.floor(containerWidth / maximumAspectRatio), minSize);
|
||||
} else if (isPortrait(aspectRatio)) {
|
||||
height = Math.max(Math.floor(containerWidth / minimumAspectRatio), minSize);
|
||||
} else {
|
||||
height = Math.floor(containerWidth / aspectRatio);
|
||||
}
|
||||
|
||||
playerStyle.height = height || DEFAULT_HEIGHT;
|
||||
}
|
||||
|
||||
let warning;
|
||||
|
||||
if (sensitive) {
|
||||
warning = <FormattedMessage id='status.sensitive_warning' defaultMessage='Sensitive content' />;
|
||||
} else {
|
||||
warning = <FormattedMessage id='status.media_hidden' defaultMessage='Media hidden' />;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('fullscreenchange', handleFullscreenChange, true);
|
||||
document.addEventListener('webkitfullscreenchange', handleFullscreenChange, true);
|
||||
document.addEventListener('mozfullscreenchange', handleFullscreenChange, true);
|
||||
document.addEventListener('MSFullscreenChange', handleFullscreenChange, true);
|
||||
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
window.addEventListener('resize', handleResize, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', handleScroll);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
|
||||
document.removeEventListener('fullscreenchange', handleFullscreenChange, true);
|
||||
document.removeEventListener('webkitfullscreenchange', handleFullscreenChange, true);
|
||||
document.removeEventListener('mozfullscreenchange', handleFullscreenChange, true);
|
||||
document.removeEventListener('MSFullscreenChange', handleFullscreenChange, true);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setRevealed(true);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
video.current?.pause();
|
||||
}
|
||||
}, [revealed]);
|
||||
|
||||
return (
|
||||
<div
|
||||
role='menuitem'
|
||||
className={classNames('video-player', { 'video-player--inactive': !revealed, detailed, 'video-player--inline': inline && !fullscreen, fullscreen })}
|
||||
style={playerStyle}
|
||||
ref={player}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onClick={handleClickRoot}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
>
|
||||
<Blurhash
|
||||
hash={blurhash}
|
||||
className={classNames('media-gallery__preview', {
|
||||
'media-gallery__preview--hidden': revealed,
|
||||
})}
|
||||
/>
|
||||
|
||||
{revealed && (
|
||||
<video
|
||||
ref={video}
|
||||
src={src}
|
||||
loop
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
aria-label={alt}
|
||||
title={alt}
|
||||
width={width}
|
||||
height={height || DEFAULT_HEIGHT}
|
||||
onClick={togglePlay}
|
||||
onKeyDown={handleVideoKeyDown}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onLoadedData={handleLoadedData}
|
||||
onProgress={handleProgress}
|
||||
onVolumeChange={handleVolumeChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={classNames('spoiler-button', { 'spoiler-button--hidden': !sensitive || revealed })}>
|
||||
<button type='button' className='spoiler-button__overlay' onClick={toggleReveal}>
|
||||
<span className='spoiler-button__overlay__label'>{warning}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={classNames('video-player__controls', { active: paused || hovered })}>
|
||||
<div className='video-player__seek' onMouseDown={handleMouseDown} ref={seek}>
|
||||
<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={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={togglePlay}
|
||||
autoFocus={detailed}
|
||||
>
|
||||
<Icon src={paused ? require('@tabler/icons/player-play.svg') : require('@tabler/icons/player-pause.svg')} />
|
||||
</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={toggleMute}
|
||||
>
|
||||
<Icon src={muted ? require('@tabler/icons/volume-3.svg') : require('@tabler/icons/volume.svg')} />
|
||||
</button>
|
||||
|
||||
<div className={classNames('video-player__volume', { active: hovered })} onMouseDown={handleVolumeMouseDown} ref={slider}>
|
||||
<div className='video-player__volume__current' style={{ width: `${volume * 100}%` }} />
|
||||
<span
|
||||
className={classNames('video-player__volume__handle')}
|
||||
tabIndex={0}
|
||||
style={{ left: `${volume * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(detailed || fullscreen) && (
|
||||
<span>
|
||||
<span className='video-player__time-current'>{formatTime(currentTime)}</span>
|
||||
<span className='video-player__time-sep'>/</span>
|
||||
<span className='video-player__time-total'>{formatTime(duration)}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{link && (
|
||||
<span className='video-player__link'>{link}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='video-player__buttons right'>
|
||||
{(sensitive && !onCloseVideo) && (
|
||||
<button
|
||||
type='button'
|
||||
title={intl.formatMessage(messages.hide)}
|
||||
aria-label={intl.formatMessage(messages.hide)}
|
||||
className='player-button'
|
||||
onClick={toggleReveal}
|
||||
>
|
||||
<Icon src={require('@tabler/icons/eye-off.svg')} />
|
||||
</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={toggleFullscreen}
|
||||
>
|
||||
<Icon src={fullscreen ? require('@tabler/icons/arrows-minimize.svg') : require('@tabler/icons/arrows-maximize.svg')} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Video;
|
||||
Reference in New Issue
Block a user