Bluesky app fork with some witchin' additions 💫

[Video] Add disable autoplay for native, more tweaking (#5178)

authored by hailey.at and committed by

GitHub 60182cd8 bdff8752

+81 -60
+15 -12
src/lib/hooks/useDedupe.ts
··· 1 import React from 'react' 2 3 - export const useDedupe = () => { 4 const canDo = React.useRef(true) 5 6 - return React.useCallback((cb: () => unknown) => { 7 - if (canDo.current) { 8 - canDo.current = false 9 - setTimeout(() => { 10 - canDo.current = true 11 - }, 250) 12 - cb() 13 - return true 14 - } 15 - return false 16 - }, []) 17 }
··· 1 import React from 'react' 2 3 + export const useDedupe = (timeout = 250) => { 4 const canDo = React.useRef(true) 5 6 + return React.useCallback( 7 + (cb: () => unknown) => { 8 + if (canDo.current) { 9 + canDo.current = false 10 + setTimeout(() => { 11 + canDo.current = true 12 + }, timeout) 13 + cb() 14 + return true 15 + } 16 + return false 17 + }, 18 + [timeout], 19 + ) 20 }
+7 -2
src/view/com/util/List.tsx
··· 7 import {useScrollHandlers} from '#/lib/ScrollContext' 8 import {useDedupe} from 'lib/hooks/useDedupe' 9 import {addStyle} from 'lib/styles' 10 import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' 11 import {FlatList_INTERNAL} from './Views' 12 ··· 49 ) { 50 const isScrolledDown = useSharedValue(false) 51 const pal = usePalette('default') 52 - const dedupe = useDedupe() 53 54 function handleScrolledDownChange(didScrollDown: boolean) { 55 onScrolledDownChange?.(didScrollDown) ··· 68 onBeginDragFromContext?.(e, ctx) 69 }, 70 onEndDrag(e, ctx) { 71 onEndDragFromContext?.(e, ctx) 72 }, 73 onScroll(e, ctx) { ··· 81 } 82 } 83 84 - runOnJS(dedupe)(updateActiveViewAsync) 85 }, 86 // Note: adding onMomentumBegin here makes simulator scroll 87 // lag on Android. So either don't add it, or figure out why. 88 onMomentumEnd(e, ctx) { 89 onMomentumEndFromContext?.(e, ctx) 90 }, 91 })
··· 7 import {useScrollHandlers} from '#/lib/ScrollContext' 8 import {useDedupe} from 'lib/hooks/useDedupe' 9 import {addStyle} from 'lib/styles' 10 + import {isIOS} from 'platform/detection' 11 import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' 12 import {FlatList_INTERNAL} from './Views' 13 ··· 50 ) { 51 const isScrolledDown = useSharedValue(false) 52 const pal = usePalette('default') 53 + const dedupe = useDedupe(400) 54 55 function handleScrolledDownChange(didScrollDown: boolean) { 56 onScrolledDownChange?.(didScrollDown) ··· 69 onBeginDragFromContext?.(e, ctx) 70 }, 71 onEndDrag(e, ctx) { 72 + runOnJS(updateActiveViewAsync)() 73 onEndDragFromContext?.(e, ctx) 74 }, 75 onScroll(e, ctx) { ··· 83 } 84 } 85 86 + if (isIOS) { 87 + runOnJS(dedupe)(updateActiveViewAsync) 88 + } 89 }, 90 // Note: adding onMomentumBegin here makes simulator scroll 91 // lag on Android. So either don't add it, or figure out why. 92 onMomentumEnd(e, ctx) { 93 + runOnJS(updateActiveViewAsync)() 94 onMomentumEndFromContext?.(e, ctx) 95 }, 96 })
+5 -4
src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx
··· 6 const Context = React.createContext<{ 7 activeSource: string 8 activeViewId: string | undefined 9 - setActiveSource: (src: string, viewId: string) => void 10 player: VideoPlayer 11 } | null>(null) 12 ··· 21 const player = useVideoPlayer(activeSource, p => { 22 p.muted = true 23 p.loop = true 24 p.play() 25 }) 26 27 - const setActiveSourceOuter = (src: string, viewId: string) => { 28 - setActiveSource(src) 29 - setActiveViewId(viewId) 30 } 31 32 return (
··· 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 ··· 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 + setActiveSource(src ? src : '') 30 + setActiveViewId(viewId ? viewId : '') 31 } 32 33 return (
+54 -39
src/view/com/util/post-embeds/VideoEmbed.tsx
··· 1 import React, {useCallback, useEffect, useId, useState} from 'react' 2 import {View} from 'react-native' 3 - import {Image} from 'expo-image' 4 import {PlayerError, VideoPlayerStatus} from 'expo-video' 5 import {AppBskyEmbedVideo} from '@atproto/api' 6 import {msg, Trans} from '@lingui/macro' ··· 8 9 import {clamp} from '#/lib/numbers' 10 import {useGate} from '#/lib/statsig/statsig' 11 import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' 12 import {atoms as a} from '#/alf' 13 import {Button} from '#/components/Button' ··· 69 const viewId = useId() 70 71 const [playerStatus, setPlayerStatus] = useState< 72 - VideoPlayerStatus | 'switching' 73 - >('loading') 74 const [isMuted, setIsMuted] = useState(player.muted) 75 const [isFullscreen, setIsFullscreen] = React.useState(false) 76 const [timeRemaining, setTimeRemaining] = React.useState(0) 77 const isActive = embed.playlist === activeSource && activeViewId === viewId 78 const isLoading = 79 isActive && 80 (playerStatus === 'waitingToPlayAtSpecifiedRate' || 81 playerStatus === 'loading') 82 - const isSwitching = playerStatus === 'switching' 83 - const showOverlay = !isActive || isLoading || isSwitching 84 85 // send error up to error boundary 86 const [error, setError] = useState<Error | PlayerError | null>(null) ··· 102 ) 103 const statusSub = player.addListener( 104 'statusChange', 105 - (status, _oldStatus, playerError) => { 106 setPlayerStatus(status) 107 if (status === 'error') { 108 setError(playerError ?? new Error('Unknown player error')) 109 } 110 }, 111 ) ··· 115 statusSub.remove() 116 } 117 } 118 - }, [player, isActive]) 119 120 - useEffect(() => { 121 - if (!isActive && playerStatus !== 'loading') { 122 - setPlayerStatus('loading') 123 } 124 - }, [isActive, playerStatus]) 125 126 - const onChangeStatus = (isVisible: boolean) => { 127 if (isFullscreen) { 128 return 129 } 130 - 131 if (isVisible) { 132 - setActiveSource(embed.playlist, viewId) 133 - if (!player.playing) { 134 - player.play() 135 - } 136 } else { 137 - setPlayerStatus('switching') 138 - player.muted = true 139 - if (player.playing) { 140 - player.pause() 141 } 142 } 143 } 144 145 return ( 146 - <VisibilityView enabled={true} onChangeStatus={onChangeStatus}> 147 {isActive ? ( 148 <VideoEmbedInnerNative 149 embed={embed} ··· 153 setIsFullscreen={setIsFullscreen} 154 /> 155 ) : null} 156 - <View 157 style={[ 158 { 159 position: 'absolute', 160 top: 0, 161 - bottom: 0, 162 left: 0, 163 right: 0, 164 display: showOverlay ? 'flex' : 'none', 165 }, 166 - ]}> 167 - <Image 168 - source={{uri: embed.thumbnail}} 169 - alt={embed.alt} 170 - style={a.flex_1} 171 - contentFit="cover" 172 - accessibilityIgnoresInvertColors 173 - /> 174 <Button 175 - style={[a.absolute, a.inset_0]} 176 - onPress={() => { 177 - setActiveSource(embed.playlist, viewId) 178 - }} 179 label={_(msg`Play video`)} 180 color="secondary"> 181 {isLoading ? ( ··· 183 style={[ 184 a.rounded_full, 185 a.p_xs, 186 - a.absolute, 187 - {top: 'auto', left: 'auto'}, 188 {backgroundColor: 'rgba(0,0,0,0.5)'}, 189 ]}> 190 <Loader size="2xl" style={{color: 'white'}} /> ··· 193 <PlayButtonIcon /> 194 )} 195 </Button> 196 - </View> 197 </VisibilityView> 198 ) 199 }
··· 1 import React, {useCallback, useEffect, useId, useState} from 'react' 2 import {View} from 'react-native' 3 + import {ImageBackground} from 'expo-image' 4 import {PlayerError, VideoPlayerStatus} from 'expo-video' 5 import {AppBskyEmbedVideo} from '@atproto/api' 6 import {msg, Trans} from '@lingui/macro' ··· 8 9 import {clamp} from '#/lib/numbers' 10 import {useGate} from '#/lib/statsig/statsig' 11 + import {useAutoplayDisabled} from 'state/preferences' 12 import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' 13 import {atoms as a} from '#/alf' 14 import {Button} from '#/components/Button' ··· 70 const viewId = useId() 71 72 const [playerStatus, setPlayerStatus] = useState< 73 + VideoPlayerStatus | 'paused' 74 + >(player.playing ? 'readyToPlay' : 'paused') 75 const [isMuted, setIsMuted] = useState(player.muted) 76 const [isFullscreen, setIsFullscreen] = React.useState(false) 77 const [timeRemaining, setTimeRemaining] = React.useState(0) 78 + const disableAutoplay = useAutoplayDisabled() 79 const isActive = embed.playlist === activeSource && activeViewId === viewId 80 + // There are some different loading states that we should pay attention to and show a spinner for 81 const isLoading = 82 isActive && 83 (playerStatus === 'waitingToPlayAtSpecifiedRate' || 84 playerStatus === 'loading') 85 + // This happens whenever the visibility view decides that another video should start playing 86 + const showOverlay = !isActive || isLoading || playerStatus === 'paused' 87 88 // send error up to error boundary 89 const [error, setError] = useState<Error | PlayerError | null>(null) ··· 105 ) 106 const statusSub = player.addListener( 107 'statusChange', 108 + (status, oldStatus, playerError) => { 109 setPlayerStatus(status) 110 if (status === 'error') { 111 setError(playerError ?? new Error('Unknown player error')) 112 + } 113 + if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') { 114 + player.play() 115 } 116 }, 117 ) ··· 121 statusSub.remove() 122 } 123 } 124 + }, [player, isActive, disableAutoplay]) 125 + 126 + // The source might already be active (for example, if you are scrolling a list of quotes and its all the same 127 + // video). In those cases, just start playing. Otherwise, setting the active source will result in the video 128 + // start playback immediately 129 + const startPlaying = (ignoreAutoplayPreference: boolean) => { 130 + if (disableAutoplay && !ignoreAutoplayPreference) { 131 + return 132 + } 133 134 + if (isActive) { 135 + player.play() 136 + } else { 137 + setActiveSource(embed.playlist, viewId) 138 } 139 + } 140 141 + const onVisibilityStatusChange = (isVisible: boolean) => { 142 + // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change 143 + // events 144 if (isFullscreen) { 145 return 146 } 147 if (isVisible) { 148 + startPlaying(false) 149 } else { 150 + // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted 151 + // until it gets replaced by another video 152 + if (disableAutoplay) { 153 + setActiveSource(null, null) 154 + } else { 155 + player.muted = true 156 + if (player.playing) { 157 + player.pause() 158 + } 159 } 160 } 161 } 162 163 return ( 164 + <VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}> 165 {isActive ? ( 166 <VideoEmbedInnerNative 167 embed={embed} ··· 171 setIsFullscreen={setIsFullscreen} 172 /> 173 ) : null} 174 + <ImageBackground 175 + source={{uri: embed.thumbnail}} 176 + accessibilityIgnoresInvertColors 177 style={[ 178 { 179 position: 'absolute', 180 top: 0, 181 left: 0, 182 right: 0, 183 + bottom: 0, 184 + backgroundColor: 'transparent', // If you don't add `backgroundColor` to the styles here, 185 + // the play button won't show up on the first render on android 🥴😮‍💨 186 display: showOverlay ? 'flex' : 'none', 187 }, 188 + ]} 189 + cachePolicy="memory-disk" // Preferring memory cache helps to avoid flicker when re-displaying on android 190 + > 191 <Button 192 + style={[a.flex_1, a.align_center, a.justify_center]} 193 + onPress={() => startPlaying(true)} 194 label={_(msg`Play video`)} 195 color="secondary"> 196 {isLoading ? ( ··· 198 style={[ 199 a.rounded_full, 200 a.p_xs, 201 + a.align_center, 202 + a.justify_center, 203 {backgroundColor: 'rgba(0,0,0,0.5)'}, 204 ]}> 205 <Loader size="2xl" style={{color: 'white'}} /> ··· 208 <PlayButtonIcon /> 209 )} 210 </Button> 211 + </ImageBackground> 212 </VisibilityView> 213 ) 214 }
-3
src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx
··· 67 PlatformInfo.setAudioActive(false) 68 player.muted = true 69 player.playbackRate = 1 70 - if (!player.playing) { 71 - player.play() 72 - } 73 setIsFullscreen(false) 74 }} 75 accessibilityLabel={
··· 67 PlatformInfo.setAudioActive(false) 68 player.muted = true 69 player.playbackRate = 1 70 setIsFullscreen(false) 71 }} 72 accessibilityLabel={