From 0b7416b6e5d142cb87a6ab56212bec3c78e34607 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 30 Sep 2022 19:33:46 -0500 Subject: [PATCH 1/3] Video: convert to TSX+FC --- app/soapbox/features/video/index.js | Bin 19326 -> 0 bytes app/soapbox/features/video/index.tsx | 636 +++++++++++++++++++++++++++ 2 files changed, 636 insertions(+) delete mode 100644 app/soapbox/features/video/index.js create mode 100644 app/soapbox/features/video/index.tsx diff --git a/app/soapbox/features/video/index.js b/app/soapbox/features/video/index.js deleted file mode 100644 index 1d6b55ff68a744f416f5b554825a19080025e223..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 19326 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 DpHdzy diff --git a/app/soapbox/features/video/index.tsx b/app/soapbox/features/video/index.tsx new file mode 100644 index 0000000000..503d0105c1 --- /dev/null +++ b/app/soapbox/features/video/index.tsx @@ -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 = ({ + width, + visible = false, + sensitive = false, + detailed = false, + cacheWidth, + onToggleVisibility, + startTime, + src, + height, + alt, + onCloseVideo, + inline, + aspectRatio = 0, + link, + blurhash, +}) => { + const intl = useIntl(); + const settings = useSettings(); + const displayMedia = settings.get('displayMedia') as string | undefined; + + const player = useRef(null); + const video = useRef(null); + const seek = useRef(null); + const slider = useRef(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(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 { + 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 = ; + } else { + warning = ; + } + + 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 ( +
+ + + {revealed && ( +