3
0
mirror of https://github.com/Qortal/q-tube.git synced 2025-02-11 17:55:51 +00:00

added preview images on hover

This commit is contained in:
PhilReact 2023-12-24 01:50:37 +02:00
parent 5e211737b0
commit 469688b4e0
8 changed files with 387 additions and 158 deletions

View File

@ -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<any>(null);
const [selectedSubCategoryVideos, setSelectedSubCategoryVideos] =
useState<any>(null);
const [imageExtracts, setImageExtracts] = useState<any>([])
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<string>
@ -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<void>((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 (
<>
<Modal
@ -466,6 +500,9 @@ export const EditVideo = () => {
</FormControl>
)}
</Box>
{file && (
<FrameExtractor videoFile={file} onFramesExtracted={(imgs)=> onFramesExtracted(imgs)}/>
)}
<React.Fragment>
{!coverImage ? (
<ImageUploader onPick={(img: string) => setCoverImage(img)}>
@ -548,7 +585,11 @@ export const EditVideo = () => {
onClick={() => {
publishQDNResource();
}}
disabled={file && imageExtracts.length === 0}
>
{file && imageExtracts.length === 0 && (
<CircularProgress color="secondary" size={14} />
)}
Publish
</CrowdfundActionButton>
</Box>

View File

@ -44,7 +44,7 @@ const ResponsiveImage: React.FC<ResponsiveImageProps> = ({
<Box
sx={{
padding: '2px',
maxHeight: '50%'
height: '100%'
}}
>

View File

@ -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<string | ArrayBuffer | null> =>
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<any>({})
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<void>((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 (
<React.Fragment key={index}>
<FrameExtractor videoFile={file.file} onFramesExtracted={(imgs)=> onFramesExtracted(imgs, index)}/>
<Typography>{file?.file?.name}</Typography>
{!isCheckSameCoverImage && (
<>
@ -1126,24 +1186,30 @@ export const UploadVideo = ({ editId, editContent }: NewCrowdfundProps) => {
) : (
<CrowdfundActionButton
variant="contained"
disabled={files?.length !== Object.keys(imageExtracts)?.length}
onClick={() => {
next();
}}
>
Next
{files?.length !== Object.keys(imageExtracts)?.length ? 'Generating image extracts' : ''}
{files?.length !== Object.keys(imageExtracts)?.length && (
<CircularProgress color="secondary" size={14} />
)}
Next
</CrowdfundActionButton>
)}
</Box>
</CrowdfundActionButtonRow>
</ModalBody>
</Modal>
{isOpenMultiplePublish && (
<MultiplePublish
isOpen={isOpenMultiplePublish}
onSubmit={() => {
setIsOpenMultiplePublish(false);
setIsOpen(false);
setImageExtracts({})
setFiles([]);
setStep("videos");
setPlaylistCoverImage(null);

View File

@ -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 (
<div>
<video ref={videoRef} style={{ display: 'none' }} src={fileUrl}></video>
<canvas ref={canvasRef} style={{ display: 'none' }}></canvas>
</div>
);
};

View File

@ -1,4 +1,4 @@
const useTestIdentifiers = false;
const useTestIdentifiers = true;
export const QTUBE_VIDEO_BASE = useTestIdentifiers
? "MYTEST_vid_"

View File

@ -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 (
<div
style={{
height,
maxWidth: "100%",
}}
onMouseEnter={startPreview}
onMouseLeave={stopPreview}
>
<ResponsiveImage
src={previewImage || videoImage}
width={266}
height={150}
/>
</div>
);
};

View File

@ -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}`);
}}
>
<ResponsiveImage
<VideoCardImageContainer width={266}
height={150} videoImage={videoObj.videoImage} frameImages={videoObj?.extracts || []} />
{/* <ResponsiveImage
src={videoObj.videoImage}
width={266}
height={150}
/>
/> */}
<VideoCardTitle>{videoObj.title}</VideoCardTitle>
<BottomParent>
<NameContainer

View File

@ -1,66 +1,68 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useSelector } from "react-redux";
import { RootState } from "../../state/store";
import { Avatar, Box, Button, Typography, useTheme } from "@mui/material";
import { useFetchVideos } from "../../hooks/useFetchVideos";
import LazyLoad from "../../components/common/LazyLoad";
import {
Avatar,
Box,
Button,
Typography,
useTheme
} from '@mui/material'
import { useFetchVideos } from '../../hooks/useFetchVideos'
import LazyLoad from '../../components/common/LazyLoad'
import { BottomParent, NameContainer, ProductManagerRow, VideoCard, VideoCardCol, VideoCardContainer, VideoCardName, VideoCardTitle, VideoContainer, VideoUploadDate } from './VideoList-styles'
import ResponsiveImage from '../../components/ResponsiveImage'
import { formatDate, formatTimestampSeconds } from '../../utils/time'
import { Video } from '../../state/features/videoSlice'
import { queue } from '../../wrappers/GlobalWrapper'
import { QTUBE_VIDEO_BASE } from '../../constants'
BottomParent,
NameContainer,
ProductManagerRow,
VideoCard,
VideoCardCol,
VideoCardContainer,
VideoCardName,
VideoCardTitle,
VideoContainer,
VideoUploadDate,
} from "./VideoList-styles";
import ResponsiveImage from "../../components/ResponsiveImage";
import { formatDate, formatTimestampSeconds } from "../../utils/time";
import { Video } from "../../state/features/videoSlice";
import { queue } from "../../wrappers/GlobalWrapper";
import { QTUBE_VIDEO_BASE } from "../../constants";
import { VideoCardImageContainer } from "./VideoCardImageContainer";
interface VideoListProps {
mode?: string
mode?: string;
}
export const VideoListComponentLevel = ({ mode }: VideoListProps) => {
const { name: paramName } = useParams()
const theme = useTheme()
const [isLoading, setIsLoading] = useState<boolean>(true)
const { name: paramName } = useParams();
const theme = useTheme();
const [isLoading, setIsLoading] = useState<boolean>(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<Video[]>([])
const navigate = useNavigate()
const {
getVideo,
getNewVideos,
checkNewVideos,
checkAndUpdateVideo
} = useFetchVideos()
);
const [videos, setVideos] = React.useState<Video[]>([]);
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 (
<ProductManagerRow>
<Box sx={{
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}}>
<VideoCardContainer>
{videos.map((video: any, index: number) => {
const existingVideo = hashMapVideos[video.id]
let hasHash = false
let videoObj = video
if (existingVideo) {
videoObj = existingVideo
hasHash = true
}
<Box
sx={{
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
}}
>
<VideoCardContainer>
{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 (
<VideoCardCol
key={videoObj.id}
>
return (
<VideoCardCol key={videoObj.id}>
<VideoCard
onClick={() => {
navigate(`/video/${videoObj.user}/${videoObj.id}`)
navigate(`/video/${videoObj.user}/${videoObj.id}`);
}}
>
<ResponsiveImage src={videoObj.videoImage} width={266} height={150}/>
>
<VideoCardImageContainer
width={266}
height={150}
videoImage={videoObj.videoImage}
frameImages={videoObj?.extracts || []}
/>
<VideoCardTitle>{videoObj.title}</VideoCardTitle>
<BottomParent>
<NameContainer>
<Avatar sx={{height: 24, width: 24}} src={`/arbitrary/THUMBNAIL/${videoObj?.user}/qortal_avatar`} alt={`${videoObj.user}'s avatar`} />
<VideoCardName>{videoObj.user}</VideoCardName>
</NameContainer>
{videoObj?.created && (
<VideoUploadDate>{formatDate(videoObj.created)}</VideoUploadDate>
)}
<NameContainer>
<Avatar
sx={{ height: 24, width: 24 }}
src={`/arbitrary/THUMBNAIL/${videoObj?.user}/qortal_avatar`}
alt={`${videoObj.user}'s avatar`}
/>
<VideoCardName>{videoObj.user}</VideoCardName>
</NameContainer>
{videoObj?.created && (
<VideoUploadDate>
{formatDate(videoObj.created)}
</VideoUploadDate>
)}
</BottomParent>
</VideoCard>
</VideoCardCol>
)
})}
</VideoCardCol>
);
})}
</VideoCardContainer>
<LazyLoad onLoadMore={getVideosHandler} isLoading={isLoading}></LazyLoad>
</Box>
<LazyLoad
onLoadMore={getVideosHandler}
isLoading={isLoading}
></LazyLoad>
</Box>
</ProductManagerRow>
)
}
);
};