refactor(mobile): enhance video entry handling in mobile app (#2903)

* fix(mobile): improve description height for video entries in EntryGridFooter

* fix(mobile): set fixed height for MediaCarousel to prevent resizing issues

* feat(mobile): add EntryListContentVideo and EntryVideoItem components for video entries

* refactor(mobile): simplify EntryGridItem component

* feat(mobile): integrate EntryListContentVideo for video feed view

* refactor(mobile): rename grid to picture for entry list selector

* feat(mobile): add skeleton loading component for video entries
This commit is contained in:
Whitewater 2025-02-27 19:16:09 +08:00 committed by GitHub
parent 63c304c02e
commit 97dda3ed7d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 158 additions and 50 deletions

View File

@ -30,18 +30,21 @@ export const MediaCarousel = ({
noPreview?: boolean
} & Pick<PreviewImageProps, "Accessory" | "AccessoryProps">) => {
const [containerWidth, setContainerWidth] = useState(0)
const containerHeight = Math.floor(containerWidth / aspectRatio)
const hasMany = media.length > 1
// const activeIndex = useSharedValue(0)
const [activeIndex, setActiveIndex] = useState(0)
const Wrapper = noPreview ? View : ImageContextMenu
return (
<View
onLayout={(e) => {
setContainerWidth(e.nativeEvent.layout.width)
}}
>
<View className="relative">
<View className="relative overflow-hidden rounded-md">
<ScrollView
onScroll={(e) => {
setActiveIndex(Math.round(e.nativeEvent.contentOffset.x / containerWidth))
@ -52,26 +55,27 @@ export const MediaCarousel = ({
showsHorizontalScrollIndicator={false}
pagingEnabled
className="flex-1"
contentContainerClassName="flex-row overflow-hidden rounded-md"
style={{ aspectRatio }}
// We need to fixed the height of the container to prevent the carousel from resizing
// See https://github.com/Shopify/flash-list/issues/797
style={{ height: containerHeight }}
>
{media.map((m, index) => {
if (m.type === "photo") {
return (
<View key={index} className="relative" style={{ width: containerWidth }}>
<ImageContextMenu entryId={entryId} imageUrl={m.url}>
<Wrapper entryId={entryId} imageUrl={m.url}>
<PreviewImage
noPreview={noPreview}
onPreview={onPreview}
imageUrl={m.url}
aspectRatio={m.width && m.height ? m.width / m.height : 1}
aspectRatio={aspectRatio}
Accessory={Accessory}
AccessoryProps={AccessoryProps}
proxy={{
width: 200,
width: containerWidth,
}}
/>
</ImageContextMenu>
</Wrapper>
</View>
)
}
@ -84,7 +88,7 @@ export const MediaCarousel = ({
// open player
}}
imageUrl={m.url}
aspectRatio={m.width && m.height ? m.width / m.height : 1}
aspectRatio={aspectRatio}
/>
)
})}

View File

@ -54,7 +54,11 @@ export const EntryGridFooter = ({
{entry.description && (
<Text
numberOfLines={2}
className={cn("text-label text-base font-medium", descriptionClassName)}
className={cn(
"text-label shrink text-base font-medium",
view === FeedViewType.Videos && "min-h-12",
descriptionClassName,
)}
>
{entry.description}
</Text>

View File

@ -9,9 +9,9 @@ import { useFetchEntriesControls } from "@/src/modules/screen/atoms"
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
import { useOnViewableItemsChanged } from "./hooks"
// import type { MasonryItem } from "./templates/EntryGridItem"
import { EntryGridItem } from "./templates/EntryGridItem"
import { EntryPictureItem } from "./templates/EntryPictureItem"
export const EntryListContentGrid = forwardRef<
export const EntryListContentPicture = forwardRef<
ElementRef<typeof TimelineSelectorMasonryList>,
{
entryIds: string[]
@ -28,7 +28,7 @@ export const EntryListContentGrid = forwardRef<
isRefetching={isRefetching}
data={entryIds}
renderItem={useTypeScriptHappyCallback(({ item }: { item: string }) => {
return <EntryGridItem id={item} />
return <EntryPictureItem id={item} />
}, [])}
keyExtractor={defaultKeyExtractor}
onViewableItemsChanged={onViewableItemsChanged}

View File

@ -0,0 +1,81 @@
import { useTypeScriptHappyCallback } from "@follow/hooks"
import type { MasonryFlashListProps } from "@shopify/flash-list"
import type { ElementRef } from "react"
import { forwardRef, useMemo } from "react"
import { View } from "react-native"
import { useFetchEntriesControls } from "@/src/modules/screen/atoms"
import { TimelineSelectorMasonryList } from "../screen/TimelineSelectorList"
import { useOnViewableItemsChanged } from "./hooks"
import { EntryVideoItem } from "./templates/EntryVideoItem"
export const EntryListContentVideo = forwardRef<
ElementRef<typeof TimelineSelectorMasonryList>,
{
entryIds: string[]
} & Omit<MasonryFlashListProps<string>, "data" | "renderItem">
>(({ entryIds, ...rest }, ref) => {
const { fetchNextPage, refetch, isRefetching, isFetching } = useFetchEntriesControls()
const { onViewableItemsChanged, onScroll } = useOnViewableItemsChanged({
disabled: isRefetching,
})
const ListFooterComponent = useMemo(
() =>
isFetching ? (
<View className="flex flex-row justify-between">
<EntryItemSkeleton />
<EntryItemSkeleton />
</View>
) : null,
[isFetching],
)
return (
<TimelineSelectorMasonryList
ref={ref}
isRefetching={isRefetching}
data={entryIds}
renderItem={useTypeScriptHappyCallback(({ item }: { item: string }) => {
return <EntryVideoItem id={item} />
}, [])}
keyExtractor={defaultKeyExtractor}
onViewableItemsChanged={onViewableItemsChanged}
onScroll={onScroll}
onEndReached={fetchNextPage}
numColumns={2}
estimatedItemSize={100}
ListFooterComponent={ListFooterComponent}
{...rest}
onRefresh={refetch}
/>
)
})
const defaultKeyExtractor = (item: string) => {
return item
}
export function EntryItemSkeleton() {
return (
<View className="m-1 overflow-hidden rounded-md">
{/* Video thumbnail */}
<View className="bg-system-fill aspect-video h-32 w-full animate-pulse rounded-md" />
{/* Description and footer */}
<View className="my-2 px-2">
{/* Description */}
<View className="bg-system-fill mb-1 h-4 w-full animate-pulse rounded-md" />
<View className="bg-system-fill mb-3 h-4 w-3/4 animate-pulse rounded-md" />
{/* Footer with feed icon and metadata */}
<View className="flex-row items-center gap-1">
<View className="bg-system-fill size-4 animate-pulse rounded-full" />
<View className="bg-system-fill h-3 w-24 animate-pulse rounded-md" />
<View className="bg-system-fill h-3 w-20 animate-pulse rounded-md" />
</View>
</View>
</View>
)
}

View File

@ -5,10 +5,11 @@ import { useContext, useEffect, useRef } from "react"
import type { ScrollView } from "react-native"
import { SetAttachNavigationScrollViewContext } from "@/src/components/layouts/tabbar/contexts/AttachNavigationScrollViewContext"
import { EntryListContentGrid } from "@/src/modules/entry-list/EntryListContentGrid"
import { EntryListContentPicture } from "@/src/modules/entry-list/EntryListContentPicture"
import { EntryListContentArticle } from "./EntryListContentArticle"
import { EntryListContentSocial } from "./EntryListContentSocial"
import { EntryListContentVideo } from "./EntryListContentVideo"
import { EntryListContextViewContext } from "./EntryListContext"
export function EntryListSelector({
@ -30,16 +31,19 @@ export function EntryListSelector({
}
}, [setAttachNavigationScrollViewRef, ref, active])
let ContentComponent: typeof EntryListContentSocial | typeof EntryListContentGrid =
let ContentComponent: typeof EntryListContentSocial | typeof EntryListContentPicture =
EntryListContentArticle
switch (viewId) {
case FeedViewType.SocialMedia: {
ContentComponent = EntryListContentSocial
break
}
case FeedViewType.Pictures:
case FeedViewType.Pictures: {
ContentComponent = EntryListContentPicture
break
}
case FeedViewType.Videos: {
ContentComponent = EntryListContentGrid
ContentComponent = EntryListContentVideo
break
}
case FeedViewType.Articles: {

View File

@ -1,5 +1,3 @@
import { FeedViewType } from "@follow/constants"
import { transformVideoUrl } from "@follow/utils"
import { uniqBy } from "es-toolkit/compat"
import { LinearGradient } from "expo-linear-gradient"
import { useEffect, useMemo } from "react"
@ -11,19 +9,13 @@ import { EntryContentWebView } from "@/src/components/native/webview/EntryConten
import { MediaCarousel } from "@/src/components/ui/carousel/MediaCarousel"
import { RelativeDateTime } from "@/src/components/ui/datetime/RelativeDateTime"
import { FeedIcon } from "@/src/components/ui/icon/feed-icon"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import type { MediaModel } from "@/src/database/schemas/types"
import { openLink } from "@/src/lib/native"
import { useEntry } from "@/src/store/entry/hooks"
import { useFeed } from "@/src/store/feed/hooks"
import { unreadSyncService } from "@/src/store/unread/store"
import { VideoContextMenu } from "../../context-menu/video"
import { useEntryListContextView } from "../EntryListContext"
export function EntryGridItem({ id }: { id: string }) {
export function EntryPictureItem({ id }: { id: string }) {
const item = useEntry(id)
const view = useEntryListContextView()
if (!item || !item.media) {
return null
@ -39,32 +31,14 @@ export function EntryGridItem({ id }: { id: string }) {
)
}
if (view === FeedViewType.Pictures) {
return (
<View className="m-1">
<MediaItems
media={item.media}
entryId={id}
onPreview={() => unreadSyncService.markEntryAsRead(id)}
/>
</View>
)
}
return (
<VideoContextMenu entryId={id}>
<ItemPressable
className="m-1 overflow-hidden rounded-md"
onPress={() => {
unreadSyncService.markEntryAsRead(id)
if (!item.url) return
const formattedUrl = transformVideoUrl({ url: item.url }) || item.url
openLink(formattedUrl)
}}
>
<MediaItems media={item.media} entryId={id} noPreview aspectRatio={16 / 9} />
</ItemPressable>
</VideoContextMenu>
<View className="m-1">
<MediaItems
media={item.media}
entryId={id}
onPreview={() => unreadSyncService.markEntryAsRead(id)}
/>
</View>
)
}

View File

@ -0,0 +1,41 @@
import { transformVideoUrl } from "@follow/utils"
import { MediaCarousel } from "@/src/components/ui/carousel/MediaCarousel"
import { ItemPressable } from "@/src/components/ui/pressable/ItemPressable"
import { openLink } from "@/src/lib/native"
import { useEntry } from "@/src/store/entry/hooks"
import { unreadSyncService } from "@/src/store/unread/store"
import { VideoContextMenu } from "../../context-menu/video"
export function EntryVideoItem({ id }: { id: string }) {
const item = useEntry(id)
if (!item || !item.media) {
return null
}
const firstMedia = item.media[0]!
return (
<VideoContextMenu entryId={id}>
<ItemPressable
className="m-1 overflow-hidden rounded-md"
onPress={() => {
unreadSyncService.markEntryAsRead(id)
if (!item.url) return
const formattedUrl = transformVideoUrl({ url: item.url }) || item.url
openLink(formattedUrl)
}}
>
<MediaCarousel
entryId={id}
media={[firstMedia]}
aspectRatio={16 / 9}
AccessoryProps={{ id }}
noPreview={true}
/>
</ItemPressable>
</VideoContextMenu>
)
}