From 9b534b968da2a87e2cfc0c8e62cda127f98edae1 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 26 Aug 2024 22:28:45 +0100 Subject: [PATCH 01/20] [Video] add scrubber to the web player (#4943) --- bskyweb/templates/base.html | 5 + src/components/hooks/useInteractionState.ts | 4 +- .../VideoEmbedInner/VideoWebControls.tsx | 492 ++++++++++++++---- web/index.html | 5 + 4 files changed, 392 insertions(+), 114 deletions(-) diff --git a/bskyweb/templates/base.html b/bskyweb/templates/base.html index cb2caed443..c248027982 100644 --- a/bskyweb/templates/base.html +++ b/bskyweb/templates/base.html @@ -253,6 +253,11 @@ from { opacity: 1; } to { opacity: 0; } } + + .force-no-clicks > *, + .force-no-clicks * { + pointer-events: none !important; + } {% include "scripts.html" %} diff --git a/src/components/hooks/useInteractionState.ts b/src/components/hooks/useInteractionState.ts index 653b1c10e6..67042d4a8c 100644 --- a/src/components/hooks/useInteractionState.ts +++ b/src/components/hooks/useInteractionState.ts @@ -5,10 +5,10 @@ export function useInteractionState() { const onIn = React.useCallback(() => { setState(true) - }, [setState]) + }, []) const onOut = React.useCallback(() => { setState(false) - }, [setState]) + }, []) return React.useMemo( () => ({ diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx index 7caaf3abf7..09524b91c2 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoWebControls.tsx @@ -6,17 +6,19 @@ import React, { useSyncExternalStore, } from 'react' import {Pressable, View} from 'react-native' -import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {SvgProps} from 'react-native-svg' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import type Hls from 'hls.js' -import {isIPhoneWeb} from 'platform/detection' +import {isFirefox} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {isIPhoneWeb} from '#/platform/detection' import { useAutoplayDisabled, useSetSubtitlesEnabled, useSubtitlesEnabled, -} from 'state/preferences' +} from '#/state/preferences' import {atoms as a, useTheme, web} from '#/alf' import {Button} from '#/components/Button' import {useInteractionState} from '#/components/hooks/useInteractionState' @@ -173,6 +175,50 @@ export function Controls({ toggleFullscreen() }, [drawFocus, toggleFullscreen]) + const onSeek = useCallback( + (time: number) => { + if (!videoRef.current) return + if (videoRef.current.fastSeek) { + videoRef.current.fastSeek(time) + } else { + videoRef.current.currentTime = time + } + }, + [videoRef], + ) + + const playStateBeforeSeekRef = useRef(false) + + const onSeekStart = useCallback(() => { + drawFocus() + playStateBeforeSeekRef.current = playing + pause() + }, [playing, pause, drawFocus]) + + const onSeekEnd = useCallback(() => { + if (playStateBeforeSeekRef.current) { + play() + } + }, [play]) + + const seekLeft = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime - 5, 0, duration)) + }, [onSeek, videoRef]) + + const seekRight = useCallback(() => { + if (!videoRef.current) return + // eslint-disable-next-line @typescript-eslint/no-shadow + const currentTime = videoRef.current.currentTime + // eslint-disable-next-line @typescript-eslint/no-shadow + const duration = videoRef.current.duration || 0 + onSeek(clamp(currentTime + 5, 0, duration)) + }, [onSeek, videoRef]) + const showControls = (focused && !playing) || (interactingViaKeypress ? hasFocus : hovered) @@ -197,7 +243,7 @@ export function Controls({ - - - - {formatTime(currentTime)} / {formatTime(duration)} - - {hasSubtitleTrack && ( - - )} - - {!isIPhoneWeb && ( - - )} - - {(showControls || !focused) && ( - + - {duration > 0 && ( - + + + {formatTime(currentTime)} / {formatTime(duration)} + + {hasSubtitleTrack && ( + )} - - )} + + {!isIPhoneWeb && ( + + )} + + {(buffering || error) && ( - {buffering && } {error && ( @@ -314,19 +337,278 @@ export function Controls({ An error occurred )} - + )} ) } -const btnProps = { - variant: 'ghost', - shape: 'round', - size: 'medium', - style: a.p_2xs, - hoverStyle: {backgroundColor: 'rgba(255, 255, 255, 0.1)'}, -} as const +function ControlButton({ + active, + activeLabel, + inactiveLabel, + activeIcon: ActiveIcon, + inactiveIcon: InactiveIcon, + onPress, +}: { + active: boolean + activeLabel: string + inactiveLabel: string + activeIcon: React.ComponentType> + inactiveIcon: React.ComponentType> + onPress: () => void +}) { + const t = useTheme() + return ( + + ) +} + +function Scrubber({ + duration, + currentTime, + onSeek, + onSeekEnd, + onSeekStart, + seekLeft, + seekRight, + togglePlayPause, + drawFocus, +}: { + duration: number + currentTime: number + onSeek: (time: number) => void + onSeekEnd: () => void + onSeekStart: () => void + seekLeft: () => void + seekRight: () => void + togglePlayPause: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const t = useTheme() + const [scrubberActive, setScrubberActive] = useState(false) + const { + state: hovered, + onIn: onMouseEnter, + onOut: onMouseLeave, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [seekPosition, setSeekPosition] = useState(0) + const isSeekingRef = useRef(false) + const barRef = useRef(null) + const circleRef = useRef(null) + + const seek = useCallback( + (evt: React.PointerEvent) => { + if (!barRef.current) return + const {left, width} = barRef.current.getBoundingClientRect() + const x = evt.clientX + const percent = clamp((x - left) / width, 0, 1) * duration + onSeek(percent) + setSeekPosition(percent) + }, + [duration, onSeek], + ) + + const onPointerDown = useCallback( + (evt: React.PointerEvent) => { + const target = evt.target + if (target instanceof Element) { + evt.preventDefault() + target.setPointerCapture(evt.pointerId) + isSeekingRef.current = true + seek(evt) + setScrubberActive(true) + onSeekStart() + } + }, + [seek, onSeekStart], + ) + + const onPointerMove = useCallback( + (evt: React.PointerEvent) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent) => { + const target = evt.target + if (isSeekingRef.current && target instanceof Element) { + evt.preventDefault() + target.releasePointerCapture(evt.pointerId) + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + } + }, + [onSeekEnd], + ) + + useEffect(() => { + // HACK: there's divergent browser behaviour about what to do when + // a pointerUp event is fired outside the element that captured the + // pointer. Firefox clicks on the element the mouse is over, so we have + // to make everything unclickable while seeking -sfn + if (isFirefox && scrubberActive) { + document.body.classList.add('force-no-clicks') + + const abortController = new AbortController() + const {signal} = abortController + document.documentElement.addEventListener( + 'mouseleave', + () => { + isSeekingRef.current = false + onSeekEnd() + setScrubberActive(false) + }, + {signal}, + ) + + return () => { + document.body.classList.remove('force-no-clicks') + abortController.abort() + } + } + }, [scrubberActive, onSeekEnd]) + + useEffect(() => { + if (!circleRef.current) return + if (focused) { + const abortController = new AbortController() + const {signal} = abortController + circleRef.current.addEventListener( + 'keydown', + evt => { + // space: play/pause + // arrow left: seek backward + // arrow right: seek forward + + if (evt.key === ' ') { + evt.preventDefault() + drawFocus() + togglePlayPause() + } else if (evt.key === 'ArrowLeft') { + evt.preventDefault() + drawFocus() + seekLeft() + } else if (evt.key === 'ArrowRight') { + evt.preventDefault() + drawFocus() + seekRight() + } + }, + {signal}, + ) + + return () => abortController.abort() + } + }, [focused, seekLeft, seekRight, togglePlayPause, drawFocus]) + + const progress = scrubberActive ? seekPosition : currentTime + const progressPercent = (progress / duration) * 100 + + return ( + +
+ + {currentTime && duration && ( + + )} + +
+ +
+
+
+ ) +} function formatTime(time: number) { if (isNaN(time)) { @@ -421,14 +703,6 @@ function useVideoUtils(ref: React.RefObject) { setError(false) } - const handleSeeking = () => { - setBuffering(true) - } - - const handleSeeked = () => { - setBuffering(false) - } - const handleStalled = () => { if (bufferingTimeout) clearTimeout(bufferingTimeout) bufferingTimeout = setTimeout(() => { @@ -474,12 +748,6 @@ function useVideoUtils(ref: React.RefObject) { ref.current.addEventListener('playing', handlePlaying, { signal: abortController.signal, }) - ref.current.addEventListener('seeking', handleSeeking, { - signal: abortController.signal, - }) - ref.current.addEventListener('seeked', handleSeeked, { - signal: abortController.signal, - }) ref.current.addEventListener('stalled', handleStalled, { signal: abortController.signal, }) diff --git a/web/index.html b/web/index.html index 81cbc23329..825d15968e 100644 --- a/web/index.html +++ b/web/index.html @@ -257,6 +257,11 @@ from { opacity: 1; } to { opacity: 0; } } + + .force-no-clicks > *, + .force-no-clicks * { + pointer-events: none !important; + } From b69c40da33c584edbaff3f1112aad727a3631a77 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 27 Aug 2024 22:15:59 +0100 Subject: [PATCH 02/20] add indicator of time remaining (#5000) --- .../VideoEmbedInner/TimeIndicator.tsx | 48 +++++++++++++++++++ .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 41 +++------------- .../VideoEmbedInner/VideoWebControls.tsx | 4 ++ 3 files changed, 58 insertions(+), 35 deletions(-) create mode 100644 src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx new file mode 100644 index 0000000000..4d07ee78dd --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx @@ -0,0 +1,48 @@ +import React from 'react' +import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' + +import {atoms as a, native, useTheme} from '#/alf' +import {Text} from '#/components/Typography' + +/** + * Absolutely positioned time indicator showing how many seconds are remaining + * Time is in seconds + */ +export function TimeIndicator({time}: {time: number}) { + const t = useTheme() + + if (isNaN(time)) { + return null + } + + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + + return ( + + + {minutes}:{seconds} + + + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index fa49438763..8cbf32a831 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {Pressable, View} from 'react-native' -import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' +import Animated, {FadeInDown} from 'react-native-reanimated' import {VideoPlayer, VideoView} from 'expo-video' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' @@ -10,14 +10,14 @@ import {HITSLOP_30} from '#/lib/constants' import {useAppState} from '#/lib/hooks/useAppState' import {logger} from '#/logger' import {useVideoPlayer} from '#/view/com/util/post-embeds/VideoPlayerContext' -import {android, atoms as a, useTheme} from '#/alf' +import {atoms as a, useTheme} from '#/alf' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' -import {Text} from '#/components/Typography' import { AudioCategory, PlatformInfo, } from '../../../../../../modules/expo-bluesky-swiss-army' +import {TimeIndicator} from './TimeIndicator' export function VideoEmbedInnerNative() { const player = useVideoPlayer() @@ -86,10 +86,6 @@ function Controls({ Math.floor(player.currentTime), ) - const timeRemaining = duration - currentTime - const minutes = Math.floor(timeRemaining / 60) - const seconds = String(timeRemaining % 60).padStart(2, '0') - useEffect(() => { const interval = setInterval(() => { // duration gets reset to 0 on loop @@ -143,37 +139,12 @@ function Controls({ // 1. timeRemaining is a number - was seeing NaNs // 2. duration is greater than 0 - means metadata has loaded // 3. we're less than 5 second into the video + const timeRemaining = duration - currentTime const showTime = !isNaN(timeRemaining) && duration > 0 && currentTime <= 5 return ( - {showTime && ( - - - {minutes}:{seconds} - - - )} + {showTime && } + {active && !showControls && !focused && ( + + )} Date: Wed, 28 Aug 2024 04:23:30 -0700 Subject: [PATCH 03/20] bump 1.91.0 (#5002) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8f34b8b503..4a791ca293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bsky.app", - "version": "1.90.0", + "version": "1.91.0", "private": true, "engines": { "node": ">=18" From 5ae0d40a14e7015daa0161e7e9d877690f8a339e Mon Sep 17 00:00:00 2001 From: Hailey Date: Wed, 28 Aug 2024 08:46:47 -0700 Subject: [PATCH 04/20] =?UTF-8?q?[Video]=20=F0=9F=AB=A7=20Move=20logic=20a?= =?UTF-8?q?round=20by=20platform=20(#5003)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.native.tsx | 2 +- src/App.web.tsx | 2 +- .../post-embeds/ActiveVideoNativeContext.tsx | 40 +++++++++++++++ ...oContext.tsx => ActiveVideoWebContext.tsx} | 51 +++++++++---------- src/view/com/util/post-embeds/VideoEmbed.tsx | 13 +++-- .../com/util/post-embeds/VideoEmbed.web.tsx | 4 +- .../VideoEmbedInner/VideoEmbedInnerNative.tsx | 4 +- .../util/post-embeds/VideoPlayerContext.tsx | 47 ----------------- .../post-embeds/VideoPlayerContext.web.tsx | 9 ---- 9 files changed, 77 insertions(+), 95 deletions(-) create mode 100644 src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx rename src/view/com/util/post-embeds/{ActiveVideoContext.tsx => ActiveVideoWebContext.tsx} (66%) delete mode 100644 src/view/com/util/post-embeds/VideoPlayerContext.tsx delete mode 100644 src/view/com/util/post-embeds/VideoPlayerContext.web.tsx diff --git a/src/App.native.tsx b/src/App.native.tsx index 69c7629bf8..a4282e7fbf 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -52,7 +52,7 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {TestCtrls} from '#/view/com/testing/TestCtrls' -import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' +import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' diff --git a/src/App.web.tsx b/src/App.web.tsx index 9ec792530a..69a8020c2f 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -40,7 +40,7 @@ import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' -import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' +import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import * as Toast from '#/view/com/util/Toast' import {ToastContainer} from '#/view/com/util/Toast.web' import {Shell} from '#/view/shell/index' diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx new file mode 100644 index 0000000000..77616d7880 --- /dev/null +++ b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx @@ -0,0 +1,40 @@ +import React from 'react' +import {useVideoPlayer, VideoPlayer} from 'expo-video' + +import {isNative} from '#/platform/detection' + +const Context = React.createContext<{ + activeSource: string | null + setActiveSource: (src: string) => void + player: VideoPlayer +} | null>(null) + +export function Provider({children}: {children: React.ReactNode}) { + if (!isNative) { + throw new Error('ActiveVideoProvider may only be used on native.') + } + + const [activeSource, setActiveSource] = React.useState('') + + const player = useVideoPlayer(activeSource, p => { + p.muted = true + p.loop = true + p.play() + }) + + return ( + + {children} + + ) +} + +export function useActiveVideoNative() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useActiveVideoNative must be used within a ActiveVideoNativeProvider', + ) + } + return context +} diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx similarity index 66% rename from src/view/com/util/post-embeds/ActiveVideoContext.tsx rename to src/view/com/util/post-embeds/ActiveVideoWebContext.tsx index d18dfc0908..bc43e997c7 100644 --- a/src/view/com/util/post-embeds/ActiveVideoContext.tsx +++ b/src/view/com/util/post-embeds/ActiveVideoWebContext.tsx @@ -8,19 +8,21 @@ import React, { } from 'react' import {useWindowDimensions} from 'react-native' -import {isNative} from '#/platform/detection' -import {VideoPlayerProvider} from './VideoPlayerContext' +import {isNative, isWeb} from '#/platform/detection' -const ActiveVideoContext = React.createContext<{ +const Context = React.createContext<{ activeViewId: string | null - setActiveView: (viewId: string, src: string) => void + setActiveView: (viewId: string) => void sendViewPosition: (viewId: string, y: number) => void } | null>(null) -export function ActiveVideoProvider({children}: {children: React.ReactNode}) { +export function Provider({children}: {children: React.ReactNode}) { + if (!isWeb) { + throw new Error('ActiveVideoWebContext may onl be used on web.') + } + const [activeViewId, setActiveViewId] = useState(null) const activeViewLocationRef = useRef(Infinity) - const [source, setSource] = useState(null) const {height: windowHeight} = useWindowDimensions() // minimising re-renders by using refs @@ -31,9 +33,8 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) { }, [activeViewId]) const setActiveView = useCallback( - (viewId: string, src: string) => { + (viewId: string) => { setActiveViewId(viewId) - setSource(src) manuallySetRef.current = true // we don't know the exact position, but it's definitely on screen // so just guess that it's in the middle. Any value is fine @@ -88,32 +89,26 @@ export function ActiveVideoProvider({children}: {children: React.ReactNode}) { [activeViewId, setActiveView, sendViewPosition], ) - return ( - - - {children} - - - ) + return {children} } -export function useActiveVideoView({source}: {source: string}) { - const context = React.useContext(ActiveVideoContext) +export function useActiveVideoWeb() { + const context = React.useContext(Context) if (!context) { - throw new Error('useActiveVideo must be used within a ActiveVideoProvider') + throw new Error( + 'useActiveVideoWeb must be used within a ActiveVideoWebProvider', + ) } + + const {activeViewId, setActiveView, sendViewPosition} = context const id = useId() return { - active: context.activeViewId === id, - setActive: useCallback( - () => context.setActiveView(id, source), - [context, id, source], - ), - currentActiveView: context.activeViewId, - sendPosition: useCallback( - (y: number) => context.sendViewPosition(id, y), - [context, id], - ), + active: activeViewId === id, + setActive: () => { + setActiveView(id) + }, + currentActiveView: activeViewId, + sendPosition: (y: number) => sendViewPosition(id, y), } } diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index 4e2909f40b..b2bcd8511c 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -9,12 +9,13 @@ import {Button, ButtonIcon} from '#/components/Button' import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {ErrorBoundary} from '../ErrorBoundary' -import {useActiveVideoView} from './ActiveVideoContext' +import {useActiveVideoNative} from './ActiveVideoNativeContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' export function VideoEmbed({source}: {source: string}) { const t = useTheme() - const {active, setActive} = useActiveVideoView({source}) + const {activeSource, setActiveSource} = useActiveVideoNative() + const isActive = source === activeSource const {_} = useLingui() const [key, setKey] = useState(0) @@ -40,15 +41,17 @@ export function VideoEmbed({source}: {source: string}) { enabled={true} onChangeStatus={isActive => { if (isActive) { - setActive() + setActiveSource(source) } }}> - {active ? ( + {isActive ? ( ) : ( + <> + {embed.alt} + + )} diff --git a/src/view/com/util/post-embeds/VideoEmbed.web.tsx b/src/view/com/util/post-embeds/VideoEmbed.web.tsx index c0d774abeb..409f2c7bab 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.web.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.web.tsx @@ -1,19 +1,23 @@ import React, {useCallback, useEffect, useRef, useState} from 'react' import {View} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' import {Trans} from '@lingui/macro' +import {clamp} from '#/lib/numbers' +import {useGate} from '#/lib/statsig/statsig' import { HLSUnsupportedError, VideoEmbedInnerWeb, -} from 'view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' +} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb' import {atoms as a, useTheme} from '#/alf' import {ErrorBoundary} from '../ErrorBoundary' import {useActiveVideoWeb} from './ActiveVideoWebContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' -export function VideoEmbed({source}: {source: string}) { +export function VideoEmbed({embed}: {embed: AppBskyEmbedVideo.View}) { const t = useTheme() const ref = useRef(null) + const gate = useGate() const {active, setActive, sendPosition, currentActiveView} = useActiveVideoWeb() const [onScreen, setOnScreen] = useState(false) @@ -43,12 +47,25 @@ export function VideoEmbed({source}: {source: string}) { [key], ) + if (!gate('videos')) { + return null + } + + let aspectRatio = 16 / 9 + + if (embed.aspectRatio) { + const {width, height} = embed.aspectRatio + // min: 3/1, max: square + aspectRatio = clamp(width / height, 1 / 1, 3 / 1) + } + return ( @@ -61,7 +78,7 @@ export function VideoEmbed({source}: {source: string}) { sendPosition={sendPosition} isAnyViewActive={currentActiveView !== null}> (null) const isScreenFocused = useIsFocused() @@ -47,13 +54,23 @@ export function VideoEmbedInnerNative() { ref.current?.enterFullscreen() }, []) + let aspectRatio = 16 / 9 + + if (embed.aspectRatio) { + const {width, height} = embed.aspectRatio + aspectRatio = width / height + aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) + } + return ( - + { PlatformInfo.setAudioCategory(AudioCategory.Playback) PlatformInfo.setAudioActive(true) @@ -65,13 +82,17 @@ export function VideoEmbedInnerNative() { player.muted = true if (!player.playing) player.play() }} + accessibilityLabel={ + embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) + } + accessibilityHint="" /> - + ) } -function Controls({ +function VideoControls({ player, enterFullscreen, }: { diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx index c0021d9bb7..77295c00c7 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -1,31 +1,27 @@ -import React, {useEffect, useRef, useState} from 'react' +import React, {useEffect, useId, useRef, useState} from 'react' import {View} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' import Hls from 'hls.js' import {atoms as a} from '#/alf' import {Controls} from './VideoWebControls' export function VideoEmbedInnerWeb({ - source, + embed, active, setActive, onScreen, }: { - source: string - active?: boolean - setActive?: () => void - onScreen?: boolean + embed: AppBskyEmbedVideo.View + active: boolean + setActive: () => void + onScreen: boolean }) { - if (active == null || setActive == null || onScreen == null) { - throw new Error( - 'active, setActive, and onScreen are required VideoEmbedInner props on web.', - ) - } - const containerRef = useRef(null) const ref = useRef(null) const [focused, setFocused] = useState(false) const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + const figId = useId() const hlsRef = useRef(undefined) @@ -37,7 +33,7 @@ export function VideoEmbedInnerWeb({ hlsRef.current = hls hls.attachMedia(ref.current) - hls.loadSource(source) + hls.loadSource(embed.playlist) // initial value, later on it's managed by Controls hls.autoLevelCapping = 0 @@ -53,29 +49,40 @@ export function VideoEmbedInnerWeb({ hls.detachMedia() hls.destroy() } - }, [source]) + }, [embed.playlist]) return ( - -
-