refactor: refactor videocard

This commit is contained in:
Peifan Li
2025-12-28 14:58:31 -05:00
parent 8a00ef2ac1
commit ea9ead5026
11 changed files with 1130 additions and 808 deletions

View File

@@ -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>
);
};

View 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}
/>
</>
);
};

View 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>
);
};

View 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>
);
};

View 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
};
};

View 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
};
};

View 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
};
};

View 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
};
};

View 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,
};
};

View File

@@ -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,
};
}

View 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
};
};