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