From 469688b4e04bda17f44bf0df7998fc4e75b4410c Mon Sep 17 00:00:00 2001 From: PhilReact Date: Sun, 24 Dec 2023 01:50:37 +0200 Subject: [PATCH] added preview images on hover --- src/components/EditVideo/EditVideo.tsx | 71 ++++- src/components/ResponsiveImage.tsx | 2 +- src/components/UploadVideo/UploadVideo.tsx | 74 ++++- .../common/FrameExtractor/FrameExtractor.tsx | 77 +++++ src/constants/index.ts | 2 +- src/pages/Home/VideoCardImageContainer.tsx | 42 +++ src/pages/Home/VideoList.tsx | 7 +- src/pages/Home/VideoListComponentLevel.tsx | 270 +++++++++--------- 8 files changed, 387 insertions(+), 158 deletions(-) create mode 100644 src/components/common/FrameExtractor/FrameExtractor.tsx create mode 100644 src/pages/Home/VideoCardImageContainer.tsx diff --git a/src/components/EditVideo/EditVideo.tsx b/src/components/EditVideo/EditVideo.tsx index 79567d3..5a1f6b6 100644 --- a/src/components/EditVideo/EditVideo.tsx +++ b/src/components/EditVideo/EditVideo.tsx @@ -1,4 +1,6 @@ import React, { useEffect, useState } from "react"; +import Compressor from 'compressorjs' + import { AddCoverImageButton, AddLogoIcon, @@ -13,6 +15,8 @@ import { StyledButton, TimesIcon, } from "./Upload-styles"; +import { CircularProgress } from "@mui/material"; + import { Box, FormControl, @@ -46,6 +50,8 @@ import { QTUBE_VIDEO_BASE, categories, subCategories } from "../../constants"; import { MultiplePublish } from "../common/MultiplePublish/MultiplePublish"; import { TextEditor } from "../common/TextEditor/TextEditor"; import { extractTextFromHTML } from "../common/TextEditor/utils"; +import { toBase64 } from "../UploadVideo/UploadVideo"; +import { FrameExtractor } from "../common/FrameExtractor/FrameExtractor"; const uid = new ShortUniqueId(); const shortuid = new ShortUniqueId({ length: 5 }); @@ -88,6 +94,8 @@ export const EditVideo = () => { useState(null); const [selectedSubCategoryVideos, setSelectedSubCategoryVideos] = useState(null); + const [imageExtracts, setImageExtracts] = useState([]) + const { getRootProps, getInputProps } = useDropzone({ accept: { @@ -205,6 +213,7 @@ export const EditVideo = () => { setVideoPropertiesToSetToRedux(null); setFile(null); setTitle(""); + setImageExtracts([]) setDescription(""); setCoverImage(""); }; @@ -270,6 +279,7 @@ export const EditVideo = () => { fullDescription, videoImage: coverImage, videoReference: editVideoProperties.videoReference, + extracts: file ? imageExtracts : editVideoProperties?.extracts, commentsId: editVideoProperties.commentsId, category, subcategory, @@ -346,21 +356,7 @@ export const EditVideo = () => { } } - const handleOnchange = (index: number, type: string, value: string) => { - // setFiles((prev) => { - // let formattedValue = value - // console.log({type}) - // if(type === 'title'){ - // formattedValue = value.replace(/[^a-zA-Z0-9\s]/g, "") - // } - // const copyFiles = [...prev]; - // copyFiles[index] = { - // ...copyFiles[index], - // [type]: formattedValue, - // }; - // return copyFiles; - // }); - }; + const handleOptionCategoryChangeVideos = ( event: SelectChangeEvent @@ -380,6 +376,44 @@ export const EditVideo = () => { setSelectedSubCategoryVideos(selectedOption || null); }; + const onFramesExtracted = async (imgs)=> { + try { + let imagesExtracts = [] + + for (const img of imgs){ + try { + let compressedFile + const image = img + await new Promise((resolve) => { + new Compressor(image, { + quality: .8, + maxWidth: 750, + mimeType: 'image/webp', + success(result) { + const file = new File([result], 'name', { + type: 'image/webp' + }) + compressedFile = file + resolve() + }, + error(err) {} + }) + }) + if (!compressedFile) continue + const base64Img = await toBase64(compressedFile) + imagesExtracts.push(base64Img) + + } catch (error) { + console.error(error) + } + } + + setImageExtracts(imagesExtracts) + } catch (error) { + + } + } + return ( <> { )} + {file && ( + onFramesExtracted(imgs)}/> + )} {!coverImage ? ( setCoverImage(img)}> @@ -548,7 +585,11 @@ export const EditVideo = () => { onClick={() => { publishQDNResource(); }} + disabled={file && imageExtracts.length === 0} > + {file && imageExtracts.length === 0 && ( + + )} Publish diff --git a/src/components/ResponsiveImage.tsx b/src/components/ResponsiveImage.tsx index 22d8b9d..e806be8 100644 --- a/src/components/ResponsiveImage.tsx +++ b/src/components/ResponsiveImage.tsx @@ -44,7 +44,7 @@ const ResponsiveImage: React.FC = ({ diff --git a/src/components/UploadVideo/UploadVideo.tsx b/src/components/UploadVideo/UploadVideo.tsx index 134c5eb..c281090 100644 --- a/src/components/UploadVideo/UploadVideo.tsx +++ b/src/components/UploadVideo/UploadVideo.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useState } from "react"; +import Compressor from 'compressorjs' import { AddCoverImageButton, AddLogoIcon, @@ -13,6 +14,8 @@ import { StyledButton, TimesIcon, } from "./Upload-styles"; +import { CircularProgress } from "@mui/material"; + import { Box, Button, @@ -57,6 +60,17 @@ import { CardContentContainerComment } from "../common/Comments/Comments-styles" import { TextEditor } from "../common/TextEditor/TextEditor"; import { extractTextFromHTML } from "../common/TextEditor/utils"; import { FiltersCheckbox, FiltersRow, FiltersSubContainer } from "../../pages/Home/VideoList-styles"; +import { FrameExtractor } from "../common/FrameExtractor/FrameExtractor"; + +export const toBase64 = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader() + reader.readAsDataURL(file) + reader.onload = () => resolve(reader.result) + reader.onerror = (error) => { + reject(error) + } + }) const uid = new ShortUniqueId(); const shortuid = new ShortUniqueId({ length: 5 }); @@ -114,7 +128,7 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => { const [isCheckTitleByFile, setIsCheckTitleByFile] = useState(false) const [isCheckSameCoverImage, setIsCheckSameCoverImage] = useState(false) const [isCheckDescriptionIsTitle, setIsCheckDescriptionIsTitle] = useState(false) - + const [imageExtracts, setImageExtracts] = useState({}) const { getRootProps, getInputProps } = useDropzone({ accept: { "video/*": [], @@ -219,7 +233,8 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => { let listOfPublishes = []; - for (const publish of files) { + for (let i = 0; i < files.length; i++) { + const publish = files[i] const title = publish.title; const description = isCheckDescriptionIsTitle ? publish.title : publish.description; const category = selectedCategoryVideos.id; @@ -271,6 +286,7 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => { identifier: identifier, service: "VIDEO", }, + extracts: imageExtracts[i], commentsId: `${QTUBE_VIDEO_BASE}_cm_${id}`, category, subcategory, @@ -539,6 +555,49 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => { } }; + const onFramesExtracted = async (imgs, index)=> { + try { + let imagesExtracts = [] + + for (const img of imgs){ + try { + let compressedFile + const image = img + await new Promise((resolve) => { + new Compressor(image, { + quality: .8, + maxWidth: 750, + mimeType: 'image/webp', + success(result) { + const file = new File([result], 'name', { + type: 'image/webp' + }) + compressedFile = file + resolve() + }, + error(err) {} + }) + }) + if (!compressedFile) continue + const base64Img = await toBase64(compressedFile) + imagesExtracts.push(base64Img) + + } catch (error) { + console.error(error) + } + } + + setImageExtracts((prev)=> { + return { + ...prev, + [index]: imagesExtracts + } + }) + } catch (error) { + + } + } + return ( <> {username && ( @@ -719,6 +778,7 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => { {files.map((file, index) => { return ( + onFramesExtracted(imgs, index)}/> {file?.file?.name} {!isCheckSameCoverImage && ( <> @@ -1126,24 +1186,30 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => { ) : ( { next(); }} > - Next + {files?.length !== Object.keys(imageExtracts)?.length ? 'Generating image extracts' : ''} + {files?.length !== Object.keys(imageExtracts)?.length && ( + + )} + Next )} - + {isOpenMultiplePublish && ( { setIsOpenMultiplePublish(false); setIsOpen(false); + setImageExtracts({}) setFiles([]); setStep("videos"); setPlaylistCoverImage(null); diff --git a/src/components/common/FrameExtractor/FrameExtractor.tsx b/src/components/common/FrameExtractor/FrameExtractor.tsx new file mode 100644 index 0000000..ab3758b --- /dev/null +++ b/src/components/common/FrameExtractor/FrameExtractor.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useRef, useState, useMemo } from 'react'; + +export const FrameExtractor = ({ videoFile, onFramesExtracted }) => { + const videoRef = useRef(null); + const [durations, setDurations] = useState([]); + const canvasRef = useRef(null); + + useEffect(() => { + const video = videoRef.current; + video.addEventListener('loadedmetadata', () => { + const duration = video.duration; + if (isFinite(duration)) { + // Proceed with your logic + + console.log('duration', duration) + const section = duration / 4; + let timestamps = []; + + for (let i = 0; i < 4; i++) { + const randomTime = Math.random() * section + i * section; + timestamps.push(randomTime); + } + + setDurations(timestamps); + } else { + onFramesExtracted([]) + } + }); + }, [videoFile]); + + console.log({durations}) + + useEffect(() => { + if (durations.length === 4) { + extractFrames(); + } + }, [durations]); + + const fileUrl = useMemo(() => { + return URL.createObjectURL(videoFile); + }, [videoFile]); + + const extractFrames = async () => { + const video = videoRef.current; + const canvas = canvasRef.current; + canvas.width = video.videoWidth; + canvas.height = video.videoHeight; + const context = canvas.getContext('2d'); + + let frameData = []; + + for (const time of durations) { + await new Promise((resolve) => { + video.currentTime = time; + const onSeeked = () => { + context.drawImage(video, 0, 0, canvas.width, canvas.height); + canvas.toBlob(blob => { + frameData.push(blob); + resolve(); + }, 'image/png'); + video.removeEventListener('seeked', onSeeked); + }; + video.addEventListener('seeked', onSeeked, { once: true }); + }); + } + + onFramesExtracted(frameData); + }; + + + return ( +
+ + +
+ ); +}; diff --git a/src/constants/index.ts b/src/constants/index.ts index 05cab8a..c9467a4 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,4 +1,4 @@ -const useTestIdentifiers = false; +const useTestIdentifiers = true; export const QTUBE_VIDEO_BASE = useTestIdentifiers ? "MYTEST_vid_" diff --git a/src/pages/Home/VideoCardImageContainer.tsx b/src/pages/Home/VideoCardImageContainer.tsx new file mode 100644 index 0000000..cea875f --- /dev/null +++ b/src/pages/Home/VideoCardImageContainer.tsx @@ -0,0 +1,42 @@ +import React, { useState } from "react"; +import ResponsiveImage from "../../components/ResponsiveImage"; + +export const VideoCardImageContainer = ({ + videoImage, + frameImages, + height, + width, +}) => { + const [previewImage, setPreviewImage] = useState(null); + const intervalRef = React.useRef(null); + + const startPreview = () => { + let frameIndex = 0; + intervalRef.current = setInterval(() => { + setPreviewImage(frameImages[frameIndex]); + frameIndex = (frameIndex + 1) % frameImages.length; + }, 500); // Change frame every 500 ms + }; + + const stopPreview = () => { + clearInterval(intervalRef.current); + setPreviewImage(null); + }; + + return ( +
+ +
+ ); +}; diff --git a/src/pages/Home/VideoList.tsx b/src/pages/Home/VideoList.tsx index 7ff4cd7..4edd294 100644 --- a/src/pages/Home/VideoList.tsx +++ b/src/pages/Home/VideoList.tsx @@ -64,6 +64,7 @@ import { PlaylistSVG } from "../../assets/svgs/PlaylistSVG"; import BlockIcon from "@mui/icons-material/Block"; import EditIcon from '@mui/icons-material/Edit'; import { LiskSuperLikeContainer } from "../../components/common/ListSuperLikes/LiskSuperLikeContainer"; +import { VideoCardImageContainer } from "./VideoCardImageContainer"; interface VideoListProps { mode?: string; @@ -696,11 +697,13 @@ export const VideoList = ({ mode }: VideoListProps) => { navigate(`/video/${videoObj?.user}/${videoObj?.id}`); }} > - + {/* + /> */} {videoObj.title} { - const { name: paramName } = useParams() - const theme = useTheme() - const [isLoading, setIsLoading] = useState(true) + const { name: paramName } = useParams(); + const theme = useTheme(); + const [isLoading, setIsLoading] = useState(true); - const firstFetch = useRef(false) - const afterFetch = useRef(false) + const firstFetch = useRef(false); + const afterFetch = useRef(false); const hashMapVideos = useSelector( (state: RootState) => state.video.hashMapVideos - ) - + ); + const countNewVideos = useSelector( (state: RootState) => state.video.countNewVideos - ) + ); const userAvatarHash = useSelector( (state: RootState) => state.global.userAvatarHash - ) - - const [videos, setVideos] = React.useState([]) - - const navigate = useNavigate() - const { - getVideo, - getNewVideos, - checkNewVideos, - checkAndUpdateVideo - } = useFetchVideos() + ); + + const [videos, setVideos] = React.useState([]); + + const navigate = useNavigate(); + const { getVideo, getNewVideos, checkNewVideos, checkAndUpdateVideo } = + useFetchVideos(); const getVideos = React.useCallback(async () => { try { - const offset = videos.length - const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}_&limit=20&includemetadata=false&reverse=true&excludeblocked=true&name=${paramName}&exactmatchnames=true&offset=${offset}` + const offset = videos.length; + const url = `/arbitrary/resources/search?mode=ALL&service=DOCUMENT&query=${QTUBE_VIDEO_BASE}_&limit=20&includemetadata=false&reverse=true&excludeblocked=true&name=${paramName}&exactmatchnames=true&offset=${offset}`; const response = await fetch(url, { - method: 'GET', + method: "GET", headers: { - 'Content-Type': 'application/json' - } - }) - const responseData = await response.json() - + "Content-Type": "application/json", + }, + }); + const responseData = await response.json(); + const structureData = responseData.map((video: any): Video => { return { title: video?.metadata?.title, @@ -71,128 +73,126 @@ export const VideoListComponentLevel = ({ mode }: VideoListProps) => { created: video?.created, updated: video?.updated, user: video.name, - videoImage: '', - id: video.identifier - } - }) - - const copiedVideos: Video[] = [...videos] + videoImage: "", + id: video.identifier, + }; + }); + + const copiedVideos: Video[] = [...videos]; structureData.forEach((video: Video) => { - const index = videos.findIndex((p) => p.id === video.id) + const index = videos.findIndex((p) => p.id === video.id); if (index !== -1) { - copiedVideos[index] = video + copiedVideos[index] = video; } else { - copiedVideos.push(video) + copiedVideos.push(video); } - }) - setVideos(copiedVideos) + }); + setVideos(copiedVideos); for (const content of structureData) { if (content.user && content.id) { - const res = checkAndUpdateVideo(content) + const res = checkAndUpdateVideo(content); if (res) { queue.push(() => getVideo(content.user, content.id, content)); - } } } } catch (error) { } finally { - } - }, [videos, hashMapVideos]) + }, [videos, hashMapVideos]); - const getVideosHandler = React.useCallback(async () => { - if(!firstFetch.current || !afterFetch.current) return - await getVideos() - }, [getVideos]) - + if (!firstFetch.current || !afterFetch.current) return; + await getVideos(); + }, [getVideos]); const getVideosHandlerMount = React.useCallback(async () => { - if(firstFetch.current) return - firstFetch.current = true - await getVideos() - afterFetch.current = true - setIsLoading(false) - }, [getVideos]) + if (firstFetch.current) return; + firstFetch.current = true; + await getVideos(); + afterFetch.current = true; + setIsLoading(false); + }, [getVideos]); - - - - - useEffect(()=> { - if(!firstFetch.current){ - getVideosHandlerMount() + useEffect(() => { + if (!firstFetch.current) { + getVideosHandlerMount(); } + }, [getVideosHandlerMount]); - }, [getVideosHandlerMount ]) - - return ( - - - - {videos.map((video: any, index: number) => { - const existingVideo = hashMapVideos[video.id] - let hasHash = false - let videoObj = video - if (existingVideo) { - videoObj = existingVideo - hasHash = true - } + + + {videos.map((video: any, index: number) => { + const existingVideo = hashMapVideos[video.id]; + let hasHash = false; + let videoObj = video; + if (existingVideo) { + videoObj = existingVideo; + hasHash = true; + } - let avatarUrl = '' - if(userAvatarHash[videoObj?.user]){ - avatarUrl = userAvatarHash[videoObj?.user] - } + let avatarUrl = ""; + if (userAvatarHash[videoObj?.user]) { + avatarUrl = userAvatarHash[videoObj?.user]; + } - if(hasHash && (!videoObj?.videoImage || videoObj?.videoImage?.length < 50)){ - return null - } + if ( + hasHash && + (!videoObj?.videoImage || videoObj?.videoImage?.length < 50) + ) { + return null; + } - - return ( - - + return ( + { - navigate(`/video/${videoObj.user}/${videoObj.id}`) + navigate(`/video/${videoObj.user}/${videoObj.id}`); }} - > - + > + {videoObj.title} - - - {videoObj.user} - - - {videoObj?.created && ( - {formatDate(videoObj.created)} - )} - + + + {videoObj.user} + + + {videoObj?.created && ( + + {formatDate(videoObj.created)} + + )} - - - - ) - })} + + ); + })} - - + + - ) -} - - + ); +};