diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx index 43e730b3cc..49a9878dbc 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx @@ -1,23 +1,9 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' -import {Pressable, View} from 'react-native' -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' +import {View} from 'react-native' import Hls from 'hls.js' -import {useAutoplayDisabled} from '#/state/preferences' -import {atoms as a, useTheme, web} from '#/alf' -import {Button} from '#/components/Button' -import {useInteractionState} from '#/components/hooks/useInteractionState' -import { - ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, - ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, -} from '#/components/icons/ArrowsDiagonal' -import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' -import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' -import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' -import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' -import {Text} from '#/components/Typography' +import {atoms as a} from '#/alf' +import {Controls} from './VideoWebControls' export function VideoEmbedInner({ active, @@ -167,387 +153,3 @@ export function VideoPlayer({ ) } - -function Controls({ - videoRef, - active, - setActive, - focused, - setFocused, - onScreen, - enterFullscreen, -}: { - videoRef: React.RefObject - active: boolean - setActive: () => void - focused: boolean - setFocused: (focused: boolean) => void - onScreen: boolean - enterFullscreen: () => void -}) { - const { - play, - pause, - playing, - muted, - toggleMute, - togglePlayPause, - currentTime, - duration, - } = useVideoUtils(videoRef) - const t = useTheme() - const {_} = useLingui() - const { - state: hovered, - onIn: onMouseEnter, - onOut: onMouseLeave, - } = useInteractionState() - const isFullscreen = useFullscreen() - const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() - const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) - const onKeyDown = useCallback(() => { - setInteractingViaKeypress(true) - }, []) - useEffect(() => { - if (interactingViaKeypress) { - document.addEventListener('click', () => setInteractingViaKeypress(false)) - return () => { - document.removeEventListener('click', () => - setInteractingViaKeypress(false), - ) - } - } - }, [interactingViaKeypress]) - - const autoplayDisabled = useAutoplayDisabled() - - // autoplay - useEffect(() => { - if (active && !autoplayDisabled) { - play() - } - return () => { - pause() - setFocused(false) - } - }, [active, play, pause, setFocused, autoplayDisabled]) - - // pause when offscreen - useEffect(() => { - if (!onScreen) { - pause() - } - }, [onScreen, pause]) - - const onPressPlayPause = useCallback(() => { - if (!focused) { - if (!active) { - setActive() - } - setFocused(true) - } else { - togglePlayPause() - } - }, [togglePlayPause, setActive, setFocused, active, focused]) - - const showControls = - (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) - - return ( -
setInteractingViaKeypress(false)} - onMouseEnter={onMouseEnter} - onMouseLeave={onMouseLeave} - onFocus={onFocus} - onBlur={onBlur} - onKeyDown={onKeyDown}> - - - - - - {formatTime(currentTime)} / {formatTime(duration)} - - - {/* TODO: find workaround for iOS Safari */} - - - {(showControls || !focused) && ( - - {duration > 0 && ( - - )} - - )} -
- ) -} - -function formatTime(time: number) { - if (isNaN(time)) { - return '--' - } - - time = Math.round(time) - - const minutes = Math.floor(time / 60) - const seconds = String(time % 60).padStart(2, '0') - - return `${minutes}:${seconds}` -} - -function useVideoUtils(ref: React.RefObject) { - const [playing, setPlaying] = useState(false) - const [muted, setMuted] = useState(true) - const [currentTime, setCurrentTime] = useState(0) - const [duration, setDuration] = useState(0) - const playWhenReadyRef = useRef(false) - - useEffect(() => { - if (!ref.current) return - let current = ref.current - - function round(num: number) { - return Math.round(num * 100) / 100 - } - - // Initial values - setCurrentTime(round(current.currentTime) || 0) - setDuration(round(current.duration) || 0) - setMuted(current.muted) - setPlaying(!current.paused) - - const handleTimeUpdate = () => { - if (!current) return - setCurrentTime(round(current.currentTime) || 0) - } - - const handleDurationChange = () => { - if (!current) return - setDuration(round(current.duration) || 0) - } - - const handlePlay = () => { - setPlaying(true) - } - - const handlePause = () => { - setPlaying(false) - } - - const handleVolumeChange = () => { - if (!current) return - setMuted(current.muted) - } - - const handleCanPlay = () => { - if (!current) return - if (playWhenReadyRef.current) { - current.play() - playWhenReadyRef.current = false - } - } - - ref.current.addEventListener('timeupdate', handleTimeUpdate) - ref.current.addEventListener('durationchange', handleDurationChange) - ref.current.addEventListener('play', handlePlay) - ref.current.addEventListener('pause', handlePause) - ref.current.addEventListener('volumechange', handleVolumeChange) - ref.current.addEventListener('ended', handlePause) - ref.current.addEventListener('error', handlePause) - ref.current.addEventListener('canplay', handleCanPlay) - - return () => { - current.removeEventListener('timeupdate', handleTimeUpdate) - current.removeEventListener('durationchange', handleDurationChange) - current.removeEventListener('play', handlePlay) - current.removeEventListener('pause', handlePause) - current.removeEventListener('volumechange', handleVolumeChange) - current.removeEventListener('ended', handlePause) - current.removeEventListener('error', handlePause) - current.removeEventListener('canplay', handleCanPlay) - } - }, [ref]) - - const play = useCallback(() => { - if (!ref.current) return - - if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { - playWhenReadyRef.current = true - } else { - const promise = ref.current.play() - if (promise !== undefined) { - promise.catch(error => { - console.error('Error playing video:', error) - }) - } - } - }, [ref]) - - const pause = useCallback(() => { - if (!ref.current) return - - ref.current.pause() - }, [ref]) - - const togglePlayPause = useCallback(() => { - if (!ref.current) return - - if (ref.current.paused) { - play() - } else { - pause() - } - }, [ref, play, pause]) - - const mute = useCallback(() => { - if (!ref.current) return - - ref.current.muted = true - }, [ref]) - - const unmute = useCallback(() => { - if (!ref.current) return - - ref.current.muted = false - }, [ref]) - - const toggleMute = useCallback(() => { - if (!ref.current) return - - ref.current.muted = !ref.current.muted - }, [ref]) - - return { - play, - pause, - togglePlayPause, - duration, - currentTime, - playing, - muted, - mute, - unmute, - toggleMute, - } -} - -function useFullscreen() { - const [isFullscreen, setIsFullscreen] = useState( - Boolean(document.fullscreenElement), - ) - - useEffect(() => { - const handleFullscreenChange = () => { - setIsFullscreen(Boolean(document.fullscreenElement)) - } - - document.addEventListener('fullscreenchange', handleFullscreenChange) - - return () => { - document.removeEventListener('fullscreenchange', handleFullscreenChange) - } - }, []) - - return isFullscreen -} diff --git a/src/view/com/util/post-embeds/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoWebControls.tsx new file mode 100644 index 0000000000..8a4e634caa --- /dev/null +++ b/src/view/com/util/post-embeds/VideoWebControls.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export function Controls({}: { + videoRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + enterFullscreen: () => void +}): React.ReactElement { + throw new Error('Web-only component') +} diff --git a/src/view/com/util/post-embeds/VideoWebControls.web.tsx b/src/view/com/util/post-embeds/VideoWebControls.web.tsx new file mode 100644 index 0000000000..f8b34eee8f --- /dev/null +++ b/src/view/com/util/post-embeds/VideoWebControls.web.tsx @@ -0,0 +1,480 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useAutoplayDisabled} from '#/state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {Button} from '#/components/Button' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import { + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, +} from '#/components/icons/ArrowsDiagonal' +import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' + +export function Controls({ + videoRef, + active, + setActive, + focused, + setFocused, + onScreen, + enterFullscreen, +}: { + videoRef: React.RefObject + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + enterFullscreen: () => void +}) { + const { + play, + pause, + playing, + muted, + toggleMute, + togglePlayPause, + currentTime, + duration, + buffering, + error, + } = useVideoUtils(videoRef) + const t = useTheme() + const {_} = useLingui() + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const isFullscreen = useFullscreen() + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const onKeyDown = useCallback(() => { + setInteractingViaKeypress(true) + }, []) + useEffect(() => { + if (interactingViaKeypress) { + document.addEventListener('click', () => setInteractingViaKeypress(false)) + return () => { + document.removeEventListener('click', () => + setInteractingViaKeypress(false), + ) + } + } + }, [interactingViaKeypress]) + + const autoplayDisabled = useAutoplayDisabled() + + // autoplay + useEffect(() => { + if (active && !autoplayDisabled) { + play() + } + return () => { + pause() + setFocused(false) + } + }, [active, play, pause, setFocused, autoplayDisabled]) + + // pause when offscreen + useEffect(() => { + if (!onScreen) { + pause() + } + }, [onScreen, pause]) + + const onPressPlayPause = useCallback(() => { + if (!focused) { + if (!active) { + setActive() + } + setFocused(true) + } else { + togglePlayPause() + } + }, [togglePlayPause, setActive, setFocused, active, focused]) + + const showControls = + (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) + + return ( +
setInteractingViaKeypress(false)} + onMouseEnter={onMouseEnter} + onMouseLeave={onMouseLeave} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + + + + + + {formatTime(currentTime)} / {formatTime(duration)} + + + {/* TODO: find workaround for iOS Safari */} + + + {(showControls || !focused) && ( + + {duration > 0 && ( + + )} + + )} + {(buffering || error) && ( + + {buffering && } + {error && ( + + An error occurred + + )} + + )} +
+ ) +} + +function formatTime(time: number) { + if (isNaN(time)) { + return '--' + } + + time = Math.round(time) + + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + + return `${minutes}:${seconds}` +} + +function useVideoUtils(ref: React.RefObject) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [duration, setDuration] = useState(0) + const [buffering, setBuffering] = useState(false) + const [error, setError] = useState(false) + const playWhenReadyRef = useRef(false) + + useEffect(() => { + if (!ref.current) return + let current = ref.current + + function round(num: number) { + return Math.round(num * 100) / 100 + } + + // Initial values + setCurrentTime(round(current.currentTime) || 0) + setDuration(round(current.duration) || 0) + setMuted(current.muted) + setPlaying(!current.paused) + + const handleTimeUpdate = () => { + if (!current) return + setCurrentTime(round(current.currentTime) || 0) + } + + const handleDurationChange = () => { + if (!current) return + setDuration(round(current.duration) || 0) + } + + const handlePlay = () => { + setPlaying(true) + } + + const handlePause = () => { + setPlaying(false) + } + + const handleVolumeChange = () => { + if (!current) return + setMuted(current.muted) + } + + const handleError = () => { + setError(true) + } + + const handleCanPlay = () => { + setBuffering(false) + + if (!current) return + if (playWhenReadyRef.current) { + current.play() + playWhenReadyRef.current = false + } + } + + const handleCanPlayThrough = () => { + setBuffering(false) + } + + const handleWaiting = () => { + setBuffering(true) + } + + const handlePlaying = () => { + setError(false) + setBuffering(false) + } + + const handleSeeking = () => { + setBuffering(true) + } + + const handleSeeked = () => { + setError(false) + setBuffering(false) + } + + const handleStalled = () => { + setBuffering(true) + } + + const handleEnded = () => { + setError(false) + setBuffering(false) + setPlaying(false) + } + + ref.current.addEventListener('timeupdate', handleTimeUpdate) + ref.current.addEventListener('durationchange', handleDurationChange) + ref.current.addEventListener('play', handlePlay) + ref.current.addEventListener('pause', handlePause) + ref.current.addEventListener('volumechange', handleVolumeChange) + ref.current.addEventListener('ended', handlePause) + ref.current.addEventListener('error', handleError) + ref.current.addEventListener('canplay', handleCanPlay) + ref.current.addEventListener('canplaythrough', handleCanPlayThrough) + ref.current.addEventListener('waiting', handleWaiting) + ref.current.addEventListener('playing', handlePlaying) + ref.current.addEventListener('seeking', handleSeeking) + ref.current.addEventListener('seeked', handleSeeked) + ref.current.addEventListener('stalled', handleStalled) + ref.current.addEventListener('ended', handleEnded) + + return () => { + current.removeEventListener('timeupdate', handleTimeUpdate) + current.removeEventListener('durationchange', handleDurationChange) + current.removeEventListener('play', handlePlay) + current.removeEventListener('pause', handlePause) + current.removeEventListener('volumechange', handleVolumeChange) + current.removeEventListener('ended', handlePause) + current.removeEventListener('error', handleError) + current.removeEventListener('canplay', handleCanPlay) + current.removeEventListener('canplaythrough', handleCanPlayThrough) + current.removeEventListener('waiting', handleWaiting) + current.removeEventListener('playing', handlePlaying) + current.removeEventListener('seeking', handleSeeking) + current.removeEventListener('seeked', handleSeeked) + current.removeEventListener('stalled', handleStalled) + current.removeEventListener('ended', handleEnded) + } + }, [ref]) + + const play = useCallback(() => { + if (!ref.current) return + + if (ref.current.ended) { + ref.current.currentTime = 0 + } + + if (ref.current.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + playWhenReadyRef.current = true + } else { + const promise = ref.current.play() + if (promise !== undefined) { + promise.catch(err => { + console.error('Error playing video:', err) + }) + } + } + }, [ref]) + + const pause = useCallback(() => { + if (!ref.current) return + + ref.current.pause() + }, [ref]) + + const togglePlayPause = useCallback(() => { + if (!ref.current) return + + if (ref.current.paused) { + play() + } else { + pause() + } + }, [ref, play, pause]) + + const mute = useCallback(() => { + if (!ref.current) return + + ref.current.muted = true + }, [ref]) + + const unmute = useCallback(() => { + if (!ref.current) return + + ref.current.muted = false + }, [ref]) + + const toggleMute = useCallback(() => { + if (!ref.current) return + + ref.current.muted = !ref.current.muted + }, [ref]) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + mute, + unmute, + toggleMute, + buffering, + error, + } +} + +function useFullscreen() { + const [isFullscreen, setIsFullscreen] = useState( + Boolean(document.fullscreenElement), + ) + + useEffect(() => { + const handleFullscreenChange = () => { + setIsFullscreen(Boolean(document.fullscreenElement)) + } + + document.addEventListener('fullscreenchange', handleFullscreenChange) + + return () => { + document.removeEventListener('fullscreenchange', handleFullscreenChange) + } + }, []) + + return isFullscreen +}