feat: add video support in EntryNormalItem (#3798)

* feat: add video support and enhance media handling in EntryNormalItem

* chore: correct reference name for video view in VideoPlayer component

* fix: remove size class
This commit is contained in:
Whitewater 2025-05-24 19:00:15 +08:00 committed by GitHub
parent 62fc0a0cb1
commit 2573fa7428
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 68 additions and 21 deletions

View File

@ -21,7 +21,7 @@ export function VideoPlayer({
view: FeedViewType
}) {
const [isFullScreen, setIsFullScreen] = useState(false)
const viewViewRef = useRef<null | VideoView>(null)
const videoViewRef = useRef<null | VideoView>(null)
const player = useVideoPlayer(source, (player) => {
player.loop = true
player.muted = true
@ -31,12 +31,16 @@ export function VideoPlayer({
return (
<NativePressable
onPress={() => {
viewViewRef.current?.enterFullscreen()
if (!videoViewRef.current) {
console.warn("VideoView ref is not set")
return
}
videoViewRef.current?.enterFullscreen()
player.muted = false
}}
>
<VideoView
ref={viewViewRef}
ref={videoViewRef}
style={{
width: view === FeedViewType.Pictures ? width : "100%",
height: view === FeedViewType.Pictures ? height : undefined,

View File

@ -1,7 +1,8 @@
import { FeedViewType } from "@follow/constants"
import { tracker } from "@follow/tracker"
import { cn, formatEstimatedMins, formatTimeToSeconds } from "@follow/utils"
import { memo, useCallback, useMemo } from "react"
import { useVideoPlayer, VideoView } from "expo-video"
import { memo, useCallback, useMemo, useRef, useState } from "react"
import { StyleSheet, Text, View } from "react-native"
import { useUISettingKey } from "@/src/atoms/settings/ui"
@ -17,6 +18,7 @@ import { NativePressable } from "@/src/components/ui/pressable/NativePressable"
import { PauseCuteFiIcon } from "@/src/icons/pause_cute_fi"
import { PlayCuteFiIcon } from "@/src/icons/play_cute_fi"
import { useNavigation } from "@/src/lib/navigation/hooks"
import { isIOS } from "@/src/lib/platform"
import { getAttachmentState, player } from "@/src/lib/player"
import { EntryDetailScreen } from "@/src/screens/(stack)/entries/[entryId]/EntryDetailScreen"
import { useEntry } from "@/src/store/entry/hooks"
@ -61,14 +63,15 @@ export const EntryNormalItem = memo(
}
}, [entry, navigation, entryId, extraData.entryIds, view])
const audio = entry?.attachments?.find((attachment) =>
attachment.mime_type?.startsWith("audio/"),
const audioOrVideo = entry?.attachments?.find(
(attachment) =>
attachment.mime_type?.startsWith("audio/") || attachment.mime_type?.startsWith("video/"),
)
const estimatedMins = useMemo(() => {
const durationInSeconds = formatTimeToSeconds(audio?.duration_in_seconds)
const durationInSeconds = formatTimeToSeconds(audioOrVideo?.duration_in_seconds)
return durationInSeconds && Math.floor(durationInSeconds / 60)
}, [audio?.duration_in_seconds])
}, [audioOrVideo?.duration_in_seconds])
if (!entry) return <EntryItemSkeleton />
@ -135,7 +138,7 @@ export const EntryNormalItem = memo(
)}
</View>
{view !== FeedViewType.Notifications && (
<ThumbnailImage entry={entry} view={view} playingAudioUrl={extraData.playingAudioUrl} />
<ThumbnailImage entry={entry} playingAudioUrl={extraData.playingAudioUrl} />
)}
</ItemPressable>
</EntryItemContextMenu>
@ -146,27 +149,45 @@ export const EntryNormalItem = memo(
EntryNormalItem.displayName = "EntryNormalItem"
const ThumbnailImage = ({
view,
playingAudioUrl,
entry,
}: {
view: FeedViewType
playingAudioUrl: string | null
entry: EntryModel
}) => {
const feed = useFeed(entry?.feedId as string)
const thumbnailRatio = useUISettingKey("thumbnailRatio")
const coverImage = entry?.media?.[0]
const image = coverImage?.url || (view === FeedViewType.Audios ? feed?.image : null)
const blurhash = coverImage?.blurhash
const mediaModel = entry?.media?.find(
(media) => media.type === "photo" || (media.type === "video" && media.preview_image_url),
)
const image = mediaModel?.type === "photo" ? mediaModel?.url : null // mediaModel?.preview_image_url
const blurhash = mediaModel?.blurhash
const audio = entry?.attachments?.find((attachment) => attachment.mime_type?.startsWith("audio/"))
const audioState = getAttachmentState(playingAudioUrl ?? undefined, audio)
const isPlaying = audioState === "playing"
const isLoading = audioState === "loading"
const handlePressPlayAudio = useCallback(() => {
const video = entry?.media?.find((media) => media.type === "video")
const videoViewRef = useRef<null | VideoView>(null)
const videoPlayer = useVideoPlayer(video?.url ?? "")
const [showVideoNativeControlsForAndroid, setShowVideoNativeControlsForAndroid] = useState(false)
const handlePressPlay = useCallback(() => {
if (video) {
setShowVideoNativeControlsForAndroid(true)
// Ensure the nativeControls is ready before entering fullscreen for Android
setTimeout(() => {
videoViewRef.current?.enterFullscreen()
}, 0)
if (videoPlayer.playing) {
videoPlayer.pause()
} else {
videoPlayer.play()
}
return
}
if (!audio) return
if (isLoading) return
if (isPlaying) {
@ -179,10 +200,11 @@ const ThumbnailImage = ({
artist: feed?.title,
artwork: image,
})
}, [audio, entry?.title, feed?.title, image, isLoading, isPlaying])
}, [audio, entry?.title, feed?.title, image, isLoading, isPlaying, video, videoPlayer])
if (!image && !audio && !video) return null
return (
<View className="relative ml-4">
<View className="relative ml-4 overflow-hidden rounded-lg">
{image &&
(thumbnailRatio === "square" ? (
<SquareImage image={image} blurhash={blurhash} />
@ -190,18 +212,39 @@ const ThumbnailImage = ({
<AspectRatioImage
blurhash={blurhash}
image={image}
height={coverImage?.height}
width={coverImage?.width}
height={mediaModel?.height}
width={mediaModel?.width}
/>
))}
{video && (
<View className="size-24 rounded-lg">
<VideoView
className="absolute size-full rounded-lg"
style={{ aspectRatio: 1 }}
contentFit="cover"
ref={videoViewRef}
player={videoPlayer}
// The Android native controls will be shown when the video is paused
nativeControls={isIOS || showVideoNativeControlsForAndroid}
accessible={false}
allowsFullscreen={false}
allowsVideoFrameAnalysis={false}
onFullscreenExit={() => {
videoPlayer.pause()
setShowVideoNativeControlsForAndroid(false)
}}
/>
</View>
)}
{/* Show feed icon if no image but audio is present */}
{audio && !image && <FeedIcon feed={feed} size={96} />}
{audio && (
{(video || audio) && (
<NativePressable
className="absolute inset-0 flex items-center justify-center"
onPress={handlePressPlayAudio}
onPress={handlePressPlay}
>
<View className="overflow-hidden rounded-full p-2">
<ThemedBlurView