Files
MyTube/frontend/src/pages/VideoPlayer.tsx

719 lines
26 KiB
TypeScript

import {
Alert,
Box,
CircularProgress,
Container,
Grid,
Typography
} from '@mui/material';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import CollectionModal from '../components/CollectionModal';
import ConfirmationModal from '../components/ConfirmationModal';
import SubscribeModal from '../components/SubscribeModal';
import CommentsSection from '../components/VideoPlayer/CommentsSection';
import UpNextSidebar from '../components/VideoPlayer/UpNextSidebar';
import VideoControls from '../components/VideoPlayer/VideoControls';
import VideoInfo from '../components/VideoPlayer/VideoInfo';
import { useCollection } from '../contexts/CollectionContext';
import { useLanguage } from '../contexts/LanguageContext';
import { useSnackbar } from '../contexts/SnackbarContext';
import { useVideo } from '../contexts/VideoContext';
import { useVisitorMode } from '../contexts/VisitorModeContext';
import { useCloudStorageUrl } from '../hooks/useCloudStorageUrl';
import { Collection, Video } from '../types';
import { getRecommendations } from '../utils/recommendations';
import { validateUrlForOpen } from '../utils/urlValidation';
const API_URL = import.meta.env.VITE_API_URL;
const VideoPlayer: React.FC = () => {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { t } = useLanguage();
const { showSnackbar } = useSnackbar();
const queryClient = useQueryClient();
const { videos, deleteVideo } = useVideo();
const { visitorMode } = useVisitorMode();
const {
collections,
addToCollection,
createCollection,
removeFromCollection
} = useCollection();
const [showCollectionModal, setShowCollectionModal] = useState<boolean>(false);
const [videoCollections, setVideoCollections] = useState<Collection[]>([]);
const [activeCollectionVideoId, setActiveCollectionVideoId] = useState<string | null>(null);
const [showComments, setShowComments] = useState<boolean>(false);
const [showSubscribeModal, setShowSubscribeModal] = useState<boolean>(false);
const [authorChannelUrl, setAuthorChannelUrl] = useState<string | null>(null);
const [autoPlayNext, setAutoPlayNext] = useState<boolean>(() => {
const saved = localStorage.getItem('autoPlayNext');
return saved !== null ? JSON.parse(saved) : false;
});
useEffect(() => {
localStorage.setItem('autoPlayNext', JSON.stringify(autoPlayNext));
}, [autoPlayNext]);
// Confirmation Modal State
const [confirmationModal, setConfirmationModal] = useState({
isOpen: false,
title: '',
message: '',
onConfirm: () => { },
confirmText: t('confirm'),
cancelText: t('cancel'),
isDanger: false
});
// Fetch video details
const { data: video, isLoading: loading, error } = useQuery({
queryKey: ['video', id],
queryFn: async () => {
const response = await axios.get(`${API_URL}/videos/${id}`);
return response.data;
},
initialData: () => {
return videos.find(v => v.id === id);
},
enabled: !!id,
retry: false
});
// Handle error redirect and invisible videos in visitor mode
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
navigate('/');
}, 3000);
return () => clearTimeout(timer);
}
// In visitor mode, redirect if video is invisible
if (visitorMode && video && (video.visibility ?? 1) === 0) {
const timer = setTimeout(() => {
navigate('/');
}, 3000);
return () => clearTimeout(timer);
}
}, [error, navigate, visitorMode, video]);
// Fetch settings
const { data: settings } = useQuery({
queryKey: ['settings'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/settings`);
return response.data;
}
});
const autoPlay = autoPlayNext || settings?.defaultAutoPlay || false;
const autoLoop = settings?.defaultAutoLoop || false;
const availableTags = Array.isArray(settings?.tags) ? settings.tags : [];
const subtitlesEnabled = settings?.subtitlesEnabled ?? true;
// Get cloud storage URLs
const videoUrl = useCloudStorageUrl(video?.videoPath, 'video');
// Fetch comments
const { data: comments = [], isLoading: loadingComments } = useQuery({
queryKey: ['comments', id],
queryFn: async () => {
const response = await axios.get(`${API_URL}/videos/${id}/comments`);
return response.data;
},
enabled: showComments && !!id
});
// Fetch subscriptions
const { data: subscriptions = [] } = useQuery({
queryKey: ['subscriptions'],
queryFn: async () => {
const response = await axios.get(`${API_URL}/subscriptions`);
return response.data;
}
});
const handleToggleComments = () => {
setShowComments(!showComments);
};
// Find collections that contain the current video (for VideoInfo)
useEffect(() => {
if (collections && collections.length > 0 && id) {
const belongsToCollections = collections.filter(collection =>
collection.videos.includes(id)
);
setVideoCollections(belongsToCollections);
} else {
setVideoCollections([]);
}
}, [collections, id]);
// Calculate collections for the modal (can be current video or sidebar video)
const modalVideoCollections = useMemo(() => {
if (collections && collections.length > 0 && activeCollectionVideoId) {
return collections.filter(collection =>
collection.videos.includes(activeCollectionVideoId)
);
}
return [];
}, [collections, activeCollectionVideoId]);
// Get author channel URL and check subscription status
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
// This handles cases where we might have the same author but slightly different URLs (e.g. handle vs channel ID)
if (video) {
return subscriptions.some((sub: any) => {
const nameMatch = sub.author === video.author;
// sub.platform is typically "YouTube" or "Bilibili" (capitalized)
// video.source is typically "youtube" or "bilibili" (lowercase)
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 = async () => {
if (!video) return;
// 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;
}
}
}
// Default behavior: navigate to author videos page
navigate(`/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, // Pass the author name from the video
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 = () => {
if (!subscriptionId) return;
setConfirmationModal({
isOpen: true,
title: t('unsubscribe'),
message: t('confirmUnsubscribe', { author: video?.author || '' }),
onConfirm: async () => {
try {
await axios.delete(`${API_URL}/subscriptions/${subscriptionId}`);
showSnackbar(t('unsubscribedSuccessfully'));
queryClient.invalidateQueries({ queryKey: ['subscriptions'] });
setConfirmationModal({ ...confirmationModal, isOpen: false });
} catch (error) {
console.error('Error unsubscribing:', error);
showSnackbar(t('error'), 'error');
setConfirmationModal({ ...confirmationModal, isOpen: false });
}
},
confirmText: t('unsubscribe'),
cancelText: t('cancel'),
isDanger: true
});
};
const handleCollectionClick = (collectionId: string) => {
navigate(`/collection/${collectionId}`);
};
// Delete mutation
const deleteMutation = useMutation({
mutationFn: async (videoId: string) => {
return await deleteVideo(videoId);
},
onSuccess: (result) => {
if (result.success) {
navigate('/', { replace: true });
}
}
});
const executeDelete = async () => {
if (!id) return;
await deleteMutation.mutateAsync(id);
};
const handleDelete = () => {
setConfirmationModal({
isOpen: true,
title: t('deleteVideo'),
message: t('confirmDelete'),
onConfirm: executeDelete,
confirmText: t('delete'),
cancelText: t('cancel'),
isDanger: true
});
};
const handleAddToCollection = (videoId?: string) => {
setActiveCollectionVideoId(videoId || id || null);
setShowCollectionModal(true);
};
const handleCloseModal = () => {
setShowCollectionModal(false);
setActiveCollectionVideoId(null);
};
const handleCreateCollection = async (name: string) => {
if (!activeCollectionVideoId) return;
try {
await createCollection(name, activeCollectionVideoId);
} catch (error) {
console.error('Error creating collection:', error);
}
};
const handleAddToExistingCollection = async (collectionId: string) => {
if (!activeCollectionVideoId) return;
try {
await addToCollection(collectionId, activeCollectionVideoId);
} catch (error) {
console.error('Error adding to collection:', error);
}
};
const executeRemoveFromCollection = async () => {
if (!activeCollectionVideoId) return;
try {
await removeFromCollection(activeCollectionVideoId);
} catch (error) {
console.error('Error removing from collection:', error);
}
};
const handleRemoveFromCollection = () => {
setConfirmationModal({
isOpen: true,
title: t('removeFromCollection'),
message: t('confirmRemoveFromCollection'),
onConfirm: executeRemoveFromCollection,
confirmText: t('remove'),
cancelText: t('cancel'),
isDanger: true
});
};
// Rating mutation
const ratingMutation = useMutation({
mutationFn: async (newValue: number) => {
await axios.post(`${API_URL}/videos/${id}/rate`, { rating: newValue });
return newValue;
},
onSuccess: (newValue) => {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, rating: newValue } : old);
}
});
const handleRatingChange = async (newValue: number) => {
if (!id) return;
await ratingMutation.mutateAsync(newValue);
};
// Title mutation
const titleMutation = useMutation({
mutationFn: async (newTitle: string) => {
const response = await axios.put(`${API_URL}/videos/${id}`, { title: newTitle });
return response.data;
},
onSuccess: (data, newTitle) => {
if (data.success) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, title: newTitle } : old);
showSnackbar(t('titleUpdated'));
}
},
onError: () => {
showSnackbar(t('titleUpdateFailed'), 'error');
}
});
const handleSaveTitle = async (newTitle: string) => {
if (!id) return;
await titleMutation.mutateAsync(newTitle);
};
// Tags mutation
const tagsMutation = useMutation({
mutationFn: async (newTags: string[]) => {
const response = await axios.put(`${API_URL}/videos/${id}`, { tags: newTags });
return response.data;
},
onSuccess: (data, newTags) => {
if (data.success) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, tags: newTags } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleUpdateTags = async (newTags: string[]) => {
if (!id) return;
await tagsMutation.mutateAsync(newTags);
};
// Visibility mutation
const visibilityMutation = useMutation({
mutationFn: async (visibility: number) => {
const response = await axios.put(`${API_URL}/videos/${id}`, { visibility });
return response.data;
},
onSuccess: (data, visibility) => {
if (data.success) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, visibility } : old);
queryClient.setQueryData(['videos'], (old: Video[] | undefined) =>
old ? old.map(v => v.id === id ? { ...v, visibility } : v) : []
);
showSnackbar(visibility === 1 ? t('showVideo') : t('hideVideo'), 'success');
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleToggleVisibility = async () => {
if (!id || !video) return;
const newVisibility = video.visibility === 0 ? 1 : 0;
await visibilityMutation.mutateAsync(newVisibility);
};
// Subtitle preference mutation
const subtitlePreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const response = await axios.post(`${API_URL}/settings`, { ...settings, subtitlesEnabled: enabled });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
queryClient.setQueryData(['settings'], (old: any) => old ? { ...old, subtitlesEnabled: data.settings.subtitlesEnabled } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleSubtitlesToggle = async (enabled: boolean) => {
await subtitlePreferenceMutation.mutateAsync(enabled);
};
// Loop preference mutation
const loopPreferenceMutation = useMutation({
mutationFn: async (enabled: boolean) => {
const response = await axios.post(`${API_URL}/settings`, { ...settings, defaultAutoLoop: enabled });
return response.data;
},
onSuccess: (data) => {
if (data.success) {
queryClient.setQueryData(['settings'], (old: any) => old ? { ...old, defaultAutoLoop: data.settings.defaultAutoLoop } : old);
}
},
onError: () => {
showSnackbar(t('error'), 'error');
}
});
const handleLoopToggle = async (enabled: boolean) => {
await loopPreferenceMutation.mutateAsync(enabled);
};
const [hasViewed, setHasViewed] = useState<boolean>(false);
const lastProgressSave = useRef<number>(0);
const currentTimeRef = useRef<number>(0);
// Reset hasViewed when video changes
useEffect(() => {
setHasViewed(false);
currentTimeRef.current = 0;
}, [id]);
// Save progress on unmount
useEffect(() => {
return () => {
if (id && currentTimeRef.current > 0) {
axios.put(`${API_URL}/videos/${id}/progress`, { progress: Math.floor(currentTimeRef.current) })
.catch(err => console.error('Error saving progress on unmount:', err));
}
};
}, [id]);
const handleTimeUpdate = (currentTime: number) => {
currentTimeRef.current = currentTime;
// Increment view count after 10 seconds
if (currentTime > 10 && !hasViewed && id) {
setHasViewed(true);
axios.post(`${API_URL}/videos/${id}/view`)
.then(res => {
if (res.data.success && video) {
queryClient.setQueryData(['video', id], (old: Video | undefined) => old ? { ...old, viewCount: res.data.viewCount } : old);
}
})
.catch(err => console.error('Error incrementing view count:', err));
}
// Save progress every 5 seconds
const now = Date.now();
if (now - lastProgressSave.current > 5000 && id) {
lastProgressSave.current = now;
axios.put(`${API_URL}/videos/${id}/progress`, { progress: Math.floor(currentTime) })
.catch(err => console.error('Error saving progress:', err));
}
};
// Get related videos using recommendation algorithm
const relatedVideos = useMemo(() => {
if (!video) return [];
return getRecommendations({
currentVideo: video,
allVideos: videos,
collections: collections
}).slice(0, 10);
}, [video, videos, collections]);
// Scroll to top when video ID changes
useEffect(() => {
window.scrollTo(0, 0);
}, [id]);
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '50vh' }}>
<CircularProgress />
<Typography sx={{ ml: 2 }}>{t('loadingVideo')}</Typography>
</Box>
);
}
if (error || !video) {
return (
<Container sx={{ mt: 4 }}>
<Alert severity="error">{t('videoNotFoundOrLoaded')}</Alert>
</Container>
);
}
const handleVideoEnded = () => {
if (autoPlayNext && relatedVideos.length > 0) {
navigate(`/video/${relatedVideos[0].id}`);
}
};
// Get thumbnail URL for poster
// 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 posterUrl = useCloudStorageUrl(thumbnailPathForCloud, 'thumbnail');
const localPosterUrl = !isVideoInCloud && video?.thumbnailPath
? `${import.meta.env.VITE_BACKEND_URL ?? 'http://localhost:5551'}${video.thumbnailPath}`
: undefined;
return (
<Container maxWidth={false} disableGutters sx={{ py: { xs: 0, md: 4 }, px: { xs: 0, md: 2 } }}>
<Grid container spacing={{ xs: 0, md: 4 }}>
{/* Main Content Column */}
<Grid size={{ xs: 12, lg: 8 }}>
<VideoControls
src={videoUrl || video?.sourceUrl}
poster={posterUrl || localPosterUrl || video?.thumbnailUrl}
autoPlay={autoPlay}
autoLoop={autoLoop}
onTimeUpdate={handleTimeUpdate}
startTime={video.progress || 0}
subtitles={video.subtitles}
subtitlesEnabled={subtitlesEnabled}
onSubtitlesToggle={handleSubtitlesToggle}
onLoopToggle={handleLoopToggle}
onEnded={handleVideoEnded}
/>
<Box sx={{ px: { xs: 2, md: 0 } }}>
<VideoInfo
video={video}
onTitleSave={handleSaveTitle}
onRatingChange={handleRatingChange}
onAuthorClick={handleAuthorClick}
onAddToCollection={handleAddToCollection}
onDelete={handleDelete}
isDeleting={deleteMutation.isPending}
deleteError={deleteMutation.error ? (deleteMutation.error as any).message || t('deleteFailed') : null}
videoCollections={videoCollections}
onCollectionClick={handleCollectionClick}
availableTags={availableTags}
onTagsUpdate={handleUpdateTags}
isSubscribed={isSubscribed}
onSubscribe={handleSubscribe}
onUnsubscribe={handleUnsubscribe}
onToggleVisibility={handleToggleVisibility}
/>
{(video.source === 'youtube' || video.source === 'bilibili') && (
<CommentsSection
comments={comments}
loading={loadingComments}
showComments={showComments}
onToggleComments={handleToggleComments}
/>
)}
</Box>
</Grid>
{/* Sidebar Column - Up Next */}
<Grid size={{ xs: 12, lg: 4 }}>
<UpNextSidebar
relatedVideos={relatedVideos}
autoPlayNext={autoPlayNext}
onAutoPlayNextChange={setAutoPlayNext}
onVideoClick={(videoId) => navigate(`/video/${videoId}`)}
onAddToCollection={handleAddToCollection}
/>
</Grid>
</Grid>
<CollectionModal
open={showCollectionModal}
onClose={handleCloseModal}
videoCollections={modalVideoCollections}
collections={collections}
onAddToCollection={handleAddToExistingCollection}
onCreateCollection={handleCreateCollection}
onRemoveFromCollection={handleRemoveFromCollection}
/>
<ConfirmationModal
isOpen={confirmationModal.isOpen}
onClose={() => setConfirmationModal({ ...confirmationModal, isOpen: false })}
onConfirm={confirmationModal.onConfirm}
title={confirmationModal.title}
message={confirmationModal.message}
confirmText={confirmationModal.confirmText}
cancelText={confirmationModal.cancelText}
isDanger={confirmationModal.isDanger}
/>
<SubscribeModal
open={showSubscribeModal}
onClose={() => setShowSubscribeModal(false)}
onConfirm={handleSubscribeConfirm}
authorName={video?.author}
url={authorChannelUrl || ''}
/>
</Container>
);
};
export default VideoPlayer;