546 lines
23 KiB
TypeScript
546 lines
23 KiB
TypeScript
import {
|
|
Folder
|
|
} from '@mui/icons-material';
|
|
import {
|
|
Box,
|
|
Card,
|
|
CardActionArea,
|
|
CardContent,
|
|
CardMedia,
|
|
Chip,
|
|
Menu, MenuItem,
|
|
Skeleton,
|
|
Typography,
|
|
useMediaQuery,
|
|
useTheme
|
|
} from '@mui/material';
|
|
import { 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 { useShareVideo } from '../hooks/useShareVideo'; // Added
|
|
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
|
|
import { Collection, Video } from '../types';
|
|
import { formatDuration, parseDuration } from '../utils/formatUtils';
|
|
import CollectionModal from './CollectionModal';
|
|
import ConfirmationModal from './ConfirmationModal';
|
|
import VideoKebabMenuButtons from './VideoPlayer/VideoInfo/VideoKebabMenuButtons'; // Added
|
|
|
|
interface VideoCardProps {
|
|
video: Video;
|
|
collections?: Collection[];
|
|
onDeleteVideo?: (id: string) => Promise<any>;
|
|
showDeleteButton?: boolean;
|
|
disableCollectionGrouping?: boolean;
|
|
}
|
|
|
|
const VideoCard: React.FC<VideoCardProps> = ({
|
|
video,
|
|
collections = [],
|
|
onDeleteVideo,
|
|
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);
|
|
|
|
// 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 handleMouseEnter = () => {
|
|
if (!isMobile && video.videoPath) {
|
|
setIsHovered(true);
|
|
}
|
|
};
|
|
|
|
const handleMouseLeave = () => {
|
|
setIsHovered(false);
|
|
setIsVideoPlaying(false);
|
|
};
|
|
|
|
// 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)
|
|
);
|
|
|
|
// 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;
|
|
|
|
// 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 = (): string => {
|
|
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 = (player: string) => {
|
|
const videoUrl = getVideoUrl();
|
|
|
|
try {
|
|
let playerUrl = '';
|
|
|
|
switch (player) {
|
|
case 'vlc':
|
|
playerUrl = `vlc://${videoUrl}`;
|
|
break;
|
|
case 'iina':
|
|
playerUrl = `iina://weblink?url=${encodeURIComponent(videoUrl)}`;
|
|
break;
|
|
case 'mpv':
|
|
playerUrl = `mpv://${videoUrl}`;
|
|
break;
|
|
case 'potplayer':
|
|
playerUrl = `potplayer://${videoUrl}`;
|
|
break;
|
|
case 'infuse':
|
|
playerUrl = `infuse://x-callback-url/play?url=${encodeURIComponent(videoUrl)}`;
|
|
break;
|
|
case 'copy':
|
|
// Copy URL to clipboard
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(videoUrl).then(() => {
|
|
showSnackbar(t('linkCopied'), 'success');
|
|
}).catch(() => {
|
|
showSnackbar(t('copyFailed'), 'error');
|
|
});
|
|
} else {
|
|
// Fallback
|
|
const textArea = document.createElement("textarea");
|
|
textArea.value = videoUrl;
|
|
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;
|
|
default:
|
|
return;
|
|
}
|
|
|
|
// Try to open the player URL using a hidden anchor element
|
|
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);
|
|
};
|
|
|
|
// Calculate collections that contain THIS video
|
|
const currentVideoCollections = allCollections.filter(c => c.videos.includes(video.id));
|
|
|
|
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
|
|
}
|
|
}
|
|
}),
|
|
border: isFirstInAnyCollection ? `1px solid ${theme.palette.primary.main}` : 'none'
|
|
}}
|
|
>
|
|
<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;
|
|
|
|
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`}
|
|
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.8)',
|
|
color: 'white',
|
|
zIndex: 3
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{isFirstInAnyCollection && (
|
|
<Chip
|
|
icon={<Folder />}
|
|
label={firstInCollectionNames.length > 1 ? `${firstInCollectionNames[0]} +${firstInCollectionNames.length - 1}` : firstInCollectionNames[0]}
|
|
color="secondary"
|
|
size="small"
|
|
sx={{ position: 'absolute', top: 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}
|
|
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',
|
|
}}
|
|
>
|
|
<MenuItem onClick={() => handlePlayerSelect('vlc')}>VLC</MenuItem>
|
|
<MenuItem onClick={() => handlePlayerSelect('iina')}>IINA</MenuItem>
|
|
<MenuItem onClick={() => handlePlayerSelect('mpv')}>mpv</MenuItem>
|
|
<MenuItem onClick={() => handlePlayerSelect('potplayer')}>PotPlayer</MenuItem>
|
|
<MenuItem onClick={() => handlePlayerSelect('infuse')}>Infuse</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}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default VideoCard;
|