* tweaks to constants (#7478)

* add did

* use correct did

* typo

* tweak

* Prevent Drawer gesture conflicting with Suggestions scroll (#7468)

* Extract BlockDrawerGeesture

* Block drawer when scrolling interstitials

(cherry picked from commit 9e3f2f4374)

* yolo interstitial

* yolo mode

* right swipe

* fix nav gesture

* vibe controls

* collapsible post text

* rm blurview, cover for tall videos

* smarter video source handling

* use thumbnails, improve perf significantly

* better android loading

* improve aspect ratio

* optimize source changes

* rm spinner on ios

* whoops, remove debug `false`

* FIX WRONG VIDEOS SHOWING UP

* don't spring on way down

* release video players when leaving screen

* remove jank animation

* Add grid

* improve contract, fix double tap

* Filter out posts without videos

* Only do grid on native

* Pipe through feedSourceUri and link to feed

* Handle passed through params

* Partial revert, just filter posts to start at index

* Clean up cards, remove entry interstitial

* Tweak handle

* Change constant name

* Rename some things

* Make types legit

* Clean up more naming

* Add placeholder for grid view

* Handle web, set up new organization

* Begin work on Header

* Replace types

* Squashed commit of the following:

commit 3d1be4c0f1
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:08:05 2025 +0000

    extend animation

commit c9f199413b
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 01:01:24 2025 +0000

    fix gap

commit 22e520795f
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:50:16 2025 +0000

    thinner bar, format time

commit c32427f214
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:47:57 2025 +0000

    fix 2 in 3 screens

commit cbf84c08d6
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:45:46 2025 +0000

    rm unneeded var

commit 7e0e100177
Author: Samuel Newman <mozzius@protonmail.com>
Date:   Fri Jan 17 00:41:18 2025 +0000

    scrubberrrrr

* use white with opacity rather than gray

* Simultaneous gesture

* cleanup attempt

* fix jank

* link to profile on press

* fix jitter fr this time

* mostly fix android flicker

* Maybe fix row generation

* Add content hider to video card

* emoji in post text

* reduce update rate

* fix type error

* Fix grid layout trailing single item

* Add Discover interstitial, settings, includes pin for now

* Explore interstitial, handle dimissal, pinning, compact card

* Only use grid placeholder on native

* Update events

* Add feature gate

* android nav bar fixes + lower update speed

* fix interval + decel rate on interstitials

* attempt to fix broken scrub on android (not working)

* follow button

* Part out the interstitials for perf, add view more

* Remove prod web route

* Wrap interstitials with BlockDrawerGesture

* Bring video cropping in line with images (#7462)

* Mimic image cropping for videos on web

* Same on native

* Rename variables for clarity

* Fix Android scrubbing

* Add FeedFeedbackProvider

* Remove swipe gesture

* fix light status bar behaviour

* bump

* feedback

* Copy pasta to new location

* Copy pasta part deux

* Filter only videos

* Make whole text clickable to expand

(cherry picked from commit 4cf31120779f4e06eb4c296b3d4b53814d432b07)

* move scrubber to own file

* end card

* add icon to end card

* add min view time to viewability config

* play haptic on like

* tweak feedback

* tweak feedback again

* Moderation

(cherry picked from commit 6b6b471cfb363031284b3e7a1f6e0ade3ac4ae47)

* remove bad check

* fix feedback for new video grid

* change prop name to items as well

* Simplify logic

* Fix mod footer

* Give scrubber more space on android

* Add subtle track behind scrubber, adjust opacity

* wire in feed context again...

* Add better a11y desc to card

* Fix key issue

* Update a11y copy

* Fix scrubber height

* improve scrubber animation

* Make follow button more obvious

* Make header back button more clear

* Disable interactions with actual video el

* keep content away from the bottom safe area

* fix blur

* fix moderation issue

* improve contrast on mod screen

* Make moderation static per item

* Memoize rows

* Optimizations

* Take video moderation into account

* Only blur titles for list blur

* Change copy

* Bump blur radius

* animate text in both directions

* Rm unused field

* Filter by root early

* Refactor for clarity

* add compose prompt to scrubber

* rm log

* tweak gradient

* Bump SDK, use contentMode to power video feed

* Ensure ProfileFeed view also supports video feed

* improve scrubber on android

* rm border from footer

* Update prod video feed did

* Separate caches

* Add lil hover to View More

* Fix undefined logic, remove header for interstitial

* Ungate

* Fix stuckness

* remove extra useless map

* Fix effect cleanup

* Send seen without cleanup

* Simplify react stuff

* Earlier early return to avoid loading flash

* remove scrubber placeholder

* Remove opacity hack

* Render useEvent conditionally

* Fix Android flash

---------

Co-authored-by: dan <dan.abramov@gmail.com>
Co-authored-by: Samuel Newman <mozzius@protonmail.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Hailey
2025-01-19 17:17:41 -08:00
committed by GitHub
parent cb02065550
commit 34582edf3e
38 changed files with 3165 additions and 126 deletions
+32 -3
View File
@@ -1,12 +1,14 @@
import React, {useCallback, useMemo} from 'react'
import {StyleSheet, View} from 'react-native'
import {useAnimatedRef} from 'react-native-reanimated'
import {AppBskyFeedDefs} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useIsFocused, useNavigation} from '@react-navigation/native'
import {NativeStackScreenProps} from '@react-navigation/native-stack'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URIS} from '#/lib/constants'
import {usePalette} from '#/lib/hooks/usePalette'
import {useSetTitle} from '#/lib/hooks/useSetTitle'
import {ComposeIcon2} from '#/lib/icons'
@@ -18,7 +20,7 @@ import {isNative} from '#/platform/detection'
import {listenSoftReset} from '#/state/events'
import {FeedFeedbackProvider, useFeedFeedback} from '#/state/feed-feedback'
import {FeedSourceFeedInfo, useFeedSourceInfoQuery} from '#/state/queries/feed'
import {FeedDescriptor} from '#/state/queries/post-feed'
import {FeedDescriptor, FeedParams} from '#/state/queries/post-feed'
import {RQKEY as FEED_RQKEY} from '#/state/queries/post-feed'
import {
usePreferencesQuery,
@@ -46,6 +48,11 @@ type Props = NativeStackScreenProps<CommonNavigatorParams, 'ProfileFeed'>
export function ProfileFeedScreen(props: Props) {
const {rkey, name: handleOrDid} = props.route.params
const feedParams: FeedParams | undefined = props.route.params.feedCacheKey
? {
feedCacheKey: props.route.params.feedCacheKey,
}
: undefined
const pal = usePalette('default')
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
@@ -96,7 +103,10 @@ export function ProfileFeedScreen(props: Props) {
return resolvedUri ? (
<Layout.Screen>
<ProfileFeedScreenIntermediate feedUri={resolvedUri.uri} />
<ProfileFeedScreenIntermediate
feedUri={resolvedUri.uri}
feedParams={feedParams}
/>
</Layout.Screen>
) : (
<Layout.Screen>
@@ -108,7 +118,13 @@ export function ProfileFeedScreen(props: Props) {
)
}
function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
function ProfileFeedScreenIntermediate({
feedUri,
feedParams,
}: {
feedUri: string
feedParams: FeedParams | undefined
}) {
const {data: preferences} = usePreferencesQuery()
const {data: info} = useFeedSourceInfoQuery({uri: feedUri})
@@ -125,15 +141,18 @@ function ProfileFeedScreenIntermediate({feedUri}: {feedUri: string}) {
<ProfileFeedScreenInner
preferences={preferences}
feedInfo={info as FeedSourceFeedInfo}
feedParams={feedParams}
/>
)
}
export function ProfileFeedScreenInner({
feedInfo,
feedParams,
}: {
preferences: UsePreferencesQueryResponse
feedInfo: FeedSourceFeedInfo
feedParams: FeedParams | undefined
}) {
const {_} = useLingui()
const {hasSession} = useSession()
@@ -170,6 +189,14 @@ export function ProfileFeedScreenInner({
return <EmptyState icon="hashtag" message={_(msg`This feed is empty.`)} />
}, [_])
const isVideoFeed = React.useMemo(() => {
const isBskyVideoFeed = VIDEO_FEED_URIS.includes(feedInfo.uri)
const feedIsVideoMode =
feedInfo.contentMode === AppBskyFeedDefs.CONTENTMODEVIDEO
const _isVideoFeed = isBskyVideoFeed || feedIsVideoMode
return isNative && _isVideoFeed
}, [feedInfo])
return (
<>
<ProfileFeedHeader info={feedInfo} />
@@ -177,12 +204,14 @@ export function ProfileFeedScreenInner({
<FeedFeedbackProvider value={feedFeedback}>
<PostFeed
feed={feed}
feedParams={feedParams}
pollInterval={60e3}
disablePoll={hasNew}
onHasNew={setHasNew}
scrollElRef={scrollElRef}
onScrolledDownChange={setIsScrolledDown}
renderEmptyState={renderPostsEmpty}
isVideoFeed={isVideoFeed}
/>
</FeedFeedbackProvider>
@@ -0,0 +1,271 @@
import React from 'react'
import {ScrollView, View} from 'react-native'
import {AppBskyEmbedVideo, AtUri} from '@atproto/api'
import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useFocusEffect} from '@react-navigation/native'
import {useQueryClient} from '@tanstack/react-query'
import {VIDEO_FEED_URI} from '#/lib/constants'
import {makeCustomFeedLink} from '#/lib/routes/links'
import {logEvent} from '#/lib/statsig/statsig'
import {isWeb} from '#/platform/detection'
import {useSavedFeeds} from '#/state/queries/feed'
import {RQKEY, usePostFeedQuery} from '#/state/queries/post-feed'
import {useAddSavedFeedsMutation} from '#/state/queries/preferences'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, tokens, useGutters, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {GradientFill} from '#/components/GradientFill'
import {ChevronRight_Stroke2_Corner0_Rounded as ChevronRight} from '#/components/icons/Chevron'
import {Pin_Stroke2_Corner0_Rounded as Pin} from '#/components/icons/Pin'
import {Trending2_Stroke2_Corner2_Rounded as Graph} from '#/components/icons/Trending2'
import {Link} from '#/components/Link'
import {Text} from '#/components/Typography'
import {
CompactVideoPostCard,
CompactVideoPostCardPlaceholder,
} from '#/components/VideoPostCard'
const CARD_WIDTH = 100
const FEED_DESC = `feedgen|${VIDEO_FEED_URI}`
const FEED_PARAMS: {
feedCacheKey: 'explore'
} = {
feedCacheKey: 'explore',
}
export function ExploreTrendingVideos() {
const t = useTheme()
const {_} = useLingui()
const gutters = useGutters([0, 'base'])
const {data, isLoading, error} = usePostFeedQuery(FEED_DESC, FEED_PARAMS)
// Refetch on tab change if nothing else is using this query.
const queryClient = useQueryClient()
useFocusEffect(() => {
return () => {
const query = queryClient
.getQueryCache()
.find({queryKey: RQKEY(FEED_DESC, FEED_PARAMS)})
if (query && query.getObserversCount() <= 1) {
query.fetch()
}
}
})
const {data: saved} = useSavedFeeds()
const isSavedAlready = React.useMemo(() => {
return !!saved?.feeds?.some(info => info.config.value === VIDEO_FEED_URI)
}, [saved])
const {mutateAsync: addSavedFeeds, isPending: isPinPending} =
useAddSavedFeedsMutation()
const pinFeed = React.useCallback(
(e: any) => {
e.preventDefault()
addSavedFeeds([
{
type: 'feed',
value: VIDEO_FEED_URI,
pinned: true,
},
])
// prevent navigation
return false
},
[addSavedFeeds],
)
if (error) {
return null
}
return (
<View style={[a.pb_xl]}>
<View
style={[
a.flex_row,
isWeb
? [a.px_lg, a.py_lg, a.pt_2xl, a.gap_md]
: [a.p_lg, a.pt_xl, a.gap_md],
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1, a.gap_sm]}>
<View style={[a.flex_row, a.align_center, a.gap_sm]}>
<Graph
size="lg"
fill={t.palette.primary_500}
style={{marginLeft: -2}}
/>
<Text style={[a.text_2xl, a.font_heavy, t.atoms.text]}>
<Trans>Trending Videos</Trans>
</Text>
<View style={[a.py_xs, a.px_sm, a.rounded_sm, a.overflow_hidden]}>
<GradientFill gradient={tokens.gradients.primary} />
<Text style={[a.text_sm, a.font_heavy, {color: 'white'}]}>
<Trans>BETA</Trans>
</Text>
</View>
</View>
<Text style={[t.atoms.text_contrast_high, a.leading_snug]}>
<Trans>Popular videos in your network.</Trans>
</Text>
</View>
</View>
<BlockDrawerGesture>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
decelerationRate="fast"
snapToInterval={CARD_WIDTH + tokens.space.sm}>
<View
style={[
a.pt_lg,
a.flex_row,
a.gap_sm,
{
paddingLeft: gutters.paddingLeft,
paddingRight: gutters.paddingRight,
},
]}>
{isLoading ? (
Array(10)
.fill(0)
.map((_, i) => (
<View key={i} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCardPlaceholder />
</View>
))
) : error || !data ? (
<Text>
<Trans>Whoops! Trending videos failed to load.</Trans>
</Text>
) : (
<VideoCards data={data} />
)}
</View>
</ScrollView>
</BlockDrawerGesture>
{!isSavedAlready && (
<View
style={[
gutters,
a.pt_lg,
a.flex_row,
a.align_center,
a.justify_between,
a.gap_xl,
]}>
<Text style={[a.flex_1, a.text_sm, a.leading_snug]}>
<Trans>
Pin the trending videos feed to your home screen for easy access
</Trans>
</Text>
<Button
disabled={isPinPending}
label={_(msg`Pin`)}
size="small"
variant="outline"
color="secondary"
onPress={pinFeed}>
<ButtonText>{_(msg`Pin`)}</ButtonText>
<ButtonIcon icon={Pin} position="right" />
</Button>
</View>
)}
</View>
)
}
function VideoCards({
data,
}: {
data: Exclude<ReturnType<typeof usePostFeedQuery>['data'], undefined>
}) {
const t = useTheme()
const {_} = useLingui()
const items = React.useMemo(() => {
return data.pages
.flatMap(page => page.slices)
.map(slice => slice.items[0])
.filter(Boolean)
.filter(item => AppBskyEmbedVideo.isView(item.post.embed))
.slice(0, 8)
}, [data])
const href = React.useMemo(() => {
const urip = new AtUri(VIDEO_FEED_URI)
return makeCustomFeedLink(urip.host, urip.rkey, undefined, 'explore')
}, [])
return (
<>
{items.map(item => (
<View key={item.post.uri} style={[{width: CARD_WIDTH}]}>
<CompactVideoPostCard
post={item.post}
moderation={item.moderation}
sourceContext={{
type: 'feedgen',
uri: VIDEO_FEED_URI,
sourceInterstitial: 'explore',
}}
onInteract={() => {
logEvent('videoCard:click', {
context: 'interstitial:discover',
})
}}
/>
</View>
))}
<View style={[{width: CARD_WIDTH * 2}]}>
<Link
to={href}
label={_(msg`View more`)}
style={[
a.justify_center,
a.align_center,
a.flex_1,
a.rounded_md,
t.atoms.bg_contrast_25,
]}>
{({pressed}) => (
<View
style={[
a.flex_row,
a.align_center,
a.gap_md,
{
opacity: pressed ? 0.6 : 1,
},
]}>
<Text style={[a.text_md]}>
<Trans>View more</Trans>
</Text>
<View
style={[
a.align_center,
a.justify_center,
a.rounded_full,
{
width: 34,
height: 34,
backgroundColor: t.palette.primary_500,
},
]}>
<ButtonIcon icon={ChevronRight} />
</View>
</View>
)}
</Link>
</View>
</>
)
}
@@ -37,8 +37,9 @@ export function ContentAndMediaSettingsScreen({}: Props) {
const inAppBrowserPref = useInAppBrowser()
const setUseInAppBrowser = useSetInAppBrowser()
const {enabled: trendingEnabled} = useTrendingConfig()
const {trendingDisabled} = useTrendingSettings()
const {setTrendingDisabled} = useTrendingSettingsApi()
const {trendingDisabled, trendingVideoDisabled} = useTrendingSettings()
const {setTrendingDisabled, setTrendingVideoDisabled} =
useTrendingSettingsApi()
return (
<Layout.Screen>
@@ -138,6 +139,27 @@ export function ContentAndMediaSettingsScreen({}: Props) {
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<Toggle.Item
name="show_trending_videos"
label={_(msg`Enable trending videos in your Discover feed.`)}
value={!trendingVideoDisabled}
onChange={value => {
const hide = Boolean(!value)
if (hide) {
logEvent('trendingVideos:hide', {context: 'settings'})
} else {
logEvent('trendingVideos:show', {context: 'settings'})
}
setTrendingVideoDisabled(hide)
}}>
<SettingsList.Item>
<SettingsList.ItemIcon icon={Graph} />
<SettingsList.ItemText>
<Trans>Enable trending videos in your Discover feed</Trans>
</SettingsList.ItemText>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
</>
)}
</SettingsList.Container>
+180
View File
@@ -0,0 +1,180 @@
import {useCallback} from 'react'
import {GestureResponderEvent, View} from 'react-native'
import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useNavigation} from '@react-navigation/native'
import {HITSLOP_30} from '#/lib/constants'
import {NavigationProp} from '#/lib/routes/types'
import {sanitizeHandle} from '#/lib/strings/handles'
import {useFeedSourceInfoQuery} from '#/state/queries/feed'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {VideoFeedSourceContext} from '#/screens/VideoFeed/types'
import {atoms as a, useBreakpoints} from '#/alf'
import {Button, ButtonProps} from '#/components/Button'
import {ArrowLeft_Stroke2_Corner0_Rounded as ArrowLeft} from '#/components/icons/Arrow'
import * as Layout from '#/components/Layout'
import {BUTTON_VISUAL_ALIGNMENT_OFFSET} from '#/components/Layout/const'
import {Text} from '#/components/Typography'
export function HeaderPlaceholder() {
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
<View
style={[
a.rounded_sm,
{
width: 36,
height: 36,
backgroundColor: 'white',
opacity: 0.8,
},
]}
/>
<View style={[a.flex_1, a.gap_xs]}>
<View
style={[
a.w_full,
a.rounded_xs,
{
backgroundColor: 'white',
height: 14,
width: 80,
opacity: 0.8,
},
]}
/>
<View
style={[
a.w_full,
a.rounded_xs,
{
backgroundColor: 'white',
height: 10,
width: 140,
opacity: 0.6,
},
]}
/>
</View>
</View>
)
}
export function Header({
sourceContext,
}: {
sourceContext: VideoFeedSourceContext
}) {
let content = null
switch (sourceContext.type) {
case 'feedgen': {
content = <FeedHeader sourceContext={sourceContext} />
break
}
case 'author':
// TODO
default: {
break
}
}
return (
<Layout.Header.Outer noBottomBorder>
<BackButton />
<Layout.Header.Content align="left">{content}</Layout.Header.Content>
</Layout.Header.Outer>
)
}
export function FeedHeader({
sourceContext,
}: {
sourceContext: Exclude<VideoFeedSourceContext, {type: 'author'}>
}) {
const {gtMobile} = useBreakpoints()
const {
data: info,
isLoading,
error,
} = useFeedSourceInfoQuery({uri: sourceContext.uri})
if (sourceContext.sourceInterstitial !== undefined) {
// For now, don't show the header if coming from an interstitial.
return null
}
if (isLoading) {
return <HeaderPlaceholder />
} else if (error || !info) {
return null
}
return (
<View style={[a.flex_1, a.flex_row, a.align_center, a.gap_sm]}>
{info.avatar && <UserAvatar size={36} type="algo" avatar={info.avatar} />}
<View style={[a.flex_1]}>
<Text
style={[
a.text_md,
a.font_heavy,
a.leading_tight,
gtMobile && a.text_lg,
]}
numberOfLines={2}>
{info.displayName}
</Text>
<View style={[a.flex_row, {gap: 6}]}>
<Text
style={[a.flex_shrink, a.text_sm, a.leading_snug]}
numberOfLines={1}>
{sanitizeHandle(info.creatorHandle, '@')}
</Text>
</View>
</View>
</View>
)
}
// TODO: This customization should be a part of the layout component
export function BackButton({onPress, style, ...props}: Partial<ButtonProps>) {
const {_} = useLingui()
const navigation = useNavigation<NavigationProp>()
const onPressBack = useCallback(
(evt: GestureResponderEvent) => {
onPress?.(evt)
if (evt.defaultPrevented) return
if (navigation.canGoBack()) {
navigation.goBack()
} else {
navigation.navigate('Home')
}
},
[onPress, navigation],
)
return (
<Layout.Header.Slot>
<Button
label={_(msg`Go back`)}
size="small"
variant="ghost"
color="secondary"
shape="round"
onPress={onPressBack}
hitSlop={HITSLOP_30}
style={[
{marginLeft: -BUTTON_VISUAL_ALIGNMENT_OFFSET},
a.bg_transparent,
style,
]}
{...props}>
<ArrowLeft size="lg" fill="white" />
</Button>
</Layout.Header.Slot>
)
}
@@ -0,0 +1,265 @@
import {useCallback, useMemo, useState} from 'react'
import {View} from 'react-native'
import {
Gesture,
GestureDetector,
NativeGesture,
} from 'react-native-gesture-handler'
import Animated, {
interpolate,
runOnJS,
runOnUI,
SharedValue,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withTiming,
} from 'react-native-reanimated'
import {
useSafeAreaFrame,
useSafeAreaInsets,
} from 'react-native-safe-area-context'
import {useEventListener} from 'expo'
import {VideoPlayer} from 'expo-video'
import {formatTime} from '#/view/com/util/post-embeds/VideoEmbedInner/web-controls/utils'
import {tokens} from '#/alf'
import {atoms as a} from '#/alf'
import {Text} from '#/components/Typography'
// magic number that is roughly the min height of the write reply button
// we inset the video by this amount
export const VIDEO_PLAYER_BOTTOM_INSET = 57
export function Scrubber({
active,
player,
seekingAnimationSV,
scrollGesture,
children,
}: {
active: boolean
player?: VideoPlayer
seekingAnimationSV: SharedValue<number>
scrollGesture: NativeGesture
children?: React.ReactNode
}) {
const {width: screenWidth} = useSafeAreaFrame()
const insets = useSafeAreaInsets()
const currentTimeSV = useSharedValue(0)
const durationSV = useSharedValue(0)
const [currentSeekTime, setCurrentSeekTime] = useState(0)
const [duration, setDuration] = useState(0)
const updateTime = (currentTime: number, duration: number) => {
'worklet'
currentTimeSV.set(currentTime)
if (duration !== 0) {
durationSV.set(duration)
}
}
const isSeekingSV = useSharedValue(false)
const seekProgressSV = useSharedValue(0)
useAnimatedReaction(
() => Math.round(seekProgressSV.get()),
(progress, prevProgress) => {
if (progress !== prevProgress) {
runOnJS(setCurrentSeekTime)(progress)
}
},
)
const seekBy = useCallback(
(time: number) => {
player?.seekBy(time)
setTimeout(() => {
runOnUI(() => {
'worklet'
isSeekingSV.set(false)
seekingAnimationSV.set(withTiming(0, {duration: 500}))
})()
}, 50)
},
[player, isSeekingSV, seekingAnimationSV],
)
const scrubPanGesture = useMemo(() => {
return Gesture.Pan()
.blocksExternalGesture(scrollGesture)
.activeOffsetX([-10, 10])
.failOffsetY([-10, 10])
.onStart(() => {
'worklet'
seekProgressSV.set(currentTimeSV.get())
isSeekingSV.set(true)
seekingAnimationSV.set(withTiming(1, {duration: 500}))
})
.onUpdate(evt => {
'worklet'
const progress = evt.x / screenWidth
seekProgressSV.set(
clamp(progress * durationSV.get(), 0, durationSV.get()),
)
})
.onEnd(evt => {
'worklet'
isSeekingSV.get()
const progress = evt.x / screenWidth
const newTime = clamp(progress * durationSV.get(), 0, durationSV.get())
// optimisically set the progress bar
seekProgressSV.set(newTime)
// it's seek by, so offset by the current time
// seekBy sets isSeekingSV back to false, so no need to do that here
runOnJS(seekBy)(newTime - currentTimeSV.get())
})
}, [
scrollGesture,
seekingAnimationSV,
seekBy,
screenWidth,
currentTimeSV,
durationSV,
isSeekingSV,
seekProgressSV,
])
const timeStyle = useAnimatedStyle(() => {
return {
display: seekingAnimationSV.get() === 0 ? 'none' : 'flex',
opacity: seekingAnimationSV.get(),
}
})
const barStyle = useAnimatedStyle(() => {
const currentTime = isSeekingSV.get()
? seekProgressSV.get()
: currentTimeSV.get()
const progress = currentTime === 0 ? 0 : currentTime / durationSV.get()
const isSeeking = seekingAnimationSV.get()
return {
height: isSeeking * 3 + 1,
opacity: interpolate(isSeeking, [0, 1], [0.4, 0.6]),
width: `${progress * 100}%`,
}
})
const trackStyle = useAnimatedStyle(() => {
return {
height: seekingAnimationSV.get() * 3 + 1,
}
})
const childrenStyle = useAnimatedStyle(() => {
return {
opacity: 1 - seekingAnimationSV.get(),
}
})
return (
<>
{player && active && (
<PlayerListener
player={player}
setDuration={setDuration}
updateTime={updateTime}
/>
)}
<Animated.View
style={[
a.absolute,
{
left: 0,
right: 0,
bottom: insets.bottom + 80,
},
timeStyle,
]}
pointerEvents="none">
<Text style={[a.text_center, a.font_bold]}>
<Text style={[a.text_5xl, {fontVariant: ['tabular-nums']}]}>
{formatTime(currentSeekTime)}
</Text>
<Text style={[a.text_2xl, {opacity: 0.8}]}>{' / '}</Text>
<Text
style={[
a.text_5xl,
{opacity: 0.8},
{fontVariant: ['tabular-nums']},
]}>
{formatTime(duration)}
</Text>
</Text>
</Animated.View>
<GestureDetector gesture={scrubPanGesture}>
<View
style={[
a.relative,
a.w_full,
a.justify_end,
{
paddingBottom: insets.bottom,
minHeight:
// bottom padding
insets.bottom +
// scrubber height
tokens.space.lg +
// write reply height
VIDEO_PLAYER_BOTTOM_INSET,
},
a.z_10,
]}>
<View style={[a.w_full, a.relative]}>
<Animated.View
style={[
a.w_full,
{backgroundColor: 'white', opacity: 0.2},
trackStyle,
]}
/>
<Animated.View
style={[
a.absolute,
{top: 0, left: 0, backgroundColor: 'white'},
barStyle,
]}
/>
</View>
<Animated.View
style={[{minHeight: VIDEO_PLAYER_BOTTOM_INSET}, childrenStyle]}>
{children}
</Animated.View>
</View>
</GestureDetector>
</>
)
}
function PlayerListener({
player,
setDuration,
updateTime,
}: {
player: VideoPlayer
setDuration: (duration: number) => void
updateTime: (currentTime: number, duration: number) => void
}) {
useEventListener(player, 'timeUpdate', evt => {
const duration = player.duration
if (duration !== 0) {
setDuration(Math.round(duration))
}
runOnUI(updateTime)(evt.currentTime, duration)
})
return null
}
function clamp(num: number, min: number, max: number) {
'worklet'
return Math.min(Math.max(num, min), max)
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
export function VideoScreen() {
return null
}
+18
View File
@@ -0,0 +1,18 @@
import {AuthorFilter} from '#/state/queries/post-feed'
/**
* Kind of like `FeedDescriptor` but not
*/
export type VideoFeedSourceContext =
| {
type: 'feedgen'
uri: string
sourceInterstitial: 'discover' | 'explore' | 'none'
initialPostUri?: string
}
| {
type: 'author'
did: string
filter: AuthorFilter
initialPostUri?: string
}