From ea9ead50266f97d8e7aa52e89e30d7dfe513ba9e Mon Sep 17 00:00:00 2001 From: Peifan Li Date: Sun, 28 Dec 2025 14:58:31 -0500 Subject: [PATCH] refactor: refactor videocard --- frontend/src/components/VideoCard.tsx | 727 +++--------------- .../components/VideoCard/VideoCardActions.tsx | 146 ++++ .../components/VideoCard/VideoCardContent.tsx | 84 ++ .../VideoCard/VideoCardThumbnail.tsx | 188 +++++ frontend/src/hooks/usePlayerSelection.ts | 106 +++ frontend/src/hooks/useVideoCardActions.ts | 60 ++ frontend/src/hooks/useVideoCardMetadata.ts | 63 ++ frontend/src/hooks/useVideoCardNavigation.ts | 39 + frontend/src/hooks/useVideoHoverPreview.ts | 78 ++ frontend/src/hooks/useVideoSubscriptions.ts | 368 ++++----- frontend/src/utils/videoCardUtils.ts | 79 ++ 11 files changed, 1130 insertions(+), 808 deletions(-) create mode 100644 frontend/src/components/VideoCard/VideoCardActions.tsx create mode 100644 frontend/src/components/VideoCard/VideoCardContent.tsx create mode 100644 frontend/src/components/VideoCard/VideoCardThumbnail.tsx create mode 100644 frontend/src/hooks/usePlayerSelection.ts create mode 100644 frontend/src/hooks/useVideoCardActions.ts create mode 100644 frontend/src/hooks/useVideoCardMetadata.ts create mode 100644 frontend/src/hooks/useVideoCardNavigation.ts create mode 100644 frontend/src/hooks/useVideoHoverPreview.ts create mode 100644 frontend/src/utils/videoCardUtils.ts diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 32607ee..f0a02b2 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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 = ({ 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(null); - const hoverTimeoutRef = useRef(null); - - // New state for player menu - const [playerMenuAnchor, setPlayerMenuAnchor] = useState(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 => { - // 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 ( - <> - + - - - {/* Video Element (only shown on hover) */} - {isHovered && videoUrl && ( - 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 && ( - - )} - - {/* Thumbnail Image */} - 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 && ( - - )} - - {video.duration && ( - - )} - - {isNewVideo && ( - - )} - - {isFirstInAnyCollection && ( - } - 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 - }} - /> - )} - - - - - - - {isFirstInAnyCollection ? ( - <> - {firstInCollectionNames[0]} - {firstInCollectionNames.length > 1 && +{firstInCollectionNames.length - 1}} - - ) : ( - video.title - )} - - - - - {video.author} - - - - {formatDate(video.date)} - - - {video.viewCount || 0} {t('views')} - - - - - - - - - - - {getAvailablePlayers().map((player) => ( - handlePlayerSelect(player.id)}> - {player.name} - - ))} - handlePlayerSelect('copy')}>{t('copyUrl')} - - - setShowDeleteModal(false)} - onConfirm={confirmDelete} - title={t('deleteVideo')} - message={`${t('confirmDelete')} "${video.title}"?`} - confirmText={t('delete')} - cancelText={t('cancel')} - isDanger={true} + - - setShowCollectionModal(false)} - videoCollections={currentVideoCollections} - collections={allCollections} - onAddToCollection={handleAddToCollection} - onCreateCollection={handleCreateCollection} - onRemoveFromCollection={handleRemoveFromCollection} - /> - + ); }; diff --git a/frontend/src/components/VideoCard/VideoCardActions.tsx b/frontend/src/components/VideoCard/VideoCardActions.tsx new file mode 100644 index 0000000..ea5a27d --- /dev/null +++ b/frontend/src/components/VideoCard/VideoCardActions.tsx @@ -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 = ({ + 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 ( + <> + e.stopPropagation()} + > + 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' + } + }} + /> + + + + {getAvailablePlayers().map((player) => ( + handlePlayerSelect(player.id)}> + {player.name} + + ))} + handlePlayerSelect('copy')}>{t('copyUrl')} + + + setShowDeleteModal(false)} + onConfirm={confirmDelete} + title={t('deleteVideo')} + message={`${t('confirmDelete')} "${video.title}"?`} + confirmText={t('delete')} + cancelText={t('cancel')} + isDanger={true} + /> + + setShowCollectionModal(false)} + videoCollections={currentVideoCollections} + collections={allCollections} + onAddToCollection={handleAddToCollection} + onCreateCollection={handleCreateCollection} + onRemoveFromCollection={handleRemoveFromCollection} + /> + + ); +}; diff --git a/frontend/src/components/VideoCard/VideoCardContent.tsx b/frontend/src/components/VideoCard/VideoCardContent.tsx new file mode 100644 index 0000000..48ef086 --- /dev/null +++ b/frontend/src/components/VideoCard/VideoCardContent.tsx @@ -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 = ({ + video, + collectionInfo, + onAuthorClick +}) => { + const { t } = useLanguage(); + + return ( + + + {collectionInfo.isFirstInAnyCollection ? ( + <> + {collectionInfo.firstInCollectionNames[0]} + {collectionInfo.firstInCollectionNames.length > 1 && ( + + {' '}+{collectionInfo.firstInCollectionNames.length - 1} + + )} + + ) : ( + video.title + )} + + + + + {video.author} + + + + {formatDate(video.date)} + + + {video.viewCount || 0} {t('views')} + + + + + ); +}; diff --git a/frontend/src/components/VideoCard/VideoCardThumbnail.tsx b/frontend/src/components/VideoCard/VideoCardThumbnail.tsx new file mode 100644 index 0000000..141faa3 --- /dev/null +++ b/frontend/src/components/VideoCard/VideoCardThumbnail.tsx @@ -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; + collectionInfo: VideoCardCollectionInfo; + isNew: boolean; +} + +export const VideoCardThumbnail: React.FC = ({ + video, + thumbnailSrc, + videoUrl, + isHovered, + isVideoPlaying, + setIsVideoPlaying, + videoRef, + collectionInfo, + isNew +}) => { + const { t } = useLanguage(); + const theme = useTheme(); + const [isImageLoaded, setIsImageLoaded] = useState(false); + + return ( + + {/* Video Element (only shown on hover) */} + {isHovered && videoUrl && ( + } + 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 && ( + + )} + + {/* Thumbnail Image */} + 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 && ( + + )} + + {video.duration && ( + + )} + + {isNew && ( + + )} + + {collectionInfo.isFirstInAnyCollection && ( + } + 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 + }} + /> + )} + + ); +}; diff --git a/frontend/src/hooks/usePlayerSelection.ts b/frontend/src/hooks/usePlayerSelection.ts new file mode 100644 index 0000000..5c10f8d --- /dev/null +++ b/frontend/src/hooks/usePlayerSelection.ts @@ -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; +} + +/** + * 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); + + 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 + }; +}; diff --git a/frontend/src/hooks/useVideoCardActions.ts b/frontend/src/hooks/useVideoCardActions.ts new file mode 100644 index 0000000..06d1b01 --- /dev/null +++ b/frontend/src/hooks/useVideoCardActions.ts @@ -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; + 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 + }; +}; diff --git a/frontend/src/hooks/useVideoCardMetadata.ts b/frontend/src/hooks/useVideoCardMetadata.ts new file mode 100644 index 0000000..9a0e4b2 --- /dev/null +++ b/frontend/src/hooks/useVideoCardMetadata.ts @@ -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 => { + // 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 + }; +}; diff --git a/frontend/src/hooks/useVideoCardNavigation.ts b/frontend/src/hooks/useVideoCardNavigation.ts new file mode 100644 index 0000000..c55edbe --- /dev/null +++ b/frontend/src/hooks/useVideoCardNavigation.ts @@ -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 + }; +}; diff --git a/frontend/src/hooks/useVideoHoverPreview.ts b/frontend/src/hooks/useVideoHoverPreview.ts new file mode 100644 index 0000000..b6361f1 --- /dev/null +++ b/frontend/src/hooks/useVideoHoverPreview.ts @@ -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(null); + const hoverTimeoutRef = useRef(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, + }; +}; diff --git a/frontend/src/hooks/useVideoSubscriptions.ts b/frontend/src/hooks/useVideoSubscriptions.ts index c954ad8..ed7e9d5 100644 --- a/frontend/src/hooks/useVideoSubscriptions.ts +++ b/frontend/src/hooks/useVideoSubscriptions.ts @@ -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(null); - const [showSubscribeModal, setShowSubscribeModal] = useState(false); + const { t } = useLanguage(); + const { showSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const [authorChannelUrl, setAuthorChannelUrl] = useState(null); + const [showSubscribeModal, setShowSubscribeModal] = useState(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, + }; } diff --git a/frontend/src/utils/videoCardUtils.ts b/frontend/src/utils/videoCardUtils.ts new file mode 100644 index 0000000..477cc8b --- /dev/null +++ b/frontend/src/utils/videoCardUtils.ts @@ -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 + }; +};