Merge remote-tracking branch 'origin/main' into font-scaling

* origin/main:
  bump (#5348)
  Release 1.91.1 prep (#5339)
  [Video] Fix regression on audio session at launch (#5338)
  Don't open composer via hotkey if other dialog is already open (#5334)
  "N" keyboard shortcut to open a new post modal (#5197)
  [Video] Bump video (#5333)
  Apply Following settings to Lists (#5313)
  Always display next button on login page (#5326)
  [Video] Don't require email verification on self-host (#5332)
  [Video] Remember mute state while scrolling (#5331)
  Fix lexicon validation in PWI Discover (#5329)
  [Video] Remove `expo-video`, use `bluesky-video` (#5282)
This commit is contained in:
Eric Bailey
2024-09-15 13:21:20 -05:00
42 changed files with 5098 additions and 3733 deletions
+1 -13
View File
@@ -74,8 +74,7 @@ appId: xyz.blueskyweb.app
- tapOn: "Delete List" - tapOn: "Delete List"
- tapOn: - tapOn:
id: "confirmBtn" id: "confirmBtn"
- assertVisible: - assertVisible: "This list is empty!"
id: "listsEmpty"
- tapOn: - tapOn:
label: "Create a new curatelist" label: "Create a new curatelist"
@@ -161,17 +160,6 @@ appId: xyz.blueskyweb.app
- assertNotVisible: - assertNotVisible:
id: "userAddRemoveListsModal" id: "userAddRemoveListsModal"
- tapOn:
label: "Shows the curatelist on my profile"
id: "bottomBarProfileBtn"
- swipe:
from:
id: "profilePager-selector"
direction: LEFT
- tapOn:
id: "profilePager-selector-6"
- tapOn: "Good Ppl"
- tapOn: - tapOn:
label: "Adds and removes users on curatelists from the profile" label: "Adds and removes users on curatelists from the profile"
id: "bottomBarSearchBtn" id: "bottomBarSearchBtn"
+2 -4
View File
@@ -21,14 +21,12 @@ appId: xyz.blueskyweb.app
id: "likeBtn" id: "likeBtn"
childOf: childOf:
id: "postThreadItem-by-bob.test" id: "postThreadItem-by-bob.test"
- assertVisible: - assertVisible: "1 like"
id: "likeCount-expanded"
- tapOn: - tapOn:
id: "likeBtn" id: "likeBtn"
childOf: childOf:
id: "postThreadItem-by-bob.test" id: "postThreadItem-by-bob.test"
- assertNotVisible: - assertNotVisible: "1 like"
id: "likeCount-expanded"
# Can like a reply post # Can like a reply post
- tapOn: - tapOn:
-1
View File
@@ -211,7 +211,6 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
}, },
], ],
'expo-video',
'react-native-compressor', 'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js', './plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js', './plugins/withAndroidManifestPlugin.js',
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "bsky.app", "name": "bsky.app",
"version": "1.91.0", "version": "1.91.1",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@@ -68,6 +68,7 @@
"@fortawesome/free-regular-svg-icons": "^6.1.1", "@fortawesome/free-regular-svg-icons": "^6.1.1",
"@fortawesome/free-solid-svg-icons": "^6.1.1", "@fortawesome/free-solid-svg-icons": "^6.1.1",
"@fortawesome/react-native-fontawesome": "^0.3.2", "@fortawesome/react-native-fontawesome": "^0.3.2",
"@haileyok/bluesky-video": "0.1.8",
"@lingui/react": "^4.5.0", "@lingui/react": "^4.5.0",
"@mattermost/react-native-paste-input": "^0.7.1", "@mattermost/react-native-paste-input": "^0.7.1",
"@miblanchard/react-native-slider": "^2.3.1", "@miblanchard/react-native-slider": "^2.3.1",
@@ -140,7 +141,6 @@
"expo-system-ui": "~3.0.4", "expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1", "expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14", "expo-updates": "~0.25.14",
"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",
"expo-web-browser": "~13.0.3", "expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6", "fast-text-encoding": "^1.0.6",
"history": "^5.3.0", "history": "^5.3.0",
+10 -12
View File
@@ -52,10 +52,10 @@ import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {TestCtrls} from '#/view/com/testing/TestCtrls' import {TestCtrls} from '#/view/com/testing/TestCtrls'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoNativeContext' import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {Shell} from '#/view/shell' import {Shell} from '#/view/shell'
import {ThemeProvider as Alf, useFonts} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {NuxDialogs} from '#/components/dialogs/nuxs' import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
@@ -63,7 +63,6 @@ import {Provider as IntentDialogProvider} from '#/components/intents/IntentDialo
import {Provider as PortalProvider} from '#/components/Portal' import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash' import {Splash} from '#/Splash'
import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {AudioCategory, PlatformInfo} from '../modules/expo-bluesky-swiss-army'
SplashScreen.preventAutoHideAsync() SplashScreen.preventAutoHideAsync()
@@ -73,7 +72,6 @@ function InnerApp() {
const {resumeSession} = useSessionApi() const {resumeSession} = useSessionApi()
const theme = useColorModeTheme() const theme = useColorModeTheme()
const {_} = useLingui() const {_} = useLingui()
const [fontsLoaded] = useFonts()
useIntentHandler() useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry() const hasCheckedReferrer = useStarterPackEntry()
@@ -108,12 +106,14 @@ function InnerApp() {
}, [_]) }, [_])
return ( return (
<StatsigProvider key={currentAccount?.did}> <StatsigProvider
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<Alf theme={theme}> <Alf theme={theme}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<Splash isReady={isReady && hasCheckedReferrer && fontsLoaded}> <Splash isReady={isReady && hasCheckedReferrer}>
<ActiveVideoProvider> <RootSiblingParent>
<RootSiblingParent> <VideoVolumeProvider>
<QueryProvider currentDid={currentAccount?.did}> <QueryProvider currentDid={currentAccount?.did}>
<MessagesProvider> <MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
@@ -142,8 +142,8 @@ function InnerApp() {
</LabelDefsProvider> </LabelDefsProvider>
</MessagesProvider> </MessagesProvider>
</QueryProvider> </QueryProvider>
</RootSiblingParent> </VideoVolumeProvider>
</ActiveVideoProvider> </RootSiblingParent>
</Splash> </Splash>
</ThemeProvider> </ThemeProvider>
</Alf> </Alf>
@@ -155,8 +155,6 @@ function App() {
const [isReady, setReady] = useState(false) const [isReady, setReady] = useState(false)
React.useEffect(() => { React.useEffect(() => {
PlatformInfo.setAudioCategory(AudioCategory.Ambient)
PlatformInfo.setAudioActive(false)
initPersistedState().then(() => setReady(true)) initPersistedState().then(() => setReady(true))
}, []) }, [])
+41 -34
View File
@@ -35,16 +35,18 @@ import {
} from '#/state/session' } from '#/state/session'
import {readLastActiveAccount} from '#/state/session/util' import {readLastActiveAccount} from '#/state/session/util'
import {Provider as ShellStateProvider} from '#/state/shell' import {Provider as ShellStateProvider} from '#/state/shell'
import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out' import {Provider as LoggedOutViewProvider} from '#/state/shell/logged-out'
import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide' import {Provider as ProgressGuideProvider} from '#/state/shell/progress-guide'
import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed' import {Provider as SelectedFeedProvider} from '#/state/shell/selected-feed'
import {Provider as StarterPackProvider} from '#/state/shell/starter-pack' import {Provider as StarterPackProvider} from '#/state/shell/starter-pack'
import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies' import {Provider as HiddenRepliesProvider} from '#/state/threadgate-hidden-replies'
import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext' import {Provider as ActiveVideoProvider} from '#/view/com/util/post-embeds/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/view/com/util/post-embeds/VideoVolumeContext'
import * as Toast from '#/view/com/util/Toast' import * as Toast from '#/view/com/util/Toast'
import {ToastContainer} from '#/view/com/util/Toast.web' import {ToastContainer} from '#/view/com/util/Toast.web'
import {Shell} from '#/view/shell/index' import {Shell} from '#/view/shell/index'
import {ThemeProvider as Alf, useFonts} from '#/alf' import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme' import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {NuxDialogs} from '#/components/dialogs/nuxs' import {NuxDialogs} from '#/components/dialogs/nuxs'
import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry' import {useStarterPackEntry} from '#/components/hooks/useStarterPackEntry'
@@ -60,7 +62,8 @@ function InnerApp() {
const {_} = useLingui() const {_} = useLingui()
useIntentHandler() useIntentHandler()
const hasCheckedReferrer = useStarterPackEntry() const hasCheckedReferrer = useStarterPackEntry()
const [fontsLoaded] = useFonts()
useComposerKeyboardShortcut()
// init // init
useEffect(() => { useEffect(() => {
@@ -89,44 +92,48 @@ function InnerApp() {
}, [_]) }, [_])
// wait for session to resume // wait for session to resume
if (!isReady || !hasCheckedReferrer || !fontsLoaded) return null if (!isReady || !hasCheckedReferrer) return null
return ( return (
<KeyboardProvider enabled={false}> <KeyboardProvider enabled={false}>
<StatsigProvider key={currentAccount?.did}> <StatsigProvider
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<Alf theme={theme}> <Alf theme={theme}>
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<RootSiblingParent> <RootSiblingParent>
<ActiveVideoProvider> <VideoVolumeProvider>
<QueryProvider currentDid={currentAccount?.did}> <ActiveVideoProvider>
<MessagesProvider> <QueryProvider currentDid={currentAccount?.did}>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */} <MessagesProvider>
<LabelDefsProvider> {/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<ModerationOptsProvider> <LabelDefsProvider>
<LoggedOutViewProvider> <ModerationOptsProvider>
<SelectedFeedProvider> <LoggedOutViewProvider>
<HiddenRepliesProvider> <SelectedFeedProvider>
<UnreadNotifsProvider> <HiddenRepliesProvider>
<BackgroundNotificationPreferencesProvider> <UnreadNotifsProvider>
<MutedThreadsProvider> <BackgroundNotificationPreferencesProvider>
<SafeAreaProvider> <MutedThreadsProvider>
<ProgressGuideProvider> <SafeAreaProvider>
<Shell /> <ProgressGuideProvider>
<NuxDialogs /> <Shell />
</ProgressGuideProvider> <NuxDialogs />
</SafeAreaProvider> </ProgressGuideProvider>
</MutedThreadsProvider> </SafeAreaProvider>
</BackgroundNotificationPreferencesProvider> </MutedThreadsProvider>
</UnreadNotifsProvider> </BackgroundNotificationPreferencesProvider>
</HiddenRepliesProvider> </UnreadNotifsProvider>
</SelectedFeedProvider> </HiddenRepliesProvider>
</LoggedOutViewProvider> </SelectedFeedProvider>
</ModerationOptsProvider> </LoggedOutViewProvider>
</LabelDefsProvider> </ModerationOptsProvider>
</MessagesProvider> </LabelDefsProvider>
</QueryProvider> </MessagesProvider>
<ToastContainer /> </QueryProvider>
</ActiveVideoProvider> <ToastContainer />
</ActiveVideoProvider>
</VideoVolumeProvider>
</RootSiblingParent> </RootSiblingParent>
</ThemeProvider> </ThemeProvider>
</Alf> </Alf>
@@ -18,7 +18,7 @@ interface ProfilesListProps {
export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>( export const PostsList = React.forwardRef<SectionRef, ProfilesListProps>(
function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) { function PostsListImpl({listUri, headerHeight, scrollElRef}, ref) {
const feed: FeedDescriptor = `list|${listUri}|as_following` const feed: FeedDescriptor = `list|${listUri}`
const {_} = useLingui() const {_} = useLingui()
const onScrollToTop = useCallback(() => { const onScrollToTop = useCallback(() => {
+1 -1
View File
@@ -4,7 +4,7 @@ import {View} from 'react-native'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Play_Filled_Corner0_Rounded as PlayIcon} from '#/components/icons/Play' 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 t = useTheme()
const bg = t.name === 'light' ? t.palette.contrast_25 : t.palette.contrast_975 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 const fg = t.name === 'light' ? t.palette.contrast_975 : t.palette.contrast_25
+3 -2
View File
@@ -2,6 +2,7 @@ import {
AppBskyFeedDefs, AppBskyFeedDefs,
AppBskyFeedGetFeed as GetCustomFeed, AppBskyFeedGetFeed as GetCustomFeed,
BskyAgent, BskyAgent,
jsonStringToLex,
} from '@atproto/api' } from '@atproto/api'
import {getContentLanguages} from '#/state/preferences/languages' import {getContentLanguages} from '#/state/preferences/languages'
@@ -111,7 +112,7 @@ async function loggedOutFetch({
}&limit=${limit}&lang=${contentLangs}`, }&limit=${limit}&lang=${contentLangs}`,
{method: 'GET', headers: {'Accept-Language': contentLangs}}, {method: 'GET', headers: {'Accept-Language': contentLangs}},
) )
let data = res.ok ? await res.json() : null let data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) { if (data?.feed?.length) {
return { return {
success: true, success: true,
@@ -126,7 +127,7 @@ async function loggedOutFetch({
}&limit=${limit}`, }&limit=${limit}`,
{method: 'GET', headers: {'Accept-Language': ''}}, {method: 'GET', headers: {'Accept-Language': ''}},
) )
data = res.ok ? await res.json() : null data = res.ok ? jsonStringToLex(await res.text()) : null
if (data?.feed?.length) { if (data?.feed?.length) {
return { return {
success: true, success: true,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -144,8 +144,7 @@ export const ForgotPasswordForm = ({
variant="solid" variant="solid"
color={'primary'} color={'primary'}
size="medium" size="medium"
onPress={onPressNext} onPress={onPressNext}>
disabled={!email}>
<ButtonText> <ButtonText>
<Trans>Next</Trans> <Trans>Next</Trans>
</ButtonText> </ButtonText>
+9 -22
View File
@@ -60,7 +60,6 @@ export const LoginForm = ({
const {track} = useAnalytics() const {track} = useAnalytics()
const t = useTheme() const t = useTheme()
const [isProcessing, setIsProcessing] = useState<boolean>(false) const [isProcessing, setIsProcessing] = useState<boolean>(false)
const [isReady, setIsReady] = useState<boolean>(false)
const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] = const [isAuthFactorTokenNeeded, setIsAuthFactorTokenNeeded] =
useState<boolean>(false) useState<boolean>(false)
const identifierValueRef = useRef<string>(initialHandle || '') const identifierValueRef = useRef<string>(initialHandle || '')
@@ -83,12 +82,18 @@ export const LoginForm = ({
Keyboard.dismiss() Keyboard.dismiss()
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut) LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut)
setError('') setError('')
setIsProcessing(true)
const identifier = identifierValueRef.current.toLowerCase().trim() const identifier = identifierValueRef.current.toLowerCase().trim()
const password = passwordValueRef.current const password = passwordValueRef.current
const authFactorToken = authFactorTokenValueRef.current const authFactorToken = authFactorTokenValueRef.current
if (!identifier || !password) {
setError(_(msg`Invalid username or password`))
return
}
setIsProcessing(true)
try { try {
// try to guess the handle if the user just gave their own username // try to guess the handle if the user just gave their own username
let fullIdent = identifier let fullIdent = identifier
@@ -157,22 +162,6 @@ export const LoginForm = ({
} }
} }
const checkIsReady = () => {
if (
!!serviceDescription &&
!!identifierValueRef.current &&
!!passwordValueRef.current
) {
if (!isReady) {
setIsReady(true)
}
} else {
if (isReady) {
setIsReady(false)
}
}
}
return ( return (
<FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}> <FormContainer testID="loginForm" titleText={<Trans>Sign in</Trans>}>
<View> <View>
@@ -204,7 +193,6 @@ export const LoginForm = ({
defaultValue={initialHandle || ''} defaultValue={initialHandle || ''}
onChangeText={v => { onChangeText={v => {
identifierValueRef.current = v identifierValueRef.current = v
checkIsReady()
}} }}
onSubmitEditing={() => { onSubmitEditing={() => {
passwordRef.current?.focus() passwordRef.current?.focus()
@@ -233,7 +221,6 @@ export const LoginForm = ({
clearButtonMode="while-editing" clearButtonMode="while-editing"
onChangeText={v => { onChangeText={v => {
passwordValueRef.current = v passwordValueRef.current = v
checkIsReady()
}} }}
onSubmitEditing={onPressNext} onSubmitEditing={onPressNext}
blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing blurOnSubmit={false} // HACK: https://github.com/facebook/react-native/issues/21911#issuecomment-558343069 Keyboard blur behavior is now handled in onSubmitEditing
@@ -325,7 +312,7 @@ export const LoginForm = ({
<Trans>Connecting...</Trans> <Trans>Connecting...</Trans>
</Text> </Text>
</> </>
) : isReady ? ( ) : (
<Button <Button
testID="loginNextButton" testID="loginNextButton"
label={_(msg`Next`)} label={_(msg`Next`)}
@@ -339,7 +326,7 @@ export const LoginForm = ({
</ButtonText> </ButtonText>
{isProcessing && <ButtonIcon icon={Loader} />} {isProcessing && <ButtonIcon icon={Loader} />}
</Button> </Button>
) : undefined} )}
</View> </View>
</FormContainer> </FormContainer>
) )
+1 -25
View File
@@ -21,31 +21,7 @@ export function useFeedTuners(feedDesc: FeedDescriptor) {
if (feedDesc.startsWith('feedgen')) { if (feedDesc.startsWith('feedgen')) {
return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)] return [FeedTuner.preferredLangOnly(langPrefs.contentLanguages)]
} }
if (feedDesc.startsWith('list')) { if (feedDesc === 'following' || feedDesc.startsWith('list')) {
let feedTuners = []
if (feedDesc.endsWith('|as_following')) {
// Same as Following tuners below, copypaste for now.
feedTuners.push(FeedTuner.removeOrphans)
if (preferences?.feedViewPrefs.hideReposts) {
feedTuners.push(FeedTuner.removeReposts)
}
if (preferences?.feedViewPrefs.hideReplies) {
feedTuners.push(FeedTuner.removeReplies)
} else {
feedTuners.push(
FeedTuner.followedRepliesOnly({
userDid: currentAccount?.did || '',
}),
)
}
if (preferences?.feedViewPrefs.hideQuotePosts) {
feedTuners.push(FeedTuner.removeQuotePosts)
}
feedTuners.push(FeedTuner.dedupThreads)
}
return feedTuners
}
if (feedDesc === 'following') {
const feedTuners = [FeedTuner.removeOrphans] const feedTuners = [FeedTuner.removeOrphans]
if (preferences?.feedViewPrefs.hideReposts) { if (preferences?.feedViewPrefs.hideReposts) {
-2
View File
@@ -51,7 +51,6 @@ type AuthorFilter =
| 'posts_with_media' | 'posts_with_media'
type FeedUri = string type FeedUri = string
type ListUri = string type ListUri = string
type ListFilter = 'as_following' // Applies current Following settings. Currently client-side.
export type FeedDescriptor = export type FeedDescriptor =
| 'following' | 'following'
@@ -59,7 +58,6 @@ export type FeedDescriptor =
| `feedgen|${FeedUri}` | `feedgen|${FeedUri}`
| `likes|${ActorDid}` | `likes|${ActorDid}`
| `list|${ListUri}` | `list|${ListUri}`
| `list|${ListUri}|${ListFilter}`
export interface FeedParams { export interface FeedParams {
mergeFeedEnabled?: boolean mergeFeedEnabled?: boolean
mergeFeedSources?: string[] mergeFeedSources?: string[]
@@ -0,0 +1,64 @@
import React from 'react'
import {useDialogStateContext} from '#/state/dialogs'
import {useLightbox} from '#/state/lightbox'
import {useModals} from '#/state/modals'
import {useIsDrawerOpen} from '#/state/shell/drawer-open'
import {useComposerControls} from './'
/**
* Based on {@link https://github.com/jaywcjlove/hotkeys-js/blob/b0038773f3b902574f22af747f3bb003a850f1da/src/index.js#L51C1-L64C2}
*/
function shouldIgnore(event: KeyboardEvent) {
const target: any = event.target || event.srcElement
if (!target) return false
const {tagName} = target
if (!tagName) return false
const isInput =
tagName === 'INPUT' &&
![
'checkbox',
'radio',
'range',
'button',
'file',
'reset',
'submit',
'color',
].includes(target.type)
// ignore: isContentEditable === 'true', <input> and <textarea> when readOnly state is false, <select>
if (
target.isContentEditable ||
((isInput || tagName === 'TEXTAREA' || tagName === 'SELECT') &&
!target.readOnly)
) {
return true
}
return false
}
export function useComposerKeyboardShortcut() {
const {openComposer} = useComposerControls()
const {openDialogs} = useDialogStateContext()
const {isModalActive} = useModals()
const {activeLightbox} = useLightbox()
const isDrawerOpen = useIsDrawerOpen()
React.useEffect(() => {
function handler(event: KeyboardEvent) {
if (shouldIgnore(event)) return
if (
openDialogs.current.size > 0 ||
isModalActive ||
activeLightbox ||
isDrawerOpen
)
return
if (event.key === 'n' || event.key === 'N') {
openComposer({})
}
}
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [openComposer, isModalActive, openDialogs, activeLightbox, isDrawerOpen])
}
@@ -13,6 +13,8 @@ import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
import {isNative} from '#/platform/detection' import {isNative} from '#/platform/detection'
import {useModalControls} from '#/state/modals' import {useModalControls} from '#/state/modals'
import {useSession} from '#/state/session' import {useSession} from '#/state/session'
import {BSKY_SERVICE} from 'lib/constants'
import {getHostnameFromUrl} from 'lib/strings/url-helpers'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
@@ -38,7 +40,12 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
return return
} }
if (!currentAccount?.emailConfirmed) { if (
currentAccount &&
!currentAccount.emailConfirmed &&
getHostnameFromUrl(currentAccount.service) ===
getHostnameFromUrl(BSKY_SERVICE)
) {
Keyboard.dismiss() Keyboard.dismiss()
control.open() control.open()
} else { } else {
@@ -71,12 +78,12 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
} }
} }
}, [ }, [
onSelectVideo,
requestVideoAccessIfNeeded, requestVideoAccessIfNeeded,
currentAccount,
control,
setError, setError,
_, _,
control, onSelectVideo,
currentAccount?.emailConfirmed,
]) ])
return ( return (
+8 -16
View File
@@ -1,8 +1,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
import React from 'react' import React from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImagePickerAsset} from 'expo-image-picker' 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 {CompressedVideo} from '#/lib/media/video/types'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
@@ -22,15 +21,8 @@ export function VideoPreview({
clear: () => void clear: () => void
}) { }) {
const t = useTheme() const t = useTheme()
const playerRef = React.useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled() 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 let aspectRatio = asset.width / asset.height
if (isNaN(aspectRatio)) { if (isNaN(aspectRatio)) {
@@ -50,12 +42,12 @@ export function VideoPreview({
t.atoms.border_contrast_low, t.atoms.border_contrast_low,
{backgroundColor: 'black'}, {backgroundColor: 'black'},
]}> ]}>
<VideoView <BlueskyVideoView
player={player} url={video.uri}
style={a.flex_1} autoplay={autoplayDisabled}
allowsPictureInPicture={false} beginMuted={true}
nativeControls={false} forceTakeover={true}
contentFit="contain" ref={playerRef}
/> />
<ExternalEmbedRemoveBtn onRemove={clear} /> <ExternalEmbedRemoveBtn onRemove={clear} />
{autoplayDisabled && ( {autoplayDisabled && (
+4 -4
View File
@@ -1,6 +1,7 @@
import React, {memo} from 'react' import React, {memo} from 'react'
import {FlatListProps, RefreshControl, ViewToken} from 'react-native' import {FlatListProps, RefreshControl, ViewToken} from 'react-native'
import {runOnJS, useSharedValue} from 'react-native-reanimated' import {runOnJS, useSharedValue} from 'react-native-reanimated'
import {updateActiveVideoViewAsync} from '@haileyok/bluesky-video'
import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
import {usePalette} from '#/lib/hooks/usePalette' import {usePalette} from '#/lib/hooks/usePalette'
@@ -8,7 +9,6 @@ import {useScrollHandlers} from '#/lib/ScrollContext'
import {useDedupe} from 'lib/hooks/useDedupe' import {useDedupe} from 'lib/hooks/useDedupe'
import {addStyle} from 'lib/styles' import {addStyle} from 'lib/styles'
import {isIOS} from 'platform/detection' import {isIOS} from 'platform/detection'
import {updateActiveViewAsync} from '../../../../modules/expo-bluesky-swiss-army/src/VisibilityView'
import {FlatList_INTERNAL} from './Views' import {FlatList_INTERNAL} from './Views'
export type ListMethods = FlatList_INTERNAL export type ListMethods = FlatList_INTERNAL
@@ -69,7 +69,7 @@ function ListImpl<ItemT>(
onBeginDragFromContext?.(e, ctx) onBeginDragFromContext?.(e, ctx)
}, },
onEndDrag(e, ctx) { onEndDrag(e, ctx) {
runOnJS(updateActiveViewAsync)() runOnJS(updateActiveVideoViewAsync)()
onEndDragFromContext?.(e, ctx) onEndDragFromContext?.(e, ctx)
}, },
onScroll(e, ctx) { onScroll(e, ctx) {
@@ -84,13 +84,13 @@ function ListImpl<ItemT>(
} }
if (isIOS) { if (isIOS) {
runOnJS(dedupe)(updateActiveViewAsync) runOnJS(dedupe)(updateActiveVideoViewAsync)
} }
}, },
// Note: adding onMomentumBegin here makes simulator scroll // Note: adding onMomentumBegin here makes simulator scroll
// lag on Android. So either don't add it, or figure out why. // lag on Android. So either don't add it, or figure out why.
onMomentumEnd(e, ctx) { onMomentumEnd(e, ctx) {
runOnJS(updateActiveViewAsync)() runOnJS(updateActiveVideoViewAsync)()
onMomentumEndFromContext?.(e, ctx) onMomentumEndFromContext?.(e, ctx)
}, },
}) })
@@ -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
}
+31 -111
View File
@@ -1,22 +1,18 @@
import React, {useCallback, useEffect, useId, useState} from 'react' import React, {useCallback, useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import {ImageBackground} from 'expo-image' import {ImageBackground} from 'expo-image'
import {PlayerError, VideoPlayerStatus} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {useAutoplayDisabled} from 'state/preferences'
import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative' import {VideoEmbedInnerNative} from '#/view/com/util/post-embeds/VideoEmbedInner/VideoEmbedInnerNative'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {Button} from '#/components/Button' import {Button} from '#/components/Button'
import {useIsWithinMessage} from '#/components/dms/MessageContext' import {useThrottledValue} from '#/components/hooks/useThrottledValue'
import {Loader} from '#/components/Loader' import {Loader} from '#/components/Loader'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon' import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VisibilityView} from '../../../../../modules/expo-bluesky-swiss-army'
import {ErrorBoundary} from '../ErrorBoundary' import {ErrorBoundary} from '../ErrorBoundary'
import {useActiveVideoNative} from './ActiveVideoNativeContext'
import * as VideoFallback from './VideoEmbedInner/VideoFallback' import * as VideoFallback from './VideoEmbedInner/VideoFallback'
interface Props { interface Props {
@@ -59,113 +55,36 @@ export function VideoEmbed({embed}: Props) {
function InnerWrapper({embed}: Props) { function InnerWrapper({embed}: Props) {
const {_} = useLingui() const {_} = useLingui()
const {activeSource, activeViewId, setActiveSource, player} = const ref = React.useRef<{togglePlayback: () => void}>(null)
useActiveVideoNative()
const viewId = useId()
const [playerStatus, setPlayerStatus] = useState< const [status, setStatus] = React.useState<'playing' | 'paused' | 'pending'>(
VideoPlayerStatus | 'paused' 'pending',
>('paused') )
const [isMuted, setIsMuted] = useState(player.muted) const [isLoading, setIsLoading] = React.useState(false)
const [isFullscreen, setIsFullscreen] = React.useState(false) const [isActive, setIsActive] = React.useState(false)
const [timeRemaining, setTimeRemaining] = React.useState(0) const showSpinner = useThrottledValue(isActive && isLoading, 100)
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 showOverlay =
const [error, setError] = useState<Error | PlayerError | null>(null) !isActive ||
if (error) { isLoading ||
throw error (status === 'paused' && !isActive) ||
} status === 'pending'
useEffect(() => { React.useEffect(() => {
if (isActive) { if (!isActive && status !== 'pending') {
// eslint-disable-next-line @typescript-eslint/no-shadow setStatus('pending')
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]) }, [isActive, status])
// 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
}
if (isActive) {
player.play()
} else {
setActiveSource(embed.playlist, viewId)
}
}
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
}
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()
}
}
}
}
return ( return (
<VisibilityView enabled={true} onChangeStatus={onVisibilityStatusChange}> <>
{isActive ? ( <VideoEmbedInnerNative
<VideoEmbedInnerNative embed={embed}
embed={embed} setStatus={setStatus}
timeRemaining={timeRemaining} setIsLoading={setIsLoading}
isMuted={isMuted} setIsActive={setIsActive}
isFullscreen={isFullscreen} ref={ref}
setIsFullscreen={setIsFullscreen} />
/>
) : null}
<ImageBackground <ImageBackground
source={{uri: embed.thumbnail}} source={{uri: embed.thumbnail}}
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
@@ -185,17 +104,18 @@ function InnerWrapper({embed}: Props) {
> >
<Button <Button
style={[a.flex_1, a.align_center, a.justify_center]} style={[a.flex_1, a.align_center, a.justify_center]}
onPress={() => startPlaying(true)} onPress={() => {
ref.current?.togglePlayback()
}}
label={_(msg`Play video`)} label={_(msg`Play video`)}
color="secondary"> color="secondary">
{isLoading ? ( {showSpinner ? (
<View <View
style={[ style={[
a.rounded_full, a.rounded_full,
a.p_xs, a.p_xs,
a.align_center, a.align_center,
a.justify_center, a.justify_center,
{backgroundColor: 'rgba(0,0,0,0.5)'},
]}> ]}>
<Loader size="2xl" style={{color: 'white'}} /> <Loader size="2xl" style={{color: 'white'}} />
</View> </View>
@@ -204,7 +124,7 @@ function InnerWrapper({embed}: Props) {
)} )}
</Button> </Button>
</ImageBackground> </ImageBackground>
</VisibilityView> </>
) )
} }
@@ -1,4 +1,5 @@
import React from 'react' import React from 'react'
import {StyleProp, ViewStyle} from 'react-native'
import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated' import Animated, {FadeInDown, FadeOutDown} from 'react-native-reanimated'
import {atoms as a, native, useTheme} from '#/alf' 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 * Absolutely positioned time indicator showing how many seconds are remaining
* Time is in seconds * Time is in seconds
*/ */
export function TimeIndicator({time}: {time: number}) { export function TimeIndicator({
time,
style,
}: {
time: number
style?: StyleProp<ViewStyle>
}) {
const t = useTheme() const t = useTheme()
if (isNaN(time)) { if (isNaN(time)) {
@@ -22,18 +29,20 @@ export function TimeIndicator({time}: {time: number}) {
<Animated.View <Animated.View
entering={native(FadeInDown.duration(300))} entering={native(FadeInDown.duration(300))}
exiting={native(FadeOutDown.duration(500))} exiting={native(FadeOutDown.duration(500))}
pointerEvents="none"
style={[ style={[
{ {
backgroundColor: 'rgba(0, 0, 0, 0.5)', backgroundColor: 'rgba(0, 0, 0, 0.5)',
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 6, paddingHorizontal: 6,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute',
left: 6, left: 6,
bottom: 6, bottom: 6,
minHeight: 21, minHeight: 21,
justifyContent: 'center',
}, },
a.absolute,
a.justify_center,
style,
]}> ]}>
<Text <Text
style={[ style={[
@@ -1,136 +1,134 @@
import React, {useCallback, useRef} from 'react' import React, {useRef} from 'react'
import {Pressable, View} from 'react-native' import {Pressable, StyleProp, View, ViewStyle} from 'react-native'
import Animated, {FadeInDown} from 'react-native-reanimated' import Animated, {FadeInDown} from 'react-native-reanimated'
import {VideoPlayer, VideoView} from 'expo-video'
import {AppBskyEmbedVideo} from '@atproto/api' import {AppBskyEmbedVideo} from '@atproto/api'
import {BlueskyVideoView} from '@haileyok/bluesky-video'
import {msg} from '@lingui/macro' import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {HITSLOP_30} from '#/lib/constants' import {HITSLOP_30} from '#/lib/constants'
import {clamp} from '#/lib/numbers' import {clamp} from '#/lib/numbers'
import {isAndroid} from 'platform/detection' import {useAutoplayDisabled} from '#/state/preferences'
import {useActiveVideoNative} from 'view/com/util/post-embeds/ActiveVideoNativeContext' import {useVideoVolumeState} from 'view/com/util/post-embeds/VideoVolumeContext'
import {atoms as a, useTheme} from '#/alf' 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 {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 {SpeakerVolumeFull_Stroke2_Corner0_Rounded as UnmuteIcon} from '#/components/icons/Speaker'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {
AudioCategory,
PlatformInfo,
} from '../../../../../../modules/expo-bluesky-swiss-army'
import {TimeIndicator} from './TimeIndicator' import {TimeIndicator} from './TimeIndicator'
export function VideoEmbedInnerNative({ export const VideoEmbedInnerNative = React.forwardRef(
embed, function VideoEmbedInnerNative(
isFullscreen, {
setIsFullscreen, embed,
isMuted, setStatus,
timeRemaining, setIsLoading,
}: { setIsActive,
embed: AppBskyEmbedVideo.View }: {
isFullscreen: boolean embed: AppBskyEmbedVideo.View
setIsFullscreen: (isFullscreen: boolean) => void setStatus: (status: 'playing' | 'paused') => void
timeRemaining: number setIsLoading: (isLoading: boolean) => void
isMuted: boolean setIsActive: (isActive: boolean) => void
}) { },
const {_} = useLingui() ref: React.Ref<{togglePlayback: () => void}>,
const {player} = useActiveVideoNative() ) {
const ref = useRef<VideoView>(null) const {_} = useLingui()
const videoRef = useRef<BlueskyVideoView>(null)
const autoplayDisabled = useAutoplayDisabled()
const isWithinMessage = useIsWithinMessage()
const {muted, setMuted} = useVideoVolumeState()
const enterFullscreen = useCallback(() => { const [isPlaying, setIsPlaying] = React.useState(false)
ref.current?.enterFullscreen() const [timeRemaining, setTimeRemaining] = React.useState(0)
}, []) const [error, setError] = React.useState<string>()
let aspectRatio = 16 / 9 React.useImperativeHandle(ref, () => ({
togglePlayback: () => {
videoRef.current?.togglePlayback()
},
}))
if (embed.aspectRatio) { if (error) {
const {width, height} = embed.aspectRatio throw new Error(error)
aspectRatio = width / height }
aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
}
return ( let aspectRatio = 16 / 9
<View style={[a.flex_1, a.relative, {aspectRatio}]}>
<VideoView if (embed.aspectRatio) {
ref={ref} const {width, height} = embed.aspectRatio
player={player} aspectRatio = width / height
style={[a.flex_1, a.rounded_sm]} aspectRatio = clamp(aspectRatio, 1 / 1, 3 / 1)
contentFit="cover" }
nativeControls={isFullscreen}
accessibilityIgnoresInvertColors return (
onFullscreenEnter={() => { <View style={[a.flex_1, a.relative, {aspectRatio}]}>
PlatformInfo.setAudioCategory(AudioCategory.Playback) <BlueskyVideoView
PlatformInfo.setAudioActive(true) url={embed.playlist}
player.muted = false autoplay={!autoplayDisabled && !isWithinMessage}
setIsFullscreen(true) beginMuted={autoplayDisabled ? false : muted}
if (isAndroid) { style={[a.rounded_sm]}
player.play() onActiveChange={e => {
setIsActive(e.nativeEvent.isActive)
}}
onLoadingChange={e => {
setIsLoading(e.nativeEvent.isLoading)
}}
onMutedChange={e => {
setMuted(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`)
} }
}} accessibilityHint=""
onFullscreenExit={() => { />
PlatformInfo.setAudioCategory(AudioCategory.Ambient) <VideoControls
PlatformInfo.setAudioActive(false) enterFullscreen={() => {
player.muted = true videoRef.current?.enterFullscreen()
player.playbackRate = 1 }}
setIsFullscreen(false) toggleMuted={() => {
}} videoRef.current?.toggleMuted()
accessibilityLabel={ }}
embed.alt ? _(msg`Video: ${embed.alt}`) : _(msg`Video`) togglePlayback={() => {
} videoRef.current?.togglePlayback()
accessibilityHint="" }}
/> isPlaying={isPlaying}
<VideoControls timeRemaining={timeRemaining}
player={player} />
enterFullscreen={enterFullscreen} <MediaInsetBorder />
isMuted={isMuted} </View>
timeRemaining={timeRemaining} )
/> },
<MediaInsetBorder /> )
</View>
)
}
function VideoControls({ function VideoControls({
player,
enterFullscreen, enterFullscreen,
toggleMuted,
togglePlayback,
timeRemaining, timeRemaining,
isMuted, isPlaying,
}: { }: {
player: VideoPlayer
enterFullscreen: () => void enterFullscreen: () => void
toggleMuted: () => void
togglePlayback: () => void
timeRemaining: number timeRemaining: number
isMuted: boolean isPlaying: boolean
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const {muted} = useVideoVolumeState()
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: // show countdown when:
// 1. timeRemaining is a number - was seeing NaNs // 1. timeRemaining is a number - was seeing NaNs
@@ -140,44 +138,80 @@ function VideoControls({
return ( return (
<View style={[a.absolute, a.inset_0]}> <View style={[a.absolute, a.inset_0]}>
{showTime && <TimeIndicator time={timeRemaining} />}
<Pressable <Pressable
onPress={onPressFullscreen} onPress={enterFullscreen}
style={a.flex_1} style={a.flex_1}
accessibilityLabel={_(msg`Video`)} accessibilityLabel={_(msg`Video`)}
accessibilityHint={_(msg`Tap to enter full screen`)} accessibilityHint={_(msg`Tap to enter full screen`)}
accessibilityRole="button" accessibilityRole="button"
/> />
<Animated.View <ControlButton
entering={FadeInDown.duration(300)} onPress={togglePlayback}
style={[ label={isPlaying ? _(msg`Pause`) : _(msg`Play`)}
a.absolute, accessibilityHint={_(msg`Tap to play or pause`)}
a.rounded_full, style={{left: 6}}>
a.justify_center, {isPlaying ? (
{ <PauseIcon width={13} fill={t.palette.white} />
backgroundColor: 'rgba(0, 0, 0, 0.5)', ) : (
paddingHorizontal: 4, <PlayIcon width={13} fill={t.palette.white} />
paddingVertical: 4, )}
bottom: 6, </ControlButton>
right: 6, {showTime && <TimeIndicator time={timeRemaining} style={{left: 33}} />}
minHeight: 21,
minWidth: 21, <ControlButton
}, onPress={toggleMuted}
]}> label={muted ? _(msg`Unmute`) : _(msg`Mute`)}
<Pressable accessibilityHint={_(msg`Tap to toggle sound`)}
onPress={toggleMuted} style={{right: 6}}>
style={a.flex_1} {muted ? (
accessibilityLabel={isMuted ? _(msg`Muted`) : _(msg`Unmuted`)} <MuteIcon width={13} fill={t.palette.white} />
accessibilityHint={_(msg`Tap to toggle sound`)} ) : (
accessibilityRole="button" <UnmuteIcon width={13} fill={t.palette.white} />
hitSlop={HITSLOP_30}> )}
{isMuted ? ( </ControlButton>
<MuteIcon width={13} fill={t.palette.white} />
) : (
<UnmuteIcon width={13} fill={t.palette.white} />
)}
</Pressable>
</Animated.View>
</View> </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>
)
}
@@ -0,0 +1,32 @@
import React from 'react'
const Context = React.createContext(
{} as {
muted: boolean
setMuted: (muted: boolean) => void
},
)
export function Provider({children}: {children: React.ReactNode}) {
const [muted, setMuted] = React.useState(true)
const value = React.useMemo(
() => ({
muted,
setMuted,
}),
[muted, setMuted],
)
return <Context.Provider value={value}>{children}</Context.Provider>
}
export function useVideoVolumeState() {
const context = React.useContext(Context)
if (!context) {
throw new Error(
'useVideoVolumeState must be used within a VideoVolumeProvider',
)
}
return context
}
+5 -4
View File
@@ -4104,6 +4104,11 @@
resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861" resolved "https://registry.yarnpkg.com/@graphql-typed-document-node/core/-/core-3.2.0.tgz#5f3d96ec6b2354ad6d8a28bf216a1d97b5426861"
integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ== integrity sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==
"@haileyok/bluesky-video@0.1.8":
version "0.1.8"
resolved "https://registry.yarnpkg.com/@haileyok/bluesky-video/-/bluesky-video-0.1.8.tgz#26fc6ec64993d593e7a0ecb96febff96c5037ebd"
integrity sha512-jMtGSMU5jpacLvAFRWGC5gVvVKuKHrGH3gluz9MsBEbSRvS8bb9FhNtC1VHb+A4UUgLufOyzrSbDw9fa8hR8lg==
"@hapi/accept@^6.0.3": "@hapi/accept@^6.0.3":
version "6.0.3" version "6.0.3"
resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab" resolved "https://registry.yarnpkg.com/@hapi/accept/-/accept-6.0.3.tgz#eef0800a4f89cd969da8e5d0311dc877c37279ab"
@@ -12422,10 +12427,6 @@ expo-updates@~0.25.14:
ignore "^5.3.1" ignore "^5.3.1"
resolve-from "^5.0.0" resolve-from "^5.0.0"
"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":
version "1.2.4"
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"
expo-web-browser@~13.0.3: expo-web-browser@~13.0.3:
version "13.0.3" version "13.0.3"
resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92" resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92"