refactor: refactor videocard
This commit is contained in:
@@ -1,33 +1,20 @@
|
||||
import {
|
||||
Folder
|
||||
} from '@mui/icons-material';
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardActionArea,
|
||||
CardContent,
|
||||
CardMedia,
|
||||
Chip,
|
||||
Menu, MenuItem,
|
||||
Skeleton,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useCollection } from '../contexts/CollectionContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext'; // Added
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
|
||||
import { useShareVideo } from '../hooks/useShareVideo'; // Added
|
||||
import React from 'react';
|
||||
import { usePlayerSelection } from '../hooks/usePlayerSelection';
|
||||
import { useVideoCardActions } from '../hooks/useVideoCardActions';
|
||||
import { useVideoCardMetadata } from '../hooks/useVideoCardMetadata';
|
||||
import { useVideoCardNavigation } from '../hooks/useVideoCardNavigation';
|
||||
import { useVideoHoverPreview } from '../hooks/useVideoHoverPreview';
|
||||
import { Collection, Video } from '../types';
|
||||
import { formatDuration, parseDuration } from '../utils/formatUtils';
|
||||
import { getAvailablePlayers, getPlayerUrl } from '../utils/playerUtils'; // Added
|
||||
import CollectionModal from './CollectionModal';
|
||||
import ConfirmationModal from './ConfirmationModal';
|
||||
import VideoKebabMenuButtons from './VideoPlayer/VideoInfo/VideoKebabMenuButtons'; // Added
|
||||
import { getVideoCardCollectionInfo } from '../utils/videoCardUtils';
|
||||
import { VideoCardActions } from './VideoCard/VideoCardActions';
|
||||
import { VideoCardContent } from './VideoCard/VideoCardContent';
|
||||
import { VideoCardThumbnail } from './VideoCard/VideoCardThumbnail';
|
||||
|
||||
interface VideoCardProps {
|
||||
video: Video;
|
||||
@@ -44,628 +31,102 @@ const VideoCard: React.FC<VideoCardProps> = ({
|
||||
showDeleteButton = false,
|
||||
disableCollectionGrouping = false
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const navigate = useNavigate();
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const isTouch = useMediaQuery('(hover: none), (pointer: coarse)');
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// New state for player menu
|
||||
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
// Hooks for share and snackbar
|
||||
const { handleShare } = useShareVideo(video);
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { updateVideo, incrementView } = useVideo();
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile && video.videoPath) {
|
||||
// Add delay before loading video to prevent loading on quick hovers
|
||||
// This reduces memory usage when quickly moving mouse over multiple cards
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(true);
|
||||
}, 300); // 300ms delay
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Clear hover timeout if mouse leaves before delay
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setIsHovered(false);
|
||||
setIsVideoPlaying(false);
|
||||
|
||||
// Aggressively cleanup video element when mouse leaves
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = '';
|
||||
videoRef.current.load();
|
||||
// Force garbage collection hint
|
||||
videoRef.current.removeAttribute('src');
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup video element on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear any pending hover timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Aggressively cleanup video element
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = '';
|
||||
videoRef.current.load();
|
||||
videoRef.current.removeAttribute('src');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Format the date (assuming format YYYYMMDD from youtube-dl)
|
||||
const formatDate = (dateString: string) => {
|
||||
if (!dateString || dateString.length !== 8) {
|
||||
return t('unknownDate');
|
||||
}
|
||||
|
||||
const year = dateString.substring(0, 4);
|
||||
const month = dateString.substring(4, 6);
|
||||
const day = dateString.substring(6, 8);
|
||||
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Use cloud storage hook for thumbnail URL only if video is in cloud storage
|
||||
// Only load thumbnail from cloud if the video itself is in cloud storage
|
||||
const isVideoInCloud = video.videoPath?.startsWith('cloud:') ?? false;
|
||||
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
|
||||
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
|
||||
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
|
||||
? `${import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551'}${video.thumbnailPath}`
|
||||
: undefined;
|
||||
const thumbnailSrc = thumbnailUrl || localThumbnailUrl || video.thumbnailUrl;
|
||||
|
||||
// Use cloud storage hook for video URL
|
||||
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
|
||||
|
||||
// Handle author click
|
||||
const handleAuthorClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
};
|
||||
|
||||
// Handle confirm delete
|
||||
const confirmDelete = async () => {
|
||||
if (!onDeleteVideo) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteVideo(video.id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Find collections this video belongs to
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
|
||||
// Get collection information
|
||||
const collectionInfo = getVideoCardCollectionInfo(
|
||||
video,
|
||||
collections,
|
||||
disableCollectionGrouping
|
||||
);
|
||||
|
||||
// Check if this video is the first in any collection
|
||||
const isFirstInAnyCollection = !disableCollectionGrouping && videoCollections.some(collection =>
|
||||
collection.videos[0] === video.id
|
||||
);
|
||||
|
||||
// Get collection names where this video is the first
|
||||
const firstInCollectionNames = videoCollections
|
||||
.filter(collection => collection.videos[0] === video.id)
|
||||
.map(collection => collection.name);
|
||||
|
||||
// Get the first collection ID where this video is the first video
|
||||
const firstCollectionId = isFirstInAnyCollection
|
||||
? videoCollections.find(collection => collection.videos[0] === video.id)?.id
|
||||
: null;
|
||||
|
||||
// Check if video is new (0 views and added within 7 days)
|
||||
const isNewVideo = React.useMemo(() => {
|
||||
// Check if viewCount is 0 or null/undefined (unwatched)
|
||||
// Handle both number and string types
|
||||
const viewCountNum = typeof video.viewCount === 'string' ? parseInt(video.viewCount, 10) : video.viewCount;
|
||||
const hasNoViews = viewCountNum === 0 || viewCountNum === null || viewCountNum === undefined || isNaN(viewCountNum);
|
||||
if (!hasNoViews) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if addedAt exists
|
||||
if (!video.addedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if added within 7 days
|
||||
const addedDate = new Date(video.addedAt);
|
||||
const now = new Date();
|
||||
|
||||
// Handle invalid dates
|
||||
if (isNaN(addedDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const daysDiff = (now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const isWithin7Days = daysDiff >= 0 && daysDiff <= 7; // >= 0 to handle future dates
|
||||
|
||||
// Debug log (can be removed later)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`Video ${video.id}: viewCount=${video.viewCount} (parsed: ${viewCountNum}), addedAt=${video.addedAt}, daysDiff=${daysDiff.toFixed(2)}, isNew=${isWithin7Days}`);
|
||||
}
|
||||
|
||||
return isWithin7Days;
|
||||
}, [video.viewCount, video.addedAt, video.id]);
|
||||
|
||||
// Handle video navigation
|
||||
const handleVideoNavigation = () => {
|
||||
// If this is the first video in a collection, navigate to the collection page
|
||||
if (isFirstInAnyCollection && firstCollectionId) {
|
||||
navigate(`/collection/${firstCollectionId}`);
|
||||
} else {
|
||||
// Otherwise navigate to the video player page
|
||||
navigate(`/video/${video.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Player Logic
|
||||
const getVideoUrl = async (): Promise<string> => {
|
||||
// If we have a cloud storage URL, use it directly
|
||||
if (videoUrl) {
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
// If cloud storage path but URL not loaded yet, wait for it
|
||||
if (video.videoPath?.startsWith('cloud:')) {
|
||||
// Try to get the signed URL directly
|
||||
const { getFileUrl } = await import('../utils/cloudStorage');
|
||||
const cloudUrl = await getFileUrl(video.videoPath, 'video');
|
||||
if (cloudUrl) {
|
||||
return cloudUrl;
|
||||
}
|
||||
// If still not available, return empty string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Otherwise, construct URL from videoPath
|
||||
if (video.videoPath) {
|
||||
const videoPath = video.videoPath.startsWith('/') ? video.videoPath : `/${video.videoPath}`;
|
||||
return `${window.location.origin}${videoPath}`;
|
||||
}
|
||||
return video.sourceUrl || '';
|
||||
};
|
||||
|
||||
const handlePlayerMenuClose = () => {
|
||||
setPlayerMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handlePlayerSelect = async (player: string) => {
|
||||
const resolvedVideoUrl = await getVideoUrl();
|
||||
|
||||
if (!resolvedVideoUrl) {
|
||||
showSnackbar(t('error') || 'Video URL not available', 'error');
|
||||
handlePlayerMenuClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment view count since we can't track watch time in external players
|
||||
await incrementView(video.id);
|
||||
|
||||
try {
|
||||
let playerUrl = '';
|
||||
|
||||
if (player === 'copy') {
|
||||
// Copy URL to clipboard
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(resolvedVideoUrl).then(() => {
|
||||
showSnackbar(t('linkCopied'), 'success');
|
||||
}).catch(() => {
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
});
|
||||
} else {
|
||||
// Fallback
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = resolvedVideoUrl;
|
||||
textArea.style.position = "fixed";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
showSnackbar(t('linkCopied'), 'success');
|
||||
} else {
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
handlePlayerMenuClose();
|
||||
return;
|
||||
} else {
|
||||
playerUrl = getPlayerUrl(player, resolvedVideoUrl);
|
||||
}
|
||||
|
||||
// Try to open the player URL using a hidden anchor element
|
||||
// This prevents navigation away from the page
|
||||
if (playerUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = playerUrl;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Show a message after a short delay
|
||||
setTimeout(() => {
|
||||
showSnackbar(t('openInExternalPlayer'), 'info');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error opening player:', error);
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
}
|
||||
|
||||
handlePlayerMenuClose();
|
||||
};
|
||||
|
||||
|
||||
|
||||
// Collections Logic (State and Handlers)
|
||||
const { collections: allCollections, addToCollection, createCollection, removeFromCollection } = useCollection();
|
||||
const [showCollectionModal, setShowCollectionModal] = useState(false);
|
||||
|
||||
const handleAddToCollection = async (collectionId: string) => {
|
||||
if (!video.id) return;
|
||||
await addToCollection(collectionId, video.id);
|
||||
};
|
||||
|
||||
const handleCreateCollection = async (name: string) => {
|
||||
if (!video.id) return;
|
||||
await createCollection(name, video.id);
|
||||
};
|
||||
|
||||
const handleRemoveFromCollection = async () => {
|
||||
if (!video.id) return;
|
||||
await removeFromCollection(video.id);
|
||||
};
|
||||
|
||||
// Handle visibility toggle
|
||||
const handleToggleVisibility = async () => {
|
||||
if (!video.id) return;
|
||||
const newVisibility = (video.visibility ?? 1) === 0 ? 1 : 0;
|
||||
const result = await updateVideo(video.id, { visibility: newVisibility });
|
||||
if (result.success) {
|
||||
showSnackbar(newVisibility === 1 ? t('showVideo') : t('hideVideo'), 'success');
|
||||
} else {
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate collections that contain THIS video
|
||||
const currentVideoCollections = allCollections.filter(c => c.videos.includes(video.id));
|
||||
// Hooks for different concerns
|
||||
const hoverPreview = useVideoHoverPreview({ videoPath: video.videoPath });
|
||||
const metadata = useVideoCardMetadata({ video });
|
||||
const playerSelection = usePlayerSelection({
|
||||
video,
|
||||
getVideoUrl: metadata.getVideoUrl
|
||||
});
|
||||
const actions = useVideoCardActions({
|
||||
video,
|
||||
onDeleteVideo,
|
||||
showDeleteButton
|
||||
});
|
||||
const navigation = useVideoCardNavigation({
|
||||
video,
|
||||
collectionInfo
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
||||
borderRadius: isMobile ? 0 : undefined,
|
||||
...(!isMobile && {
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[8],
|
||||
'& .delete-btn': {
|
||||
opacity: 1
|
||||
},
|
||||
'& .add-btn': {
|
||||
opacity: 1
|
||||
}
|
||||
<Card
|
||||
onMouseEnter={hoverPreview.handleMouseEnter}
|
||||
onMouseLeave={hoverPreview.handleMouseLeave}
|
||||
sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
transition: 'transform 0.2s, box-shadow 0.2s, background-color 0.3s, color 0.3s, border-color 0.3s',
|
||||
borderRadius: isMobile ? 0 : undefined,
|
||||
...(!isMobile && {
|
||||
'&:hover': {
|
||||
transform: 'translateY(-4px)',
|
||||
boxShadow: theme.shadows[8],
|
||||
'& .delete-btn': {
|
||||
opacity: 1
|
||||
},
|
||||
'& .add-btn': {
|
||||
opacity: 1
|
||||
}
|
||||
}),
|
||||
border: isFirstInAnyCollection ? `1px solid ${theme.palette.primary.main}` : 'none'
|
||||
}}
|
||||
}
|
||||
}),
|
||||
border: collectionInfo.isFirstInAnyCollection
|
||||
? `1px solid ${theme.palette.primary.main}`
|
||||
: 'none'
|
||||
}}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={navigation.handleVideoNavigation}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<CardActionArea
|
||||
onClick={handleVideoNavigation}
|
||||
sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', alignItems: 'stretch' }}
|
||||
>
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
|
||||
{/* Video Element (only shown on hover) */}
|
||||
{isHovered && videoUrl && (
|
||||
<Box
|
||||
component="video"
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlaying={() => setIsVideoPlaying(true)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
bgcolor: 'black',
|
||||
zIndex: 1 // Ensure video is above thumbnail when playing
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const videoEl = e.target as HTMLVideoElement;
|
||||
const duration = parseDuration(video.duration);
|
||||
if (duration > 5) {
|
||||
videoEl.currentTime = Math.max(0, (duration / 2) - 2.5);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const videoEl = e.target as HTMLVideoElement;
|
||||
const duration = parseDuration(video.duration);
|
||||
const startTime = Math.max(0, (duration / 2) - 2.5);
|
||||
const endTime = startTime + 5;
|
||||
<VideoCardThumbnail
|
||||
video={video}
|
||||
thumbnailSrc={metadata.thumbnailSrc}
|
||||
videoUrl={metadata.videoUrl}
|
||||
isHovered={hoverPreview.isHovered}
|
||||
isVideoPlaying={hoverPreview.isVideoPlaying}
|
||||
setIsVideoPlaying={hoverPreview.setIsVideoPlaying}
|
||||
videoRef={hoverPreview.videoRef}
|
||||
collectionInfo={collectionInfo}
|
||||
isNew={metadata.isNew}
|
||||
/>
|
||||
|
||||
if (videoEl.currentTime >= endTime) {
|
||||
videoEl.currentTime = startTime;
|
||||
videoEl.play();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<VideoCardContent
|
||||
video={video}
|
||||
collectionInfo={collectionInfo}
|
||||
onAuthorClick={navigation.handleAuthorClick}
|
||||
/>
|
||||
</CardActionArea>
|
||||
|
||||
{/* Skeleton Placeholder */}
|
||||
{!isImageLoaded && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height="100%"
|
||||
animation="wave"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bgcolor: 'grey.800',
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Image */}
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={`${video.title} thumbnail`}
|
||||
loading="lazy"
|
||||
onLoad={() => setIsImageLoaded(true)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: (isImageLoaded && (!isHovered || !isVideoPlaying)) ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
pointerEvents: 'none', // Ensure hover events pass through
|
||||
zIndex: 2
|
||||
}}
|
||||
onError={(e) => {
|
||||
// If error, we can still show the placeholder or the fallback image
|
||||
// For now, let's treat error as loaded so we see the fallback/alt text if any
|
||||
setIsImageLoaded(true);
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
||||
<Chip
|
||||
label={`${t('part')} ${video.partNumber}/${video.totalParts}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ position: 'absolute', bottom: 36, right: 8, zIndex: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{video.duration && (
|
||||
<Chip
|
||||
label={formatDuration(video.duration)}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
height: 20,
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
zIndex: 3
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNewVideo && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '25px 25px 0 0',
|
||||
borderColor: `${theme.palette.error.main} transparent transparent transparent`,
|
||||
opacity: 0.8,
|
||||
zIndex: 10,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isFirstInAnyCollection && (
|
||||
<Chip
|
||||
icon={<Folder />}
|
||||
label={firstInCollectionNames.length > 1 ? `${firstInCollectionNames[0]} +${firstInCollectionNames.length - 1}` : firstInCollectionNames[0]}
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: isNewVideo ? 32 : 8,
|
||||
left: 8,
|
||||
zIndex: 3
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
<CardContent sx={{ flexGrow: 1, p: 2, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography gutterBottom variant="subtitle1" component="div" sx={{ fontWeight: 600, lineHeight: 1.2, mb: 1, display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{isFirstInAnyCollection ? (
|
||||
<>
|
||||
{firstInCollectionNames[0]}
|
||||
{firstInCollectionNames.length > 1 && <Typography component="span" color="text.secondary" sx={{ fontSize: 'inherit' }}> +{firstInCollectionNames.length - 1}</Typography>}
|
||||
</>
|
||||
) : (
|
||||
video.title
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto', gap: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
onClick={handleAuthorClick}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
minWidth: 0, // Allows flex item to shrink below content size
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{video.author}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(video.date)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
</CardActionArea>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
opacity: (!isMobile && !isTouch && !isHovered) ? 0 : 1, // Show on hover for desktop, always for mobile/touch if needed (though usually kebab is cleaner hidden until interaction or hover? Let's stick to hover for desktop)
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VideoKebabMenuButtons
|
||||
onPlayWith={(anchor) => setPlayerMenuAnchor(anchor)}
|
||||
onShare={handleShare}
|
||||
onAddToCollection={() => setShowCollectionModal(true)}
|
||||
onDelete={(showDeleteButton && onDeleteVideo) ? () => setShowDeleteModal(true) : undefined}
|
||||
isDeleting={isDeleting}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
video={video}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0,0,0,0.8)',
|
||||
color: 'primary.main'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<Menu
|
||||
anchorEl={playerMenuAnchor}
|
||||
open={Boolean(playerMenuAnchor)}
|
||||
onClose={handlePlayerMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right', // Align right for the card menu
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
{getAvailablePlayers().map((player) => (
|
||||
<MenuItem key={player.id} onClick={() => handlePlayerSelect(player.id)}>
|
||||
{player.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onClick={() => handlePlayerSelect('copy')}>{t('copyUrl')}</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={confirmDelete}
|
||||
title={t('deleteVideo')}
|
||||
message={`${t('confirmDelete')} "${video.title}"?`}
|
||||
confirmText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
<VideoCardActions
|
||||
video={video}
|
||||
playerMenuAnchor={playerSelection.playerMenuAnchor}
|
||||
setPlayerMenuAnchor={playerSelection.setPlayerMenuAnchor}
|
||||
handlePlayerSelect={playerSelection.handlePlayerSelect}
|
||||
getAvailablePlayers={playerSelection.getAvailablePlayers}
|
||||
showDeleteModal={actions.showDeleteModal}
|
||||
setShowDeleteModal={actions.setShowDeleteModal}
|
||||
confirmDelete={actions.confirmDelete}
|
||||
isDeleting={actions.isDeleting}
|
||||
handleToggleVisibility={actions.handleToggleVisibility}
|
||||
canDelete={actions.canDelete}
|
||||
isMobile={isMobile}
|
||||
isTouch={isTouch}
|
||||
isHovered={hoverPreview.isHovered}
|
||||
/>
|
||||
|
||||
<CollectionModal
|
||||
open={showCollectionModal}
|
||||
onClose={() => setShowCollectionModal(false)}
|
||||
videoCollections={currentVideoCollections}
|
||||
collections={allCollections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
</>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
146
frontend/src/components/VideoCard/VideoCardActions.tsx
Normal file
146
frontend/src/components/VideoCard/VideoCardActions.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Box, Menu, MenuItem } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { useCollection } from '../../contexts/CollectionContext';
|
||||
import { useShareVideo } from '../../hooks/useShareVideo';
|
||||
import { Video } from '../../types';
|
||||
import CollectionModal from '../CollectionModal';
|
||||
import ConfirmationModal from '../ConfirmationModal';
|
||||
import VideoKebabMenuButtons from '../VideoPlayer/VideoInfo/VideoKebabMenuButtons';
|
||||
|
||||
interface VideoCardActionsProps {
|
||||
video: Video;
|
||||
playerMenuAnchor: HTMLElement | null;
|
||||
setPlayerMenuAnchor: (anchor: HTMLElement | null) => void;
|
||||
handlePlayerSelect: (player: string) => void;
|
||||
getAvailablePlayers: () => Array<{ id: string; name: string }>;
|
||||
showDeleteModal: boolean;
|
||||
setShowDeleteModal: (show: boolean) => void;
|
||||
confirmDelete: () => void;
|
||||
isDeleting: boolean;
|
||||
handleToggleVisibility: () => void;
|
||||
canDelete: boolean;
|
||||
isMobile: boolean;
|
||||
isTouch: boolean;
|
||||
isHovered: boolean;
|
||||
}
|
||||
|
||||
export const VideoCardActions: React.FC<VideoCardActionsProps> = ({
|
||||
video,
|
||||
playerMenuAnchor,
|
||||
setPlayerMenuAnchor,
|
||||
handlePlayerSelect,
|
||||
getAvailablePlayers,
|
||||
showDeleteModal,
|
||||
setShowDeleteModal,
|
||||
confirmDelete,
|
||||
isDeleting,
|
||||
handleToggleVisibility,
|
||||
canDelete,
|
||||
isMobile,
|
||||
isTouch,
|
||||
isHovered
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const { collections: allCollections, addToCollection, createCollection, removeFromCollection } = useCollection();
|
||||
const { handleShare } = useShareVideo(video);
|
||||
const [showCollectionModal, setShowCollectionModal] = React.useState(false);
|
||||
|
||||
// Calculate collections that contain THIS video
|
||||
const currentVideoCollections = allCollections.filter(c => c.videos.includes(video.id));
|
||||
|
||||
const handleAddToCollection = async (collectionId: string) => {
|
||||
if (!video.id) return;
|
||||
await addToCollection(collectionId, video.id);
|
||||
};
|
||||
|
||||
const handleCreateCollection = async (name: string) => {
|
||||
if (!video.id) return;
|
||||
await createCollection(name, video.id);
|
||||
};
|
||||
|
||||
const handleRemoveFromCollection = async () => {
|
||||
if (!video.id) return;
|
||||
await removeFromCollection(video.id);
|
||||
};
|
||||
|
||||
const handlePlayerMenuClose = () => {
|
||||
setPlayerMenuAnchor(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
opacity: (!isMobile && !isTouch && !isHovered) ? 0 : 1,
|
||||
transition: 'opacity 0.2s',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<VideoKebabMenuButtons
|
||||
onPlayWith={(anchor) => setPlayerMenuAnchor(anchor)}
|
||||
onShare={handleShare}
|
||||
onAddToCollection={() => setShowCollectionModal(true)}
|
||||
onDelete={canDelete ? () => setShowDeleteModal(true) : undefined}
|
||||
isDeleting={isDeleting}
|
||||
onToggleVisibility={handleToggleVisibility}
|
||||
video={video}
|
||||
sx={{
|
||||
color: 'white',
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
'&:hover': {
|
||||
bgcolor: 'rgba(0,0,0,0.8)',
|
||||
color: 'primary.main'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Menu
|
||||
anchorEl={playerMenuAnchor}
|
||||
open={Boolean(playerMenuAnchor)}
|
||||
onClose={handlePlayerMenuClose}
|
||||
anchorOrigin={{
|
||||
vertical: 'bottom',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
transformOrigin={{
|
||||
vertical: 'top',
|
||||
horizontal: 'right',
|
||||
}}
|
||||
>
|
||||
{getAvailablePlayers().map((player) => (
|
||||
<MenuItem key={player.id} onClick={() => handlePlayerSelect(player.id)}>
|
||||
{player.name}
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuItem onClick={() => handlePlayerSelect('copy')}>{t('copyUrl')}</MenuItem>
|
||||
</Menu>
|
||||
|
||||
<ConfirmationModal
|
||||
isOpen={showDeleteModal}
|
||||
onClose={() => setShowDeleteModal(false)}
|
||||
onConfirm={confirmDelete}
|
||||
title={t('deleteVideo')}
|
||||
message={`${t('confirmDelete')} "${video.title}"?`}
|
||||
confirmText={t('delete')}
|
||||
cancelText={t('cancel')}
|
||||
isDanger={true}
|
||||
/>
|
||||
|
||||
<CollectionModal
|
||||
open={showCollectionModal}
|
||||
onClose={() => setShowCollectionModal(false)}
|
||||
videoCollections={currentVideoCollections}
|
||||
collections={allCollections}
|
||||
onAddToCollection={handleAddToCollection}
|
||||
onCreateCollection={handleCreateCollection}
|
||||
onRemoveFromCollection={handleRemoveFromCollection}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
84
frontend/src/components/VideoCard/VideoCardContent.tsx
Normal file
84
frontend/src/components/VideoCard/VideoCardContent.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Box, CardContent, Typography } from '@mui/material';
|
||||
import React from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Video } from '../../types';
|
||||
import { formatDate } from '../../utils/formatUtils';
|
||||
import { VideoCardCollectionInfo } from '../../utils/videoCardUtils';
|
||||
|
||||
interface VideoCardContentProps {
|
||||
video: Video;
|
||||
collectionInfo: VideoCardCollectionInfo;
|
||||
onAuthorClick: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export const VideoCardContent: React.FC<VideoCardContentProps> = ({
|
||||
video,
|
||||
collectionInfo,
|
||||
onAuthorClick
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
|
||||
return (
|
||||
<CardContent sx={{ flexGrow: 1, p: 2, display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography
|
||||
gutterBottom
|
||||
variant="subtitle1"
|
||||
component="div"
|
||||
sx={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.2,
|
||||
mb: 1,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden'
|
||||
}}
|
||||
>
|
||||
{collectionInfo.isFirstInAnyCollection ? (
|
||||
<>
|
||||
{collectionInfo.firstInCollectionNames[0]}
|
||||
{collectionInfo.firstInCollectionNames.length > 1 && (
|
||||
<Typography
|
||||
component="span"
|
||||
color="text.secondary"
|
||||
sx={{ fontSize: 'inherit' }}
|
||||
>
|
||||
{' '}+{collectionInfo.firstInCollectionNames.length - 1}
|
||||
</Typography>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
video.title
|
||||
)}
|
||||
</Typography>
|
||||
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mt: 'auto', gap: 1 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
onClick={onAuthorClick}
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'&:hover': { color: 'primary.main' },
|
||||
fontWeight: 500,
|
||||
flex: 1,
|
||||
minWidth: 0, // Allows flex item to shrink below content size
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{video.author}
|
||||
</Typography>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', flexShrink: 0 }}>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{formatDate(video.date)}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ ml: 1 }}>
|
||||
{video.viewCount || 0} {t('views')}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
</CardContent>
|
||||
);
|
||||
};
|
||||
188
frontend/src/components/VideoCard/VideoCardThumbnail.tsx
Normal file
188
frontend/src/components/VideoCard/VideoCardThumbnail.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import { Folder } from '@mui/icons-material';
|
||||
import { Box, CardMedia, Chip, Skeleton, useTheme } from '@mui/material';
|
||||
import React, { useState } from 'react';
|
||||
import { useLanguage } from '../../contexts/LanguageContext';
|
||||
import { Video } from '../../types';
|
||||
import { formatDuration, parseDuration } from '../../utils/formatUtils';
|
||||
import { VideoCardCollectionInfo } from '../../utils/videoCardUtils';
|
||||
|
||||
interface VideoCardThumbnailProps {
|
||||
video: Video;
|
||||
thumbnailSrc?: string;
|
||||
videoUrl?: string;
|
||||
isHovered: boolean;
|
||||
isVideoPlaying: boolean;
|
||||
setIsVideoPlaying: (playing: boolean) => void;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
collectionInfo: VideoCardCollectionInfo;
|
||||
isNew: boolean;
|
||||
}
|
||||
|
||||
export const VideoCardThumbnail: React.FC<VideoCardThumbnailProps> = ({
|
||||
video,
|
||||
thumbnailSrc,
|
||||
videoUrl,
|
||||
isHovered,
|
||||
isVideoPlaying,
|
||||
setIsVideoPlaying,
|
||||
videoRef,
|
||||
collectionInfo,
|
||||
isNew
|
||||
}) => {
|
||||
const { t } = useLanguage();
|
||||
const theme = useTheme();
|
||||
const [isImageLoaded, setIsImageLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<Box sx={{ position: 'relative', paddingTop: '56.25%' /* 16:9 aspect ratio */ }}>
|
||||
{/* Video Element (only shown on hover) */}
|
||||
{isHovered && videoUrl && (
|
||||
<Box
|
||||
component="video"
|
||||
ref={videoRef as React.RefObject<HTMLVideoElement>}
|
||||
src={videoUrl}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
onPlaying={() => setIsVideoPlaying(true)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
bgcolor: 'black',
|
||||
zIndex: 1 // Ensure video is above thumbnail when playing
|
||||
}}
|
||||
onLoadedMetadata={(e) => {
|
||||
const videoEl = e.target as HTMLVideoElement;
|
||||
const duration = parseDuration(video.duration);
|
||||
if (duration > 5) {
|
||||
videoEl.currentTime = Math.max(0, (duration / 2) - 2.5);
|
||||
}
|
||||
}}
|
||||
onTimeUpdate={(e) => {
|
||||
const videoEl = e.target as HTMLVideoElement;
|
||||
const duration = parseDuration(video.duration);
|
||||
const startTime = Math.max(0, (duration / 2) - 2.5);
|
||||
const endTime = startTime + 5;
|
||||
|
||||
if (videoEl.currentTime >= endTime) {
|
||||
videoEl.currentTime = startTime;
|
||||
videoEl.play();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Skeleton Placeholder */}
|
||||
{!isImageLoaded && (
|
||||
<Skeleton
|
||||
variant="rectangular"
|
||||
width="100%"
|
||||
height="100%"
|
||||
animation="wave"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bgcolor: 'grey.800',
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Thumbnail Image */}
|
||||
<CardMedia
|
||||
component="img"
|
||||
image={thumbnailSrc || 'https://via.placeholder.com/480x360?text=No+Thumbnail'}
|
||||
alt={`${video.title} thumbnail`}
|
||||
loading="lazy"
|
||||
onLoad={() => setIsImageLoaded(true)}
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: (isImageLoaded && (!isHovered || !isVideoPlaying)) ? 1 : 0,
|
||||
transition: 'opacity 0.2s',
|
||||
pointerEvents: 'none', // Ensure hover events pass through
|
||||
zIndex: 2
|
||||
}}
|
||||
onError={(e) => {
|
||||
// If error, we can still show the placeholder or the fallback image
|
||||
// For now, let's treat error as loaded so we see the fallback/alt text if any
|
||||
setIsImageLoaded(true);
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.onerror = null;
|
||||
target.src = 'https://via.placeholder.com/480x360?text=No+Thumbnail';
|
||||
}}
|
||||
/>
|
||||
|
||||
{video.partNumber && video.totalParts && video.totalParts > 1 && (
|
||||
<Chip
|
||||
label={`${t('part')} ${video.partNumber}/${video.totalParts}`}
|
||||
size="small"
|
||||
color="primary"
|
||||
sx={{ position: 'absolute', bottom: 36, right: 8, zIndex: 3 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{video.duration && (
|
||||
<Chip
|
||||
label={formatDuration(video.duration)}
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
right: 8,
|
||||
height: 20,
|
||||
fontSize: '0.75rem',
|
||||
bgcolor: 'rgba(0,0,0,0.6)',
|
||||
color: 'white',
|
||||
zIndex: 3
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isNew && (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderStyle: 'solid',
|
||||
borderWidth: '25px 25px 0 0',
|
||||
borderColor: `${theme.palette.error.main} transparent transparent transparent`,
|
||||
opacity: 0.8,
|
||||
zIndex: 10,
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
|
||||
pointerEvents: 'none'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{collectionInfo.isFirstInAnyCollection && (
|
||||
<Chip
|
||||
icon={<Folder />}
|
||||
label={collectionInfo.firstInCollectionNames.length > 1
|
||||
? `${collectionInfo.firstInCollectionNames[0]} +${collectionInfo.firstInCollectionNames.length - 1}`
|
||||
: collectionInfo.firstInCollectionNames[0]}
|
||||
color="secondary"
|
||||
size="small"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: isNew ? 32 : 8,
|
||||
left: 8,
|
||||
zIndex: 3
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
106
frontend/src/hooks/usePlayerSelection.ts
Normal file
106
frontend/src/hooks/usePlayerSelection.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState } from 'react';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Video } from '../types';
|
||||
import { getAvailablePlayers, getPlayerUrl } from '../utils/playerUtils';
|
||||
|
||||
interface UsePlayerSelectionProps {
|
||||
video: Video;
|
||||
getVideoUrl: () => Promise<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage player selection menu and external player opening
|
||||
*/
|
||||
export const usePlayerSelection = ({ video, getVideoUrl }: UsePlayerSelectionProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { incrementView } = useVideo();
|
||||
const [playerMenuAnchor, setPlayerMenuAnchor] = useState<null | HTMLElement>(null);
|
||||
|
||||
const handlePlayerMenuClose = () => {
|
||||
setPlayerMenuAnchor(null);
|
||||
};
|
||||
|
||||
const handlePlayerSelect = async (player: string) => {
|
||||
const resolvedVideoUrl = await getVideoUrl();
|
||||
|
||||
if (!resolvedVideoUrl) {
|
||||
showSnackbar(t('error') || 'Video URL not available', 'error');
|
||||
handlePlayerMenuClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Increment view count since we can't track watch time in external players
|
||||
await incrementView(video.id);
|
||||
|
||||
try {
|
||||
let playerUrl = '';
|
||||
|
||||
if (player === 'copy') {
|
||||
// Copy URL to clipboard
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(resolvedVideoUrl).then(() => {
|
||||
showSnackbar(t('linkCopied'), 'success');
|
||||
}).catch(() => {
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
});
|
||||
} else {
|
||||
// Fallback
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = resolvedVideoUrl;
|
||||
textArea.style.position = "fixed";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
try {
|
||||
const successful = document.execCommand('copy');
|
||||
if (successful) {
|
||||
showSnackbar(t('linkCopied'), 'success');
|
||||
} else {
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
}
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
handlePlayerMenuClose();
|
||||
return;
|
||||
} else {
|
||||
playerUrl = getPlayerUrl(player, resolvedVideoUrl);
|
||||
}
|
||||
|
||||
// Try to open the player URL using a hidden anchor element
|
||||
// This prevents navigation away from the page
|
||||
if (playerUrl) {
|
||||
const link = document.createElement('a');
|
||||
link.href = playerUrl;
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// Show a message after a short delay
|
||||
setTimeout(() => {
|
||||
showSnackbar(t('openInExternalPlayer'), 'info');
|
||||
}, 500);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error opening player:', error);
|
||||
showSnackbar(t('copyFailed'), 'error');
|
||||
}
|
||||
|
||||
handlePlayerMenuClose();
|
||||
};
|
||||
|
||||
return {
|
||||
playerMenuAnchor,
|
||||
setPlayerMenuAnchor,
|
||||
handlePlayerMenuClose,
|
||||
handlePlayerSelect,
|
||||
getAvailablePlayers
|
||||
};
|
||||
};
|
||||
60
frontend/src/hooks/useVideoCardActions.ts
Normal file
60
frontend/src/hooks/useVideoCardActions.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useVideo } from '../contexts/VideoContext';
|
||||
import { Video } from '../types';
|
||||
|
||||
interface UseVideoCardActionsProps {
|
||||
video: Video;
|
||||
onDeleteVideo?: (id: string) => Promise<any>;
|
||||
showDeleteButton?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video card actions: delete, visibility toggle, share
|
||||
*/
|
||||
export const useVideoCardActions = ({
|
||||
video,
|
||||
onDeleteVideo,
|
||||
showDeleteButton = false
|
||||
}: UseVideoCardActionsProps) => {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const { updateVideo } = useVideo();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Handle confirm delete
|
||||
const confirmDelete = async () => {
|
||||
if (!onDeleteVideo) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteVideo(video.id);
|
||||
} catch (error) {
|
||||
console.error('Error deleting video:', error);
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle visibility toggle
|
||||
const handleToggleVisibility = async () => {
|
||||
if (!video.id) return;
|
||||
const newVisibility = (video.visibility ?? 1) === 0 ? 1 : 0;
|
||||
const result = await updateVideo(video.id, { visibility: newVisibility });
|
||||
if (result.success) {
|
||||
showSnackbar(newVisibility === 1 ? t('showVideo') : t('hideVideo'), 'success');
|
||||
} else {
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
isDeleting,
|
||||
showDeleteModal,
|
||||
setShowDeleteModal,
|
||||
confirmDelete,
|
||||
handleToggleVisibility,
|
||||
canDelete: showDeleteButton && !!onDeleteVideo
|
||||
};
|
||||
};
|
||||
63
frontend/src/hooks/useVideoCardMetadata.ts
Normal file
63
frontend/src/hooks/useVideoCardMetadata.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCloudStorageUrl } from './useCloudStorageUrl';
|
||||
import { Video } from '../types';
|
||||
import { isNewVideo } from '../utils/videoCardUtils';
|
||||
|
||||
interface UseVideoCardMetadataProps {
|
||||
video: Video;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video card metadata: thumbnails, URLs, new video detection
|
||||
*/
|
||||
export const useVideoCardMetadata = ({ video }: UseVideoCardMetadataProps) => {
|
||||
// Use cloud storage hook for thumbnail URL only if video is in cloud storage
|
||||
// Only load thumbnail from cloud if the video itself is in cloud storage
|
||||
const isVideoInCloud = video.videoPath?.startsWith('cloud:') ?? false;
|
||||
const thumbnailPathForCloud = isVideoInCloud ? video.thumbnailPath : null;
|
||||
const thumbnailUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
|
||||
const localThumbnailUrl = !isVideoInCloud && video.thumbnailPath
|
||||
? `${import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551'}${video.thumbnailPath}`
|
||||
: undefined;
|
||||
const thumbnailSrc = thumbnailUrl || localThumbnailUrl || video.thumbnailUrl;
|
||||
|
||||
// Use cloud storage hook for video URL
|
||||
const videoUrl = useCloudStorageUrl(video.videoPath, 'video');
|
||||
|
||||
// Get video URL with fallback logic
|
||||
const getVideoUrl = async (): Promise<string> => {
|
||||
// If we have a cloud storage URL, use it directly
|
||||
if (videoUrl) {
|
||||
return videoUrl;
|
||||
}
|
||||
|
||||
// If cloud storage path but URL not loaded yet, wait for it
|
||||
if (video.videoPath?.startsWith('cloud:')) {
|
||||
// Try to get the signed URL directly
|
||||
const { getFileUrl } = await import('../utils/cloudStorage');
|
||||
const cloudUrl = await getFileUrl(video.videoPath, 'video');
|
||||
if (cloudUrl) {
|
||||
return cloudUrl;
|
||||
}
|
||||
// If still not available, return empty string
|
||||
return '';
|
||||
}
|
||||
|
||||
// Otherwise, construct URL from videoPath
|
||||
if (video.videoPath) {
|
||||
const videoPath = video.videoPath.startsWith('/') ? video.videoPath : `/${video.videoPath}`;
|
||||
return `${window.location.origin}${videoPath}`;
|
||||
}
|
||||
return video.sourceUrl || '';
|
||||
};
|
||||
|
||||
// Check if video is new (memoized)
|
||||
const isNew = useMemo(() => isNewVideo(video), [video.viewCount, video.addedAt, video.id]);
|
||||
|
||||
return {
|
||||
thumbnailSrc,
|
||||
videoUrl,
|
||||
getVideoUrl,
|
||||
isNew
|
||||
};
|
||||
};
|
||||
39
frontend/src/hooks/useVideoCardNavigation.ts
Normal file
39
frontend/src/hooks/useVideoCardNavigation.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Video } from '../types';
|
||||
import { VideoCardCollectionInfo } from '../utils/videoCardUtils';
|
||||
|
||||
interface UseVideoCardNavigationProps {
|
||||
video: Video;
|
||||
collectionInfo: VideoCardCollectionInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to handle video card navigation logic
|
||||
* Determines whether to navigate to video player or collection page
|
||||
*/
|
||||
export const useVideoCardNavigation = ({
|
||||
video,
|
||||
collectionInfo
|
||||
}: UseVideoCardNavigationProps) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleVideoNavigation = () => {
|
||||
// If this is the first video in a collection, navigate to the collection page
|
||||
if (collectionInfo.isFirstInAnyCollection && collectionInfo.firstCollectionId) {
|
||||
navigate(`/collection/${collectionInfo.firstCollectionId}`);
|
||||
} else {
|
||||
// Otherwise navigate to the video player page
|
||||
navigate(`/video/${video.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthorClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/author/${encodeURIComponent(video.author)}`);
|
||||
};
|
||||
|
||||
return {
|
||||
handleVideoNavigation,
|
||||
handleAuthorClick
|
||||
};
|
||||
};
|
||||
78
frontend/src/hooks/useVideoHoverPreview.ts
Normal file
78
frontend/src/hooks/useVideoHoverPreview.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useMediaQuery, useTheme } from "@mui/material";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
interface UseVideoHoverPreviewProps {
|
||||
videoPath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to manage video hover preview functionality
|
||||
* Handles showing video preview on hover with delay and cleanup
|
||||
*/
|
||||
export const useVideoHoverPreview = ({
|
||||
videoPath,
|
||||
}: UseVideoHoverPreviewProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down("sm"));
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isVideoPlaying, setIsVideoPlaying] = useState(false);
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const hoverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (!isMobile && videoPath) {
|
||||
// Add delay before loading video to prevent loading on quick hovers
|
||||
// This reduces memory usage when quickly moving mouse over multiple cards
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(true);
|
||||
}, 300); // 300ms delay
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Clear hover timeout if mouse leaves before delay
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
setIsHovered(false);
|
||||
setIsVideoPlaying(false);
|
||||
|
||||
// Aggressively cleanup video element when mouse leaves
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = "";
|
||||
videoRef.current.load();
|
||||
// Force garbage collection hint
|
||||
videoRef.current.removeAttribute("src");
|
||||
}
|
||||
};
|
||||
|
||||
// Cleanup video element on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear any pending hover timeout
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Aggressively cleanup video element
|
||||
if (videoRef.current) {
|
||||
videoRef.current.pause();
|
||||
videoRef.current.src = "";
|
||||
videoRef.current.load();
|
||||
videoRef.current.removeAttribute("src");
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
isHovered,
|
||||
isVideoPlaying,
|
||||
setIsVideoPlaying,
|
||||
videoRef,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
};
|
||||
};
|
||||
@@ -1,196 +1,214 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import axios from 'axios';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useLanguage } from '../contexts/LanguageContext';
|
||||
import { useSnackbar } from '../contexts/SnackbarContext';
|
||||
import { Video } from '../types';
|
||||
import { validateUrlForOpen } from '../utils/urlValidation';
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import axios from "axios";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useLanguage } from "../contexts/LanguageContext";
|
||||
import { useSnackbar } from "../contexts/SnackbarContext";
|
||||
import { Video } from "../types";
|
||||
import { validateUrlForOpen } from "../utils/urlValidation";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL;
|
||||
|
||||
interface UseVideoSubscriptionsProps {
|
||||
video: Video | undefined;
|
||||
video: Video | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage video subscriptions
|
||||
*/
|
||||
export function useVideoSubscriptions({ video }: UseVideoSubscriptionsProps) {
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
const [authorChannelUrl, setAuthorChannelUrl] = useState<string | null>(null);
|
||||
const [showSubscribeModal, setShowSubscribeModal] = useState<boolean>(false);
|
||||
const { t } = useLanguage();
|
||||
const { showSnackbar } = useSnackbar();
|
||||
const queryClient = useQueryClient();
|
||||
const [authorChannelUrl, setAuthorChannelUrl] = useState<string | null>(null);
|
||||
const [showSubscribeModal, setShowSubscribeModal] = useState<boolean>(false);
|
||||
|
||||
// Fetch subscriptions
|
||||
const { data: subscriptions = [] } = useQuery({
|
||||
queryKey: ['subscriptions'],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/subscriptions`);
|
||||
return response.data;
|
||||
// Fetch subscriptions
|
||||
const { data: subscriptions = [] } = useQuery({
|
||||
queryKey: ["subscriptions"],
|
||||
queryFn: async () => {
|
||||
const response = await axios.get(`${API_URL}/subscriptions`);
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
|
||||
// Get author channel URL
|
||||
useEffect(() => {
|
||||
const fetchChannelUrl = async () => {
|
||||
if (
|
||||
!video ||
|
||||
(video.source !== "youtube" && video.source !== "bilibili")
|
||||
) {
|
||||
setAuthorChannelUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(
|
||||
`${API_URL}/videos/author-channel-url`,
|
||||
{
|
||||
params: { sourceUrl: video.sourceUrl },
|
||||
}
|
||||
);
|
||||
|
||||
if (response.data.success && response.data.channelUrl) {
|
||||
setAuthorChannelUrl(response.data.channelUrl);
|
||||
} else {
|
||||
setAuthorChannelUrl(null);
|
||||
}
|
||||
});
|
||||
|
||||
// Get author channel URL
|
||||
useEffect(() => {
|
||||
const fetchChannelUrl = async () => {
|
||||
if (!video || (video.source !== 'youtube' && video.source !== 'bilibili')) {
|
||||
setAuthorChannelUrl(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.get(`${API_URL}/videos/author-channel-url`, {
|
||||
params: { sourceUrl: video.sourceUrl }
|
||||
});
|
||||
|
||||
if (response.data.success && response.data.channelUrl) {
|
||||
setAuthorChannelUrl(response.data.channelUrl);
|
||||
} else {
|
||||
setAuthorChannelUrl(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching author channel URL:', error);
|
||||
setAuthorChannelUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchChannelUrl();
|
||||
}, [video]);
|
||||
|
||||
// Check if author is subscribed
|
||||
const isSubscribed = useMemo(() => {
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Strict check by Channel URL (most accurate)
|
||||
if (authorChannelUrl) {
|
||||
const hasUrlMatch = subscriptions.some((sub: any) => sub.authorUrl === authorChannelUrl);
|
||||
if (hasUrlMatch) return true;
|
||||
}
|
||||
|
||||
// 2. Fallback check by Author Name and Platform matching
|
||||
if (video) {
|
||||
return subscriptions.some((sub: any) => {
|
||||
const nameMatch = sub.author === video.author;
|
||||
const platformMatch = sub.platform?.toLowerCase() === video.source?.toLowerCase();
|
||||
return nameMatch && platformMatch;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [authorChannelUrl, subscriptions, video]);
|
||||
|
||||
// Get subscription ID if subscribed
|
||||
const subscriptionId = useMemo(() => {
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Strict check by Channel URL
|
||||
if (authorChannelUrl) {
|
||||
const subscription = subscriptions.find((sub: any) => sub.authorUrl === authorChannelUrl);
|
||||
if (subscription) return subscription.id;
|
||||
}
|
||||
|
||||
// 2. Fallback check by Author Name and Platform matching
|
||||
if (video) {
|
||||
const subscription = subscriptions.find((sub: any) => {
|
||||
const nameMatch = sub.author === video.author;
|
||||
const platformMatch = sub.platform?.toLowerCase() === video.source?.toLowerCase();
|
||||
return nameMatch && platformMatch;
|
||||
});
|
||||
if (subscription) return subscription.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [authorChannelUrl, subscriptions, video]);
|
||||
|
||||
// Handle navigation to author videos page or external channel
|
||||
const handleAuthorClick = () => {
|
||||
if (!video) return null;
|
||||
|
||||
// If it's a YouTube or Bilibili video, try to get the channel URL
|
||||
if (video.source === 'youtube' || video.source === 'bilibili') {
|
||||
if (authorChannelUrl) {
|
||||
// Validate URL to prevent open redirect attacks
|
||||
const validatedUrl = validateUrlForOpen(authorChannelUrl);
|
||||
if (validatedUrl) {
|
||||
// Open the channel URL in a new tab
|
||||
window.open(validatedUrl, '_blank', 'noopener,noreferrer');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior: navigate to author videos page
|
||||
// Note: navigate function should be passed from component
|
||||
return { shouldNavigate: true, path: `/author/${encodeURIComponent(video.author)}` };
|
||||
} catch (error) {
|
||||
console.error("Error fetching author channel URL:", error);
|
||||
setAuthorChannelUrl(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle subscribe
|
||||
const handleSubscribe = () => {
|
||||
if (!authorChannelUrl) return;
|
||||
setShowSubscribeModal(true);
|
||||
};
|
||||
fetchChannelUrl();
|
||||
}, [video]);
|
||||
|
||||
// Handle subscribe confirmation
|
||||
const handleSubscribeConfirm = async (interval: number, downloadAllPrevious: boolean) => {
|
||||
if (!authorChannelUrl || !video) return;
|
||||
// Check if author is subscribed
|
||||
const isSubscribed = useMemo(() => {
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/subscriptions`, {
|
||||
url: authorChannelUrl,
|
||||
interval,
|
||||
authorName: video.author,
|
||||
downloadAllPrevious
|
||||
});
|
||||
showSnackbar(t('subscribedSuccessfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
setShowSubscribeModal(false);
|
||||
} catch (error: any) {
|
||||
console.error('Error subscribing:', error);
|
||||
if (error.response && error.response.status === 409) {
|
||||
showSnackbar(t('subscriptionAlreadyExists'), 'warning');
|
||||
} else {
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
setShowSubscribeModal(false);
|
||||
// 1. Strict check by Channel URL (most accurate)
|
||||
if (authorChannelUrl) {
|
||||
const hasUrlMatch = subscriptions.some(
|
||||
(sub: any) => sub.authorUrl === authorChannelUrl
|
||||
);
|
||||
if (hasUrlMatch) return true;
|
||||
}
|
||||
|
||||
// 2. Fallback check by Author Name and Platform matching
|
||||
if (video) {
|
||||
return subscriptions.some((sub: any) => {
|
||||
const nameMatch = sub.author === video.author;
|
||||
const platformMatch =
|
||||
sub.platform?.toLowerCase() === video.source?.toLowerCase();
|
||||
return nameMatch && platformMatch;
|
||||
});
|
||||
}
|
||||
|
||||
return false;
|
||||
}, [authorChannelUrl, subscriptions, video]);
|
||||
|
||||
// Get subscription ID if subscribed
|
||||
const subscriptionId = useMemo(() => {
|
||||
if (!subscriptions || subscriptions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Strict check by Channel URL
|
||||
if (authorChannelUrl) {
|
||||
const subscription = subscriptions.find(
|
||||
(sub: any) => sub.authorUrl === authorChannelUrl
|
||||
);
|
||||
if (subscription) return subscription.id;
|
||||
}
|
||||
|
||||
// 2. Fallback check by Author Name and Platform matching
|
||||
if (video) {
|
||||
const subscription = subscriptions.find((sub: any) => {
|
||||
const nameMatch = sub.author === video.author;
|
||||
const platformMatch =
|
||||
sub.platform?.toLowerCase() === video.source?.toLowerCase();
|
||||
return nameMatch && platformMatch;
|
||||
});
|
||||
if (subscription) return subscription.id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [authorChannelUrl, subscriptions, video]);
|
||||
|
||||
// Handle navigation to author videos page or external channel
|
||||
const handleAuthorClick = () => {
|
||||
if (!video) return null;
|
||||
|
||||
// If it's a YouTube or Bilibili video, try to get the channel URL
|
||||
if (video.source === "youtube" || video.source === "bilibili") {
|
||||
if (authorChannelUrl) {
|
||||
// Validate URL to prevent open redirect attacks
|
||||
const validatedUrl = validateUrlForOpen(authorChannelUrl);
|
||||
if (validatedUrl) {
|
||||
// Open the channel URL in a new tab
|
||||
window.open(validatedUrl, "_blank", "noopener,noreferrer");
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Handle unsubscribe
|
||||
const handleUnsubscribe = (onConfirm: () => void) => {
|
||||
if (!subscriptionId) return;
|
||||
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
// Unsubscribe mutation
|
||||
const unsubscribeMutation = useMutation({
|
||||
mutationFn: async (subId: string) => {
|
||||
await axios.delete(`${API_URL}/subscriptions/${subId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t('unsubscribedSuccessfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t('error'), 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Default behavior: navigate to author videos page
|
||||
// Note: navigate function should be passed from component
|
||||
return {
|
||||
authorChannelUrl,
|
||||
isSubscribed,
|
||||
subscriptionId,
|
||||
showSubscribeModal,
|
||||
setShowSubscribeModal,
|
||||
handleAuthorClick,
|
||||
handleSubscribe,
|
||||
handleSubscribeConfirm,
|
||||
handleUnsubscribe,
|
||||
unsubscribeMutation
|
||||
shouldNavigate: true,
|
||||
path: `/author/${encodeURIComponent(video.author)}`,
|
||||
};
|
||||
};
|
||||
|
||||
// Handle subscribe
|
||||
const handleSubscribe = () => {
|
||||
if (!authorChannelUrl) return;
|
||||
setShowSubscribeModal(true);
|
||||
};
|
||||
|
||||
// Handle subscribe confirmation
|
||||
const handleSubscribeConfirm = async (
|
||||
interval: number,
|
||||
downloadAllPrevious: boolean
|
||||
) => {
|
||||
if (!authorChannelUrl || !video) return;
|
||||
|
||||
try {
|
||||
await axios.post(`${API_URL}/subscriptions`, {
|
||||
url: authorChannelUrl,
|
||||
interval,
|
||||
authorName: video.author,
|
||||
downloadAllPrevious,
|
||||
});
|
||||
showSnackbar(t("subscribedSuccessfully"));
|
||||
queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
setShowSubscribeModal(false);
|
||||
} catch (error: any) {
|
||||
console.error("Error subscribing:", error);
|
||||
if (error.response && error.response.status === 409) {
|
||||
showSnackbar(t("subscriptionAlreadyExists"), "warning");
|
||||
} else {
|
||||
showSnackbar(t("error"), "error");
|
||||
}
|
||||
setShowSubscribeModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle unsubscribe
|
||||
const handleUnsubscribe = (onConfirm: () => void) => {
|
||||
if (!subscriptionId) return;
|
||||
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
// Unsubscribe mutation
|
||||
const unsubscribeMutation = useMutation({
|
||||
mutationFn: async (subId: string) => {
|
||||
await axios.delete(`${API_URL}/subscriptions/${subId}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
showSnackbar(t("unsubscribedSuccessfully"));
|
||||
queryClient.invalidateQueries({ queryKey: ["subscriptions"] });
|
||||
},
|
||||
onError: () => {
|
||||
showSnackbar(t("error"), "error");
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
authorChannelUrl,
|
||||
isSubscribed,
|
||||
subscriptionId,
|
||||
showSubscribeModal,
|
||||
setShowSubscribeModal,
|
||||
handleAuthorClick,
|
||||
handleSubscribe,
|
||||
handleSubscribeConfirm,
|
||||
handleUnsubscribe,
|
||||
unsubscribeMutation,
|
||||
};
|
||||
}
|
||||
|
||||
79
frontend/src/utils/videoCardUtils.ts
Normal file
79
frontend/src/utils/videoCardUtils.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Collection, Video } from '../types';
|
||||
|
||||
/**
|
||||
* Check if video is new (0 views and added within 7 days)
|
||||
*/
|
||||
export const isNewVideo = (video: Video): boolean => {
|
||||
// Check if viewCount is 0 or null/undefined (unwatched)
|
||||
// Handle both number and string types
|
||||
const viewCountNum = typeof video.viewCount === 'string'
|
||||
? parseInt(video.viewCount, 10)
|
||||
: video.viewCount;
|
||||
const hasNoViews = viewCountNum === 0 || viewCountNum === null || viewCountNum === undefined || isNaN(viewCountNum);
|
||||
|
||||
if (!hasNoViews) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if addedAt exists
|
||||
if (!video.addedAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if added within 7 days
|
||||
const addedDate = new Date(video.addedAt);
|
||||
const now = new Date();
|
||||
|
||||
// Handle invalid dates
|
||||
if (isNaN(addedDate.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const daysDiff = (now.getTime() - addedDate.getTime()) / (1000 * 60 * 60 * 24);
|
||||
const isWithin7Days = daysDiff >= 0 && daysDiff <= 7; // >= 0 to handle future dates
|
||||
|
||||
return isWithin7Days;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get collection information for a video card
|
||||
*/
|
||||
export interface VideoCardCollectionInfo {
|
||||
videoCollections: Collection[];
|
||||
isFirstInAnyCollection: boolean;
|
||||
firstInCollectionNames: string[];
|
||||
firstCollectionId: string | null;
|
||||
}
|
||||
|
||||
export const getVideoCardCollectionInfo = (
|
||||
video: Video,
|
||||
collections: Collection[],
|
||||
disableCollectionGrouping: boolean
|
||||
): VideoCardCollectionInfo => {
|
||||
// Find collections this video belongs to
|
||||
const videoCollections = collections.filter(collection =>
|
||||
collection.videos.includes(video.id)
|
||||
);
|
||||
|
||||
// Check if this video is the first in any collection
|
||||
const isFirstInAnyCollection = !disableCollectionGrouping && videoCollections.some(collection =>
|
||||
collection.videos[0] === video.id
|
||||
);
|
||||
|
||||
// Get collection names where this video is the first
|
||||
const firstInCollectionNames = videoCollections
|
||||
.filter(collection => collection.videos[0] === video.id)
|
||||
.map(collection => collection.name);
|
||||
|
||||
// Get the first collection ID where this video is the first video
|
||||
const firstCollectionId = isFirstInAnyCollection
|
||||
? videoCollections.find(collection => collection.videos[0] === video.id)?.id || null
|
||||
: null;
|
||||
|
||||
return {
|
||||
videoCollections,
|
||||
isFirstInAnyCollection,
|
||||
firstInCollectionNames,
|
||||
firstCollectionId
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user