diff options
Diffstat (limited to 'src/view/com/util/post-embeds')
-rw-r--r-- | src/view/com/util/post-embeds/ActiveVideoContext.tsx | 48 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbed.tsx | 44 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbedInner.tsx | 138 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbedInner.web.tsx | 52 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoPlayerContext.tsx | 41 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoPlayerContext.web.tsx | 9 |
6 files changed, 332 insertions, 0 deletions
diff --git a/src/view/com/util/post-embeds/ActiveVideoContext.tsx b/src/view/com/util/post-embeds/ActiveVideoContext.tsx new file mode 100644 index 000000000..6804436a7 --- /dev/null +++ b/src/view/com/util/post-embeds/ActiveVideoContext.tsx @@ -0,0 +1,48 @@ +import React, {useCallback, useId, useMemo, useState} from 'react' + +import {VideoPlayerProvider} from './VideoPlayerContext' + +const ActiveVideoContext = React.createContext<{ + activeViewId: string | null + setActiveView: (viewId: string, src: string) => void +} | null>(null) + +export function ActiveVideoProvider({children}: {children: React.ReactNode}) { + const [activeViewId, setActiveViewId] = useState<string | null>(null) + const [source, setSource] = useState<string | null>(null) + + const value = useMemo( + () => ({ + activeViewId, + setActiveView: (viewId: string, src: string) => { + setActiveViewId(viewId) + setSource(src) + }, + }), + [activeViewId], + ) + + return ( + <ActiveVideoContext.Provider value={value}> + <VideoPlayerProvider source={source ?? ''} viewId={activeViewId}> + {children} + </VideoPlayerProvider> + </ActiveVideoContext.Provider> + ) +} + +export function useActiveVideoView() { + const context = React.useContext(ActiveVideoContext) + if (!context) { + throw new Error('useActiveVideo must be used within a ActiveVideoProvider') + } + const id = useId() + + return { + active: context.activeViewId === id, + setActive: useCallback( + (source: string) => context.setActiveView(id, source), + [context, id], + ), + } +} diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx new file mode 100644 index 000000000..5e5293a55 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -0,0 +1,44 @@ +import React, {useCallback} from 'react' +import {View} from 'react-native' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {atoms as a, useTheme} from '#/alf' +import {Button, ButtonIcon} from '#/components/Button' +import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' +import {useActiveVideoView} from './ActiveVideoContext' +import {VideoEmbedInner} from './VideoEmbedInner' + +export function VideoEmbed({source}: {source: string}) { + const t = useTheme() + const {active, setActive} = useActiveVideoView() + const {_} = useLingui() + + const onPress = useCallback(() => setActive(source), [setActive, source]) + + return ( + <View + style={[ + a.w_full, + a.rounded_sm, + {aspectRatio: 16 / 9}, + a.overflow_hidden, + t.atoms.bg_contrast_25, + a.my_xs, + ]}> + {active ? ( + <VideoEmbedInner source={source} /> + ) : ( + <Button + style={[a.flex_1, t.atoms.bg_contrast_25]} + onPress={onPress} + label={_(msg`Play video`)} + variant="ghost" + color="secondary" + size="large"> + <ButtonIcon icon={PlayIcon} /> + </Button> + )} + </View> + ) +} diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.tsx new file mode 100644 index 000000000..ef0678709 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner.tsx @@ -0,0 +1,138 @@ +import React, {useCallback, useEffect, useRef, useState} from 'react' +import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native' +import Animated, { + measure, + runOnJS, + useAnimatedRef, + useFrameCallback, + useSharedValue, +} from 'react-native-reanimated' +import {VideoPlayer, VideoView} from 'expo-video' + +import {atoms as a} from '#/alf' +import {Text} from '#/components/Typography' +import {useVideoPlayer} from './VideoPlayerContext' + +export const VideoEmbedInner = ({}: {source: string}) => { + const player = useVideoPlayer() + const aref = useAnimatedRef<Animated.View>() + const {height: windowHeight} = useWindowDimensions() + const hasLeftView = useSharedValue(false) + const ref = useRef<VideoView>(null) + + const onEnterView = useCallback(() => { + if (player.status === 'readyToPlay') { + player.play() + } + }, [player]) + + const onLeaveView = useCallback(() => { + player.pause() + }, [player]) + + const enterFullscreen = useCallback(() => { + if (ref.current) { + ref.current.enterFullscreen() + } + }, []) + + useFrameCallback(() => { + const measurement = measure(aref) + + if (measurement) { + if (hasLeftView.value) { + // Check if the video is in view + if ( + measurement.pageY >= 0 && + measurement.pageY + measurement.height <= windowHeight + ) { + runOnJS(onEnterView)() + hasLeftView.value = false + } + } else { + // Check if the video is out of view + if ( + measurement.pageY + measurement.height < 0 || + measurement.pageY > windowHeight + ) { + runOnJS(onLeaveView)() + hasLeftView.value = true + } + } + } + }) + + return ( + <Animated.View + style={[a.flex_1, a.relative]} + ref={aref} + collapsable={false}> + <VideoView + ref={ref} + player={player} + style={a.flex_1} + nativeControls={true} + /> + <VideoControls player={player} enterFullscreen={enterFullscreen} /> + </Animated.View> + ) +} + +function VideoControls({ + player, + enterFullscreen, +}: { + player: VideoPlayer + enterFullscreen: () => void +}) { + const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime)) + + useEffect(() => { + const interval = setInterval(() => { + setCurrentTime(Math.floor(player.duration - player.currentTime)) + // how often should we update the time? + // 1000 gets out of sync with the video time + }, 250) + + return () => { + clearInterval(interval) + } + }, [player]) + + const minutes = Math.floor(currentTime / 60) + const seconds = String(currentTime % 60).padStart(2, '0') + + return ( + <View style={[a.absolute, a.inset_0]}> + <View style={styles.timeContainer} pointerEvents="none"> + <Text style={styles.timeElapsed}> + {minutes}:{seconds} + </Text> + </View> + <Pressable + onPress={enterFullscreen} + style={a.flex_1} + accessibilityLabel="Video" + accessibilityHint="Tap to enter full screen" + accessibilityRole="button" + /> + </View> + ) +} + +const styles = StyleSheet.create({ + timeContainer: { + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 6, + paddingHorizontal: 6, + paddingVertical: 3, + position: 'absolute', + left: 5, + bottom: 5, + }, + timeElapsed: { + color: 'white', + fontSize: 12, + fontWeight: 'bold', + }, +}) diff --git a/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx new file mode 100644 index 000000000..cb02743c6 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoEmbedInner.web.tsx @@ -0,0 +1,52 @@ +import React, {useEffect, useRef} from 'react' +import Hls from 'hls.js' + +import {atoms as a} from '#/alf' + +export const VideoEmbedInner = ({source}: {source: string}) => { + const ref = useRef<HTMLVideoElement>(null) + + // Use HLS.js to play HLS video + useEffect(() => { + if (ref.current) { + if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { + ref.current.src = source + } else if (Hls.isSupported()) { + var hls = new Hls() + hls.loadSource(source) + hls.attachMedia(ref.current) + } else { + // TODO: fallback + } + } + }, [source]) + + useEffect(() => { + if (ref.current) { + const observer = new IntersectionObserver( + ([entry]) => { + if (ref.current) { + if (entry.isIntersecting) { + if (ref.current.paused) { + ref.current.play() + } + } else { + if (!ref.current.paused) { + ref.current.pause() + } + } + } + }, + {threshold: 0}, + ) + + observer.observe(ref.current) + + return () => { + observer.disconnect() + } + } + }, []) + + return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop /> +} diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.tsx new file mode 100644 index 000000000..bc5d9d370 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoPlayerContext.tsx @@ -0,0 +1,41 @@ +import React, {useContext, useEffect} from 'react' +import type {VideoPlayer} from 'expo-video' +import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video' + +const VideoPlayerContext = React.createContext<VideoPlayer | null>(null) + +export function VideoPlayerProvider({ + viewId, + source, + children, +}: { + viewId: string | null + source: string + children: React.ReactNode +}) { + // eslint-disable-next-line @typescript-eslint/no-shadow + const player = useExpoVideoPlayer(source, player => { + player.loop = true + player.play() + }) + + // make sure we're playing every time the viewId changes + // this means the video is different + useEffect(() => { + player.play() + }, [viewId, player]) + + return ( + <VideoPlayerContext.Provider value={player}> + {children} + </VideoPlayerContext.Provider> + ) +} + +export function useVideoPlayer() { + const context = useContext(VideoPlayerContext) + if (!context) { + throw new Error('useVideoPlayer must be used within a VideoPlayerProvider') + } + return context +} diff --git a/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx new file mode 100644 index 000000000..329fb1206 --- /dev/null +++ b/src/view/com/util/post-embeds/VideoPlayerContext.web.tsx @@ -0,0 +1,9 @@ +import React from 'react' + +export function VideoPlayerProvider({children}: {children: React.ReactNode}) { + return children +} + +export function useVideoPlayer() { + throw new Error('useVideoPlayer must not be used on web') +} |