An ATproto social media client -- with an independent Appview.

[Videos] Video player - PR #1 - basic player (#4731)

* add ffmpeg-kit-react-native

* get select video button + compression working

* up res to 1080p

* add progress component

* move logic out of compressVideo

* (WIP) add lonestar compression

* rework web compression a bit

* mess around with adding a thumbnail

* 3mbps

* replace

* use 3mbps

* add expo-video

* remove unnecessary try/catch

* rm ToastAndroid

* fix web

* wrap lazy component in suspense

* gate video select button

* rm web compression

* flip sign

* remove expo-video from web

* review nits

* add video picker permissions + rm temp buttons

* add ffmpeg-kit-react-native

* replace

* hls-capable player

* start trying to hoist up video player instance

* hoist video player and move things around

* always show native controls

* fix controls on expo video android

* gate temp video player in feed

* rm IS_DEV, doesn't do what I thought it did

* use __DEV__ instead

---------

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>

authored by samuel.fm

Samuel Newman
Hailey
and committed by
GitHub
00240b95 4ec999ca

+489 -105
+1
assets/icons/play_filled_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" d="M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z"/></svg>
+1
assets/icons/play_stroke2_corner2_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z" clip-rule="evenodd"/></svg>
+1
package.json
··· 143 143 "expo-web-browser": "~13.0.3", 144 144 "fast-text-encoding": "^1.0.6", 145 145 "history": "^5.3.0", 146 + "hls.js": "^1.5.11", 146 147 "js-sha256": "^0.9.0", 147 148 "jwt-decode": "^4.0.0", 148 149 "lande": "^1.0.10",
+20
patches/expo-video+1.1.10.patch
··· 1 + --- a/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt 2 + +++ b/node_modules/expo-video/android/src/main/java/expo/modules/video/PlayerViewExtension.kt 3 + @@ -11,6 +11,7 @@ internal fun PlayerView.applyRequiresLinearPlayback(requireLinearPlayback: Boole 4 + setShowPreviousButton(!requireLinearPlayback) 5 + setShowNextButton(!requireLinearPlayback) 6 + setTimeBarInteractive(requireLinearPlayback) 7 + + setShowSubtitleButton(true) 8 + } 9 + 10 + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) 11 + @@ -27,7 +28,8 @@ internal fun PlayerView.setTimeBarInteractive(interactive: Boolean) { 12 + 13 + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) 14 + internal fun PlayerView.setFullscreenButtonVisibility(visible: Boolean) { 15 + - val fullscreenButton = findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen) 16 + + val fullscreenButton = 17 + + findViewById<android.widget.ImageButton>(androidx.media3.ui.R.id.exo_fullscreen) 18 + fullscreenButton?.visibility = if (visible) { 19 + android.view.View.VISIBLE 20 + } else {
+41 -38
src/App.native.tsx
··· 23 23 } from '#/lib/statsig/statsig' 24 24 import {s} from '#/lib/styles' 25 25 import {ThemeProvider} from '#/lib/ThemeContext' 26 + import I18nProvider from '#/locale/i18nProvider' 26 27 import {logger} from '#/logger' 27 28 import {Provider as A11yProvider} from '#/state/a11y' 28 29 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 29 30 import {Provider as DialogStateProvider} from '#/state/dialogs' 31 + import {listenSessionDropped} from '#/state/events' 30 32 import {Provider as InvitesStateProvider} from '#/state/invites' 31 33 import {Provider as LightboxStateProvider} from '#/state/lightbox' 32 34 import {MessagesProvider} from '#/state/messages' ··· 49 51 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 50 52 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 51 53 import {TestCtrls} from '#/view/com/testing/TestCtrls' 54 + import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' 52 55 import * as Toast from '#/view/com/util/Toast' 53 56 import {Shell} from '#/view/shell' 54 57 import {ThemeProvider as Alf} from '#/alf' ··· 58 61 import {Splash} from '#/Splash' 59 62 import {Provider as TourProvider} from '#/tours' 60 63 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 61 - import I18nProvider from './locale/i18nProvider' 62 - import {listenSessionDropped} from './state/events' 63 64 64 65 SplashScreen.preventAutoHideAsync() 65 66 ··· 107 108 <Alf theme={theme}> 108 109 <ThemeProvider theme={theme}> 109 110 <Splash isReady={isReady && hasCheckedReferrer}> 110 - <RootSiblingParent> 111 - <React.Fragment 112 - // Resets the entire tree below when it changes: 113 - key={currentAccount?.did}> 114 - <QueryProvider currentDid={currentAccount?.did}> 115 - <StatsigProvider> 116 - <MessagesProvider> 117 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 118 - <LabelDefsProvider> 119 - <ModerationOptsProvider> 120 - <LoggedOutViewProvider> 121 - <SelectedFeedProvider> 122 - <UnreadNotifsProvider> 123 - <BackgroundNotificationPreferencesProvider> 124 - <MutedThreadsProvider> 125 - <TourProvider> 126 - <ProgressGuideProvider> 127 - <GestureHandlerRootView 128 - style={s.h100pct}> 129 - <TestCtrls /> 130 - <Shell /> 131 - </GestureHandlerRootView> 132 - </ProgressGuideProvider> 133 - </TourProvider> 134 - </MutedThreadsProvider> 135 - </BackgroundNotificationPreferencesProvider> 136 - </UnreadNotifsProvider> 137 - </SelectedFeedProvider> 138 - </LoggedOutViewProvider> 139 - </ModerationOptsProvider> 140 - </LabelDefsProvider> 141 - </MessagesProvider> 142 - </StatsigProvider> 143 - </QueryProvider> 144 - </React.Fragment> 145 - </RootSiblingParent> 111 + <ActiveVideoProvider> 112 + <RootSiblingParent> 113 + <React.Fragment 114 + // Resets the entire tree below when it changes: 115 + key={currentAccount?.did}> 116 + <QueryProvider currentDid={currentAccount?.did}> 117 + <StatsigProvider> 118 + <MessagesProvider> 119 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 120 + <LabelDefsProvider> 121 + <ModerationOptsProvider> 122 + <LoggedOutViewProvider> 123 + <SelectedFeedProvider> 124 + <UnreadNotifsProvider> 125 + <BackgroundNotificationPreferencesProvider> 126 + <MutedThreadsProvider> 127 + <TourProvider> 128 + <ProgressGuideProvider> 129 + <GestureHandlerRootView 130 + style={s.h100pct}> 131 + <TestCtrls /> 132 + <Shell /> 133 + </GestureHandlerRootView> 134 + </ProgressGuideProvider> 135 + </TourProvider> 136 + </MutedThreadsProvider> 137 + </BackgroundNotificationPreferencesProvider> 138 + </UnreadNotifsProvider> 139 + </SelectedFeedProvider> 140 + </LoggedOutViewProvider> 141 + </ModerationOptsProvider> 142 + </LabelDefsProvider> 143 + </MessagesProvider> 144 + </StatsigProvider> 145 + </QueryProvider> 146 + </React.Fragment> 147 + </RootSiblingParent> 148 + </ActiveVideoProvider> 146 149 </Splash> 147 150 </ThemeProvider> 148 151 </Alf>
+38 -35
src/App.web.tsx
··· 12 12 import {QueryProvider} from '#/lib/react-query' 13 13 import {Provider as StatsigProvider} from '#/lib/statsig/statsig' 14 14 import {ThemeProvider} from '#/lib/ThemeContext' 15 + import I18nProvider from '#/locale/i18nProvider' 15 16 import {logger} from '#/logger' 16 17 import {Provider as A11yProvider} from '#/state/a11y' 17 18 import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes' 18 19 import {Provider as DialogStateProvider} from '#/state/dialogs' 20 + import {listenSessionDropped} from '#/state/events' 19 21 import {Provider as InvitesStateProvider} from '#/state/invites' 20 22 import {Provider as LightboxStateProvider} from '#/state/lightbox' 21 23 import {MessagesProvider} from '#/state/messages' ··· 37 39 import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' 38 40 import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' 39 41 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 42 + import {ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoContext' 40 43 import * as Toast from '#/view/com/util/Toast' 41 44 import {ToastContainer} from '#/view/com/util/Toast.web' 42 45 import {Shell} from '#/view/shell/index' ··· 46 49 import {Provider as PortalProvider} from '#/components/Portal' 47 50 import {Provider as TourProvider} from '#/tours' 48 51 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 49 - import I18nProvider from './locale/i18nProvider' 50 - import {listenSessionDropped} from './state/events' 51 52 52 53 function InnerApp() { 53 54 const [isReady, setIsReady] = React.useState(false) ··· 92 93 <Alf theme={theme}> 93 94 <ThemeProvider theme={theme}> 94 95 <RootSiblingParent> 95 - <React.Fragment 96 - // Resets the entire tree below when it changes: 97 - key={currentAccount?.did}> 98 - <QueryProvider currentDid={currentAccount?.did}> 99 - <StatsigProvider> 100 - <MessagesProvider> 101 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 102 - <LabelDefsProvider> 103 - <ModerationOptsProvider> 104 - <LoggedOutViewProvider> 105 - <SelectedFeedProvider> 106 - <UnreadNotifsProvider> 107 - <BackgroundNotificationPreferencesProvider> 108 - <MutedThreadsProvider> 109 - <SafeAreaProvider> 110 - <TourProvider> 111 - <ProgressGuideProvider> 112 - <Shell /> 113 - </ProgressGuideProvider> 114 - </TourProvider> 115 - </SafeAreaProvider> 116 - </MutedThreadsProvider> 117 - </BackgroundNotificationPreferencesProvider> 118 - </UnreadNotifsProvider> 119 - </SelectedFeedProvider> 120 - </LoggedOutViewProvider> 121 - </ModerationOptsProvider> 122 - </LabelDefsProvider> 123 - </MessagesProvider> 124 - </StatsigProvider> 125 - </QueryProvider> 126 - </React.Fragment> 127 - <ToastContainer /> 96 + <ActiveVideoProvider> 97 + <React.Fragment 98 + // Resets the entire tree below when it changes: 99 + key={currentAccount?.did}> 100 + <QueryProvider currentDid={currentAccount?.did}> 101 + <StatsigProvider> 102 + <MessagesProvider> 103 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 104 + <LabelDefsProvider> 105 + <ModerationOptsProvider> 106 + <LoggedOutViewProvider> 107 + <SelectedFeedProvider> 108 + <UnreadNotifsProvider> 109 + <BackgroundNotificationPreferencesProvider> 110 + <MutedThreadsProvider> 111 + <SafeAreaProvider> 112 + <TourProvider> 113 + <ProgressGuideProvider> 114 + <Shell /> 115 + </ProgressGuideProvider> 116 + </TourProvider> 117 + </SafeAreaProvider> 118 + </MutedThreadsProvider> 119 + </BackgroundNotificationPreferencesProvider> 120 + </UnreadNotifsProvider> 121 + </SelectedFeedProvider> 122 + </LoggedOutViewProvider> 123 + </ModerationOptsProvider> 124 + </LabelDefsProvider> 125 + </MessagesProvider> 126 + </StatsigProvider> 127 + </QueryProvider> 128 + </React.Fragment> 129 + <ToastContainer /> 130 + </ActiveVideoProvider> 128 131 </RootSiblingParent> 129 132 </ThemeProvider> 130 133 </Alf>
+9
src/components/icons/Play.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const Play_Stroke2_Corner2_Rounded = createSinglePathSVG({ 4 + path: 'M5 5.086C5 2.736 7.578 1.3 9.576 2.534L20.77 9.448c1.899 1.172 1.899 3.932 0 5.104L9.576 21.466C7.578 22.701 5 21.263 5 18.914V5.086Zm3.525-.85A1 1 0 0 0 7 5.085v13.828a1 1 0 0 0 1.525.85l11.194-6.913a1 1 0 0 0 0-1.702L8.525 4.235Z', 5 + }) 6 + 7 + export const Play_Filled_Corner2_Rounded = createSinglePathSVG({ 8 + path: 'M9.576 2.534C7.578 1.299 5 2.737 5 5.086v13.828c0 2.35 2.578 3.787 4.576 2.552l11.194-6.914c1.899-1.172 1.899-3.932 0-5.104L9.576 2.534Z', 9 + })
+32 -30
src/view/com/post/Post.tsx
··· 210 210 </View> 211 211 )} 212 212 <LabelsOnMyPost post={post} /> 213 - <ContentHider 214 - modui={moderation.ui('contentView')} 215 - style={styles.contentHider} 216 - childContainerStyle={styles.contentHiderChild}> 217 - <PostAlerts 213 + {false && ( 214 + <ContentHider 218 215 modui={moderation.ui('contentView')} 219 - style={[a.py_xs]} 220 - /> 221 - {richText.text ? ( 222 - <View style={styles.postTextContainer}> 223 - <RichText 224 - enableTags 225 - testID="postText" 226 - value={richText} 227 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 228 - style={[a.flex_1, a.text_md]} 229 - authorHandle={post.author.handle} 230 - /> 231 - </View> 232 - ) : undefined} 233 - {limitLines ? ( 234 - <TextLink 235 - text={_(msg`Show More`)} 236 - style={pal.link} 237 - onPress={onPressShowMore} 238 - href="#" 216 + style={styles.contentHider} 217 + childContainerStyle={styles.contentHiderChild}> 218 + <PostAlerts 219 + modui={moderation.ui('contentView')} 220 + style={[a.py_xs]} 239 221 /> 240 - ) : undefined} 241 - {post.embed ? ( 242 - <PostEmbeds embed={post.embed} moderation={moderation} /> 243 - ) : null} 244 - </ContentHider> 222 + {richText.text ? ( 223 + <View style={styles.postTextContainer}> 224 + <RichText 225 + enableTags 226 + testID="postText" 227 + value={richText} 228 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 229 + style={[a.flex_1, a.text_md]} 230 + authorHandle={post.author.handle} 231 + /> 232 + </View> 233 + ) : undefined} 234 + {limitLines ? ( 235 + <TextLink 236 + text={_(msg`Show More`)} 237 + style={pal.link} 238 + onPress={onPressShowMore} 239 + href="#" 240 + /> 241 + ) : undefined} 242 + {post.embed ? ( 243 + <PostEmbeds embed={post.embed} moderation={moderation} /> 244 + ) : null} 245 + </ContentHider> 246 + )} 245 247 <PostCtrls 246 248 post={post} 247 249 record={record}
+9 -2
src/view/com/posts/FeedItem.tsx
··· 16 16 import {useLingui} from '@lingui/react' 17 17 import {useQueryClient} from '@tanstack/react-query' 18 18 19 + import {useGate} from '#/lib/statsig/statsig' 19 20 import {POST_TOMBSTONE, Shadow, usePostShadow} from '#/state/cache/post-shadow' 20 21 import {useFeedFeedbackContext} from '#/state/feed-feedback' 22 + import {useSession} from '#/state/session' 21 23 import {useComposerControls} from '#/state/shell/composer' 22 24 import {isReasonFeedSource, ReasonFeedSource} from 'lib/api/feed/types' 23 25 import {MAX_POST_LINES} from 'lib/constants' ··· 29 31 import {s} from 'lib/styles' 30 32 import {precacheProfile} from 'state/queries/profile' 31 33 import {atoms as a} from '#/alf' 34 + import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 32 35 import {ContentHider} from '#/components/moderation/ContentHider' 33 36 import {ProfileHoverCard} from '#/components/ProfileHoverCard' 34 37 import {RichText} from '#/components/RichText' ··· 38 41 import {Link, TextLink, TextLinkOnWebOnly} from '../util/Link' 39 42 import {PostCtrls} from '../util/post-ctrls/PostCtrls' 40 43 import {PostEmbeds} from '../util/post-embeds' 44 + import {VideoEmbed} from '../util/post-embeds/VideoEmbed' 41 45 import {PostMeta} from '../util/PostMeta' 42 46 import {Text} from '../util/text/Text' 43 47 import {PreviewableUserAvatar} from '../util/UserAvatar' 44 48 import {AviFollowButton} from './AviFollowButton' 45 49 import hairlineWidth = StyleSheet.hairlineWidth 46 - import {useSession} from '#/state/session' 47 - import {Repost_Stroke2_Corner2_Rounded as Repost} from '#/components/icons/Repost' 48 50 49 51 interface FeedItemProps { 50 52 record: AppBskyFeedPost.Record ··· 136 138 const {openComposer} = useComposerControls() 137 139 const pal = usePalette('default') 138 140 const {_} = useLingui() 141 + const gate = useGate() 142 + 139 143 const href = useMemo(() => { 140 144 const urip = new AtUri(post.uri) 141 145 return makeProfileLink(post.author, 'post', urip.rkey) ··· 354 358 postAuthor={post.author} 355 359 onOpenEmbed={onOpenEmbed} 356 360 /> 361 + {__DEV__ && gate('videos') && ( 362 + <VideoEmbed source="https://lumi.jazco.dev/watch/did:plc:q6gjnaw2blty4crticxkmujt/Qmc8w93UpTa2adJHg4ZhnDPrBs1EsbzrekzPcqF5SwusuZ/playlist.m3u8" /> 363 + )} 357 364 <PostCtrls 358 365 post={post} 359 366 record={record}
+48
src/view/com/util/post-embeds/ActiveVideoContext.tsx
··· 1 + import React, {useCallback, useId, useMemo, useState} from 'react' 2 + 3 + import {VideoPlayerProvider} from './VideoPlayerContext' 4 + 5 + const ActiveVideoContext = React.createContext<{ 6 + activeViewId: string | null 7 + setActiveView: (viewId: string, src: string) => void 8 + } | null>(null) 9 + 10 + export function ActiveVideoProvider({children}: {children: React.ReactNode}) { 11 + const [activeViewId, setActiveViewId] = useState<string | null>(null) 12 + const [source, setSource] = useState<string | null>(null) 13 + 14 + const value = useMemo( 15 + () => ({ 16 + activeViewId, 17 + setActiveView: (viewId: string, src: string) => { 18 + setActiveViewId(viewId) 19 + setSource(src) 20 + }, 21 + }), 22 + [activeViewId], 23 + ) 24 + 25 + return ( 26 + <ActiveVideoContext.Provider value={value}> 27 + <VideoPlayerProvider source={source ?? ''} viewId={activeViewId}> 28 + {children} 29 + </VideoPlayerProvider> 30 + </ActiveVideoContext.Provider> 31 + ) 32 + } 33 + 34 + export function useActiveVideoView() { 35 + const context = React.useContext(ActiveVideoContext) 36 + if (!context) { 37 + throw new Error('useActiveVideo must be used within a ActiveVideoProvider') 38 + } 39 + const id = useId() 40 + 41 + return { 42 + active: context.activeViewId === id, 43 + setActive: useCallback( 44 + (source: string) => context.setActiveView(id, source), 45 + [context, id], 46 + ), 47 + } 48 + }
+44
src/view/com/util/post-embeds/VideoEmbed.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import {View} from 'react-native' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + 6 + import {atoms as a, useTheme} from '#/alf' 7 + import {Button, ButtonIcon} from '#/components/Button' 8 + import {Play_Filled_Corner2_Rounded as PlayIcon} from '#/components/icons/Play' 9 + import {useActiveVideoView} from './ActiveVideoContext' 10 + import {VideoEmbedInner} from './VideoEmbedInner' 11 + 12 + export function VideoEmbed({source}: {source: string}) { 13 + const t = useTheme() 14 + const {active, setActive} = useActiveVideoView() 15 + const {_} = useLingui() 16 + 17 + const onPress = useCallback(() => setActive(source), [setActive, source]) 18 + 19 + return ( 20 + <View 21 + style={[ 22 + a.w_full, 23 + a.rounded_sm, 24 + {aspectRatio: 16 / 9}, 25 + a.overflow_hidden, 26 + t.atoms.bg_contrast_25, 27 + a.my_xs, 28 + ]}> 29 + {active ? ( 30 + <VideoEmbedInner source={source} /> 31 + ) : ( 32 + <Button 33 + style={[a.flex_1, t.atoms.bg_contrast_25]} 34 + onPress={onPress} 35 + label={_(msg`Play video`)} 36 + variant="ghost" 37 + color="secondary" 38 + size="large"> 39 + <ButtonIcon icon={PlayIcon} /> 40 + </Button> 41 + )} 42 + </View> 43 + ) 44 + }
+138
src/view/com/util/post-embeds/VideoEmbedInner.tsx
··· 1 + import React, {useCallback, useEffect, useRef, useState} from 'react' 2 + import {Pressable, StyleSheet, useWindowDimensions, View} from 'react-native' 3 + import Animated, { 4 + measure, 5 + runOnJS, 6 + useAnimatedRef, 7 + useFrameCallback, 8 + useSharedValue, 9 + } from 'react-native-reanimated' 10 + import {VideoPlayer, VideoView} from 'expo-video' 11 + 12 + import {atoms as a} from '#/alf' 13 + import {Text} from '#/components/Typography' 14 + import {useVideoPlayer} from './VideoPlayerContext' 15 + 16 + export const VideoEmbedInner = ({}: {source: string}) => { 17 + const player = useVideoPlayer() 18 + const aref = useAnimatedRef<Animated.View>() 19 + const {height: windowHeight} = useWindowDimensions() 20 + const hasLeftView = useSharedValue(false) 21 + const ref = useRef<VideoView>(null) 22 + 23 + const onEnterView = useCallback(() => { 24 + if (player.status === 'readyToPlay') { 25 + player.play() 26 + } 27 + }, [player]) 28 + 29 + const onLeaveView = useCallback(() => { 30 + player.pause() 31 + }, [player]) 32 + 33 + const enterFullscreen = useCallback(() => { 34 + if (ref.current) { 35 + ref.current.enterFullscreen() 36 + } 37 + }, []) 38 + 39 + useFrameCallback(() => { 40 + const measurement = measure(aref) 41 + 42 + if (measurement) { 43 + if (hasLeftView.value) { 44 + // Check if the video is in view 45 + if ( 46 + measurement.pageY >= 0 && 47 + measurement.pageY + measurement.height <= windowHeight 48 + ) { 49 + runOnJS(onEnterView)() 50 + hasLeftView.value = false 51 + } 52 + } else { 53 + // Check if the video is out of view 54 + if ( 55 + measurement.pageY + measurement.height < 0 || 56 + measurement.pageY > windowHeight 57 + ) { 58 + runOnJS(onLeaveView)() 59 + hasLeftView.value = true 60 + } 61 + } 62 + } 63 + }) 64 + 65 + return ( 66 + <Animated.View 67 + style={[a.flex_1, a.relative]} 68 + ref={aref} 69 + collapsable={false}> 70 + <VideoView 71 + ref={ref} 72 + player={player} 73 + style={a.flex_1} 74 + nativeControls={true} 75 + /> 76 + <VideoControls player={player} enterFullscreen={enterFullscreen} /> 77 + </Animated.View> 78 + ) 79 + } 80 + 81 + function VideoControls({ 82 + player, 83 + enterFullscreen, 84 + }: { 85 + player: VideoPlayer 86 + enterFullscreen: () => void 87 + }) { 88 + const [currentTime, setCurrentTime] = useState(Math.floor(player.currentTime)) 89 + 90 + useEffect(() => { 91 + const interval = setInterval(() => { 92 + setCurrentTime(Math.floor(player.duration - player.currentTime)) 93 + // how often should we update the time? 94 + // 1000 gets out of sync with the video time 95 + }, 250) 96 + 97 + return () => { 98 + clearInterval(interval) 99 + } 100 + }, [player]) 101 + 102 + const minutes = Math.floor(currentTime / 60) 103 + const seconds = String(currentTime % 60).padStart(2, '0') 104 + 105 + return ( 106 + <View style={[a.absolute, a.inset_0]}> 107 + <View style={styles.timeContainer} pointerEvents="none"> 108 + <Text style={styles.timeElapsed}> 109 + {minutes}:{seconds} 110 + </Text> 111 + </View> 112 + <Pressable 113 + onPress={enterFullscreen} 114 + style={a.flex_1} 115 + accessibilityLabel="Video" 116 + accessibilityHint="Tap to enter full screen" 117 + accessibilityRole="button" 118 + /> 119 + </View> 120 + ) 121 + } 122 + 123 + const styles = StyleSheet.create({ 124 + timeContainer: { 125 + backgroundColor: 'rgba(0, 0, 0, 0.75)', 126 + borderRadius: 6, 127 + paddingHorizontal: 6, 128 + paddingVertical: 3, 129 + position: 'absolute', 130 + left: 5, 131 + bottom: 5, 132 + }, 133 + timeElapsed: { 134 + color: 'white', 135 + fontSize: 12, 136 + fontWeight: 'bold', 137 + }, 138 + })
+52
src/view/com/util/post-embeds/VideoEmbedInner.web.tsx
··· 1 + import React, {useEffect, useRef} from 'react' 2 + import Hls from 'hls.js' 3 + 4 + import {atoms as a} from '#/alf' 5 + 6 + export const VideoEmbedInner = ({source}: {source: string}) => { 7 + const ref = useRef<HTMLVideoElement>(null) 8 + 9 + // Use HLS.js to play HLS video 10 + useEffect(() => { 11 + if (ref.current) { 12 + if (ref.current.canPlayType('application/vnd.apple.mpegurl')) { 13 + ref.current.src = source 14 + } else if (Hls.isSupported()) { 15 + var hls = new Hls() 16 + hls.loadSource(source) 17 + hls.attachMedia(ref.current) 18 + } else { 19 + // TODO: fallback 20 + } 21 + } 22 + }, [source]) 23 + 24 + useEffect(() => { 25 + if (ref.current) { 26 + const observer = new IntersectionObserver( 27 + ([entry]) => { 28 + if (ref.current) { 29 + if (entry.isIntersecting) { 30 + if (ref.current.paused) { 31 + ref.current.play() 32 + } 33 + } else { 34 + if (!ref.current.paused) { 35 + ref.current.pause() 36 + } 37 + } 38 + } 39 + }, 40 + {threshold: 0}, 41 + ) 42 + 43 + observer.observe(ref.current) 44 + 45 + return () => { 46 + observer.disconnect() 47 + } 48 + } 49 + }, []) 50 + 51 + return <video ref={ref} style={a.flex_1} controls playsInline autoPlay loop /> 52 + }
+41
src/view/com/util/post-embeds/VideoPlayerContext.tsx
··· 1 + import React, {useContext, useEffect} from 'react' 2 + import type {VideoPlayer} from 'expo-video' 3 + import {useVideoPlayer as useExpoVideoPlayer} from 'expo-video' 4 + 5 + const VideoPlayerContext = React.createContext<VideoPlayer | null>(null) 6 + 7 + export function VideoPlayerProvider({ 8 + viewId, 9 + source, 10 + children, 11 + }: { 12 + viewId: string | null 13 + source: string 14 + children: React.ReactNode 15 + }) { 16 + // eslint-disable-next-line @typescript-eslint/no-shadow 17 + const player = useExpoVideoPlayer(source, player => { 18 + player.loop = true 19 + player.play() 20 + }) 21 + 22 + // make sure we're playing every time the viewId changes 23 + // this means the video is different 24 + useEffect(() => { 25 + player.play() 26 + }, [viewId, player]) 27 + 28 + return ( 29 + <VideoPlayerContext.Provider value={player}> 30 + {children} 31 + </VideoPlayerContext.Provider> 32 + ) 33 + } 34 + 35 + export function useVideoPlayer() { 36 + const context = useContext(VideoPlayerContext) 37 + if (!context) { 38 + throw new Error('useVideoPlayer must be used within a VideoPlayerProvider') 39 + } 40 + return context 41 + }
+9
src/view/com/util/post-embeds/VideoPlayerContext.web.tsx
··· 1 + import React from 'react' 2 + 3 + export function VideoPlayerProvider({children}: {children: React.ReactNode}) { 4 + return children 5 + } 6 + 7 + export function useVideoPlayer() { 8 + throw new Error('useVideoPlayer must not be used on web') 9 + }
+5
yarn.lock
··· 13345 13345 dependencies: 13346 13346 "@babel/runtime" "^7.7.6" 13347 13347 13348 + hls.js@^1.5.11: 13349 + version "1.5.11" 13350 + resolved "https://registry.yarnpkg.com/hls.js/-/hls.js-1.5.11.tgz#3941347df454983859ae8c75fe19e8818719a826" 13351 + integrity sha512-q3We1izi2+qkOO+TvZdHv+dx6aFzdtk3xc1/Qesrvto4thLTT/x/1FK85c5h1qZE4MmMBNgKg+MIW8nxQfxwBw== 13352 + 13348 13353 hmac-drbg@^1.0.1: 13349 13354 version "1.0.1" 13350 13355 resolved "https://registry.yarnpkg.com/hmac-drbg/-/hmac-drbg-1.0.1.tgz#d2745701025a6c775a6c545793ed502fc0c649a1"