diff --git a/package.json b/package.json index 32c17bdb66..086837f0f2 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "merkletreejs": "^0.4.0", "metamask-react": "^2.4.1", "moment": "^2.29.4", + "mp4box": "^0.5.3", "multiformats": "^12.1.3", "osmojs": "16.9.0", "papaparse": "^5.4.1", diff --git a/packages/components/video/VideoCard.tsx b/packages/components/video/VideoCard.tsx index 04bac4fdd6..9e8d53177a 100644 --- a/packages/components/video/VideoCard.tsx +++ b/packages/components/video/VideoCard.tsx @@ -1,5 +1,7 @@ +import { AVPlaybackStatus } from "expo-av"; import { isEqual } from "lodash"; -import React, { memo, useState } from "react"; +import MP4Box from "mp4box"; +import React, { memo, useEffect, useState } from "react"; import { StyleProp, StyleSheet, @@ -30,7 +32,10 @@ import { } from "../../utils/style/fonts"; import { layout, RESPONSIVE_BREAKPOINT_S } from "../../utils/style/layout"; import { tinyAddress } from "../../utils/text"; -import { ZodSocialFeedVideoMetadata } from "../../utils/types/feed"; +import { + SocialFeedVideoMetadata, + ZodSocialFeedVideoMetadata, +} from "../../utils/types/feed"; import { BrandText } from "../BrandText"; import { OmniLink } from "../OmniLink"; import { OptimizedImage } from "../OptimizedImage"; @@ -40,7 +45,9 @@ import { DateTime } from "../socialFeed/SocialCard/DateTime"; import { SpacerColumn, SpacerRow } from "../spacer"; import { LocationButton } from "@/components/socialFeed/NewsFeed/LocationButton"; +import { useVideoAudioDuration } from "@/hooks/feed/useVideoAudioDuration"; import { useAppNavigation } from "@/hooks/navigation/useAppNavigation"; +import { web3ToWeb2URI } from "@/utils/ipfs"; const IMAGE_HEIGHT = 173; const VIDEO_CARD_WIDTH = 261; @@ -56,6 +63,7 @@ export const VideoCard: React.FC<{ const authorNSInfo = useNSUserInfo(post.authorId); const [, userAddress] = parseUserId(post.authorId); const [isHovered, setIsHovered] = useState(false); + // const [duration, setDuration] = useState(0); let cardWidth = StyleSheet.flatten(style)?.width; if (typeof cardWidth !== "number") { @@ -71,6 +79,18 @@ export const VideoCard: React.FC<{ ? video.videoFile.thumbnailFileData.url : "ipfs://" + video.videoFile.thumbnailFileData?.url // we need this hack because ipfs "urls" in feed are raw CIDs : defaultThumbnailImage; + // useEffect(() => { + // (async () => { + // try { + // if (video && !video.videoFile.videoMetadata?.duration) { + // const duration = await getVideoDurationFromURL(); + // setDuration(duration); + // } + // } catch (error) { + // console.log(error); + // } + // })(); + // }, [video]); if (!video) return ( @@ -78,6 +98,7 @@ export const VideoCard: React.FC<{ Video not found ); + return ( - - - {prettyMediaDuration(video.videoFile.videoMetadata?.duration)} - - - - {video?.location && ( + + {video?.location ? ( @@ -135,6 +151,8 @@ export const VideoCard: React.FC<{ stroke={neutralFF} /> + ) : ( + )} @@ -208,6 +226,27 @@ export const VideoCard: React.FC<{ ); }, isEqual); +function VideoDuration({ video }: { video: SocialFeedVideoMetadata }) { + const { duration, isLoading } = useVideoAudioDuration( + web3ToWeb2URI(video?.videoFile.url), + video?.videoFile.videoMetadata?.duration || 0, + ); + + return ( + <> + {!isLoading ? ( + + + {prettyMediaDuration(duration)} + + + ) : ( + + )} + + ); +} + const imgBoxStyle: ViewStyle = { position: "relative", }; diff --git a/packages/hooks/feed/useVideoAudioDuration.tsx b/packages/hooks/feed/useVideoAudioDuration.tsx new file mode 100644 index 0000000000..97033bcf72 --- /dev/null +++ b/packages/hooks/feed/useVideoAudioDuration.tsx @@ -0,0 +1,24 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getVideoDurationFromURL } from "@/utils/video"; + +export const useVideoAudioDuration = (url: string, duration: number) => { + const { data, isLoading } = useQuery( + ["getVideoDuration", url], + async () => { + if (duration) return duration; + + try { + const duration = await getVideoDurationFromURL(url); + console.log(duration); + return duration; + } catch (error) { + console.log(error); + return 0; + } + }, + { staleTime: Infinity }, + ); + + return { duration: data || 0, isLoading }; +}; diff --git a/packages/utils/types/mp4box.d.ts b/packages/utils/types/mp4box.d.ts new file mode 100644 index 0000000000..49d1aefcea --- /dev/null +++ b/packages/utils/types/mp4box.d.ts @@ -0,0 +1,43 @@ +declare module "mp4box" { + export interface MP4FileInfo { + duration: number; // Duration in timescale units + timescale: number; // Timescale of the file + isFragmented: boolean; // Whether the MP4 is fragmented + brands: string[]; // Major and compatible brands + created: Date; // Creation time + modified: Date; // Modification time + tracks: MP4Track[]; // Array of tracks in the file + } + + export interface MP4Track { + id: number; // Track ID + type: string; // Track type (e.g., "video", "audio") + codec: string; // Codec used for this track + language: string; // Language of the track + created: Date; // Creation time + modified: Date; // Modification time + timescale: number; // Timescale for the track + duration: number; // Duration in timescale units + bitrate: number; // Average bitrate of the track + width?: number; // Video width (if applicable) + height?: number; // Video height (if applicable) + sampleCount: number; // Number of samples in the track + samples: any[]; // Detailed sample information + } + + export interface MP4ArrayBuffer extends ArrayBuffer { + fileStart: number; // Start position of the buffer in the file + } + + export interface MP4BoxFile { + onReady?: (info: MP4FileInfo) => void; + onError?: (error: string) => void; + appendBuffer(data: ArrayBuffer): number; + start(): void; + stop(): void; + flush(): void; + createFile(): MP4BoxFile; + } + + export function createFile(): MP4BoxFile; +} diff --git a/packages/utils/video/index.ts b/packages/utils/video/index.ts index a31bfdad58..65f69f3b5a 100644 --- a/packages/utils/video/index.ts +++ b/packages/utils/video/index.ts @@ -1,3 +1,5 @@ +import MP4Box, { MP4ArrayBuffer, MP4BoxFile, MP4FileInfo } from "mp4box"; + import { VideoFileMetadata } from "../types/files"; const getVideoDuration = (file: File) => { @@ -19,11 +21,47 @@ const getVideoDuration = (file: File) => { } catch (e) { console.error("Fail to get video duration: ", e); } + + return duration; +}; + +export const getVideoDurationFromBuffer = async (buffer: ArrayBuffer) => { + let duration = 0; + const mp4boxFile: MP4BoxFile = MP4Box.createFile(); + + mp4boxFile.onReady = (info: MP4FileInfo) => { + const durationInSec = info.duration / info.timescale; + duration = durationInSec * 1000; + }; + + const fileData = buffer.slice(0); + (fileData as MP4ArrayBuffer).fileStart = 0; + mp4boxFile.appendBuffer(fileData); + + return duration; +}; + +export const getVideoDurationFromURL = async (url: string) => { + let duration = 0; + + try { + const response = await fetch(url, { + method: "GET", + }); + const buffer = await response.arrayBuffer(); + + duration = await getVideoDurationFromBuffer(buffer); + } catch (error) { + console.log(error); + } + return duration; }; -export const getVideoData = (file: File): VideoFileMetadata => { - const duration = getVideoDuration(file) * 1000; +export const getVideoData = async (file: File): Promise => { + const fileBuffer = await file.arrayBuffer(); + const duration = await getVideoDurationFromBuffer(fileBuffer); + return { duration, }; diff --git a/yarn.lock b/yarn.lock index 240fc5ed44..0d67d78ca2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15881,6 +15881,13 @@ __metadata: languageName: node linkType: hard +"mp4box@npm:^0.5.3": + version: 0.5.3 + resolution: "mp4box@npm:0.5.3" + checksum: 6d38b47beebd71d00151f2df6fdc5a3bcafd3163c42783a432a05d9c0dae97120afa14c7a30adeb7480833bde7f75374f4ce546f77180903e7e93052ecad7419 + languageName: node + linkType: hard + "ms@npm:2.0.0": version: 2.0.0 resolution: "ms@npm:2.0.0" @@ -20243,6 +20250,7 @@ __metadata: merkletreejs: ^0.4.0 metamask-react: ^2.4.1 moment: ^2.29.4 + mp4box: ^0.5.3 multiformats: ^12.1.3 osmojs: 16.9.0 papaparse: ^5.4.1