diff options
author | Eric Bailey <git@esb.lol> | 2025-06-13 12:05:41 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2025-06-13 12:05:41 -0500 |
commit | 45f0f7eefecae1922c2f30d4e7760d2b93b1ae56 (patch) | |
tree | a2fd6917867f18fe334b54dd3289775c2930bc85 /src/components/Post/Embed/VideoEmbed | |
parent | ba0f5a9bdef5bd0447ded23cab1af222b65511cc (diff) | |
download | voidsky-45f0f7eefecae1922c2f30d4e7760d2b93b1ae56.tar.zst |
Port post embeds to new arch (#7408)
* Direct port of embeds to new arch (cherry picked from commit cc3fa1f6cea396dd9222486c633a508bfee1ecd6) * Re-org * Split out ListEmbed and FeedEmbed * Split out ImageEmbed * DRY up a bit * Port over ExternalLinkEmbed * Port over Player and Gif embeds * Migrate ComposerReplyTo * Replace other usages of old post-embeds * Migrate view contexts * Copy pasta VideoEmbed * Copy pasta GifEmbed * Swap in new file location * Clean up * Fix up native * Add back in correct moderation on List and Feed embeds * Format * Prettier * delete old video utils * move bandwidth-estimate.ts * Remove log * Add LazyQuoteEmbed for composer use * Clean up unused things * Remove remaining items * Prettier * Fix imports * Handle nested quotes same as prod * Add back silenced error handling * Fix lint --------- Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src/components/Post/Embed/VideoEmbed')
17 files changed, 2254 insertions, 0 deletions
diff --git a/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx new file mode 100644 index 000000000..a038403b2 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/ActiveVideoWebContext.tsx @@ -0,0 +1,114 @@ +import React, { + useCallback, + useEffect, + useId, + useMemo, + useRef, + useState, +} from 'react' +import {useWindowDimensions} from 'react-native' + +import {isNative, isWeb} from '#/platform/detection' + +const Context = React.createContext<{ + activeViewId: string | null + setActiveView: (viewId: string) => void + sendViewPosition: (viewId: string, y: number) => void +} | null>(null) + +export function Provider({children}: {children: React.ReactNode}) { + if (!isWeb) { + throw new Error('ActiveVideoWebContext may only be used on web.') + } + + const [activeViewId, setActiveViewId] = useState<string | null>(null) + const activeViewLocationRef = useRef(Infinity) + const {height: windowHeight} = useWindowDimensions() + + // minimising re-renders by using refs + const manuallySetRef = useRef(false) + const activeViewIdRef = useRef(activeViewId) + useEffect(() => { + activeViewIdRef.current = activeViewId + }, [activeViewId]) + + const setActiveView = useCallback( + (viewId: string) => { + setActiveViewId(viewId) + 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 + // so long as it's not offscreen + activeViewLocationRef.current = windowHeight / 2 + }, + [windowHeight], + ) + + const sendViewPosition = useCallback( + (viewId: string, y: number) => { + if (isNative) return + + if (viewId === activeViewIdRef.current) { + activeViewLocationRef.current = y + } else { + if ( + distanceToIdealPosition(y) < + distanceToIdealPosition(activeViewLocationRef.current) + ) { + // if the old view was manually set, only usurp if the old view is offscreen + if ( + manuallySetRef.current && + withinViewport(activeViewLocationRef.current) + ) { + return + } + + setActiveViewId(viewId) + activeViewLocationRef.current = y + manuallySetRef.current = false + } + } + + function distanceToIdealPosition(yPos: number) { + return Math.abs(yPos - windowHeight / 2.5) + } + + function withinViewport(yPos: number) { + return yPos > 0 && yPos < windowHeight + } + }, + [windowHeight], + ) + + const value = useMemo( + () => ({ + activeViewId, + setActiveView, + sendViewPosition, + }), + [activeViewId, setActiveView, sendViewPosition], + ) + + return <Context.Provider value={value}>{children}</Context.Provider> +} + +export function useActiveVideoWeb() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useActiveVideoWeb must be used within a ActiveVideoWebProvider', + ) + } + + const {activeViewId, setActiveView, sendViewPosition} = context + const id = useId() + + return { + active: activeViewId === id, + setActive: () => { + setActiveView(id) + }, + currentActiveView: activeViewId, + sendPosition: (y: number) => sendViewPosition(id, y), + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx new file mode 100644 index 000000000..95401309f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/TimeIndicator.tsx @@ -0,0 +1,64 @@ +import {StyleProp, ViewStyle} from 'react-native' +import {View} from 'react-native' +import {msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, 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, + style, +}: { + time: number + style?: StyleProp<ViewStyle> +}) { + const t = useTheme() + const {_} = useLingui() + + if (isNaN(time)) { + return null + } + + const minutes = Math.floor(time / 60) + const seconds = String(time % 60).padStart(2, '0') + + return ( + <View + pointerEvents="none" + accessibilityLabel={_( + msg`Time remaining: ${plural(Number(time) || 0, { + one: '# second', + other: '# seconds', + })}`, + )} + accessibilityHint="" + style={[ + { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + left: 6, + bottom: 6, + minHeight: 21, + }, + a.absolute, + a.justify_center, + style, + ]}> + <Text + style={[ + {color: t.palette.white, fontSize: 12, fontVariant: ['tabular-nums']}, + a.font_bold, + {lineHeight: 1.25}, + ]}> + {`${minutes}:${seconds}`} + </Text> + </View> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx new file mode 100644 index 000000000..88879d45a --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -0,0 +1,210 @@ +import React, {useRef} from 'react' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' +import {BlueskyVideoView} from '@haileyok/bluesky-video' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {HITSLOP_30} from '#/lib/constants' +import {useAutoplayDisabled} from '#/state/preferences' +import {atoms as a, useTheme} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +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 {MediaInsetBorder} from '#/components/MediaInsetBorder' +import {useVideoMuteState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {TimeIndicator} from './TimeIndicator' + +export const VideoEmbedInnerNative = React.forwardRef( + function VideoEmbedInnerNative( + { + embed, + setStatus, + setIsLoading, + setIsActive, + }: { + embed: AppBskyEmbedVideo.View + setStatus: (status: 'playing' | 'paused') => void + setIsLoading: (isLoading: boolean) => void + setIsActive: (isActive: boolean) => void + }, + ref: React.Ref<{togglePlayback: () => void}>, + ) { + const {_} = useLingui() + const videoRef = useRef<BlueskyVideoView>(null) + const autoplayDisabled = useAutoplayDisabled() + const isWithinMessage = useIsWithinMessage() + const [muted, setMuted] = useVideoMuteState() + + const [isPlaying, setIsPlaying] = React.useState(false) + const [timeRemaining, setTimeRemaining] = React.useState(0) + const [error, setError] = React.useState<string>() + + React.useImperativeHandle(ref, () => ({ + togglePlayback: () => { + videoRef.current?.togglePlayback() + }, + })) + + if (error) { + throw new Error(error) + } + + return ( + <View style={[a.flex_1, a.relative]}> + <BlueskyVideoView + url={embed.playlist} + autoplay={!autoplayDisabled && !isWithinMessage} + beginMuted={autoplayDisabled ? false : muted} + style={[a.rounded_sm]} + onActiveChange={e => { + setIsActive(e.nativeEvent.isActive) + }} + onLoadingChange={e => { + setIsLoading(e.nativeEvent.isLoading) + }} + onMutedChange={e => { + setMuted(e.nativeEvent.isMuted) + }} + onStatusChange={e => { + setStatus(e.nativeEvent.status) + setIsPlaying(e.nativeEvent.status === 'playing') + }} + onTimeRemainingChange={e => { + setTimeRemaining(e.nativeEvent.timeRemaining) + }} + onError={e => { + setError(e.nativeEvent.error) + }} + ref={videoRef} + accessibilityLabel={ + embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) + } + accessibilityHint="" + /> + <VideoControls + enterFullscreen={() => { + videoRef.current?.enterFullscreen(true) + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + <MediaInsetBorder /> + </View> + ) + }, +) + +function VideoControls({ + enterFullscreen, + toggleMuted, + togglePlayback, + timeRemaining, + isPlaying, +}: { + enterFullscreen: () => void + toggleMuted: () => void + togglePlayback: () => void + timeRemaining: number + isPlaying: boolean +}) { + const {_} = useLingui() + const t = useTheme() + const [muted] = useVideoMuteState() + + // show countdown when: + // 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 showTime = !isNaN(timeRemaining) + + return ( + <View style={[a.absolute, a.inset_0]}> + <Pressable + onPress={enterFullscreen} + style={a.flex_1} + accessibilityLabel={_(msg`Video`)} + accessibilityHint={_(msg`Enters full screen`)} + accessibilityRole="button" + /> + <ControlButton + onPress={togglePlayback} + label={isPlaying ? _(msg`Pause`) : _(msg`Play`)} + accessibilityHint={_(msg`Plays or pauses the video`)} + style={{left: 6}}> + {isPlaying ? ( + <PauseIcon width={13} fill={t.palette.white} /> + ) : ( + <PlayIcon width={13} fill={t.palette.white} /> + )} + </ControlButton> + {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />} + + <ControlButton + onPress={toggleMuted} + label={ + muted + ? _(msg({message: `Unmute`, context: 'video'})) + : _(msg({message: `Mute`, context: 'video'})) + } + accessibilityHint={_(msg`Toggles the sound`)} + style={{right: 6}}> + {muted ? ( + <MuteIcon width={13} fill={t.palette.white} /> + ) : ( + <UnmuteIcon width={13} fill={t.palette.white} /> + )} + </ControlButton> + </View> + ) +} + +function ControlButton({ + onPress, + children, + label, + accessibilityHint, + style, +}: { + onPress: () => void + children: React.ReactNode + label: string + accessibilityHint: string + style?: StyleProp<ViewStyle> +}) { + return ( + <View + style={[ + a.absolute, + a.rounded_full, + a.justify_center, + { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + paddingHorizontal: 4, + paddingVertical: 4, + bottom: 6, + minHeight: 21, + minWidth: 21, + }, + style, + ]}> + <Pressable + onPress={onPress} + style={a.flex_1} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + accessibilityRole="button" + hitSlop={HITSLOP_30}> + {children} + </Pressable> + </View> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx new file mode 100644 index 000000000..2760c7faf --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerNative.web.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerNative() { + throw new Error('VideoEmbedInnerNative may not be used on web.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx new file mode 100644 index 000000000..8664aae14 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.native.tsx @@ -0,0 +1,3 @@ +export function VideoEmbedInnerWeb() { + throw new Error('VideoEmbedInnerWeb may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx new file mode 100644 index 000000000..ce3a7b2c9 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb.tsx @@ -0,0 +1,307 @@ +import {useEffect, useId, useRef, useState} from 'react' +import {View} from 'react-native' +import {type AppBskyEmbedVideo} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type * as HlsTypes from 'hls.js' + +import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' +import {atoms as a} from '#/alf' +import {MediaInsetBorder} from '#/components/MediaInsetBorder' +import * as BandwidthEstimate from './bandwidth-estimate' +import {Controls} from './web-controls/VideoControls' + +export function VideoEmbedInnerWeb({ + embed, + active, + setActive, + onScreen, + lastKnownTime, +}: { + embed: AppBskyEmbedVideo.View + active: boolean + setActive: () => void + onScreen: boolean + lastKnownTime: React.MutableRefObject<number | undefined> +}) { + const containerRef = useRef<HTMLDivElement>(null) + const videoRef = useRef<HTMLVideoElement>(null) + const [focused, setFocused] = useState(false) + const [hasSubtitleTrack, setHasSubtitleTrack] = useState(false) + const [hlsLoading, setHlsLoading] = useState(false) + const figId = useId() + const {_} = useLingui() + + // send error up to error boundary + const [error, setError] = useState<Error | null>(null) + if (error) { + throw error + } + + const hlsRef = useHLS({ + playlist: embed.playlist, + setHasSubtitleTrack, + setError, + videoRef, + setHlsLoading, + }) + + useEffect(() => { + if (lastKnownTime.current && videoRef.current) { + videoRef.current.currentTime = lastKnownTime.current + } + }, [lastKnownTime]) + + return ( + <View + style={[a.flex_1, a.rounded_md, a.overflow_hidden]} + accessibilityLabel={_(msg`Embedded video player`)} + accessibilityHint=""> + <div ref={containerRef} style={{height: '100%', width: '100%'}}> + <figure style={{margin: 0, position: 'absolute', inset: 0}}> + <video + ref={videoRef} + poster={embed.thumbnail} + style={{width: '100%', height: '100%', objectFit: 'contain'}} + playsInline + preload="none" + muted={!focused} + aria-labelledby={embed.alt ? figId : undefined} + onTimeUpdate={e => { + lastKnownTime.current = e.currentTarget.currentTime + }} + /> + {embed.alt && ( + <figcaption + id={figId} + style={{ + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + borderWidth: 0, + }}> + {embed.alt} + </figcaption> + )} + </figure> + <Controls + videoRef={videoRef} + hlsRef={hlsRef} + active={active} + setActive={setActive} + focused={focused} + setFocused={setFocused} + hlsLoading={hlsLoading} + onScreen={onScreen} + fullscreenRef={containerRef} + hasSubtitleTrack={hasSubtitleTrack} + /> + </div> + <MediaInsetBorder /> + </View> + ) +} + +export class HLSUnsupportedError extends Error { + constructor() { + super('HLS is not supported') + } +} + +export class VideoNotFoundError extends Error { + constructor() { + super('Video not found') + } +} + +type CachedPromise<T> = Promise<T> & {value: undefined | T} +const promiseForHls = import( + // @ts-ignore + 'hls.js/dist/hls.min' +).then(mod => mod.default) as CachedPromise<typeof HlsTypes.default> +promiseForHls.value = undefined +promiseForHls.then(Hls => { + promiseForHls.value = Hls +}) + +function useHLS({ + playlist, + setHasSubtitleTrack, + setError, + videoRef, + setHlsLoading, +}: { + playlist: string + setHasSubtitleTrack: (v: boolean) => void + setError: (v: Error | null) => void + videoRef: React.RefObject<HTMLVideoElement> + setHlsLoading: (v: boolean) => void +}) { + const [Hls, setHls] = useState<typeof HlsTypes.default | undefined>( + () => promiseForHls.value, + ) + useEffect(() => { + if (!Hls) { + setHlsLoading(true) + promiseForHls.then(loadedHls => { + setHls(() => loadedHls) + setHlsLoading(false) + }) + } + }, [Hls, setHlsLoading]) + + const hlsRef = useRef<HlsTypes.default | undefined>(undefined) + const [lowQualityFragments, setLowQualityFragments] = useState< + HlsTypes.Fragment[] + >([]) + + // purge low quality segments from buffer on next frag change + const handleFragChange = useNonReactiveCallback( + ( + _event: HlsTypes.Events.FRAG_CHANGED, + {frag}: HlsTypes.FragChangedData, + ) => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + + // if the current quality level goes above 0, flush the low quality segments + if (hls.nextAutoLevel > 0) { + const flushed: HlsTypes.Fragment[] = [] + + for (const lowQualFrag of lowQualityFragments) { + // avoid if close to the current fragment + if (Math.abs(frag.start - lowQualFrag.start) < 0.1) { + continue + } + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + + flushed.push(lowQualFrag) + } + + setLowQualityFragments(prev => prev.filter(f => !flushed.includes(f))) + } + }, + ) + + const flushOnLoop = useNonReactiveCallback(() => { + if (!Hls) return + if (!hlsRef.current) return + const hls = hlsRef.current + // the above callback will catch most stale frags, but there's a corner case - + // if there's only one segment in the video, it won't get flushed because it avoids + // flushing the currently active segment. Therefore, we have to catch it when we loop + if ( + hls.nextAutoLevel > 0 && + lowQualityFragments.length === 1 && + lowQualityFragments[0].start === 0 + ) { + const lowQualFrag = lowQualityFragments[0] + + hls.trigger(Hls.Events.BUFFER_FLUSHING, { + startOffset: lowQualFrag.start, + endOffset: lowQualFrag.end, + type: 'video', + }) + setLowQualityFragments([]) + } + }) + + useEffect(() => { + if (!videoRef.current) return + if (!Hls) return + if (!Hls.isSupported()) { + throw new HLSUnsupportedError() + } + + const hls = new Hls({ + maxMaxBufferLength: 10, // only load 10s ahead + // note: the amount buffered is affected by both maxBufferLength and maxBufferSize + // it will buffer until it is greater than *both* of those values + // so we use maxMaxBufferLength to set the actual maximum amount of buffering instead + }) + hlsRef.current = hls + + const latestEstimate = BandwidthEstimate.get() + if (latestEstimate !== undefined) { + hls.bandwidthEstimate = latestEstimate + } + + hls.attachMedia(videoRef.current) + hls.loadSource(playlist) + + // manually loop, so if we've flushed the first buffer it doesn't get confused + const abortController = new AbortController() + const {signal} = abortController + const videoNode = videoRef.current + videoNode.addEventListener( + 'ended', + () => { + flushOnLoop() + videoNode.currentTime = 0 + videoNode.play() + }, + {signal}, + ) + + hls.on(Hls.Events.FRAG_LOADED, () => { + BandwidthEstimate.set(hls.bandwidthEstimate) + }) + + hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_event, data) => { + if (data.subtitleTracks.length > 0) { + setHasSubtitleTrack(true) + } + }) + + hls.on(Hls.Events.FRAG_BUFFERED, (_event, {frag}) => { + if (frag.level === 0) { + setLowQualityFragments(prev => [...prev, frag]) + } + }) + + hls.on(Hls.Events.ERROR, (_event, data) => { + if (data.fatal) { + if ( + data.details === 'manifestLoadError' && + data.response?.code === 404 + ) { + setError(new VideoNotFoundError()) + } else { + setError(data.error) + } + } else { + console.error(data.error) + } + }) + + hls.on(Hls.Events.FRAG_CHANGED, handleFragChange) + + return () => { + hlsRef.current = undefined + hls.detachMedia() + hls.destroy() + abortController.abort() + } + }, [ + playlist, + setError, + setHasSubtitleTrack, + videoRef, + handleFragChange, + flushOnLoop, + Hls, + ]) + + return hlsRef +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx new file mode 100644 index 000000000..1b46163cc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoFallback.tsx @@ -0,0 +1,61 @@ +import React from 'react' +import {View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonText} from '#/components/Button' +import {Text as TypoText} from '#/components/Typography' + +export function Container({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <View + style={[ + a.flex_1, + t.atoms.bg_contrast_25, + a.justify_center, + a.align_center, + a.px_lg, + a.border, + t.atoms.border_contrast_low, + a.rounded_sm, + a.gap_lg, + ]}> + {children} + </View> + ) +} + +export function Text({children}: {children: React.ReactNode}) { + const t = useTheme() + return ( + <TypoText + style={[ + a.text_center, + t.atoms.text_contrast_high, + a.text_md, + a.leading_snug, + {maxWidth: 300}, + ]}> + {children} + </TypoText> + ) +} + +export function RetryButton({onPress}: {onPress: () => void}) { + const {_} = useLingui() + + return ( + <Button + onPress={onPress} + size="small" + color="secondary_inverted" + variant="solid" + label={_(msg`Retry`)}> + <ButtonText> + <Trans>Retry</Trans> + </ButtonText> + </Button> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts new file mode 100644 index 000000000..122e10aef --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/bandwidth-estimate.ts @@ -0,0 +1,11 @@ +let latestBandwidthEstimate: number | undefined + +export function get() { + return latestBandwidthEstimate +} + +export function set(estimate: number) { + if (!isNaN(estimate)) { + latestBandwidthEstimate = estimate + } +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx new file mode 100644 index 000000000..1b69a3e25 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/ControlButton.tsx @@ -0,0 +1,42 @@ +import React from 'react' +import {SvgProps} from 'react-native-svg' + +import {PressableWithHover} from '#/view/com/util/PressableWithHover' +import {atoms as a, useTheme, web} from '#/alf' + +export function ControlButton({ + active, + activeLabel, + inactiveLabel, + activeIcon: ActiveIcon, + inactiveIcon: InactiveIcon, + onPress, +}: { + active: boolean + activeLabel: string + inactiveLabel: string + activeIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> + inactiveIcon: React.ComponentType<Pick<SvgProps, 'fill' | 'width'>> + onPress: () => void +}) { + const t = useTheme() + return ( + <PressableWithHover + accessibilityRole="button" + accessibilityLabel={active ? activeLabel : inactiveLabel} + accessibilityHint="" + onPress={onPress} + style={[ + a.p_xs, + a.rounded_full, + web({transition: 'background-color 0.1s'}), + ]} + hoverStyle={{backgroundColor: 'rgba(255, 255, 255, 0.2)'}}> + {active ? ( + <ActiveIcon fill={t.palette.white} width={20} aria-hidden /> + ) : ( + <InactiveIcon fill={t.palette.white} width={20} aria-hidden /> + )} + </PressableWithHover> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx new file mode 100644 index 000000000..96960bad4 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/Scrubber.tsx @@ -0,0 +1,238 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isFirefox, isTouchDevice} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {atoms as a, useTheme, web} from '#/alf' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import {formatTime} from './utils' + +export 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: onStartHover, + onOut: onEndHover, + } = useInteractionState() + const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [seekPosition, setSeekPosition] = useState(0) + const isSeekingRef = useRef(false) + const barRef = useRef<HTMLDivElement>(null) + const circleRef = useRef<HTMLDivElement>(null) + + const seek = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + 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<HTMLDivElement>) => { + 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<HTMLDivElement>) => { + if (isSeekingRef.current) { + evt.preventDefault() + seek(evt) + } + }, + [seek], + ) + + const onPointerUp = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + 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') + + return () => { + document.body.classList.remove('force-no-clicks') + } + } + }, [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 ( + <View + testID="scrubber" + style={[ + {height: isTouchDevice ? 32 : 18, width: '100%'}, + a.flex_shrink_0, + a.px_xs, + ]} + onPointerEnter={onStartHover} + onPointerLeave={onEndHover}> + <div + ref={barRef} + style={{ + flex: 1, + display: 'flex', + alignItems: 'center', + position: 'relative', + cursor: scrubberActive ? 'grabbing' : 'grab', + padding: '4px 0', + }} + onPointerDown={onPointerDown} + onPointerMove={onPointerMove} + onPointerUp={onPointerUp} + onPointerCancel={onPointerUp}> + <View + style={[ + a.w_full, + a.rounded_full, + a.overflow_hidden, + {backgroundColor: 'rgba(255, 255, 255, 0.4)'}, + {height: hovered || scrubberActive ? 6 : 3}, + web({transition: 'height 0.1s ease'}), + ]}> + {duration > 0 && ( + <View + style={[ + a.h_full, + {backgroundColor: t.palette.white}, + {width: `${progressPercent}%`}, + ]} + /> + )} + </View> + <div + ref={circleRef} + aria-label={_( + msg`Seek slider. Use the arrow keys to seek forwards and backwards, and space to play/pause`, + )} + role="slider" + aria-valuemax={duration} + aria-valuemin={0} + aria-valuenow={currentTime} + aria-valuetext={_( + msg`${formatTime(currentTime)} of ${formatTime(duration)}`, + )} + tabIndex={0} + onFocus={onFocus} + onBlur={onBlur} + style={{ + position: 'absolute', + height: 16, + width: 16, + left: `calc(${progressPercent}% - 8px)`, + borderRadius: 8, + pointerEvents: 'none', + }}> + <View + style={[ + a.w_full, + a.h_full, + a.rounded_full, + {backgroundColor: t.palette.white}, + { + transform: [ + { + scale: + hovered || scrubberActive || focused + ? scrubberActive + ? 1 + : 0.6 + : 0, + }, + ], + }, + ]} + /> + </div> + </div> + </View> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx new file mode 100644 index 000000000..e2e24ed36 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.native.tsx @@ -0,0 +1,3 @@ +export function Controls() { + throw new Error('VideoWebControls may not be used on native.') +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx new file mode 100644 index 000000000..6d14deafc --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VideoControls.tsx @@ -0,0 +1,427 @@ +import {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, View} from 'react-native' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import type Hls from 'hls.js' + +import {isTouchDevice} from '#/lib/browser' +import {clamp} from '#/lib/numbers' +import {isIPhoneWeb} from '#/platform/detection' +import { + useAutoplayDisabled, + useSetSubtitlesEnabled, + useSubtitlesEnabled, +} from '#/state/preferences' +import {atoms as a, useTheme, web} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' +import {useInteractionState} from '#/components/hooks/useInteractionState' +import { + ArrowsDiagonalIn_Stroke2_Corner0_Rounded as ArrowsInIcon, + ArrowsDiagonalOut_Stroke2_Corner0_Rounded as ArrowsOutIcon, +} from '#/components/icons/ArrowsDiagonal' +import { + CC_Filled_Corner0_Rounded as CCActiveIcon, + CC_Stroke2_Corner0_Rounded as CCInactiveIcon, +} from '#/components/icons/CC' +import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' +import {Loader} from '#/components/Loader' +import {Text} from '#/components/Typography' +import {TimeIndicator} from '../TimeIndicator' +import {ControlButton} from './ControlButton' +import {Scrubber} from './Scrubber' +import {formatTime, useVideoElement} from './utils' +import {VolumeControl} from './VolumeControl' + +export function Controls({ + videoRef, + hlsRef, + active, + setActive, + focused, + setFocused, + onScreen, + fullscreenRef, + hlsLoading, + hasSubtitleTrack, +}: { + videoRef: React.RefObject<HTMLVideoElement> + hlsRef: React.RefObject<Hls | undefined> + active: boolean + setActive: () => void + focused: boolean + setFocused: (focused: boolean) => void + onScreen: boolean + fullscreenRef: React.RefObject<HTMLDivElement> + hlsLoading: boolean + hasSubtitleTrack: boolean +}) { + const { + play, + pause, + playing, + muted, + changeMuted, + togglePlayPause, + currentTime, + duration, + buffering, + error, + canPlay, + } = useVideoElement(videoRef) + const t = useTheme() + const {_} = useLingui() + const subtitlesEnabled = useSubtitlesEnabled() + const setSubtitlesEnabled = useSetSubtitlesEnabled() + const { + state: hovered, + onIn: onHover, + onOut: onEndHover, + } = useInteractionState() + const [isFullscreen, toggleFullscreen] = useFullscreen(fullscreenRef) + const {state: hasFocus, onIn: onFocus, onOut: onBlur} = useInteractionState() + const [interactingViaKeypress, setInteractingViaKeypress] = useState(false) + const showSpinner = hlsLoading || buffering + const { + state: volumeHovered, + onIn: onVolumeHover, + onOut: onVolumeEndHover, + } = useInteractionState() + + const onKeyDown = useCallback(() => { + setInteractingViaKeypress(true) + }, []) + + useEffect(() => { + if (interactingViaKeypress) { + document.addEventListener('click', () => setInteractingViaKeypress(false)) + return () => { + document.removeEventListener('click', () => + setInteractingViaKeypress(false), + ) + } + } + }, [interactingViaKeypress]) + + useEffect(() => { + if (isFullscreen) { + document.documentElement.style.scrollbarGutter = 'unset' + return () => { + document.documentElement.style.removeProperty('scrollbar-gutter') + } + } + }, [isFullscreen]) + + // pause + unfocus when another video is active + useEffect(() => { + if (!active) { + pause() + setFocused(false) + } + }, [active, pause, setFocused]) + + // autoplay/pause based on visibility + const isWithinMessage = useIsWithinMessage() + const autoplayDisabled = useAutoplayDisabled() || isWithinMessage + useEffect(() => { + if (active) { + if (onScreen) { + if (!autoplayDisabled) play() + } else { + pause() + } + } + }, [onScreen, pause, active, play, autoplayDisabled]) + + // use minimal quality when not focused + useEffect(() => { + if (!hlsRef.current) return + if (focused) { + // allow 30s of buffering + hlsRef.current.config.maxMaxBufferLength = 30 + } else { + // back to what we initially set + hlsRef.current.config.maxMaxBufferLength = 10 + } + }, [hlsRef, focused]) + + useEffect(() => { + if (!hlsRef.current) return + if (hasSubtitleTrack && subtitlesEnabled && canPlay) { + hlsRef.current.subtitleTrack = 0 + } else { + hlsRef.current.subtitleTrack = -1 + } + }, [hasSubtitleTrack, subtitlesEnabled, hlsRef, canPlay]) + + // clicking on any button should focus the player, if it's not already focused + const drawFocus = useCallback(() => { + if (!active) { + setActive() + } + setFocused(true) + }, [active, setActive, setFocused]) + + const onPressEmptySpace = useCallback(() => { + if (!focused) { + drawFocus() + if (autoplayDisabled) play() + } else { + togglePlayPause() + } + }, [togglePlayPause, drawFocus, focused, autoplayDisabled, play]) + + const onPressPlayPause = useCallback(() => { + drawFocus() + togglePlayPause() + }, [drawFocus, togglePlayPause]) + + const onPressSubtitles = useCallback(() => { + drawFocus() + setSubtitlesEnabled(!subtitlesEnabled) + }, [drawFocus, setSubtitlesEnabled, subtitlesEnabled]) + + const onPressFullscreen = useCallback(() => { + drawFocus() + 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 [showCursor, setShowCursor] = useState(true) + const cursorTimeoutRef = useRef<ReturnType<typeof setTimeout>>() + const onPointerMoveEmptySpace = useCallback(() => { + setShowCursor(true) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + cursorTimeoutRef.current = setTimeout(() => { + setShowCursor(false) + onEndHover() + }, 2000) + }, [onEndHover]) + const onPointerLeaveEmptySpace = useCallback(() => { + setShowCursor(false) + if (cursorTimeoutRef.current) { + clearTimeout(cursorTimeoutRef.current) + } + }, []) + + // these are used to trigger the hover state. on mobile, the hover state + // should stick around for a bit after they tap, and if the controls aren't + // present this initial tab should *only* show the controls and not activate anything + + const onPointerDown = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + if (evt.pointerType !== 'mouse' && !hovered) { + evt.preventDefault() + } + clearTimeout(timeoutRef.current) + }, + [hovered], + ) + + const timeoutRef = useRef<ReturnType<typeof setTimeout>>() + + const onHoverWithTimeout = useCallback(() => { + onHover() + clearTimeout(timeoutRef.current) + }, [onHover]) + + const onEndHoverWithTimeout = useCallback( + (evt: React.PointerEvent<HTMLDivElement>) => { + // if touch, end after 3s + // if mouse, end immediately + if (evt.pointerType !== 'mouse') { + setTimeout(onEndHover, 3000) + } else { + onEndHover() + } + }, + [onEndHover], + ) + + const showControls = + ((focused || autoplayDisabled) && !playing) || + (interactingViaKeypress ? hasFocus : hovered) + + return ( + <div + style={{ + position: 'absolute', + inset: 0, + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + }} + onClick={evt => { + evt.stopPropagation() + setInteractingViaKeypress(false) + }} + onPointerEnter={onHoverWithTimeout} + onPointerMove={onHoverWithTimeout} + onPointerLeave={onEndHoverWithTimeout} + onPointerDown={onPointerDown} + onFocus={onFocus} + onBlur={onBlur} + onKeyDown={onKeyDown}> + <Pressable + accessibilityRole="button" + onPointerEnter={onPointerMoveEmptySpace} + onPointerMove={onPointerMoveEmptySpace} + onPointerLeave={onPointerLeaveEmptySpace} + accessibilityLabel={_( + !focused + ? msg`Unmute video` + : playing + ? msg`Pause video` + : msg`Play video`, + )} + accessibilityHint="" + style={[ + a.flex_1, + web({cursor: showCursor || !playing ? 'pointer' : 'none'}), + ]} + onPress={onPressEmptySpace} + /> + {!showControls && !focused && duration > 0 && ( + <TimeIndicator time={Math.floor(duration - currentTime)} /> + )} + <View + style={[ + a.flex_shrink_0, + a.w_full, + a.px_xs, + web({ + background: + 'linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.7))', + }), + {opacity: showControls ? 1 : 0}, + {transition: 'opacity 0.2s ease-in-out'}, + ]}> + {(!volumeHovered || isTouchDevice) && ( + <Scrubber + duration={duration} + currentTime={currentTime} + onSeek={onSeek} + onSeekStart={onSeekStart} + onSeekEnd={onSeekEnd} + seekLeft={seekLeft} + seekRight={seekRight} + togglePlayPause={togglePlayPause} + drawFocus={drawFocus} + /> + )} + <View + style={[ + a.flex_1, + a.px_xs, + a.pb_sm, + a.gap_sm, + a.flex_row, + a.align_center, + ]}> + <ControlButton + active={playing} + activeLabel={_(msg`Pause`)} + inactiveLabel={_(msg`Play`)} + activeIcon={PauseIcon} + inactiveIcon={PlayIcon} + onPress={onPressPlayPause} + /> + <View style={a.flex_1} /> + <Text + style={[ + a.px_xs, + {color: t.palette.white, fontVariant: ['tabular-nums']}, + ]}> + {formatTime(currentTime)} / {formatTime(duration)} + </Text> + {hasSubtitleTrack && ( + <ControlButton + active={subtitlesEnabled} + activeLabel={_(msg`Disable subtitles`)} + inactiveLabel={_(msg`Enable subtitles`)} + activeIcon={CCActiveIcon} + inactiveIcon={CCInactiveIcon} + onPress={onPressSubtitles} + /> + )} + <VolumeControl + muted={muted} + changeMuted={changeMuted} + hovered={volumeHovered} + onHover={onVolumeHover} + onEndHover={onVolumeEndHover} + drawFocus={drawFocus} + /> + {!isIPhoneWeb && ( + <ControlButton + active={isFullscreen} + activeLabel={_(msg`Exit fullscreen`)} + inactiveLabel={_(msg`Enter fullscreen`)} + activeIcon={ArrowsInIcon} + inactiveIcon={ArrowsOutIcon} + onPress={onPressFullscreen} + /> + )} + </View> + </View> + {(showSpinner || error) && ( + <View + pointerEvents="none" + style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}> + {showSpinner && <Loader fill={t.palette.white} size="lg" />} + {error && ( + <Text style={{color: t.palette.white}}> + <Trans>An error occurred</Trans> + </Text> + )} + </View> + )} + </div> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx new file mode 100644 index 000000000..e0b688075 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -0,0 +1,110 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import Animated, {FadeIn, FadeOut} from 'react-native-reanimated' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isSafari, isTouchDevice} from '#/lib/browser' +import {atoms as a} 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 {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' +import {ControlButton} from './ControlButton' + +export function VolumeControl({ + muted, + changeMuted, + hovered, + onHover, + onEndHover, + drawFocus, +}: { + muted: boolean + changeMuted: (muted: boolean | ((prev: boolean) => boolean)) => void + hovered: boolean + onHover: () => void + onEndHover: () => void + drawFocus: () => void +}) { + const {_} = useLingui() + const [volume, setVolume] = useVideoVolumeState() + + const onVolumeChange = useCallback( + (evt: React.ChangeEvent<HTMLInputElement>) => { + drawFocus() + const vol = sliderVolumeToVideoVolume(Number(evt.target.value)) + setVolume(vol) + changeMuted(vol === 0) + }, + [setVolume, drawFocus, changeMuted], + ) + + const sliderVolume = muted ? 0 : videoVolumeToSliderVolume(volume) + + const isZeroVolume = volume === 0 + const onPressMute = useCallback(() => { + drawFocus() + if (isZeroVolume) { + setVolume(1) + changeMuted(false) + } else { + changeMuted(prevMuted => !prevMuted) + } + }, [drawFocus, setVolume, isZeroVolume, changeMuted]) + + return ( + <View + onPointerEnter={onHover} + onPointerLeave={onEndHover} + style={[a.relative]}> + {hovered && !isTouchDevice && ( + <Animated.View + entering={FadeIn.duration(100)} + exiting={FadeOut.duration(100)} + style={[a.absolute, a.w_full, {height: 100, bottom: '100%'}]}> + <View + style={[ + a.flex_1, + a.mb_xs, + a.px_2xs, + a.py_xs, + {backgroundColor: 'rgba(0, 0, 0, 0.6)'}, + a.rounded_xs, + a.align_center, + ]}> + <input + type="range" + min={0} + max={100} + value={sliderVolume} + aria-label={_(msg`Volume`)} + style={ + // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h + isSafari ? {height: 92, minHeight: '100%'} : {height: '100%'} + } + onChange={onVolumeChange} + // @ts-expect-error for old versions of firefox, and then re-using it for targeting the CSS -sfn + orient="vertical" + /> + </View> + </Animated.View> + )} + <ControlButton + active={muted || volume === 0} + activeLabel={_(msg({message: `Unmute`, context: 'video'}))} + inactiveLabel={_(msg({message: `Mute`, context: 'video'}))} + activeIcon={MuteIcon} + inactiveIcon={UnmuteIcon} + onPress={onPressMute} + /> + </View> + ) +} + +function sliderVolumeToVideoVolume(value: number) { + return Math.pow(value / 100, 4) +} + +function videoVolumeToSliderVolume(value: number) { + return Math.round(Math.pow(value, 1 / 4) * 100) +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx new file mode 100644 index 000000000..320f61a5f --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/utils.tsx @@ -0,0 +1,240 @@ +import {type RefObject, useCallback, useEffect, useRef, useState} from 'react' + +import {isSafari} from '#/lib/browser' +import {useVideoVolumeState} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext' + +export function useVideoElement(ref: RefObject<HTMLVideoElement>) { + const [playing, setPlaying] = useState(false) + const [muted, setMuted] = useState(true) + const [currentTime, setCurrentTime] = useState(0) + const [volume, setVolume] = useVideoVolumeState() + const [duration, setDuration] = useState(0) + const [buffering, setBuffering] = useState(false) + const [error, setError] = useState(false) + const [canPlay, setCanPlay] = useState(false) + const playWhenReadyRef = useRef(false) + + useEffect(() => { + if (!ref.current) return + ref.current.volume = volume + }, [ref, volume]) + + useEffect(() => { + if (!ref.current) return + + let bufferingTimeout: ReturnType<typeof setTimeout> | undefined + + function round(num: number) { + return Math.round(num * 100) / 100 + } + + // Initial values + setCurrentTime(round(ref.current.currentTime) || 0) + setDuration(round(ref.current.duration) || 0) + setMuted(ref.current.muted) + setPlaying(!ref.current.paused) + setVolume(ref.current.volume) + + const handleTimeUpdate = () => { + if (!ref.current) return + setCurrentTime(round(ref.current.currentTime) || 0) + // HACK: Safari randomly fires `stalled` events when changing between segments + // let's just clear the buffering state if the video is still progressing -sfn + if (isSafari) { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + } + + const handleDurationChange = () => { + if (!ref.current) return + setDuration(round(ref.current.duration) || 0) + } + + const handlePlay = () => { + setPlaying(true) + } + + const handlePause = () => { + setPlaying(false) + } + + const handleVolumeChange = () => { + if (!ref.current) return + setMuted(ref.current.muted) + } + + const handleError = () => { + setError(true) + } + + const handleCanPlay = async () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setCanPlay(true) + + if (!ref.current) return + if (playWhenReadyRef.current) { + try { + await ref.current.play() + } catch (e: any) { + if ( + !e.message?.includes(`The request is not allowed by the user agent`) + ) { + throw e + } + } + playWhenReadyRef.current = false + } + } + + const handleCanPlayThrough = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + } + + const handleWaiting = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // Delay to avoid frequent buffering state changes + } + + const handlePlaying = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + setBuffering(false) + setError(false) + } + + const handleStalled = () => { + if (bufferingTimeout) clearTimeout(bufferingTimeout) + bufferingTimeout = setTimeout(() => { + setBuffering(true) + }, 500) // Delay to avoid frequent buffering state changes + } + + const handleEnded = () => { + setPlaying(false) + setBuffering(false) + setError(false) + } + + const abortController = new AbortController() + + ref.current.addEventListener('timeupdate', handleTimeUpdate, { + signal: abortController.signal, + }) + ref.current.addEventListener('durationchange', handleDurationChange, { + signal: abortController.signal, + }) + ref.current.addEventListener('play', handlePlay, { + signal: abortController.signal, + }) + ref.current.addEventListener('pause', handlePause, { + signal: abortController.signal, + }) + ref.current.addEventListener('volumechange', handleVolumeChange, { + signal: abortController.signal, + }) + ref.current.addEventListener('error', handleError, { + signal: abortController.signal, + }) + ref.current.addEventListener('canplay', handleCanPlay, { + signal: abortController.signal, + }) + ref.current.addEventListener('canplaythrough', handleCanPlayThrough, { + signal: abortController.signal, + }) + ref.current.addEventListener('waiting', handleWaiting, { + signal: abortController.signal, + }) + ref.current.addEventListener('playing', handlePlaying, { + signal: abortController.signal, + }) + ref.current.addEventListener('stalled', handleStalled, { + signal: abortController.signal, + }) + ref.current.addEventListener('ended', handleEnded, { + signal: abortController.signal, + }) + + return () => { + abortController.abort() + clearTimeout(bufferingTimeout) + } + }, [ref, setVolume]) + + 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() + playWhenReadyRef.current = false + }, [ref]) + + const togglePlayPause = useCallback(() => { + if (!ref.current) return + + if (ref.current.paused) { + play() + } else { + pause() + } + }, [ref, play, pause]) + + const changeMuted = useCallback( + (newMuted: boolean | ((prev: boolean) => boolean)) => { + if (!ref.current) return + + const value = + typeof newMuted === 'function' ? newMuted(ref.current.muted) : newMuted + ref.current.muted = value + }, + [ref], + ) + + return { + play, + pause, + togglePlayPause, + duration, + currentTime, + playing, + muted, + changeMuted, + buffering, + error, + canPlay, + } +} + +export 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}` +} diff --git a/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx new file mode 100644 index 000000000..6343081da --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/VideoVolumeContext.tsx @@ -0,0 +1,47 @@ +import React from 'react' + +const Context = React.createContext<{ + // native + muted: boolean + setMuted: React.Dispatch<React.SetStateAction<boolean>> + // web + volume: number + setVolume: React.Dispatch<React.SetStateAction<number>> +} | null>(null) + +export function Provider({children}: {children: React.ReactNode}) { + const [muted, setMuted] = React.useState(true) + const [volume, setVolume] = React.useState(1) + + const value = React.useMemo( + () => ({ + muted, + setMuted, + volume, + setVolume, + }), + [muted, setMuted, volume, setVolume], + ) + + return <Context.Provider value={value}>{children}</Context.Provider> +} + +export function useVideoVolumeState() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useVideoVolumeState must be used within a VideoVolumeProvider', + ) + } + return [context.volume, context.setVolume] as const +} + +export function useVideoMuteState() { + const context = React.useContext(Context) + if (!context) { + throw new Error( + 'useVideoMuteState must be used within a VideoVolumeProvider', + ) + } + return [context.muted, context.setMuted] as const +} diff --git a/src/components/Post/Embed/VideoEmbed/index.tsx b/src/components/Post/Embed/VideoEmbed/index.tsx new file mode 100644 index 000000000..fe29ecad6 --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/index.tsx @@ -0,0 +1,167 @@ +import React, {useCallback, useState} from 'react' +import {ActivityIndicator, View} from 'react-native' +import {ImageBackground} from 'expo-image' +import {AppBskyEmbedVideo} from '@atproto/api' +import {msg, Trans} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' +import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' +import {VideoEmbedInnerNative} from './VideoEmbedInner/VideoEmbedInnerNative' +import * as VideoFallback from './VideoEmbedInner/VideoFallback' + +interface Props { + embed: AppBskyEmbedVideo.View + crop?: 'none' | 'square' | 'constrained' +} + +export function VideoEmbed({embed, crop}: Props) { + const t = useTheme() + const [key, setKey] = useState(0) + + const renderError = useCallback( + (error: unknown) => ( + <VideoError error={error} retry={() => setKey(key + 1)} /> + ), + [key], + ) + + let aspectRatio: number | undefined + const dims = embed.aspectRatio + if (dims) { + aspectRatio = dims.width / dims.height + if (Number.isNaN(aspectRatio)) { + aspectRatio = undefined + } + } + + let constrained: number | undefined + let max: number | undefined + if (aspectRatio !== undefined) { + const ratio = 1 / 2 // max of 1:2 ratio in feeds + constrained = Math.max(aspectRatio, ratio) + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread + } + const cropDisabled = crop === 'none' + + const contents = ( + <ErrorBoundary renderError={renderError} key={key}> + <InnerWrapper embed={embed} /> + </ErrorBoundary> + ) + + return ( + <View style={[a.pt_xs]}> + {cropDisabled ? ( + <View + style={[ + a.w_full, + a.overflow_hidden, + {aspectRatio: max ?? 1}, + a.rounded_md, + a.overflow_hidden, + t.atoms.bg_contrast_25, + ]}> + {contents} + </View> + ) : ( + <ConstrainedImage + fullBleed={crop === 'square'} + aspectRatio={constrained || 1}> + {contents} + </ConstrainedImage> + )} + </View> + ) +} + +function InnerWrapper({embed}: Props) { + const {_} = useLingui() + const ref = React.useRef<{togglePlayback: () => void}>(null) + + const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( + 'pending', + ) + const [isLoading, setIsLoading] = React.useState(false) + const [isActive, setIsActive] = React.useState(false) + const showSpinner = useThrottledValue(isActive && isLoading, 100) + + const showOverlay = + !isActive || + isLoading || + (status === 'paused' && !isActive) || + status === 'pending' + + React.useEffect(() => { + if (!isActive && status !== 'pending') { + setStatus('pending') + } + }, [isActive, status]) + + return ( + <> + <VideoEmbedInnerNative + embed={embed} + setStatus={setStatus} + setIsLoading={setIsLoading} + setIsActive={setIsActive} + ref={ref} + /> + <ImageBackground + source={{uri: embed.thumbnail}} + accessibilityIgnoresInvertColors + style={[ + a.absolute, + a.inset_0, + { + backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here, + // the play button won't show up on the first render on android 🥴😮💨 + display: showOverlay ? 'flex' : 'none', + }, + ]} + cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android + > + {showOverlay && ( + <Button + style={[a.flex_1, a.align_center, a.justify_center]} + onPress={() => { + ref.current?.togglePlayback() + }} + label={_(msg`Play video`)} + color="secondary"> + {showSpinner ? ( + <View + style={[ + a.rounded_full, + a.p_xs, + a.align_center, + a.justify_center, + ]}> + <ActivityIndicator size="large" color="white" /> + </View> + ) : ( + <PlayButtonIcon /> + )} + </Button> + )} + </ImageBackground> + </> + ) +} + +function VideoError({retry}: {error: unknown; retry: () => void}) { + return ( + <VideoFallback.Container> + <VideoFallback.Text> + <Trans> + An error occurred while loading the video. Please try again later. + </Trans> + </VideoFallback.Text> + <VideoFallback.RetryButton onPress={retry} /> + </VideoFallback.Container> + ) +} diff --git a/src/components/Post/Embed/VideoEmbed/index.web.tsx b/src/components/Post/Embed/VideoEmbed/index.web.tsx new file mode 100644 index 000000000..53adc3b6a --- /dev/null +++ b/src/components/Post/Embed/VideoEmbed/index.web.tsx @@ -0,0 +1,207 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {View} from 'react-native' +import {AppBskyEmbedVideo} from '@atproto/api' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {isFirefox} from '#/lib/browser' +import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' +import {ConstrainedImage} from '#/view/com/util/images/AutoSizedImage' +import {atoms as a} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useFullscreen} from '#/components/hooks/useFullscreen' +import { + HLSUnsupportedError, + VideoEmbedInnerWeb, + VideoNotFoundError, +} from '#/components/Post/Embed/VideoEmbed/VideoEmbedInner/VideoEmbedInnerWeb' +import {useActiveVideoWeb} from './ActiveVideoWebContext' +import * as VideoFallback from './VideoEmbedInner/VideoFallback' + +export function VideoEmbed({ + embed, + crop, +}: { + embed: AppBskyEmbedVideo.View + crop?: 'none' | 'square' | 'constrained' +}) { + const ref = useRef<HTMLDivElement>(null) + const {active, setActive, sendPosition, currentActiveView} = + useActiveVideoWeb() + const [onScreen, setOnScreen] = useState(false) + const [isFullscreen] = useFullscreen() + const lastKnownTime = useRef<number | undefined>() + + useEffect(() => { + if (!ref.current) return + if (isFullscreen && !isFirefox) return + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (!entry) return + setOnScreen(entry.isIntersecting) + sendPosition( + entry.boundingClientRect.y + entry.boundingClientRect.height / 2, + ) + }, + {threshold: 0.5}, + ) + observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition, isFullscreen]) + + const [key, setKey] = useState(0) + const renderError = useCallback( + (error: unknown) => ( + <VideoError error={error} retry={() => setKey(key + 1)} /> + ), + [key], + ) + + let aspectRatio: number | undefined + const dims = embed.aspectRatio + if (dims) { + aspectRatio = dims.width / dims.height + if (Number.isNaN(aspectRatio)) { + aspectRatio = undefined + } + } + + let constrained: number | undefined + let max: number | undefined + if (aspectRatio !== undefined) { + const ratio = 1 / 2 // max of 1:2 ratio in feeds + constrained = Math.max(aspectRatio, ratio) + max = Math.max(aspectRatio, 0.25) // max of 1:4 in thread + } + const cropDisabled = crop === 'none' + + const contents = ( + <div + ref={ref} + style={{display: 'flex', flex: 1, cursor: 'default'}} + onClick={evt => evt.stopPropagation()}> + <ErrorBoundary renderError={renderError} key={key}> + <ViewportObserver + sendPosition={sendPosition} + isAnyViewActive={currentActiveView !== null}> + <VideoEmbedInnerWeb + embed={embed} + active={active} + setActive={setActive} + onScreen={onScreen} + lastKnownTime={lastKnownTime} + /> + </ViewportObserver> + </ErrorBoundary> + </div> + ) + + return ( + <View style={[a.pt_xs]}> + {cropDisabled ? ( + <View style={[a.w_full, a.overflow_hidden, {aspectRatio: max ?? 1}]}> + {contents} + </View> + ) : ( + <ConstrainedImage + fullBleed={crop === 'square'} + aspectRatio={constrained || 1}> + {contents} + </ConstrainedImage> + )} + </View> + ) +} + +/** + * Renders a 100vh tall div and watches it with an IntersectionObserver to + * send the position of the div when it's near the screen. + */ +function ViewportObserver({ + children, + sendPosition, + isAnyViewActive, +}: { + children: React.ReactNode + sendPosition: (position: number) => void + isAnyViewActive: boolean +}) { + const ref = useRef<HTMLDivElement>(null) + const [nearScreen, setNearScreen] = useState(false) + const [isFullscreen] = useFullscreen() + const isWithinMessage = useIsWithinMessage() + + // Send position when scrolling. This is done with an IntersectionObserver + // observing a div of 100vh height + useEffect(() => { + if (!ref.current) return + if (isFullscreen && !isFirefox) return + const observer = new IntersectionObserver( + entries => { + const entry = entries[0] + if (!entry) return + const position = + entry.boundingClientRect.y + entry.boundingClientRect.height / 2 + sendPosition(position) + setNearScreen(entry.isIntersecting) + }, + {threshold: Array.from({length: 101}, (_, i) => i / 100)}, + ) + observer.observe(ref.current) + return () => observer.disconnect() + }, [sendPosition, isFullscreen]) + + // In case scrolling hasn't started yet, send up the position + useEffect(() => { + if (ref.current && !isAnyViewActive) { + const rect = ref.current.getBoundingClientRect() + const position = rect.y + rect.height / 2 + sendPosition(position) + } + }, [isAnyViewActive, sendPosition]) + + return ( + <View style={[a.flex_1, a.flex_row]}> + {nearScreen && children} + <div + ref={ref} + style={{ + // Don't escape bounds when in a message + ...(isWithinMessage + ? {top: 0, height: '100%'} + : {top: 'calc(50% - 50vh)', height: '100vh'}), + position: 'absolute', + left: '50%', + width: 1, + pointerEvents: 'none', + }} + /> + </View> + ) +} + +function VideoError({error, retry}: {error: unknown; retry: () => void}) { + const {_} = useLingui() + + let showRetryButton = true + let text = null + + if (error instanceof VideoNotFoundError) { + text = _(msg`Video not found.`) + } else if (error instanceof HLSUnsupportedError) { + showRetryButton = false + text = _( + msg`Your browser does not support the video format. Please try a different browser.`, + ) + } else { + text = _(msg`An error occurred while loading the video. Please try again.`) + } + + return ( + <VideoFallback.Container> + <VideoFallback.Text>{text}</VideoFallback.Text> + {showRetryButton && <VideoFallback.RetryButton onPress={retry} />} + </VideoFallback.Container> + ) +} |