diff options
author | Hailey <me@haileyok.com> | 2024-09-13 12:44:42 -0700 |
---|---|---|
committer | GitHub <noreply@github.com> | 2024-09-13 12:44:42 -0700 |
commit | 26508cfe6a89df4ae1ab1256753faa860597bbc8 (patch) | |
tree | 8f0bf4e8f65863ddbe8d1ede7df3fd342e6ab69b /src | |
parent | 78a531f5ffe9287b5384ec1649dfbc45435ced28 (diff) | |
download | voidsky-26508cfe6a89df4ae1ab1256753faa860597bbc8.tar.zst |
[Video] Remove `expo-video`, use `bluesky-video` (#5282)
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Diffstat (limited to 'src')
-rw-r--r-- | src/App.native.tsx | 79 | ||||
-rw-r--r-- | src/components/video/PlayButtonIcon.tsx | 2 | ||||
-rw-r--r-- | src/view/com/composer/videos/VideoPreview.tsx | 24 | ||||
-rw-r--r-- | src/view/com/util/List.tsx | 8 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx | 65 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbed.tsx | 142 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx | 15 | ||||
-rw-r--r-- | src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx | 305 |
8 files changed, 262 insertions, 378 deletions
diff --git a/src/App.native.tsx b/src/App.native.tsx index 83f133e99..04fea126c 100644 --- a/src/App.native.tsx +++ b/src/App.native.tsx @@ -52,7 +52,6 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {TestCtrls} from '#/view/com/testing/TestCtrls' -import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext' import * as Toast from '#/view/com/util/Toast' import {Shell} from '#/view/shell' import {ThemeProvider as Alf} from '#/alf' @@ -63,7 +62,6 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo import {Provider as PortalProvider} from '#/components/Portal' import {Splash} from '#/Splash' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' -import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army' SplashScreen.preventAutoHideAsync() @@ -110,45 +108,42 @@ function InnerApp() { <Alf theme={theme}> <ThemeProvider theme={theme}> <Splash isReady={isReady && hasCheckedReferrer}> - <ActiveVideoProvider> - <RootSiblingParent> - <React.Fragment - // Resets the entire tree below when it changes: - key={currentAccount?.did}> - <QueryProvider currentDid={currentAccount?.did}> - <StatsigProvider> - <MessagesProvider> - {/* LabelDefsProvider MUST come before ModerationOptsProvider */} - <LabelDefsProvider> - <ModerationOptsProvider> - <LoggedOutViewProvider> - <SelectedFeedProvider> - <HiddenRepliesProvider> - <UnreadNotifsProvider> - <BackgroundNotificationPreferencesProvider> - <MutedThreadsProvider> - <ProgressGuideProvider> - <GestureHandlerRootView - style={s.h100pct}> - <TestCtrls /> - <Shell /> - <NuxDialogs /> - </GestureHandlerRootView> - </ProgressGuideProvider> - </MutedThreadsProvider> - </BackgroundNotificationPreferencesProvider> - </UnreadNotifsProvider> - </HiddenRepliesProvider> - </SelectedFeedProvider> - </LoggedOutViewProvider> - </ModerationOptsProvider> - </LabelDefsProvider> - </MessagesProvider> - </StatsigProvider> - </QueryProvider> - </React.Fragment> - </RootSiblingParent> - </ActiveVideoProvider> + <RootSiblingParent> + <React.Fragment + // Resets the entire tree below when it changes: + key={currentAccount?.did}> + <QueryProvider currentDid={currentAccount?.did}> + <StatsigProvider> + <MessagesProvider> + {/* LabelDefsProvider MUST come before ModerationOptsProvider */} + <LabelDefsProvider> + <ModerationOptsProvider> + <LoggedOutViewProvider> + <SelectedFeedProvider> + <HiddenRepliesProvider> + <UnreadNotifsProvider> + <BackgroundNotificationPreferencesProvider> + <MutedThreadsProvider> + <ProgressGuideProvider> + <GestureHandlerRootView style={s.h100pct}> + <TestCtrls /> + <Shell /> + <NuxDialogs /> + </GestureHandlerRootView> + </ProgressGuideProvider> + </MutedThreadsProvider> + </BackgroundNotificationPreferencesProvider> + </UnreadNotifsProvider> + </HiddenRepliesProvider> + </SelectedFeedProvider> + </LoggedOutViewProvider> + </ModerationOptsProvider> + </LabelDefsProvider> + </MessagesProvider> + </StatsigProvider> + </QueryProvider> + </React.Fragment> + </RootSiblingParent> </Splash> </ThemeProvider> </Alf> @@ -159,8 +154,6 @@ function App() { const [isReady, setReady] = useState(false) React.useEffect(() => { - PlatformInfo.setAudioCategory(AudioCategory.Ambient) - PlatformInfo.setAudioActive(false) initPersistedState().then(() => setReady(true)) }, []) diff --git a/src/components/video/PlayButtonIcon.tsx b/src/components/video/PlayButtonIcon.tsx index 90e93f744..8e0a6bb7a 100644 --- a/src/components/video/PlayButtonIcon.tsx +++ b/src/components/video/PlayButtonIcon.tsx @@ -4,7 +4,7 @@ import {View} from 'react-native' import {atoms as a, useTheme} from '#/alf' import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' -export function PlayButtonIcon({size = 36}: {size?: number}) { +export function PlayButtonIcon({size = 32}: {size?: number}) { const t = useTheme() const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975 const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25 diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 60b467d62..b1bfd6715 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -1,8 +1,7 @@ -/* eslint-disable @typescript-eslint/no-shadow */ import React from 'react' import {View} from 'react-native' import {ImagePickerAsset} from 'expo-image-picker' -import {useVideoPlayer, VideoView} from 'expo-video' +import {BlueskyVideoView} from '@haileyok/bluesky-video' import {CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' @@ -22,15 +21,8 @@ export function VideoPreview({ clear: () => void }) { const t = useTheme() + const playerRef = React.useRef<BlueskyVideoView>(null) const autoplayDisabled = useAutoplayDisabled() - const player = useVideoPlayer(video.uri, player => { - player.loop = true - player.muted = true - if (!autoplayDisabled) { - player.play() - } - }) - let aspectRatio = asset.width / asset.height if (isNaN(aspectRatio)) { @@ -50,12 +42,12 @@ export function VideoPreview({ t.atoms.border_contrast_low, {backgroundColor: 'black'}, ]}> - <VideoView - player={player} - style={a.flex_1} - allowsPictureInPicture={false} - nativeControls={false} - contentFit="contain" + <BlueskyVideoView + url={video.uri} + autoplay={autoplayDisabled} + beginMuted={true} + forceTakeover={true} + ref={playerRef} /> <ExternalEmbedRemoveBtn onRemove={clear} /> {autoplayDisabled && ( diff --git a/src/view/com/util/List.tsx b/src/view/com/util/List.tsx index 79dd2f491..f9aeae1a8 100644 --- a/src/view/com/util/List.tsx +++ b/src/view/com/util/List.tsx @@ -1,6 +1,7 @@ import React, {memo} from 'react' import {FlatListProps, RefreshControl, ViewToken} from 'react-native' import {runOnJS, useSharedValue} from 'react-native-reanimated' +import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {usePalette} from '#/lib/hooks/usePalette' @@ -8,7 +9,6 @@ import {useScrollHandlers} from '#/lib/ScrollContext' import {useDedupe} from 'lib/hooks/useDedupe' import {addStyle} from 'lib/styles' import {isIOS} from 'platform/detection' -import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView' import {FlatList_INTERNAL} from './Views' export type ListMethods = FlatList_INTERNAL @@ -69,7 +69,7 @@ function ListImpl<ItemT>( onBeginDragFromContext?.(e, ctx) }, onEndDrag(e, ctx) { - runOnJS(updateActiveViewAsync)() + runOnJS(updateActiveVideoViewAsync)() onEndDragFromContext?.(e, ctx) }, onScroll(e, ctx) { @@ -84,13 +84,13 @@ function ListImpl<ItemT>( } if (isIOS) { - runOnJS(dedupe)(updateActiveViewAsync) + runOnJS(dedupe)(updateActiveVideoViewAsync) } }, // Note: adding onMomentumBegin here makes simulator scroll // lag on Android. So either don't add it, or figure out why. onMomentumEnd(e, ctx) { - runOnJS(updateActiveViewAsync)() + runOnJS(updateActiveVideoViewAsync)() onMomentumEndFromContext?.(e, ctx) }, }) diff --git a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx b/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx deleted file mode 100644 index 95fa0bb0e..000000000 --- a/src/view/com/util/post-embeds/ActiveVideoNativeContext.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react' -import {useVideoPlayer, VideoPlayer} from 'expo-video' - -import {isAndroid, isNative} from '#/platform/detection' - -const Context = React.createContext<{ - activeSource: string - activeViewId: string | undefined - setActiveSource: (src: string | null, viewId: string | null) => void - player: VideoPlayer -} | null>(null) - -export function Provider({children}: {children: React.ReactNode}) { - if (!isNative) { - throw new Error('ActiveVideoProvider may only be used on native.') - } - - const [activeSource, setActiveSource] = React.useState('') - const [activeViewId, setActiveViewId] = React.useState<string>() - - const player = useVideoPlayer(activeSource, p => { - p.muted = true - p.loop = true - // We want to immediately call `play` so we get the loading state - p.play() - }) - - const setActiveSourceOuter = (src: string | null, viewId: string | null) => { - // HACK - // expo-video doesn't like it when you try and move a `player` to another `VideoView`. Instead, we need to actually - // unregister that player to let the new screen register it. This is only a problem on Android, so we only need to - // apply it there. - if (src === activeSource && isAndroid) { - setActiveSource('') - setTimeout(() => { - setActiveSource(src ? src : '') - }, 100) - } else { - setActiveSource(src ? src : '') - } - setActiveViewId(viewId ? viewId : '') - } - - return ( - <Context.Provider - value={{ - activeSource, - setActiveSource: setActiveSourceOuter, - activeViewId, - player, - }}> - {children} - </Context.Provider> - ) -} - -export function useActiveVideoNative() { - const context = React.useContext(Context) - if (!context) { - throw new Error( - 'useActiveVideoNative must be used within a ActiveVideoNativeProvider', - ) - } - return context -} diff --git a/src/view/com/util/post-embeds/VideoEmbed.tsx b/src/view/com/util/post-embeds/VideoEmbed.tsx index a672830db..267b5d184 100644 --- a/src/view/com/util/post-embeds/VideoEmbed.tsx +++ b/src/view/com/util/post-embeds/VideoEmbed.tsx @@ -1,22 +1,18 @@ -import React, {useCallback, useEffect, useId, useState} from 'react' +import React, {useCallback, useState} from 'react' import {View} from 'react-native' import {ImageBackground} from 'expo-image' -import {PlayerError, VideoPlayerStatus} from 'expo-video' import {AppBskyEmbedVideo} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {clamp} from '#/lib/numbers' -import {useAutoplayDisabled} from 'state/preferences' import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {atoms as a} from '#/alf' import {Button} from '#/components/Button' -import {useIsWithinMessage} from '#/components/dms/MessageContext' +import {useThrottledValue} from '#/components/hooks/useThrottledValue' import {Loader} from '#/components/Loader' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' -import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army' import {ErrorBoundary} from '../ErrorBoundary' -import {useActiveVideoNative} from './ActiveVideoNativeContext' import * as VideoFallback from './VideoEmbedInner/VideoFallback' interface Props { @@ -59,113 +55,36 @@ export function VideoEmbed({embed}: Props) { function InnerWrapper({embed}: Props) { const {_} = useLingui() - const {activeSource, activeViewId, setActiveSource, player} = - useActiveVideoNative() - const viewId = useId() + const ref = React.useRef<{togglePlayback: () => void}>(null) - const [playerStatus, setPlayerStatus] = useState< - VideoPlayerStatus | 'paused' - >('paused') - const [isMuted, setIsMuted] = useState(player.muted) - const [isFullscreen, setIsFullscreen] = React.useState(false) - const [timeRemaining, setTimeRemaining] = React.useState(0) - const isWithinMessage = useIsWithinMessage() - const disableAutoplay = useAutoplayDisabled() || isWithinMessage - const isActive = embed.playlist === activeSource && activeViewId === viewId - // There are some different loading states that we should pay attention to and show a spinner for - const isLoading = - isActive && - (playerStatus === 'waitingToPlayAtSpecifiedRate' || - playerStatus === 'loading') - // This happens whenever the visibility view decides that another video should start playing - const showOverlay = !isActive || isLoading || playerStatus === 'paused' - - // send error up to error boundary - const [error, setError] = useState<Error | PlayerError | null>(null) - if (error) { - throw error - } - - useEffect(() => { - if (isActive) { - // eslint-disable-next-line @typescript-eslint/no-shadow - const volumeSub = player.addListener('volumeChange', ({isMuted}) => { - setIsMuted(isMuted) - }) - const timeSub = player.addListener( - 'timeRemainingChange', - secondsRemaining => { - setTimeRemaining(secondsRemaining) - }, - ) - const statusSub = player.addListener( - 'statusChange', - (status, oldStatus, playerError) => { - setPlayerStatus(status) - if (status === 'error') { - setError(playerError ?? new Error('Unknown player error')) - } - if (status === 'readyToPlay' && oldStatus !== 'readyToPlay') { - player.play() - } - }, - ) - return () => { - volumeSub.remove() - timeSub.remove() - statusSub.remove() - } - } - }, [player, isActive, disableAutoplay]) - - // The source might already be active (for example, if you are scrolling a list of quotes and its all the same - // video). In those cases, just start playing. Otherwise, setting the active source will result in the video - // start playback immediately - const startPlaying = (ignoreAutoplayPreference: boolean) => { - if (disableAutoplay && !ignoreAutoplayPreference) { - return - } + const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>( + 'pending', + ) + const [isLoading, setIsLoading] = React.useState(false) + const [isActive, setIsActive] = React.useState(false) + const showSpinner = useThrottledValue(isActive && isLoading, 100) - if (isActive) { - player.play() - } else { - setActiveSource(embed.playlist, viewId) - } - } + const showOverlay = + !isActive || + isLoading || + (status === 'paused' && !isActive) || + status === 'pending' - const onVisibilityStatusChange = (isVisible: boolean) => { - // When `isFullscreen` is true, it means we're actually still exiting the fullscreen player. Ignore these change - // events - if (isFullscreen) { - return + React.useEffect(() => { + if (!isActive && status !== 'pending') { + setStatus('pending') } - if (isVisible) { - startPlaying(false) - } else { - // Clear the active source so the video view unmounts when autoplay is disabled. Otherwise, leave it mounted - // until it gets replaced by another video - if (disableAutoplay) { - setActiveSource(null, null) - } else { - player.muted = true - if (player.playing) { - player.pause() - } - } - } - } + }, [isActive, status]) return ( - <VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}> - {isActive ? ( - <VideoEmbedInnerNative - embed={embed} - timeRemaining={timeRemaining} - isMuted={isMuted} - isFullscreen={isFullscreen} - setIsFullscreen={setIsFullscreen} - /> - ) : null} + <> + <VideoEmbedInnerNative + embed={embed} + setStatus={setStatus} + setIsLoading={setIsLoading} + setIsActive={setIsActive} + ref={ref} + /> <ImageBackground source={{uri: embed.thumbnail}} accessibilityIgnoresInvertColors @@ -185,17 +104,18 @@ function InnerWrapper({embed}: Props) { > <Button style={[a.flex_1, a.align_center, a.justify_center]} - onPress={() => startPlaying(true)} + onPress={() => { + ref.current?.togglePlayback() + }} label={_(msg`Play video`)} color="secondary"> - {isLoading ? ( + {showSpinner ? ( <View style={[ a.rounded_full, a.p_xs, a.align_center, a.justify_center, - {backgroundColor: 'rgba(0,0,0,0.5)'}, ]}> <Loader size="2xl" style={{color: 'white'}} /> </View> @@ -204,7 +124,7 @@ function InnerWrapper({embed}: Props) { )} </Button> </ImageBackground> - </VisibilityView> + </> ) } diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx index be3f90711..66e1df50d 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/TimeIndicator.tsx @@ -1,4 +1,5 @@ import React from 'react' +import {StyleProp, ViewStyle} from 'react-native' import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' import {atoms as a, native, useTheme} from '#/alf' @@ -8,7 +9,13 @@ import {Text} from '#/components/Typography' * Absolutely positioned time indicator showing how many seconds are remaining * Time is in seconds */ -export function TimeIndicator({time}: {time: number}) { +export function TimeIndicator({ + time, + style, +}: { + time: number + style?: StyleProp<ViewStyle> +}) { const t = useTheme() if (isNaN(time)) { @@ -22,18 +29,20 @@ export function TimeIndicator({time}: {time: number}) { <Animated.View entering={native(FadeInDown.duration(300))} exiting={native(FadeOutDown.duration(500))} + pointerEvents="none" style={[ { backgroundColor: 'rgba(0, 0, 0, 0.5)', borderRadius: 6, paddingHorizontal: 6, paddingVertical: 3, - position: 'absolute', left: 6, bottom: 6, minHeight: 21, - justifyContent: 'center', }, + a.absolute, + a.justify_center, + style, ]}> <Text style={[ diff --git a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx index 8ed7658a6..39ed990ab 100644 --- a/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx +++ b/src/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative.tsx @@ -1,137 +1,136 @@ -import React, {useCallback, useRef} from 'react' -import {Pressable, View} from 'react-native' +import React, {useRef} from 'react' +import {Pressable, StyleProp, View, ViewStyle} from 'react-native' import Animated, {FadeInDown} from 'react-native-reanimated' -import {VideoPlayer, VideoView} from 'expo-video' import {AppBskyEmbedVideo} from '@atproto/api' +import {BlueskyVideoView} from '@haileyok/bluesky-video' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' import {HITSLOP_30} from '#/lib/constants' import {clamp} from '#/lib/numbers' -import {isAndroid} from 'platform/detection' -import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext' +import {useAutoplayDisabled} from '#/state/preferences' import {atoms as a, useTheme} from '#/alf' +import {useIsWithinMessage} from '#/components/dms/MessageContext' import {Mute_Stroke2_Corner0_Rounded as MuteIcon} from '#/components/icons/Mute' +import {Pause_Filled_Corner0_Rounded as PauseIcon} from '#/components/icons/Pause' +import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' import {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker' import {MediaInsetBorder} from '#/components/MediaInsetBorder' -import { - AudioCategory, - PlatformInfo, -} from '../../../../../../modules/expo-bluesky-swiss-army' import {TimeIndicator} from './TimeIndicator' -export function VideoEmbedInnerNative({ - embed, - isFullscreen, - setIsFullscreen, - isMuted, - timeRemaining, -}: { - embed: AppBskyEmbedVideo.View - isFullscreen: boolean - setIsFullscreen: (isFullscreen: boolean) => void - timeRemaining: number - isMuted: boolean -}) { - const {_} = useLingui() - const {player} = useActiveVideoNative() - const ref = useRef<VideoView>(null) +export const VideoEmbedInnerNative = React.forwardRef( + function VideoEmbedInnerNative( + { + embed, + setStatus, + setIsLoading, + setIsActive, + }: { + embed: AppBskyEmbedVideo.View + setStatus: (status: 'playing' | 'paused') => void + setIsLoading: (isLoading: boolean) => void + setIsActive: (isActive: boolean) => void + }, + ref: React.Ref<{togglePlayback: () => void}>, + ) { + const {_} = useLingui() + const videoRef = useRef<BlueskyVideoView>(null) + const autoplayDisabled = useAutoplayDisabled() + const isWithinMessage = useIsWithinMessage() + + const [isMuted, setIsMuted] = React.useState(true) + const [isPlaying, setIsPlaying] = React.useState(false) + const [timeRemaining, setTimeRemaining] = React.useState(0) + const [error, setError] = React.useState<string>() - const enterFullscreen = useCallback(() => { - ref.current?.enterFullscreen() - }, []) + React.useImperativeHandle(ref, () => ({ + togglePlayback: () => { + videoRef.current?.togglePlayback() + }, + })) - let aspectRatio = 16 / 9 + if (error) { + throw new Error(error) + } - if (embed.aspectRatio) { - const {width, height} = embed.aspectRatio - aspectRatio = width / height - aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) - } + let aspectRatio = 16 / 9 - return ( - <View style={[a.flex_1, a.relative, {aspectRatio}]}> - <VideoView - ref={ref} - player={player} - style={[a.flex_1, a.rounded_sm]} - contentFit="cover" - nativeControls={isFullscreen} - accessibilityIgnoresInvertColors - onFullscreenEnter={() => { - PlatformInfo.setAudioCategory(AudioCategory.Playback) - PlatformInfo.setAudioActive(true) - player.muted = false - setIsFullscreen(true) - if (isAndroid) { - player.play() + if (embed.aspectRatio) { + const {width, height} = embed.aspectRatio + aspectRatio = width / height + aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1) + } + + return ( + <View style={[a.flex_1, a.relative, {aspectRatio}]}> + <BlueskyVideoView + url={embed.playlist} + autoplay={!autoplayDisabled && !isWithinMessage} + beginMuted={true} + style={[a.rounded_sm]} + onActiveChange={e => { + setIsActive(e.nativeEvent.isActive) + }} + onLoadingChange={e => { + setIsLoading(e.nativeEvent.isLoading) + }} + onMutedChange={e => { + setIsMuted(e.nativeEvent.isMuted) + }} + onStatusChange={e => { + setStatus(e.nativeEvent.status) + setIsPlaying(e.nativeEvent.status === 'playing') + }} + onTimeRemainingChange={e => { + setTimeRemaining(e.nativeEvent.timeRemaining) + }} + onError={e => { + setError(e.nativeEvent.error) + }} + ref={videoRef} + accessibilityLabel={ + embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) } - }} - onFullscreenExit={() => { - PlatformInfo.setAudioCategory(AudioCategory.Ambient) - PlatformInfo.setAudioActive(false) - player.muted = true - player.playbackRate = 1 - setIsFullscreen(false) - }} - accessibilityLabel={ - embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) - } - accessibilityHint="" - /> - <VideoControls - player={player} - enterFullscreen={enterFullscreen} - isMuted={isMuted} - timeRemaining={timeRemaining} - /> - <MediaInsetBorder /> - </View> - ) -} + accessibilityHint="" + /> + <VideoControls + enterFullscreen={() => { + videoRef.current?.enterFullscreen() + }} + toggleMuted={() => { + videoRef.current?.toggleMuted() + }} + togglePlayback={() => { + videoRef.current?.togglePlayback() + }} + isMuted={isMuted} + isPlaying={isPlaying} + timeRemaining={timeRemaining} + /> + <MediaInsetBorder /> + </View> + ) + }, +) function VideoControls({ - player, enterFullscreen, + toggleMuted, + togglePlayback, timeRemaining, + isPlaying, isMuted, }: { - player: VideoPlayer enterFullscreen: () => void + toggleMuted: () => void + togglePlayback: () => void timeRemaining: number + isPlaying: boolean isMuted: boolean }) { const {_} = useLingui() const t = useTheme() - const onPressFullscreen = useCallback(() => { - switch (player.status) { - case 'idle': - case 'loading': - case 'readyToPlay': { - if (!player.playing) player.play() - enterFullscreen() - break - } - case 'error': { - player.replay() - break - } - } - }, [player, enterFullscreen]) - - const toggleMuted = useCallback(() => { - const muted = !player.muted - // We want to set this to the _inverse_ of the new value, because we actually want for the audio to be mixed when - // the video is muted, and vice versa. - const mix = !muted - const category = muted ? AudioCategory.Ambient : AudioCategory.Playback - - PlatformInfo.setAudioCategory(category) - PlatformInfo.setAudioActive(mix) - player.muted = muted - }, [player]) - // show countdown when: // 1. timeRemaining is a number - was seeing NaNs // 2. duration is greater than 0 - means metadata has loaded @@ -140,44 +139,80 @@ function VideoControls({ return ( <View style={[a.absolute, a.inset_0]}> - {showTime && <TimeIndicator time={timeRemaining} />} <Pressable - onPress={onPressFullscreen} + onPress={enterFullscreen} style={a.flex_1} accessibilityLabel={_(msg`Video`)} accessibilityHint={_(msg`Tap to enter full screen`)} accessibilityRole="button" /> - <Animated.View - entering={FadeInDown.duration(300)} - style={[ - a.absolute, - a.rounded_full, - a.justify_center, - { - backgroundColor: 'rgba(0, 0, 0, 0.5)', - paddingHorizontal: 4, - paddingVertical: 4, - bottom: 6, - right: 6, - minHeight: 21, - minWidth: 21, - }, - ]}> - <Pressable - onPress={toggleMuted} - style={a.flex_1} - accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} - accessibilityHint={_(msg`Tap to toggle sound`)} - accessibilityRole="button" - hitSlop={HITSLOP_30}> - {isMuted ? ( - <MuteIcon width={13} fill={t.palette.white} /> - ) : ( - <UnmuteIcon width={13} fill={t.palette.white} /> - )} - </Pressable> - </Animated.View> + <ControlButton + onPress={togglePlayback} + label={isPlaying ? _(msg`Pause`) : _(msg`Play`)} + accessibilityHint={_(msg`Tap to play or pause`)} + style={{left: 6}}> + {isPlaying ? ( + <PauseIcon width={13} fill={t.palette.white} /> + ) : ( + <PlayIcon width={13} fill={t.palette.white} /> + )} + </ControlButton> + {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />} + + <ControlButton + onPress={toggleMuted} + label={isMuted ? _(msg`Unmute`) : _(msg`Mute`)} + accessibilityHint={_(msg`Tap to toggle sound`)} + style={{right: 6}}> + {isMuted ? ( + <MuteIcon width={13} fill={t.palette.white} /> + ) : ( + <UnmuteIcon width={13} fill={t.palette.white} /> + )} + </ControlButton> </View> ) } + +function ControlButton({ + onPress, + children, + label, + accessibilityHint, + style, +}: { + onPress: () => void + children: React.ReactNode + label: string + accessibilityHint: string + style?: StyleProp<ViewStyle> +}) { + return ( + <Animated.View + entering={FadeInDown.duration(300)} + style={[ + a.absolute, + a.rounded_full, + a.justify_center, + { + backgroundColor: 'rgba(0, 0, 0, 0.5)', + paddingHorizontal: 4, + paddingVertical: 4, + bottom: 6, + minHeight: 21, + minWidth: 21, + }, + style, + ]}> + <Pressable + onPress={onPress} + style={a.flex_1} + accessibilityLabel={label} + accessibilityHint={accessibilityHint} + accessibilityRole="button" + hitSlop={HITSLOP_30}> + {children} + </Pressable> + </Animated.View> + ) +} |