Add video cover images

This commit is contained in:
2025-07-27 07:29:13 +00:00
parent 3695fcea44
commit fa08f0c4f2
4 changed files with 4936 additions and 0 deletions

953
BlogIndividualPost.tsx Normal file
View File

@@ -0,0 +1,953 @@
import React, { useMemo, useRef, useState } from 'react'
import { useParams } from 'react-router-dom'
import {
Button,
Box,
Typography,
CardHeader,
Avatar,
useTheme,
Tooltip
} from '@mui/material'
import { useNavigate } from 'react-router-dom'
import { styled } from '@mui/system'
import AudiotrackIcon from '@mui/icons-material/Audiotrack'
import ReadOnlySlate from '../../components/editor/ReadOnlySlate'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { checkStructure } from '../../utils/checkStructure'
import { BlogContent } from '../../interfaces/interfaces'
import ShareIcon from '@mui/icons-material/Share'
import {
setAudio,
setCurrAudio,
setIsLoadingGlobal,
setVisitingBlog
} from '../../state/features/globalSlice'
import { VideoPlayer } from '../../components/common/VideoPlayer'
import { AudioPlayer, IPlaylist } from '../../components/common/AudioPlayer'
import { Responsive, WidthProvider } from 'react-grid-layout'
import '/node_modules/react-grid-layout/css/styles.css'
import '/node_modules/react-resizable/css/styles.css'
import DynamicHeightItem from '../../components/DynamicHeightItem'
import {
addPrefix,
buildIdentifierFromCreateTitleIdAndId,
removePrefix
} from '../../utils/blogIdformats'
import { DynamicHeightItemMinimal } from '../../components/DynamicHeightItemMinimal'
import { ReusableModal } from '../../components/modals/ReusableModal'
import AudioElement from '../../components/AudioElement'
import ErrorBoundary from '../../components/common/ErrorBoundary'
import { CommentSection } from '../../components/common/Comments/CommentSection'
import { Tipping } from '../../components/common/Tipping/Tipping'
import FileElement from '../../components/FileElement'
import { CopyToClipboard } from 'react-copy-to-clipboard'
import { setNotification } from '../../state/features/notificationsSlice'
import ContextMenuResource from '../../components/common/ContextMenu/ContextMenuResource'
const ResponsiveGridLayout = WidthProvider(Responsive)
const initialMinHeight = 2 // Define an initial minimum height for grid items
const md = [
{ i: 'a', x: 0, y: 0, w: 4, h: initialMinHeight },
{ i: 'b', x: 6, y: 0, w: 4, h: initialMinHeight }
]
const sm = [
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight },
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight }
]
const xs = [
{ i: 'a', x: 0, y: 0, w: 6, h: initialMinHeight },
{ i: 'b', x: 6, y: 0, w: 6, h: initialMinHeight }
]
interface ILayoutGeneralSettings {
padding: number
blogPostType: string
}
export const BlogIndividualPost = () => {
const { user, postId: postIdTemp, blog:blogTemp } = useParams()
const blog = React.useMemo(()=> {
if(postIdTemp && postIdTemp?.includes('-post-')){
const str = postIdTemp
const arr = str.split('-post-')
const str1 = arr[0]
const blogId = removePrefix(str1)
return blogId
} else {
return blogTemp
}
}, [postIdTemp])
const postId = React.useMemo(()=> {
if(postIdTemp && postIdTemp?.includes('-post-')){
const str = postIdTemp
const arr = str.split('-post-')
const str2 = arr[1]
return str2
} else {
return postIdTemp
}
}, [postIdTemp])
const blogFull = React.useMemo(() => {
if (!blog) return ''
return addPrefix(blog)
}, [blog])
const { user: userState } = useSelector((state: RootState) => state.auth)
const { audios, audioPostId } = useSelector(
(state: RootState) => state.global
)
const [avatarUrl, setAvatarUrl] = React.useState<string>('')
const dispatch = useDispatch()
const navigate = useNavigate()
const theme = useTheme()
// const [currAudio, setCurrAudio] = React.useState<number | null>(null)
const [layouts, setLayouts] = React.useState<any>({ md, sm, xs })
const [count, setCount] = React.useState<number>(1)
const [layoutGeneralSettings, setLayoutGeneralSettings] =
React.useState<ILayoutGeneralSettings | null>(null)
const [currentBreakpoint, setCurrentBreakpoint] = React.useState<any>()
const handleLayoutChange = (layout: any, layoutss: any) => {
// const redoLayouts = setAutoHeight(layoutss)
setLayouts(layoutss)
// saveLayoutsToLocalStorage(layoutss)
}
const [blogContent, setBlogContent] = React.useState<BlogContent | null>(null)
const [isOpenSwitchPlaylistModal, setisOpenSwitchPlaylistModal] =
useState<boolean>(false)
const tempSaveAudio = useRef<any>(null)
const saveAudio = React.useRef<any>(null)
const fullPostId = useMemo(() => {
if (!blog || !postId) return ''
dispatch(setIsLoadingGlobal(true))
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId)
return formPostId
}, [blog, postId])
const getBlogPost = React.useCallback(async () => {
try {
if (!blog || !postId) return
dispatch(setIsLoadingGlobal(true))
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(
formBlogId,
postId
)
const url = `/arbitrary/BLOG_POST/${user}/${formPostId}`
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
if (checkStructure(responseData)) {
setBlogContent(responseData)
if (responseData?.layouts) {
setLayouts(responseData?.layouts)
}
if (responseData?.layoutGeneralSettings) {
setLayoutGeneralSettings(responseData.layoutGeneralSettings)
}
const filteredAudios = (responseData?.postContent || []).filter(
(content: any) => content?.type === 'audio'
)
const transformAudios = filteredAudios?.map((fa: any) => {
return {
...(fa?.content || {}),
id: fa?.id
}
})
if (!audios && transformAudios.length > 0) {
saveAudio.current = { audios: transformAudios, postId: formPostId }
dispatch(setAudio({ audios: transformAudios, postId: formPostId }))
} else if (
formPostId === audioPostId &&
audios?.length !== transformAudios.length
) {
tempSaveAudio.current = {
message:
"This post's audio playlist has updated. Would you like to switch?"
}
setisOpenSwitchPlaylistModal(true)
}
}
} catch (error) {
} finally {
dispatch(setIsLoadingGlobal(false))
}
}, [user, postId, blog])
React.useEffect(() => {
getBlogPost()
}, [postId])
const switchPlayList = () => {
const filteredAudios = (blogContent?.postContent || []).filter(
(content) => content?.type === 'audio'
)
const formatAudios = filteredAudios.map((fa) => {
return {
...(fa?.content || {}),
id: fa?.id
}
})
if (!blog || !postId) return
const formBlogId = addPrefix(blog)
const formPostId = buildIdentifierFromCreateTitleIdAndId(formBlogId, postId)
dispatch(setAudio({ audios: formatAudios, postId: formPostId }))
if (tempSaveAudio?.current?.currentSelection) {
const findIndex = (formatAudios || []).findIndex(
(item) =>
item?.identifier ===
tempSaveAudio?.current?.currentSelection?.content?.identifier
)
if (findIndex >= 0) {
dispatch(setCurrAudio(findIndex))
}
}
setisOpenSwitchPlaylistModal(false)
}
const getAvatar = React.useCallback(async () => {
try {
let url = await qortalRequest({
action: 'GET_QDN_RESOURCE_URL',
name: user,
service: 'THUMBNAIL',
identifier: 'qortal_avatar'
})
setAvatarUrl(url)
} catch (error) {}
}, [user])
React.useEffect(() => {
getAvatar()
}, [])
const onBreakpointChange = React.useCallback((newBreakpoint: any) => {
setCurrentBreakpoint(newBreakpoint)
}, [])
const onResizeStop = React.useCallback((layout: any, layoutItem: any) => {
// Update the layout state with the new position and size of the component
setCount((prev) => prev + 1)
}, [])
// const audios = React.useMemo<IPlaylist[]>(() => {
// const filteredAudios = (blogContent?.postContent || []).filter(
// (content) => content.type === 'audio'
// )
// return filteredAudios.map((fa) => {
// return {
// ...fa.content,
// id: fa.id
// }
// })
// }, [blogContent])
const handleResize = () => {
setCount((prev) => prev + 1)
}
React.useEffect(() => {
window.addEventListener('resize', handleResize)
return () => {
window.removeEventListener('resize', handleResize)
}
}, [])
const handleCount = React.useCallback(() => {
// Update the layout state with the new position and size of the component
setCount((prev) => prev + 1)
}, [])
const getBlog = React.useCallback(async () => {
let name = user
if (!name) return
if (!blogFull) return
try {
const urlBlog = `/arbitrary/BLOG/${name}/${blogFull}`
const response = await fetch(urlBlog, {
method: 'GET',
headers: {
'Content-Type': 'application/json'
}
})
const responseData = await response.json()
dispatch(setVisitingBlog({ ...responseData, name }))
} catch (error) {}
}, [user, blogFull])
React.useEffect(() => {
getBlog()
}, [user, blogFull])
if (!blogContent) return null
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: 'column'
}}
>
<Box
sx={{
maxWidth: '1400px',
// margin: '15px',
width: '95%',
paddingBottom: '50px'
}}
>
{user === userState?.name && (
<Button
sx={{ backgroundColor: theme.palette.secondary.main }}
onClick={() => {
navigate(`/${user}/${blog}/${postId}/edit`)
}}
>
Edit Post
</Button>
)}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<CardHeader
onClick={() => {
navigate(`/${user}/${blog}`)
}}
sx={{
cursor: 'pointer',
'& .MuiCardHeader-content': {
overflow: 'hidden'
},
padding: '10px 0px'
}}
avatar={<Avatar src={avatarUrl} alt={`${user}'s avatar`} />}
subheader={
<Typography
sx={{ fontFamily: 'Cairo', fontSize: '25px' }}
color={theme.palette.text.primary}
>{` ${user}`}</Typography>
}
/>
{user && (
<Tipping
name={user || ''}
onSubmit={() => {
// setNameTip('')
}}
onClose={() => {
// setNameTip('')
}}
/>
)}
</Box>
<Box
sx={{
display: 'flex',
gap: 1,
alignItems: 'center',
justifyContent: 'center'
}}
>
<Typography
variant="h1"
color="textPrimary"
sx={{
textAlign: 'center'
}}
>
{blogContent?.title}
</Typography>
<Tooltip title={`Copy post link`} arrow>
<Box
sx={{
cursor: 'pointer'
}}
>
<CopyToClipboard
text={`qortal://APP/Q-Blog/${user}/${blog}/${postId}`}
onCopy={() => {
dispatch(
setNotification({
msg: 'Copied to clipboard!',
alertType: 'success'
})
)
}}
>
<ShareIcon />
</CopyToClipboard>
</Box>
</Tooltip>
<CommentSection postId={fullPostId} postName={user || ''} />
</Box>
{(layoutGeneralSettings?.blogPostType === 'builder' ||
!layoutGeneralSettings?.blogPostType) && (
<Content
layouts={layouts}
blogContent={blogContent}
onResizeStop={onResizeStop}
onBreakpointChange={onBreakpointChange}
handleLayoutChange={handleLayoutChange}
>
{blogContent?.postContent?.map((section: any) => {
if (section?.type === 'editor') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<ReadOnlySlate content={section.content} />
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'image') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<img
src={section.content.image}
className="post-image"
/>
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'video') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<VideoPlayer
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
poster={section.content.poster}
setCount={handleCount}
user={user}
postId={fullPostId}
/>
</ContextMenuResource>
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'audio') {
return (
<div key={section?.id} className="grid-item-view">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItem
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={layoutGeneralSettings?.padding}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<AudioElement
key={section.id}
audioInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
onClick={() => {
if (!blog || !postId) return
const formBlogId = addPrefix(blog)
const formPostId =
buildIdentifierFromCreateTitleIdAndId(
formBlogId,
postId
)
if (audioPostId && formPostId !== audioPostId) {
tempSaveAudio.current = {
...(tempSaveAudio.current || {}),
currentSelection: section,
message:
'You are current on a playlist. Would you like to switch?'
}
setisOpenSwitchPlaylistModal(true)
} else {
if (!audios && saveAudio?.current) {
const findIndex = (
saveAudio?.current?.audios || []
).findIndex(
(item: any) =>
item.identifier ===
section.content.identifier
)
dispatch(setAudio(saveAudio?.current))
dispatch(setCurrAudio(findIndex))
return
}
const findIndex = (audios || []).findIndex(
(item) =>
item.identifier ===
section.content.identifier
)
if (findIndex >= 0) {
dispatch(setCurrAudio(findIndex))
}
}
}}
title={section.content?.title}
description={section.content?.description}
author=""
/>
</ContextMenuResource>
</DynamicHeightItem>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'file') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<FileElement
key={section.id}
fileInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
title={section.content?.title}
description={section.content?.description}
mimeType={section.content?.mimeType}
author=""
/>
</ContextMenuResource>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
})}
</Content>
)}
{layoutGeneralSettings?.blogPostType === 'minimal' && (
<>
{layouts?.rows?.map((row: any, rowIndex: number) => {
return (
<Box
sx={{
display: 'flex',
width: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: '25px',
gap: 2
}}
>
{row?.ids?.map((elementId: string) => {
const section: any = blogContent?.postContent?.find(
(el) => el?.id === elementId
)
if (!section) return null
if (section?.type === 'editor') {
return (
<div
key={section?.id}
className="grid-item"
style={{
maxWidth: '800px',
display: 'flex',
flexDirection: 'column',
width: '100%'
}}
>
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<ReadOnlySlate
key={section.id}
content={section.content}
/>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'image') {
return (
<div key={section.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
type="image"
padding={0}
>
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%'
}}
>
<img
src={section.content.image}
className="post-image"
style={{
objectFit: 'contain',
maxHeight: '50vh'
}}
/>
</Box>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'video') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<Box
sx={{
position: 'relative',
width: '100%',
height: '100%'
}}
>
<VideoPlayer
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
poster={section.content.poster}
customStyle={{
height: '50vh'
}}
user={user}
postId={fullPostId}
/>
</Box>
</ContextMenuResource>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'audio') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<AudioElement
key={section.id}
audioInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
onClick={() => {
if (!blog || !postId) return
const formBlogId = addPrefix(blog)
const formPostId =
buildIdentifierFromCreateTitleIdAndId(
formBlogId,
postId
)
if (formPostId !== audioPostId) {
tempSaveAudio.current = {
...(tempSaveAudio.current || {}),
currentSelection: section,
message:
'You are current on a playlist. Would you like to switch?'
}
setisOpenSwitchPlaylistModal(true)
} else {
const findIndex = (
audios || []
).findIndex(
(item) =>
item.identifier ===
section.content.identifier
)
if (findIndex >= 0) {
dispatch(setCurrAudio(findIndex))
}
}
}}
title={section.content?.title}
description={section.content?.description}
author=""
/>
</ContextMenuResource>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
if (section?.type === 'file') {
return (
<div key={section?.id} className="grid-item">
<ErrorBoundary
fallback={
<Typography>
Error loading content: Invalid Data
</Typography>
}
>
<DynamicHeightItemMinimal
layouts={layouts}
setLayouts={setLayouts}
i={section.id}
breakpoint={currentBreakpoint}
count={count}
padding={0}
>
<ContextMenuResource
name={section.content.name}
service={section.content.service}
identifier={section.content.identifier}
link={`qortal://${section?.content?.service}/${section?.content?.name}/${section?.content?.identifier}`}
>
<FileElement
key={section.id}
fileInfo={section.content}
postId={fullPostId}
user={user ? user : ''}
title={section.content?.title}
description={section.content?.description}
mimeType={section.content?.mimeType}
author=""
/>
</ContextMenuResource>
</DynamicHeightItemMinimal>
</ErrorBoundary>
</div>
)
}
})}
</Box>
)
})}
</>
)}
<ReusableModal open={isOpenSwitchPlaylistModal}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1
}}
>
<Typography>
{tempSaveAudio?.current?.message
? tempSaveAudio?.current?.message
: 'You are current on a playlist. Would you like to switch?'}
</Typography>
</Box>
<Button
variant="contained"
onClick={() => setisOpenSwitchPlaylistModal(false)}
>
Cancel
</Button>
<Button variant="contained" onClick={switchPlayList}>
Switch
</Button>
</ReusableModal>
</Box>
</Box>
)
}
const Content = ({
children,
layouts,
blogContent,
onResizeStop,
onBreakpointChange,
handleLayoutChange
}: any) => {
if (layouts && blogContent?.layouts) {
return (
<ErrorBoundary
fallback={
<Typography>Error loading content: Invalid Layout</Typography>
}
>
<ResponsiveGridLayout
layouts={layouts}
breakpoints={{ md: 996, sm: 768, xs: 480 }}
cols={{ md: 4, sm: 3, xs: 1 }}
measureBeforeMount={false}
onLayoutChange={handleLayoutChange}
autoSize={true}
compactType={null}
isBounded={true}
resizeHandles={['se', 'sw', 'ne', 'nw']}
rowHeight={25}
onResizeStop={onResizeStop}
onBreakpointChange={onBreakpointChange}
isDraggable={false}
isResizable={false}
margin={[0, 0]}
>
{children}
</ResponsiveGridLayout>
</ErrorBoundary>
)
}
return children
}

