yolo (#7499)
* 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 commit9e3f2f4374) * 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: commit3d1be4c0f1Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 01:08:05 2025 +0000 extend animation commitc9f199413bAuthor: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 01:01:24 2025 +0000 fix gap commit22e520795fAuthor: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:50:16 2025 +0000 thinner bar, format time commitc32427f214Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:47:57 2025 +0000 fix 2 in 3 screens commitcbf84c08d6Author: Samuel Newman <mozzius@protonmail.com> Date: Fri Jan 17 00:45:46 2025 +0000 rm unneeded var commit7e0e100177Author: 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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
export function VideoScreen() {
|
||||
return null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user