Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[Video] Remove `expo-video`, use `bluesky-video` (#5282)

Co-authored-by: Samuel Newman <mozzius@protonmail.com>

authored by

Hailey
Samuel Newman
and committed by
GitHub
26508cfe 78a531f5

+269 -385
-1
app.config.js
··· 211 211 sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], 212 212 }, 213 213 ], 214 - 'expo-video', 215 214 'react-native-compressor', 216 215 './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', 217 216 './plugins/withAndroidManifestPlugin.js',
+2 -2
package.json
··· 1 1 { 2 2 "name": "bsky.app", 3 - "version": "1.91.0", 3 + "version": "1.91.1", 4 4 "private": true, 5 5 "engines": { 6 6 "node": ">=18" ··· 68 68 "@fortawesome/free-regular-svg-icons": "^6.1.1", 69 69 "@fortawesome/free-solid-svg-icons": "^6.1.1", 70 70 "@fortawesome/react-native-fontawesome": "^0.3.2", 71 + "@haileyok/bluesky-video": "0.1.2", 71 72 "@lingui/react": "^4.5.0", 72 73 "@mattermost/react-native-paste-input": "^0.7.1", 73 74 "@miblanchard/react-native-slider": "^2.3.1", ··· 139 140 "expo-system-ui": "~3.0.4", 140 141 "expo-task-manager": "~11.8.1", 141 142 "expo-updates": "~0.25.14", 142 - "expo-video": "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz", 143 143 "expo-web-browser": "~13.0.3", 144 144 "fast-text-encoding": "^1.0.6", 145 145 "history": "^5.3.0",
+36 -43
src/App.native.tsx
··· 52 52 import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' 53 53 import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' 54 54 import {TestCtrls} from '#/view/com/testing/TestCtrls' 55 - import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext' 56 55 import * as Toast from '#/view/com/util/Toast' 57 56 import {Shell} from '#/view/shell' 58 57 import {ThemeProvider as Alf} from '#/alf' ··· 63 62 import {Provider as PortalProvider} from '#/components/Portal' 64 63 import {Splash} from '#/Splash' 65 64 import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 66 - import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army' 67 65 68 66 SplashScreen.preventAutoHideAsync() 69 67 ··· 110 108 <Alf theme={theme}> 111 109 <ThemeProvider theme={theme}> 112 110 <Splash isReady={isReady && hasCheckedReferrer}> 113 - <ActiveVideoProvider> 114 - <RootSiblingParent> 115 - <React.Fragment 116 - // Resets the entire tree below when it changes: 117 - key={currentAccount?.did}> 118 - <QueryProvider currentDid={currentAccount?.did}> 119 - <StatsigProvider> 120 - <MessagesProvider> 121 - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 122 - <LabelDefsProvider> 123 - <ModerationOptsProvider> 124 - <LoggedOutViewProvider> 125 - <SelectedFeedProvider> 126 - <HiddenRepliesProvider> 127 - <UnreadNotifsProvider> 128 - <BackgroundNotificationPreferencesProvider> 129 - <MutedThreadsProvider> 130 - <ProgressGuideProvider> 131 - <GestureHandlerRootView 132 - style={s.h100pct}> 133 - <TestCtrls /> 134 - <Shell /> 135 - <NuxDialogs /> 136 - </GestureHandlerRootView> 137 - </ProgressGuideProvider> 138 - </MutedThreadsProvider> 139 - </BackgroundNotificationPreferencesProvider> 140 - </UnreadNotifsProvider> 141 - </HiddenRepliesProvider> 142 - </SelectedFeedProvider> 143 - </LoggedOutViewProvider> 144 - </ModerationOptsProvider> 145 - </LabelDefsProvider> 146 - </MessagesProvider> 147 - </StatsigProvider> 148 - </QueryProvider> 149 - </React.Fragment> 150 - </RootSiblingParent> 151 - </ActiveVideoProvider> 111 + <RootSiblingParent> 112 + <React.Fragment 113 + // Resets the entire tree below when it changes: 114 + key={currentAccount?.did}> 115 + <QueryProvider currentDid={currentAccount?.did}> 116 + <StatsigProvider> 117 + <MessagesProvider> 118 + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} 119 + <LabelDefsProvider> 120 + <ModerationOptsProvider> 121 + <LoggedOutViewProvider> 122 + <SelectedFeedProvider> 123 + <HiddenRepliesProvider> 124 + <UnreadNotifsProvider> 125 + <BackgroundNotificationPreferencesProvider> 126 + <MutedThreadsProvider> 127 + <ProgressGuideProvider> 128 + <GestureHandlerRootView style={s.h100pct}> 129 + <TestCtrls /> 130 + <Shell /> 131 + <NuxDialogs /> 132 + </GestureHandlerRootView> 133 + </ProgressGuideProvider> 134 + </MutedThreadsProvider> 135 + </BackgroundNotificationPreferencesProvider> 136 + </UnreadNotifsProvider> 137 + </HiddenRepliesProvider> 138 + </SelectedFeedProvider> 139 + </LoggedOutViewProvider> 140 + </ModerationOptsProvider> 141 + </LabelDefsProvider> 142 + </MessagesProvider> 143 + </StatsigProvider> 144 + </QueryProvider> 145 + </React.Fragment> 146 + </RootSiblingParent> 152 147 </Splash> 153 148 </ThemeProvider> 154 149 </Alf> ··· 159 154 const [isReady, setReady] = useState(false) 160 155 161 156 React.useEffect(() => { 162 - PlatformInfo.setAudioCategory(AudioCategory.Ambient) 163 - PlatformInfo.setAudioActive(false) 164 157 initPersistedState().then(() => setReady(true)) 165 158 }, []) 166 159
+1 -1
src/components/video/PlayButtonIcon.tsx
··· 4 4 import {atoms as a, useTheme} from '#/alf' 5 5 import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 6 6 7 - export function PlayButtonIcon({size = 36}: {size?: number}) { 7 + export function PlayButtonIcon({size = 32}: {size?: number}) { 8 8 const t = useTheme() 9 9 const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975 10 10 const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
+8 -16
src/view/com/composer/videos/VideoPreview.tsx
··· 1 - /* eslint-disable @typescript-eslint/no-shadow */ 2 1 import React from 'react' 3 2 import {View} from 'react-native' 4 3 import {ImagePickerAsset} from 'expo-image-picker' 5 - import {useVideoPlayer, VideoView} from 'expo-video' 4 + import {BlueskyVideoView} from '@haileyok/bluesky-video' 6 5 7 6 import {CompressedVideo} from '#/lib/media/video/types' 8 7 import {clamp} from '#/lib/numbers' ··· 22 21 clear: () => void 23 22 }) { 24 23 const t = useTheme() 24 + const playerRef = React.useRef<BlueskyVideoView>(null) 25 25 const autoplayDisabled = useAutoplayDisabled() 26 - const player = useVideoPlayer(video.uri, player => { 27 - player.loop = true 28 - player.muted = true 29 - if (!autoplayDisabled) { 30 - player.play() 31 - } 32 - }) 33 - 34 26 let aspectRatio = asset.width / asset.height 35 27 36 28 if (isNaN(aspectRatio)) { ··· 50 42 t.atoms.border_contrast_low, 51 43 {backgroundColor: 'black'}, 52 44 ]}> 53 - <VideoView 54 - player={player} 55 - style={a.flex_1} 56 - allowsPictureInPicture={false} 57 - nativeControls={false} 58 - contentFit="contain" 45 + <BlueskyVideoView 46 + url={video.uri} 47 + autoplay={autoplayDisabled} 48 + beginMuted={true} 49 + forceTakeover={true} 50 + ref={playerRef} 59 51 /> 60 52 <ExternalEmbedRemoveBtn onRemove={clear} /> 61 53 {autoplayDisabled && (
+4 -4
src/view/com/util/List.tsx
··· 1 1 import React, {memo} from 'react' 2 2 import {FlatListProps, RefreshControl, ViewToken} from 'react-native' 3 3 import {runOnJS, useSharedValue} from 'react-native-reanimated' 4 + import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' 4 5 5 6 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 6 7 import {usePalette} from '#/lib/hooks/usePalette' ··· 8 9 import {useDedupe} from 'lib/hooks/useDedupe' 9 10 import {addStyle} from 'lib/styles' 10 11 import {isIOS} from 'platform/detection' 11 - import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' 12 12 import {FlatList_INTERNAL} from './Views' 13 13 14 14 export type ListMethods = FlatList_INTERNAL ··· 69 69 onBeginDragFromContext?.(e, ctx) 70 70 }, 71 71 onEndDrag(e, ctx) { 72 - runOnJS(updateActiveViewAsync)() 72 + runOnJS(updateActiveVideoViewAsync)() 73 73 onEndDragFromContext?.(e, ctx) 74 74 }, 75 75 onScroll(e, ctx) { ··· 84 84 } 85 85 86 86 if (isIOS) { 87 - runOnJS(dedupe)(updateActiveViewAsync) 87 + runOnJS(dedupe)(updateActiveVideoViewAsync) 88 88 } 89 89 }, 90 90 // Note: adding onMomentumBegin here makes simulator scroll 91 91 // lag on Android. So either don't add it, or figure out why. 92 92 onMomentumEnd(e, ctx) { 93 - runOnJS(updateActiveViewAsync)() 93 + runOnJS(updateActiveVideoViewAsync)() 94 94 onMomentumEndFromContext?.(e, ctx) 95 95 }, 96 96 })
-65
src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
··· 1 - import React from 'react' 2 - import {useVideoPlayer, VideoPlayer} from 'expo-video' 3 - 4 - import {isAndroid, isNative} from '#/platform/detection' 5 - 6 - const Context = React.createContext<{ 7 - activeSource: string 8 - activeViewId: string | undefined 9 - setActiveSource: (src: string | null, viewId: string | null) => void 10 - player: VideoPlayer 11 - } | null>(null) 12 - 13 - export function Provider({children}: {children: React.ReactNode}) { 14 - if (!isNative) { 15 - throw new Error('ActiveVideoProvider may only be used on native.') 16 - } 17 - 18 - const [activeSource, setActiveSource] = React.useState('') 19 - const [activeViewId, setActiveViewId] = React.useState<string>() 20 - 21 - const player = useVideoPlayer(activeSource, p => { 22 - p.muted = true 23 - p.loop = true 24 - // We want to immediately call `play` so we get the loading state 25 - p.play() 26 - }) 27 - 28 - const setActiveSourceOuter = (src: string | null, viewId: string | null) => { 29 - // HACK 30 - // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually 31 - // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to 32 - // apply it there. 33 - if (src === activeSource && isAndroid) { 34 - setActiveSource('') 35 - setTimeout(() => { 36 - setActiveSource(src ? src : '') 37 - }, 100) 38 - } else { 39 - setActiveSource(src ? src : '') 40 - } 41 - setActiveViewId(viewId ? viewId : '') 42 - } 43 - 44 - return ( 45 - <Context.Provider 46 - value={{ 47 - activeSource, 48 - setActiveSource: setActiveSourceOuter, 49 - activeViewId, 50 - player, 51 - }}> 52 - {children} 53 - </Context.Provider> 54 - ) 55 - } 56 - 57 - export function useActiveVideoNative() { 58 - const context = React.useContext(Context) 59 - if (!context) { 60 - throw new Error( 61 - 'useActiveVideoNative must be used within a ActiveVideoNativeProvider', 62 - ) 63 - } 64 - return context 65 - }
+31 -111
src/view/com/util/post-embeds/VideoEmbed.tsx
··· 1 - import React, {useCallback, useEffect, useId, useState} from 'react' 1 + import React, {useCallback, useState} from 'react' 2 2 import {View} from 'react-native' 3 3 import {ImageBackground} from 'expo-image' 4 - import {PlayerError, VideoPlayerStatus} from 'expo-video' 5 4 import {AppBskyEmbedVideo} from '@atproto/api' 6 5 import {msg, Trans} from '@lingui/macro' 7 6 import {useLingui} from '@lingui/react' 8 7 9 8 import {clamp} from '#/lib/numbers' 10 - import {useAutoplayDisabled} from 'state/preferences' 11 9 import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' 12 10 import {atoms as a} from '#/alf' 13 11 import {Button} from '#/components/Button' 14 - import {useIsWithinMessage} from '#/components/dms/MessageContext' 12 + import {useThrottledValue} from '#/components/hooks/useThrottledValue' 15 13 import {Loader} from '#/components/Loader' 16 14 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' 17 - import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' 18 15 import {ErrorBoundary} from '../ErrorBoundary' 19 - import {useActiveVideoNative} from './ActiveVideoNativeContext' 20 16 import * as VideoFallback from './VideoEmbedInner/VideoFallback' 21 17 22 18 interface Props { ··· 59 55 60 56 function InnerWrapper({embed}: Props) { 61 57 const {_} = useLingui() 62 - const {activeSource, activeViewId, setActiveSource, player} = 63 - useActiveVideoNative() 64 - const viewId = useId() 65 - 66 - const [playerStatus, setPlayerStatus] = useState< 67 - VideoPlayerStatus | 'paused' 68 - >('paused') 69 - const [isMuted, setIsMuted] = useState(player.muted) 70 - const [isFullscreen, setIsFullscreen] = React.useState(false) 71 - const [timeRemaining, setTimeRemaining] = React.useState(0) 72 - const isWithinMessage = useIsWithinMessage() 73 - const disableAutoplay = useAutoplayDisabled() || isWithinMessage 74 - const isActive = embed.playlist === activeSource && activeViewId === viewId 75 - // There are some different loading states that we should pay attention to and show a spinner for 76 - const isLoading = 77 - isActive && 78 - (playerStatus === 'waitingToPlayAtSpecifiedRate' || 79 - playerStatus === 'loading') 80 - // This happens whenever the visibility view decides that another video should start playing 81 - const showOverlay = !isActive || isLoading || playerStatus === 'paused' 58 + const ref = React.useRef<{togglePlayback: () => void}>(null) 82 59 83 - // send error up to error boundary 84 - const [error, setError] = useState<Error | PlayerError | null>(null) 85 - if (error) { 86 - throw error 87 - } 88 - 89 - useEffect(() => { 90 - if (isActive) { 91 - // eslint-disable-next-line @typescript-eslint/no-shadow 92 - const volumeSub = player.addListener('volumeChange', ({isMuted}) => { 93 - setIsMuted(isMuted) 94 - }) 95 - const timeSub = player.addListener( 96 - 'timeRemainingChange', 97 - secondsRemaining => { 98 - setTimeRemaining(secondsRemaining) 99 - }, 100 - ) 101 - const statusSub = player.addListener( 102 - 'statusChange', 103 - (status, oldStatus, playerError) => { 104 - setPlayerStatus(status) 105 - if (status === 'error') { 106 - setError(playerError ?? new Error('Unknown player error')) 107 - } 108 - if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') { 109 - player.play() 110 - } 111 - }, 112 - ) 113 - return () => { 114 - volumeSub.remove() 115 - timeSub.remove() 116 - statusSub.remove() 117 - } 118 - } 119 - }, [player, isActive, disableAutoplay]) 120 - 121 - // The source might already be active (for example, if you are scrolling a list of quotes and its all the same 122 - // video). In those cases, just start playing. Otherwise, setting the active source will result in the video 123 - // start playback immediately 124 - const startPlaying = (ignoreAutoplayPreference: boolean) => { 125 - if (disableAutoplay && !ignoreAutoplayPreference) { 126 - return 127 - } 60 + const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( 61 + 'pending', 62 + ) 63 + const [isLoading, setIsLoading] = React.useState(false) 64 + const [isActive, setIsActive] = React.useState(false) 65 + const showSpinner = useThrottledValue(isActive && isLoading, 100) 128 66 129 - if (isActive) { 130 - player.play() 131 - } else { 132 - setActiveSource(embed.playlist, viewId) 133 - } 134 - } 67 + const showOverlay = 68 + !isActive || 69 + isLoading || 70 + (status === 'paused' && !isActive) || 71 + status === 'pending' 135 72 136 - const onVisibilityStatusChange = (isVisible: boolean) => { 137 - // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change 138 - // events 139 - if (isFullscreen) { 140 - return 73 + React.useEffect(() => { 74 + if (!isActive && status !== 'pending') { 75 + setStatus('pending') 141 76 } 142 - if (isVisible) { 143 - startPlaying(false) 144 - } else { 145 - // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted 146 - // until it gets replaced by another video 147 - if (disableAutoplay) { 148 - setActiveSource(null, null) 149 - } else { 150 - player.muted = true 151 - if (player.playing) { 152 - player.pause() 153 - } 154 - } 155 - } 156 - } 77 + }, [isActive, status]) 157 78 158 79 return ( 159 - <VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}> 160 - {isActive ? ( 161 - <VideoEmbedInnerNative 162 - embed={embed} 163 - timeRemaining={timeRemaining} 164 - isMuted={isMuted} 165 - isFullscreen={isFullscreen} 166 - setIsFullscreen={setIsFullscreen} 167 - /> 168 - ) : null} 80 + <> 81 + <VideoEmbedInnerNative 82 + embed={embed} 83 + setStatus={setStatus} 84 + setIsLoading={setIsLoading} 85 + setIsActive={setIsActive} 86 + ref={ref} 87 + /> 169 88 <ImageBackground 170 89 source={{uri: embed.thumbnail}} 171 90 accessibilityIgnoresInvertColors ··· 185 104 > 186 105 <Button 187 106 style={[a.flex_1, a.align_center, a.justify_center]} 188 - onPress={() => startPlaying(true)} 107 + onPress={() => { 108 + ref.current?.togglePlayback() 109 + }} 189 110 label={_(msg`Play video`)} 190 111 color="secondary"> 191 - {isLoading ? ( 112 + {showSpinner ? ( 192 113 <View 193 114 style={[ 194 115 a.rounded_full, 195 116 a.p_xs, 196 117 a.align_center, 197 118 a.justify_center, 198 - {backgroundColor: 'rgba(0,0,0,0.5)'}, 199 119 ]}> 200 120 <Loader size="2xl" style={{color: 'white'}} /> 201 121 </View> ··· 204 124 )} 205 125 </Button> 206 126 </ImageBackground> 207 - </VisibilityView> 127 + </> 208 128 ) 209 129 } 210 130
+12 -3
src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx
··· 1 1 import React from 'react' 2 + import {StyleProp, ViewStyle} from 'react-native' 2 3 import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' 3 4 4 5 import {atoms as a, native, useTheme} from '#/alf' ··· 8 9 * Absolutely positioned time indicator showing how many seconds are remaining 9 10 * Time is in seconds 10 11 */ 11 - export function TimeIndicator({time}: {time: number}) { 12 + export function TimeIndicator({ 13 + time, 14 + style, 15 + }: { 16 + time: number 17 + style?: StyleProp<ViewStyle> 18 + }) { 12 19 const t = useTheme() 13 20 14 21 if (isNaN(time)) { ··· 22 29 <Animated.View 23 30 entering={native(FadeInDown.duration(300))} 24 31 exiting={native(FadeOutDown.duration(500))} 32 + pointerEvents="none" 25 33 style={[ 26 34 { 27 35 backgroundColor: 'rgba(0, 0, 0, 0.5)', 28 36 borderRadius: 6, 29 37 paddingHorizontal: 6, 30 38 paddingVertical: 3, 31 - position: 'absolute', 32 39 left: 6, 33 40 bottom: 6, 34 41 minHeight: 21, 35 - justifyContent: 'center', 36 42 }, 43 + a.absolute, 44 + a.justify_center, 45 + style, 37 46 ]}> 38 47 <Text 39 48 style={[
+170 -135
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 1 - import React, {useCallback, useRef} from 'react' 2 - import {Pressable, View} from 'react-native' 1 + import React, {useRef} from 'react' 2 + import {Pressable, StyleProp, View, ViewStyle} from 'react-native' 3 3 import Animated, {FadeInDown} from 'react-native-reanimated' 4 - import {VideoPlayer, VideoView} from 'expo-video' 5 4 import {AppBskyEmbedVideo} from '@atproto/api' 5 + import {BlueskyVideoView} from '@haileyok/bluesky-video' 6 6 import {msg} from '@lingui/macro' 7 7 import {useLingui} from '@lingui/react' 8 8 9 9 import {HITSLOP_30} from '#/lib/constants' 10 10 import {clamp} from '#/lib/numbers' 11 - import {isAndroid} from 'platform/detection' 12 - import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext' 11 + import {useAutoplayDisabled} from '#/state/preferences' 13 12 import {atoms as a, useTheme} from '#/alf' 13 + import {useIsWithinMessage} from '#/components/dms/MessageContext' 14 14 import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' 15 + import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' 16 + import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 15 17 import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' 16 18 import {MediaInsetBorder} from '#/components/MediaInsetBorder' 17 - import { 18 - AudioCategory, 19 - PlatformInfo, 20 - } from '../../../../../../modules/expo-bluesky-swiss-army' 21 19 import {TimeIndicator} from './TimeIndicator' 22 20 23 - export function VideoEmbedInnerNative({ 24 - embed, 25 - isFullscreen, 26 - setIsFullscreen, 27 - isMuted, 28 - timeRemaining, 29 - }: { 30 - embed: AppBskyEmbedVideo.View 31 - isFullscreen: boolean 32 - setIsFullscreen: (isFullscreen: boolean) => void 33 - timeRemaining: number 34 - isMuted: boolean 35 - }) { 36 - const {_} = useLingui() 37 - const {player} = useActiveVideoNative() 38 - const ref = useRef<VideoView>(null) 21 + export const VideoEmbedInnerNative = React.forwardRef( 22 + function VideoEmbedInnerNative( 23 + { 24 + embed, 25 + setStatus, 26 + setIsLoading, 27 + setIsActive, 28 + }: { 29 + embed: AppBskyEmbedVideo.View 30 + setStatus: (status: 'playing' | 'paused') => void 31 + setIsLoading: (isLoading: boolean) => void 32 + setIsActive: (isActive: boolean) => void 33 + }, 34 + ref: React.Ref<{togglePlayback: () => void}>, 35 + ) { 36 + const {_} = useLingui() 37 + const videoRef = useRef<BlueskyVideoView>(null) 38 + const autoplayDisabled = useAutoplayDisabled() 39 + const isWithinMessage = useIsWithinMessage() 40 + 41 + const [isMuted, setIsMuted] = React.useState(true) 42 + const [isPlaying, setIsPlaying] = React.useState(false) 43 + const [timeRemaining, setTimeRemaining] = React.useState(0) 44 + const [error, setError] = React.useState<string>() 39 45 40 - const enterFullscreen = useCallback(() => { 41 - ref.current?.enterFullscreen() 42 - }, []) 46 + React.useImperativeHandle(ref, () => ({ 47 + togglePlayback: () => { 48 + videoRef.current?.togglePlayback() 49 + }, 50 + })) 51 + 52 + if (error) { 53 + throw new Error(error) 54 + } 43 55 44 - let aspectRatio = 16 / 9 56 + let aspectRatio = 16 / 9 45 57 46 - if (embed.aspectRatio) { 47 - const {width, height} = embed.aspectRatio 48 - aspectRatio = width / height 49 - aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) 50 - } 58 + if (embed.aspectRatio) { 59 + const {width, height} = embed.aspectRatio 60 + aspectRatio = width / height 61 + aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) 62 + } 51 63 52 - return ( 53 - <View style={[a.flex_1, a.relative, {aspectRatio}]}> 54 - <VideoView 55 - ref={ref} 56 - player={player} 57 - style={[a.flex_1, a.rounded_sm]} 58 - contentFit="cover" 59 - nativeControls={isFullscreen} 60 - accessibilityIgnoresInvertColors 61 - onFullscreenEnter={() => { 62 - PlatformInfo.setAudioCategory(AudioCategory.Playback) 63 - PlatformInfo.setAudioActive(true) 64 - player.muted = false 65 - setIsFullscreen(true) 66 - if (isAndroid) { 67 - player.play() 64 + return ( 65 + <View style={[a.flex_1, a.relative, {aspectRatio}]}> 66 + <BlueskyVideoView 67 + url={embed.playlist} 68 + autoplay={!autoplayDisabled && !isWithinMessage} 69 + beginMuted={true} 70 + style={[a.rounded_sm]} 71 + onActiveChange={e => { 72 + setIsActive(e.nativeEvent.isActive) 73 + }} 74 + onLoadingChange={e => { 75 + setIsLoading(e.nativeEvent.isLoading) 76 + }} 77 + onMutedChange={e => { 78 + setIsMuted(e.nativeEvent.isMuted) 79 + }} 80 + onStatusChange={e => { 81 + setStatus(e.nativeEvent.status) 82 + setIsPlaying(e.nativeEvent.status === 'playing') 83 + }} 84 + onTimeRemainingChange={e => { 85 + setTimeRemaining(e.nativeEvent.timeRemaining) 86 + }} 87 + onError={e => { 88 + setError(e.nativeEvent.error) 89 + }} 90 + ref={videoRef} 91 + accessibilityLabel={ 92 + embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) 68 93 } 69 - }} 70 - onFullscreenExit={() => { 71 - PlatformInfo.setAudioCategory(AudioCategory.Ambient) 72 - PlatformInfo.setAudioActive(false) 73 - player.muted = true 74 - player.playbackRate = 1 75 - setIsFullscreen(false) 76 - }} 77 - accessibilityLabel={ 78 - embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) 79 - } 80 - accessibilityHint="" 81 - /> 82 - <VideoControls 83 - player={player} 84 - enterFullscreen={enterFullscreen} 85 - isMuted={isMuted} 86 - timeRemaining={timeRemaining} 87 - /> 88 - <MediaInsetBorder /> 89 - </View> 90 - ) 91 - } 94 + accessibilityHint="" 95 + /> 96 + <VideoControls 97 + enterFullscreen={() => { 98 + videoRef.current?.enterFullscreen() 99 + }} 100 + toggleMuted={() => { 101 + videoRef.current?.toggleMuted() 102 + }} 103 + togglePlayback={() => { 104 + videoRef.current?.togglePlayback() 105 + }} 106 + isMuted={isMuted} 107 + isPlaying={isPlaying} 108 + timeRemaining={timeRemaining} 109 + /> 110 + <MediaInsetBorder /> 111 + </View> 112 + ) 113 + }, 114 + ) 92 115 93 116 function VideoControls({ 94 - player, 95 117 enterFullscreen, 118 + toggleMuted, 119 + togglePlayback, 96 120 timeRemaining, 121 + isPlaying, 97 122 isMuted, 98 123 }: { 99 - player: VideoPlayer 100 124 enterFullscreen: () => void 125 + toggleMuted: () => void 126 + togglePlayback: () => void 101 127 timeRemaining: number 128 + isPlaying: boolean 102 129 isMuted: boolean 103 130 }) { 104 131 const {_} = useLingui() 105 132 const t = useTheme() 106 133 107 - const onPressFullscreen = useCallback(() => { 108 - switch (player.status) { 109 - case 'idle': 110 - case 'loading': 111 - case 'readyToPlay': { 112 - if (!player.playing) player.play() 113 - enterFullscreen() 114 - break 115 - } 116 - case 'error': { 117 - player.replay() 118 - break 119 - } 120 - } 121 - }, [player, enterFullscreen]) 122 - 123 - const toggleMuted = useCallback(() => { 124 - const muted = !player.muted 125 - // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when 126 - // the video is muted, and vice versa. 127 - const mix = !muted 128 - const category = muted ? AudioCategory.Ambient : AudioCategory.Playback 129 - 130 - PlatformInfo.setAudioCategory(category) 131 - PlatformInfo.setAudioActive(mix) 132 - player.muted = muted 133 - }, [player]) 134 - 135 134 // show countdown when: 136 135 // 1. timeRemaining is a number - was seeing NaNs 137 136 // 2. duration is greater than 0 - means metadata has loaded ··· 140 139 141 140 return ( 142 141 <View style={[a.absolute, a.inset_0]}> 143 - {showTime && <TimeIndicator time={timeRemaining} />} 144 142 <Pressable 145 - onPress={onPressFullscreen} 143 + onPress={enterFullscreen} 146 144 style={a.flex_1} 147 145 accessibilityLabel={_(msg`Video`)} 148 146 accessibilityHint={_(msg`Tap to enter full screen`)} 149 147 accessibilityRole="button" 150 148 /> 151 - <Animated.View 152 - entering={FadeInDown.duration(300)} 153 - style={[ 154 - a.absolute, 155 - a.rounded_full, 156 - a.justify_center, 157 - { 158 - backgroundColor: 'rgba(0, 0, 0, 0.5)', 159 - paddingHorizontal: 4, 160 - paddingVertical: 4, 161 - bottom: 6, 162 - right: 6, 163 - minHeight: 21, 164 - minWidth: 21, 165 - }, 166 - ]}> 167 - <Pressable 168 - onPress={toggleMuted} 169 - style={a.flex_1} 170 - accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} 171 - accessibilityHint={_(msg`Tap to toggle sound`)} 172 - accessibilityRole="button" 173 - hitSlop={HITSLOP_30}> 174 - {isMuted ? ( 175 - <MuteIcon width={13} fill={t.palette.white} /> 176 - ) : ( 177 - <UnmuteIcon width={13} fill={t.palette.white} /> 178 - )} 179 - </Pressable> 180 - </Animated.View> 149 + <ControlButton 150 + onPress={togglePlayback} 151 + label={isPlaying ? _(msg`Pause`) : _(msg`Play`)} 152 + accessibilityHint={_(msg`Tap to play or pause`)} 153 + style={{left: 6}}> 154 + {isPlaying ? ( 155 + <PauseIcon width={13} fill={t.palette.white} /> 156 + ) : ( 157 + <PlayIcon width={13} fill={t.palette.white} /> 158 + )} 159 + </ControlButton> 160 + {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />} 161 + 162 + <ControlButton 163 + onPress={toggleMuted} 164 + label={isMuted ? _(msg`Unmute`) : _(msg`Mute`)} 165 + accessibilityHint={_(msg`Tap to toggle sound`)} 166 + style={{right: 6}}> 167 + {isMuted ? ( 168 + <MuteIcon width={13} fill={t.palette.white} /> 169 + ) : ( 170 + <UnmuteIcon width={13} fill={t.palette.white} /> 171 + )} 172 + </ControlButton> 181 173 </View> 182 174 ) 183 175 } 176 + 177 + function ControlButton({ 178 + onPress, 179 + children, 180 + label, 181 + accessibilityHint, 182 + style, 183 + }: { 184 + onPress: () => void 185 + children: React.ReactNode 186 + label: string 187 + accessibilityHint: string 188 + style?: StyleProp<ViewStyle> 189 + }) { 190 + return ( 191 + <Animated.View 192 + entering={FadeInDown.duration(300)} 193 + style={[ 194 + a.absolute, 195 + a.rounded_full, 196 + a.justify_center, 197 + { 198 + backgroundColor: 'rgba(0, 0, 0, 0.5)', 199 + paddingHorizontal: 4, 200 + paddingVertical: 4, 201 + bottom: 6, 202 + minHeight: 21, 203 + minWidth: 21, 204 + }, 205 + style, 206 + ]}> 207 + <Pressable 208 + onPress={onPress} 209 + style={a.flex_1} 210 + accessibilityLabel={label} 211 + accessibilityHint={accessibilityHint} 212 + accessibilityRole="button" 213 + hitSlop={HITSLOP_30}> 214 + {children} 215 + </Pressable> 216 + </Animated.View> 217 + ) 218 + }
+5 -4
yarn.lock
··· 4104 4104 resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" 4105 4105 integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== 4106 4106 4107 + "@haileyok/bluesky-video@0.1.2": 4108 + version "0.1.2" 4109 + resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.2.tgz#53abb04c22885fcf8a1d8a7510d2cfbe7d45ff91" 4110 + integrity sha512-OPltVPNhjrm/+d4YYbaSsKLK7VQWC62ci8J05GO4I/PhWsYLWsAu79CGfZ1YTpfpIjYXyo0HjMmiig5X/hhOsQ== 4111 + 4107 4112 "@hapi/accept@^6.0.3": 4108 4113 version "6.0.3" 4109 4114 resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab" ··· 12414 12419 fbemitter "^3.0.0" 12415 12420 ignore "^5.3.1" 12416 12421 resolve-from "^5.0.0" 12417 - 12418 - "expo-video@https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz": 12419 - version "1.2.4" 12420 - resolved "https://github.com/bluesky-social/expo/raw/expo-video-1.2.4-patch/packages/expo-video/expo-video-v1.2.4-2.tgz#4127dd5cea5fdf7ab745104c73b8ecf5506f5d34" 12421 12422 12422 12423 expo-web-browser@~13.0.3: 12423 12424 version "13.0.3"