1586
CreatePostBuilder.tsx Normal file

File diff suppressed because it is too large Load Diff

1565
CreatePostMinimal.tsx Normal file

File diff suppressed because it is too large Load Diff

832
VideoPlayer.tsx Normal file
View File

@@ -0,0 +1,832 @@
import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
import ReactDOM from 'react-dom'
import { Box, IconButton, Slider } from '@mui/material'
import { CircularProgress, Typography } from '@mui/material'
import { Key } from 'ts-key-enum'
import {
PlayArrow,
Pause,
VolumeUp,
Fullscreen,
PictureInPicture, VolumeOff
} from '@mui/icons-material'
import { styled } from '@mui/system'
import { MyContext } from '../../wrappers/DownloadWrapper'
import { useDispatch, useSelector } from 'react-redux'
import { RootState } from '../../state/store'
import { Refresh } from '@mui/icons-material'
import { Menu, MenuItem } from '@mui/material'
import { MoreVert as MoreIcon } from '@mui/icons-material'
const VideoContainer = styled(Box)`
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 0px;
padding: 0px;
`
const VideoElement = styled('video')`
width: 100%;
height: auto;
background: rgb(33, 33, 33);
`
const ControlsContainer = styled(Box)`
position: absolute;
display: flex;
align-items: center;
justify-content: space-between;
bottom: 0;
left: 0;
right: 0;
padding: 8px;
background-color: rgba(0, 0, 0, 0.6);
`
interface VideoPlayerProps {
src?: string
poster?: string
name?: string
identifier?: string
service?: string
autoplay?: boolean
from?: string | null
setCount?: () => void
customStyle?: any
user?: string
postId?: string
}
export const VideoPlayer: React.FC<VideoPlayerProps> = ({
poster,
name,
identifier,
service,
autoplay = true,
from = null,
setCount,
customStyle = {},
user = '',
postId = ''
}) => {
const videoRef = useRef<HTMLVideoElement | null>(null)
const [playing, setPlaying] = useState(false)
const [volume, setVolume] = useState(1)
const [mutedVolume, setMutedVolume] = useState(1)
const [isMuted, setIsMuted] = useState(false)
const [progress, setProgress] = useState(0)
const [isLoading, setIsLoading] = useState(false)
const [canPlay, setCanPlay] = useState(false)
const [startPlay, setStartPlay] = useState(false)
const [isMobileView, setIsMobileView] = useState(false)
const [playbackRate, setPlaybackRate] = useState(1)
const [anchorEl, setAnchorEl] = useState(null)
const [consoleLog, setConsoleLog] = useState('Console Log Here')
const [debug, setDebug] = useState(false)
const reDownload = useRef<boolean>(false)
const { downloads } = useSelector((state: RootState) => state.global)
const download = useMemo(() => {
if (!downloads || !identifier) return {}
const findDownload = downloads[identifier]
if (!findDownload) return {}
return findDownload
}, [downloads, identifier])
const src = useMemo(() => {
return download?.url || ''
}, [download?.url])
const resourceStatus = useMemo(() => {
return download?.status || {}
}, [download])
const minSpeed = 0.25;
const maxSpeed = 4.0;
const speedChange = 0.25;
const updatePlaybackRate = (newSpeed: number) => {
if(videoRef.current) {
if(newSpeed > maxSpeed || newSpeed < minSpeed)
newSpeed = minSpeed
videoRef.current.playbackRate = newSpeed
setPlaybackRate(newSpeed)
}
}
const increaseSpeed = (wrapOverflow = true) => {
const changedSpeed = playbackRate + speedChange
let newSpeed = wrapOverflow ? changedSpeed: Math.min(changedSpeed, maxSpeed)
if (videoRef.current) {
updatePlaybackRate(newSpeed);
}
}
const decreaseSpeed = () => {
if (videoRef.current) {
updatePlaybackRate(playbackRate - speedChange);
}
}
const toggleRef = useRef<any>(null)
const { downloadVideo } = useContext(MyContext)
const togglePlay = async () => {
if (!videoRef.current) return
setStartPlay(true)
if (!src) {
const el = document.getElementById('videoWrapper')
if (el) {
el?.parentElement?.removeChild(el)
}
ReactDOM.flushSync(() => {
setIsLoading(true)
})
getSrc()
}
if (playing) {
videoRef.current.pause()
} else {
videoRef.current.play()
}
setPlaying(!playing)
}
const onVolumeChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.volume = value as number
setVolume(value as number)
setIsMuted(false)
}
const onProgressChange = (_: any, value: number | number[]) => {
if (!videoRef.current) return
videoRef.current.currentTime = value as number
setProgress(value as number)
if (!playing) {
videoRef.current.play()
setPlaying(true)
}
}
const handleEnded = () => {
setPlaying(false)
}
const updateProgress = () => {
if (!videoRef.current) return
setProgress(videoRef.current.currentTime)
}
const [isFullscreen, setIsFullscreen] = useState(false)
const enterFullscreen = () => {
if (!videoRef.current) return
if (videoRef.current.requestFullscreen) {
videoRef.current.requestFullscreen()
}
}
const exitFullscreen = () => {
if (document.exitFullscreen) {
document.exitFullscreen()
}
}
const toggleFullscreen = () => {
isFullscreen ? exitFullscreen(): enterFullscreen()
}
const togglePictureInPicture = async () => {
if (!videoRef.current) return
if (document.pictureInPictureElement === videoRef.current) {
await document.exitPictureInPicture()
} else {
await videoRef.current.requestPictureInPicture()
}
}
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement)
}
document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => {
document.removeEventListener('fullscreenchange', handleFullscreenChange)
}
}, [])
const handleLoadedMetadata = () => {
setIsLoading(false)
}
const handleCanPlay = () => {
if (setCount) {
setCount()
}
setIsLoading(false)
setCanPlay(true)
}
const getSrc = React.useCallback(async () => {
if (!name || !identifier || !service || !postId || !user) return
try {
downloadVideo({
name,
service,
identifier,
blogPost: {
postId,
user
}
})
} catch (error) {}
}, [identifier, name, service])
useEffect(() => {
const videoElement = videoRef.current
const handleLeavePictureInPicture = async (event: any) => {
const target = event?.target
if (target) {
target.pause()
if (setPlaying) {
setPlaying(false)
}
}
}
if (videoElement) {
videoElement.addEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
return () => {
if (videoElement) {
videoElement.removeEventListener(
'leavepictureinpicture',
handleLeavePictureInPicture
)
}
}
}, [])
useEffect(() => {
const videoElement = videoRef.current
const minimizeVideo = async () => {
if (!videoElement) return
const handleClose = () => {
if (videoElement && videoElement.parentElement) {
const el = document.getElementById('videoWrapper')
if (el) {
el?.parentElement?.removeChild(el)
}
}
}
const createCloseButton = (): HTMLButtonElement => {
const closeButton = document.createElement('button')
closeButton.textContent = 'X'
closeButton.style.position = 'absolute'
closeButton.style.top = '0'
closeButton.style.right = '0'
closeButton.style.backgroundColor = 'rgba(255, 255, 255, 0.7)'
closeButton.style.border = 'none'
closeButton.style.fontWeight = 'bold'
closeButton.style.fontSize = '1.2rem'
closeButton.style.cursor = 'pointer'
closeButton.style.padding = '2px 8px'
closeButton.style.borderRadius = '0 0 0 4px'
closeButton.addEventListener('click', handleClose)
return closeButton
}
const buttonClose = createCloseButton()
const videoWrapper = document.createElement('div')
videoWrapper.id = 'videoWrapper'
videoWrapper.style.position = 'fixed'
videoWrapper.style.zIndex = '900000009'
videoWrapper.style.bottom = '0px'
videoWrapper.style.right = '0px'
videoElement.parentElement?.insertBefore(videoWrapper, videoElement)
videoWrapper.appendChild(videoElement)
videoWrapper.appendChild(buttonClose)
videoElement.controls = true
videoElement.style.height = 'auto'
videoElement.style.width = '300px'
document.body.appendChild(videoWrapper)
}
return () => {
if (videoElement) {
if (videoElement && !videoElement.paused && !videoElement.ended) {
minimizeVideo()
}
}
}
}, [])
function formatTime(seconds: number): string {
seconds = Math.floor(seconds)
let minutes: number | string = Math.floor(seconds / 60)
let hours: number | string = Math.floor(minutes / 60)
let remainingSeconds: number | string = seconds % 60
let remainingMinutes: number | string = minutes % 60
if (remainingSeconds < 10) {
remainingSeconds = '0' + remainingSeconds
}
if (remainingMinutes < 10) {
remainingMinutes = '0' + remainingMinutes
}
if(hours === 0){
hours = ''
}
else
{
hours = hours + ':'
}
return hours + remainingMinutes + ':' + remainingSeconds
}
const reloadVideo = () => {
if (!videoRef.current) return
const currentTime = videoRef.current.currentTime
videoRef.current.src = src
videoRef.current.load()
videoRef.current.currentTime = currentTime
if (playing) {
videoRef.current.play()
}
}
useEffect(() => {
if (
resourceStatus?.status === 'DOWNLOADED' &&
reDownload?.current === false
) {
getSrc()
reDownload.current = true
}
}, [getSrc, resourceStatus])
const handleMenuOpen = (event: any) => {
setAnchorEl(event.currentTarget)
}
const handleMenuClose = () => {
setAnchorEl(null)
}
useEffect(() => {
const videoWidth = videoRef?.current?.offsetWidth
if (videoWidth && videoWidth <= 600) {
setIsMobileView(true)
}
}, [canPlay])
const getDownloadProgress = (current: number, total: number) => {
const progress = current /total * 100;
return Number.isNaN(progress) ? '': progress.toFixed(0)+'%'
}
const mute = () => {
setIsMuted(true)
setMutedVolume(volume)
setVolume(0)
if(videoRef.current) videoRef.current.volume = 0
}
const unMute = () => {
setIsMuted(false)
setVolume(mutedVolume)
if(videoRef.current) videoRef.current.volume = mutedVolume
}
const toggleMute = () => {
isMuted ? unMute() : mute();
}
const changeVolume = (volumeChange: number) =>
{
if(videoRef.current){
const minVolume = 0;
const maxVolume = 1;
let newVolume = volumeChange + volume
newVolume = Math.max(newVolume, minVolume)
newVolume = Math.min(newVolume, maxVolume)
setIsMuted(false)
setMutedVolume(newVolume)
videoRef.current.volume = newVolume
setVolume(newVolume);
}
}
const setProgressRelative = (secondsChange: number) => {
if(videoRef.current){
const currentTime = videoRef.current?.currentTime
const minTime = 0
const maxTime = videoRef.current?.duration || 100
let newTime = currentTime + secondsChange;
newTime = Math.max(newTime, minTime)
newTime = Math.min(newTime, maxTime)
videoRef.current.currentTime = newTime;
setProgress(newTime);
}
}
const setProgressAbsolute = (videoPercent: number) => {
if(videoRef.current){
videoPercent = Math.min(videoPercent, 100)
videoPercent = Math.max(videoPercent, 0)
const finalTime = videoRef.current?.duration*videoPercent / 100
videoRef.current.currentTime = finalTime
setProgress(finalTime);
}
}
const keyboardShortcutsDown = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
e.preventDefault()
//setConsoleLog(`Alt: ${e.altKey} Shift: ${e.shiftKey} Control: ${e.ctrlKey} Key: ${e.key}`)
switch(e.key) {
case Key.Add: increaseSpeed(false); break;
case '+': increaseSpeed(false); break;
case '>': increaseSpeed(false); break;
case Key.Subtract: decreaseSpeed(); break;
case '-': decreaseSpeed(); break;
case '<': decreaseSpeed(); break;
case Key.ArrowLeft: {
if(e.shiftKey) setProgressRelative(-300);
else if(e.ctrlKey) setProgressRelative(-60);
else if(e.altKey) setProgressRelative(-10);
else setProgressRelative(-5);
} break;
case Key.ArrowRight: {
if(e.shiftKey) setProgressRelative(300);
else if(e.ctrlKey) setProgressRelative(60);
else if(e.altKey) setProgressRelative(10);
else setProgressRelative(5);
} break;
case Key.ArrowDown: changeVolume(-0.05) ; break;
case Key.ArrowUp: changeVolume(0.05) ; break;
}
}
const keyboardShortcutsUp = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
e.preventDefault()
//setConsoleLog(`Alt: ${e.altKey} Shift: ${e.shiftKey} Control: ${e.ctrlKey} Key: ${e.key}`)
switch(e.key) {
case ' ': togglePlay(); break;
case 'm': toggleMute(); break;
case 'f': enterFullscreen(); break;
case Key.Escape: exitFullscreen(); break;
case '0': setProgressAbsolute(0); break;
case '1': setProgressAbsolute(10); break;
case '2': setProgressAbsolute(20); break;
case '3': setProgressAbsolute(30); break;
case '4': setProgressAbsolute(40); break;
case '5': setProgressAbsolute(50); break;
case '6': setProgressAbsolute(60); break;
case '7': setProgressAbsolute(70); break;
case '8': setProgressAbsolute(80); break;
case '9': setProgressAbsolute(90); break;
}
}
return (
<VideoContainer
tabIndex={0}
onKeyUp={keyboardShortcutsUp}
onKeyDown={keyboardShortcutsDown}
style={{
padding: from === 'create' ? '8px' : 0
}}
>
{/* <Box
sx={{
position: 'absolute',
top: '-30px',
right: '-15px'
}}
>
<CopyToClipboard
text={`qortal://${service}/${name}/${identifier}`}
onCopy={() => {
dispatch(
setNotification({
msg: 'Copied to clipboard!',
alertType: 'success'
})
)
}}
>
<LinkIcon
sx={{
fontSize: '14px',
cursor: 'pointer'
}}
/>
</CopyToClipboard>
</Box> */}
{isLoading && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={resourceStatus?.status === 'READY' ? '55px ' : 0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={4999}
bgcolor="rgba(0, 0, 0, 0.6)"
sx={{
display: 'flex',
flexDirection: 'column',
gap: '10px'
}}
>
<CircularProgress color="secondary" />
{resourceStatus && (
<Typography
variant="subtitle2"
component="div"
sx={{
color: 'white',
fontSize: '15px',
textAlign: 'center'
}}
>
{resourceStatus?.status === 'REFETCHING' ? (
<>
<>
{getDownloadProgress(resourceStatus?.localChunkCount,resourceStatus?.totalChunkCount)}
</>
<> Refetching in 25 seconds</>
</>
) : resourceStatus?.status === 'DOWNLOADED' ? (
<>Download Completed: building video...</>
) : resourceStatus?.status !== 'READY' ? (
<>
{getDownloadProgress(resourceStatus?.localChunkCount,resourceStatus?.totalChunkCount)}
</>
) : (
<>Download Completed: fetching video...</>
)}
</Typography>
)}
</Box>
)}
{((!src && !isLoading) || !startPlay) && (
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
display="flex"
justifyContent="center"
alignItems="center"
zIndex={500}
bgcolor={poster ? 'transparent' : 'rgba(0, 0, 0, 0.6)'}
onClick={() => {
if (from === 'create') return
togglePlay()
}}
sx={{
cursor: 'pointer'
}}
>
<PlayArrow
sx={{
width: '50px',
height: '50px',
color: 'white'
}}
/>
</Box>
)}
<VideoElement
ref={videoRef}
src={!startPlay ? '' : resourceStatus?.status === 'READY' ? src : ''}
poster={poster}
onTimeUpdate={updateProgress}
autoPlay={autoplay}
onClick={togglePlay}
onEnded={handleEnded}
// onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}
preload="metadata"
style={{
...customStyle
}}
/>
<ControlsContainer
style={{
bottom: from === 'create' ? '15px' : 0
}}
>
{isMobileView && canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<IconButton
edge="end"
color="inherit"
aria-label="menu"
onClick={handleMenuOpen}
>
<MoreIcon />
</IconButton>
<Menu
id="simple-menu"
anchorEl={anchorEl}
keepMounted
open={Boolean(anchorEl)}
onClose={handleMenuClose}
PaperProps={{
style: {
width: '250px'
}
}}
>
<MenuItem>
<VolumeUp />
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}/>
</MenuItem>
<MenuItem onClick={() => increaseSpeed()}>
<Typography
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px'
}}
>
Speed: {playbackRate}x
</Typography>
</MenuItem>
<MenuItem onClick={togglePictureInPicture}>
<PictureInPicture />
</MenuItem>
<MenuItem onClick={toggleFullscreen}>
<Fullscreen />
</MenuItem>
</Menu>
</>
) : canPlay ? (
<>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={togglePlay}
>
{playing ? <Pause /> : <PlayArrow />}
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
onClick={reloadVideo}
>
<Refresh />
</IconButton>
<Slider
value={progress}
onChange={onProgressChange}
min={0}
max={videoRef.current?.duration || 100}
sx={{ flexGrow: 1, mx: 2 }}
/>
<Typography
sx={{
fontSize: '14px',
marginRight: '5px',
color: 'rgba(255, 255, 255, 0.7)',
visibility:
!videoRef.current?.duration || !progress
? 'hidden'
: 'visible'
}}
>
{progress && videoRef.current?.duration && formatTime(progress)}/
{progress &&
videoRef.current?.duration &&
formatTime(videoRef.current?.duration)}
</Typography>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginRight: '10px'
}}
onClick={toggleMute}
>
{isMuted ? <VolumeOff/>:<VolumeUp/>}
</IconButton>
<Slider
value={volume}
onChange={onVolumeChange}
min={0}
max={1}
step={0.01}
/>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
fontSize: '14px',
marginLeft: '5px'
}}
onClick={(e) => increaseSpeed()}
>
Speed: {playbackRate}x
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)',
marginLeft: '15px'
}}
ref={toggleRef}
onClick={togglePictureInPicture}
>
<PictureInPicture />
</IconButton>
<IconButton
sx={{
color: 'rgba(255, 255, 255, 0.7)'
}}
onClick={toggleFullscreen}
>
<Fullscreen />
</IconButton>
</>
) : null}
</ControlsContainer>
{debug ? <span>{consoleLog}</span>: <></>}
</VideoContainer>
)
}