jbilcke's picture
add VR video viewer
2156c54
raw
history blame
No virus
17.3 kB
"use client"
import { useEffect, useRef, useState, useTransition } from "react"
import { RiCheckboxCircleFill } from "react-icons/ri"
import { PiShareFatLight } from "react-icons/pi"
import { BsHeadsetVr } from "react-icons/bs"
import CopyToClipboard from "react-copy-to-clipboard"
import { LuCopyCheck } from "react-icons/lu"
import { LuScrollText } from "react-icons/lu"
import { BiCameraMovie } from "react-icons/bi"
import { useStore } from "@/app/state/useStore"
import { cn } from "@/lib/utils"
import { VideoPlayer } from "@/app/interface/video-player"
import { ActionButton, actionButtonClassName } from "@/app/interface/action-button"
import { RecommendedVideos } from "@/app/interface/recommended-videos"
import { isCertifiedUser } from "@/app/certification"
import { watchVideo } from "@/app/server/actions/stats"
import { formatTimeAgo } from "@/lib/formatTimeAgo"
import { DefaultAvatar } from "@/app/interface/default-avatar"
import { LikeButton } from "@/app/interface/like-button"
import { ReportModal } from "../report-modal"
import { formatLargeNumber } from "@/lib/formatLargeNumber"
import { CommentList } from "@/app/interface/comment-list"
import { Input } from "@/components/ui/input"
import { localStorageKeys } from "@/app/state/localStorageKeys"
import { defaultSettings } from "@/app/state/defaultSettings"
import { getComments, submitComment } from "@/app/server/actions/comments"
import { useCurrentUser } from "@/app/state/useCurrentUser"
import { useLocalStorage } from "usehooks-ts"
import { parseProjectionFromLoRA } from "@/app/server/actions/utils/parseProjectionFromLoRA"
export function PublicVideoView() {
const [_pending, startTransition] = useTransition()
const [commentDraft, setCommentDraft] = useState("")
const [isCommenting, setCommenting] = useState(false)
const [isFocusedOnInput, setFocusedOnInput] = useState(false)
// current time in the video
// note: this is used to *set* the current time, not to read it
// EDIT: you know what, let's do this the dirty way for now
// const [desiredCurrentTime, setDesiredCurrentTime] = useState()
const { user } = useCurrentUser()
const [userThumbnail, setUserThumbnail] = useState("")
useEffect(() => {
setUserThumbnail(user?.thumbnail || "")
}, [user?.thumbnail])
const handleBadUserThumbnail = () => {
if (userThumbnail) {
setUserThumbnail("")
}
}
const video = useStore(s => s.publicVideo)
const videoId = `${video?.id || ""}`
const [copied, setCopied] = useState<boolean>(false)
const [channelThumbnail, setChannelThumbnail] = useState(`${video?.channel.thumbnail || ""}`)
const setPublicVideo = useStore(s => s.setPublicVideo)
const publicComments = useStore(s => s.publicComments)
const setPublicComments = useStore(s => s.setPublicComments)
const isEquirectangular = (
video?.projection === "equirectangular" ||
parseProjectionFromLoRA(video?.lora) === "equirectangular"
)
// we inject the current videoId in the URL, if it's not already present
// this is a hack for Hugging Face iframes
useEffect(() => {
const queryString = new URL(location.href).search
const searchParams = new URLSearchParams(queryString)
if (videoId) {
if (searchParams.get("v") !== videoId) {
console.log(`current videoId "${videoId}" isn't set in the URL query params.. TODO we should set it`)
// searchParams.set("v", videoId)
// location.search = searchParams.toString()
}
} else {
// searchParams.delete("v")
// location.search = searchParams.toString()
}
}, [videoId])
useEffect(() => {
if (copied) {
setTimeout(() => {
setCopied(false)
}, 2000)
}
}, [copied])
const handleBadChannelThumbnail = () => {
if (channelThumbnail) {
setChannelThumbnail("")
}
}
useEffect(() => {
startTransition(async () => {
if (!video || !video.id) {
return
}
const numberOfViews = await watchVideo(videoId)
setPublicVideo({
...video,
numberOfViews
})
})
}, [video?.id])
useEffect(() => {
startTransition(async () => {
if (!video || !video.id) {
return
}
const comments = await getComments(videoId)
setPublicComments(comments)
})
}, [video?.id])
const [huggingfaceApiKey] = useLocalStorage<string>(
localStorageKeys.huggingfaceApiKey,
defaultSettings.huggingfaceApiKey
)
/*
useEffect(() => {
window.addEventListener("keydown", function (e) {
if (e.code === "Space") {
e.preventDefault();
}
})
}, [])
*/
if (!video) { return null }
const handleSubmitComment = () => {
startTransition(async () => {
if (!commentDraft || !huggingfaceApiKey || !videoId) { return }
const limitedSizeComment = commentDraft.trim().slice(0, 1024).trim()
const comment = await submitComment(video.id, limitedSizeComment, huggingfaceApiKey)
setPublicComments(
[comment].concat(publicComments)
)
setCommentDraft("")
setFocusedOnInput(false)
setCommenting(false)
})
}
return (
<div className={cn(
`w-full`,
`flex flex-col lg:flex-row`
)}>
<div className={cn(
`flex-grow`,
`flex flex-col`,
`transition-all duration-200 ease-in-out`,
`px-2 xl:px-0`
)}>
{/** VIDEO PLAYER - HORIZONTAL */}
<VideoPlayer
video={video}
enableShortcuts={!isFocusedOnInput}
// that could be, but let's do it the dirty way for now
// currentTime={desiredCurrentTime}
className="mb-4"
/>
{/** VIDEO TITLE - HORIZONTAL */}
<div className={cn(
`flex flex-row space-x-2`,
`transition-all duration-200 ease-in-out`,
`text-lg lg:text-xl text-zinc-100 font-medium mb-0 line-clamp-2`,
`mb-2`,
)}>
<div className="">{video.label}</div>
{/*
<div className={cn(
`flex flex-row`, // `inline-block`,
`bg-neutral-700 text-neutral-300 rounded-lg`,
// `items-center justify-center`,
`text-center`,
`px-1.5 py-0.5`,
`text-xs`
)}>
AI Video Model: {video.model || "HotshotXL"}
</div>
*/}
</div>
{/** VIDEO TOOLBAR - HORIZONTAL */}
<div className={cn(
`flex flex-col space-y-3 xl:space-y-0 xl:flex-row`,
`transition-all duration-200 ease-in-out`,
`items-start xl:items-center`,
`justify-between`,
`mb-2 lg:mb-3`,
)}>
{/** LEFT PART OF THE TOOLBAR */}
<div className={cn(
`flex flex-row`,
`items-center`
)}>
{/** CHANNEL LOGO - VERTICAL */}
<a
className={cn(
`flex flex-col`,
`mr-3`,
`cursor-pointer`
)}
href={`https://huggingface.co/datasets/${video.channel.datasetUser}/${video.channel.datasetName}`}
target="_blank">
<div className="flex w-10 rounded-full overflow-hidden">
{
channelThumbnail ? <div className="flex flex-col">
<div className="flex w-9 rounded-full overflow-hidden">
<img
src={channelThumbnail}
onError={handleBadChannelThumbnail}
/>
</div>
</div>
: <DefaultAvatar
username={video.channel.datasetUser}
bgColor="#fde047"
textColor="#1c1917"
width={36}
roundShape
/>}
</div>
</a>
{/** CHANNEL INFO - VERTICAL */}
<a className={cn(
`flex flex-row sm:flex-col`,
`transition-all duration-200 ease-in-out`,
`cursor-pointer`,
)}
href={`https://huggingface.co/datasets/${video.channel.datasetUser}/${video.channel.datasetName}`}
target="_blank">
<div className={cn(
`flex flex-row items-center`,
`transition-all duration-200 ease-in-out`,
`text-zinc-100 text-sm lg:text-base font-medium space-x-1`,
)}>
<div>{video.channel.label}</div>
{isCertifiedUser(video.channel.datasetUser) ? <div className="text-sm text-neutral-400"><RiCheckboxCircleFill className="" /></div> : null}
</div>
<div className={cn(
`flex flex-row items-center`,
`text-neutral-400 text-xs font-normal space-x-1`,
)}>
<div>{
// TODO implement the follower system
formatLargeNumber(0)
} followers</div>
<div></div>
</div>
</a>
</div>
{/** RIGHT PART OF THE TOOLBAR */}
<div className={cn(
`flex flex-row`,
`items-center`,
`space-x-2`
)}>
<LikeButton video={video} />
{/* SHARE */}
<div className={cn(
`flex flex-row`,
`items-center`
)}>
<CopyToClipboard
text={`https://jbilcke-hf-ai-tube.hf.space/watch?v=${video.id}`}
onCopy={() => setCopied(true)}>
<div className={cn(
actionButtonClassName,
`bg-neutral-700/50 hover:bg-neutral-700/90 text-zinc-100`
)}>
<div className="flex items-center justify-center">
{
copied ? <LuCopyCheck className="w-5 h-5" />
: <PiShareFatLight className="w-6 h-6" />
}
</div>
<span>
{copied ? "Copied!" : "Share"}
</span>
</div>
</CopyToClipboard>
</div>
<ActionButton
href={
video.model === "LaVie"
? "https://huggingface.co/vdo/LaVie"
: video.model === "SVD"
? "https://huggingface.co/stabilityai/stable-video-diffusion-img2vid"
: "https://huggingface.co/hotshotco/Hotshot-XL"
}
>
<BiCameraMovie className="w-5 h-5" />
<span className="hidden 2xl:inline">
Made with {video.model}
</span>
<span className="inline 2xl:hidden">
{video.model}
</span>
</ActionButton>
{isEquirectangular && <ActionButton
href={`/api/video/${video.id}`}
>
<BsHeadsetVr className="w-5 h-5" />
<span>
See in VR
</span>
</ActionButton>}
<ActionButton
href={
`https://huggingface.co/datasets/${
video.channel.datasetUser
}/${
video.channel.datasetName
}/raw/main/prompt_${
video.id
}.md`
}
>
<LuScrollText className="w-5 h-5" />
<span>
Source
</span>
</ActionButton>
<ReportModal video={video} />
</div>
</div>
{/** VIDEO DESCRIPTION - VERTICAL */}
<div className={cn(
`flex flex-col p-3`,
`transition-all duration-200 ease-in-out`,
`rounded-xl`,
`bg-neutral-700/50`,
`text-sm text-zinc-100`,
)}>
{/* DESCRIPTION BLOCK */}
<div className="flex flex-row space-x-2 font-medium mb-1">
<div>{formatLargeNumber(video.numberOfViews)} views</div>
<div>{formatTimeAgo(video.updatedAt).replace("about ", "")}</div>
</div>
<p>{video.description}</p>
</div>
{/* COMMENTS */}
<div className={cn(
`flex-col font-medium mb-1 py-6`,
)}>
<div className="flex flex-row text-xl text-zinc-100 w-full mb-4">
{Number(publicComments?.length || 0).toLocaleString()} Comment{
Number(publicComments?.length || 0) === 1 ? '' : 's'
}
</div>
{/* COMMENT INPUT BLOCK - HORIZONTAL */}
{user && <div className="flex flex-row w-full">
{/* AVATAR */}
<div
// className="flex flex-col w-10 pr-13 overflow-hidden"
className="flex flex-none flex-col w-10 pr-13 overflow-hidden">
{
userThumbnail ?
<div className="flex w-9 rounded-full overflow-hidden">
<img
src={userThumbnail}
onError={handleBadUserThumbnail}
/>
</div>
: <DefaultAvatar
username={user?.userName}
bgColor="#fde047"
textColor="#1c1917"
width={36}
roundShape
/>}
</div>
{/* COMMENT INPUTS AND BUTTONS - VERTICAL */}
<div className="flex flex-col flex-grow">
<Input
placeholder="Add a comment.."
type="text"
className={cn(
`w-full`,
`rounded-none`,
`bg-transparent dark:bg-transparent`,
`border-l-transparent border-r-transparent border-t-transparent dark:border-l-transparent dark:border-r-transparent dark:border-t-transparent`,
`border-b border-b-zinc-600 dark:border-b dark:border-b-zinc-600`,
`hover:pt-[2px] hover:pb-[1px] hover:border-b-2 hover:border-b-zinc-200 dark:hover:border-b-2 dark:hover:border-b-zinc-200`,
`outline-transparent ring-transparent ring-offset-transparent`,
`dark:outline-transparent dark:ring-transparent dark:ring-offset-transparent`,
`focus-visible:outline-transparent focus-visible:ring-transparent focus-visible:ring-offset-transparent`,
`dark:focus-visible:outline-transparent dark:focus-visible:ring-transparent dark:focus-visible:ring-offset-transparent`,
`font-normal`,
`pl-0 h-8`,
`mb-3`
)}
onChange={(x) => {
if (!isFocusedOnInput) {
setFocusedOnInput(true)
}
if (!isCommenting) {
setCommenting(true)
}
setCommentDraft(x.target.value)
}}
value={commentDraft}
onFocus={() => {
if (!isFocusedOnInput) {
setFocusedOnInput(true)
}
if (!isCommenting) {
setCommenting(true)
}
}}
onBlur={() => {
setFocusedOnInput(false)
}}
onKeyDown={({ key }) => {
if (key === 'Enter') {
handleSubmitComment()
} else {
if (!isFocusedOnInput) {
setFocusedOnInput(true)
}
if (!isCommenting) {
setCommenting(true)
}
}
}}
/>
<div className={cn(
`flex-row space-x-3 w-full justify-end`,
isCommenting ? `flex` : `hidden`
)}>
<div className="flex flex-row space-x-3">
<ActionButton
variant="ghost"
onClick={() => {
setCommentDraft("")
setCommenting(false)
setFocusedOnInput(false)
}}
>Cancel</ActionButton>
<ActionButton
variant={commentDraft ? "primary" : "secondary"}
onClick={handleSubmitComment}
>Comment</ActionButton>
</div>
</div>
</div>
</div>}
<CommentList
comments={publicComments}
/>
</div>
</div>
<div className={cn(
// this one is very important to make sure the right panel is not compressed
`flex flex-col`,
`flex-none`,
`pl-2 lg:pl-4 lg:pr-2`,
`w-full md:w-[360px] lg:w-[400px] xl:w-[450px]`,
`transition-all duration-200 ease-in-out`,
)}>
<RecommendedVideos video={video} />
</div>
</div>
)
}