diff --git a/frontend/src/components/VideoPlayer/VideoInfo.tsx b/frontend/src/components/VideoPlayer/VideoInfo.tsx index db63737..4d92e00 100644 --- a/frontend/src/components/VideoPlayer/VideoInfo.tsx +++ b/frontend/src/components/VideoPlayer/VideoInfo.tsx @@ -1,43 +1,14 @@ -import { - Add, - CalendarToday, - Check, - Close, - Delete, - Download, - Edit, - ExpandLess, - ExpandMore, - Folder, - HighQuality, - Link as LinkIcon, - LocalOffer, - PlayArrow, - Share, - VideoLibrary -} from '@mui/icons-material'; -import { - Alert, - Autocomplete, - Avatar, - Box, - Button, - Chip, - Divider, - ListItemText, - Menu, - MenuItem, - Rating, - Stack, - TextField, - Tooltip, - Typography, - useTheme -} from '@mui/material'; -import React, { useEffect, useRef, useState } from 'react'; -import { useLanguage } from '../../contexts/LanguageContext'; -import { useSnackbar } from '../../contexts/SnackbarContext'; +import { Alert, Box, Divider, Stack } from '@mui/material'; +import React from 'react'; +import { useVideoResolution } from '../../hooks/useVideoResolution'; import { Collection, Video } from '../../types'; +import EditableTitle from './VideoInfo/EditableTitle'; +import VideoActionButtons from './VideoInfo/VideoActionButtons'; +import VideoAuthorInfo from './VideoInfo/VideoAuthorInfo'; +import VideoDescription from './VideoInfo/VideoDescription'; +import VideoMetadata from './VideoInfo/VideoMetadata'; +import VideoRating from './VideoInfo/VideoRating'; +import VideoTags from './VideoInfo/VideoTags'; const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; @@ -70,268 +41,7 @@ const VideoInfo: React.FC = ({ availableTags, onTagsUpdate }) => { - const theme = useTheme(); - const { t } = useLanguage(); - const { showSnackbar } = useSnackbar(); - - const [isEditingTitle, setIsEditingTitle] = useState(false); - const [editedTitle, setEditedTitle] = useState(''); - const [isTitleExpanded, setIsTitleExpanded] = useState(false); - const [showExpandButton, setShowExpandButton] = useState(false); - const titleRef = useRef(null); - - const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); - const [showDescriptionExpandButton, setShowDescriptionExpandButton] = useState(false); - - const descriptionRef = useRef(null); - const videoRef = useRef(null); - const [detectedResolution, setDetectedResolution] = useState(null); - - const [playerMenuAnchor, setPlayerMenuAnchor] = useState(null); - - useEffect(() => { - const checkOverflow = () => { - const element = titleRef.current; - if (element && !isTitleExpanded) { - setShowExpandButton(element.scrollHeight > element.clientHeight); - } - }; - - checkOverflow(); - window.addEventListener('resize', checkOverflow); - return () => window.removeEventListener('resize', checkOverflow); - }, [video.title, isTitleExpanded]); - - useEffect(() => { - const checkDescriptionOverflow = () => { - const element = descriptionRef.current; - if (element && !isDescriptionExpanded) { - setShowDescriptionExpandButton(element.scrollHeight > element.clientHeight); - } - }; - - checkDescriptionOverflow(); - window.addEventListener('resize', checkDescriptionOverflow); - return () => window.removeEventListener('resize', checkDescriptionOverflow); - }, [video.description, isDescriptionExpanded]); - - // Get video resolution from video element - useEffect(() => { - const videoElement = videoRef.current; - const videoSrc = video.videoPath || video.sourceUrl; - if (!videoElement || !videoSrc) { - setDetectedResolution(null); - return; - } - - // Set the video source - const fullVideoUrl = video.videoPath ? `${BACKEND_URL}${video.videoPath}` : video.sourceUrl; - if (videoElement.src !== fullVideoUrl) { - videoElement.src = fullVideoUrl; - videoElement.load(); // Force reload - } - - const handleLoadedMetadata = () => { - const height = videoElement.videoHeight; - if (height && height > 0) { - if (height >= 2160) setDetectedResolution('4K'); - else if (height >= 1440) setDetectedResolution('1440P'); - else if (height >= 1080) setDetectedResolution('1080P'); - else if (height >= 720) setDetectedResolution('720P'); - else if (height >= 480) setDetectedResolution('480P'); - else if (height >= 360) setDetectedResolution('360P'); - else if (height >= 240) setDetectedResolution('240P'); - else if (height >= 144) setDetectedResolution('144P'); - else setDetectedResolution(null); - } else { - setDetectedResolution(null); - } - }; - - const handleError = () => { - setDetectedResolution(null); - }; - - videoElement.addEventListener('loadedmetadata', handleLoadedMetadata); - videoElement.addEventListener('error', handleError); - - // If metadata is already loaded - if (videoElement.readyState >= 1 && videoElement.videoHeight > 0) { - handleLoadedMetadata(); - } - - return () => { - videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata); - videoElement.removeEventListener('error', handleError); - }; - }, [video.videoPath, video.sourceUrl, video.id]); - - const handleStartEditingTitle = () => { - setEditedTitle(video.title); - setIsEditingTitle(true); - }; - - const handleCancelEditingTitle = () => { - setIsEditingTitle(false); - setEditedTitle(''); - }; - - const handleSaveTitle = async () => { - if (!editedTitle.trim()) return; - await onTitleSave(editedTitle); - setIsEditingTitle(false); - }; - - const handleRatingChangeInternal = (_: React.SyntheticEvent, newValue: number | null) => { - if (newValue) { - onRatingChange(newValue); - } - }; - - const handleShare = async () => { - if (navigator.share) { - try { - await navigator.share({ - title: video.title, - text: `Check out this video: ${video.title}`, - url: window.location.href, - }); - } catch (error) { - console.error('Error sharing:', error); - } - } else { - const url = window.location.href; - if (navigator.clipboard && navigator.clipboard.writeText) { - try { - await navigator.clipboard.writeText(url); - showSnackbar(t('linkCopied'), 'success'); - } catch (error) { - console.error('Error copying to clipboard:', error); - showSnackbar(t('copyFailed'), 'error'); - } - } else { - // Fallback for secure context requirement or unsupported browsers - const textArea = document.createElement("textarea"); - textArea.value = url; - textArea.style.position = "fixed"; // Avoid scrolling to bottom - 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) { - console.error('Fallback: Unable to copy', err); - showSnackbar(t('copyFailed'), 'error'); - } - - document.body.removeChild(textArea); - } - } - }; - - // Format the date (assuming format YYYYMMDD from youtube-dl) - const formatDate = (dateString?: string) => { - if (!dateString || dateString.length !== 8) { - return 'Unknown date'; - } - - const year = dateString.substring(0, 4); - const month = dateString.substring(4, 6); - const day = dateString.substring(6, 8); - - return `${year}-${month}-${day}`; - }; - - // Format video resolution - const formatResolution = (video: Video): string | null => { - // Check if resolution is directly available - if (video.resolution) { - const res = String(video.resolution).toUpperCase(); - // If it's already formatted like "720P", return it - if (res.match(/^\d+P$/)) { - return res; - } - // If it's "4K" or similar, return it - if (res.match(/^\d+K$/)) { - return res; - } - } - - // Check if width and height are available - const width = (video as any).width; - const height = (video as any).height; - - if (width && height) { - const h = typeof height === 'number' ? height : parseInt(String(height)); - if (!isNaN(h)) { - if (h >= 2160) return '4K'; - if (h >= 1440) return '1440P'; - if (h >= 1080) return '1080P'; - if (h >= 720) return '720P'; - if (h >= 480) return '480P'; - if (h >= 360) return '360P'; - if (h >= 240) return '240P'; - if (h >= 144) return '144P'; - } - } - - // Check if there's a format_id or format that might contain resolution info - const formatId = (video as any).format_id; - if (formatId && typeof formatId === 'string') { - const match = formatId.match(/(\d+)p/i); - if (match) { - return match[1].toUpperCase() + 'P'; - } - } - - return null; - }; - - const handleOpenPlayerMenu = (event: React.MouseEvent) => { - setPlayerMenuAnchor(event.currentTarget); - }; - - const handleClosePlayerMenu = () => { - setPlayerMenuAnchor(null); - }; - - const handlePlayInPlayer = (scheme: string) => { - const videoUrl = `${BACKEND_URL}${video.videoPath || video.sourceUrl}`; - let url = ''; - - switch (scheme) { - case 'iina': - url = `iina://weblink?url=${videoUrl}`; - break; - case 'vlc': - url = `vlc://${videoUrl}`; - break; - case 'potplayer': - url = `potplayer://${videoUrl}`; - break; - case 'mpv': - url = `mpv://${videoUrl}`; - break; - case 'infuse': - url = `infuse://x-callback-url/play?url=${videoUrl}`; - break; - } - - if (url) { - window.location.href = url; - } - handleClosePlayerMenu(); - }; - - // Try to get resolution from video object first, fallback to detected resolution - const resolutionFromObject = formatResolution(video); - const videoResolution = resolutionFromObject || detectedResolution; + const { videoRef, videoResolution } = useVideoResolution(video); return ( @@ -353,145 +63,20 @@ const VideoInfo: React.FC = ({ crossOrigin="anonymous" /> )} - {isEditingTitle ? ( - - setEditedTitle(e.target.value)} - variant="outlined" - size="small" - autoFocus - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleSaveTitle(); - } - }} - /> - - - - ) : ( - - showExpandButton && setIsTitleExpanded(!isTitleExpanded)} - sx={{ - mr: 1, - display: '-webkit-box', - overflow: 'hidden', - WebkitBoxOrient: 'vertical', - WebkitLineClamp: isTitleExpanded ? 'unset' : 2, - wordBreak: 'break-word', - flex: 1, - cursor: showExpandButton ? 'pointer' : 'default' - }} - > - {video.title} - - - - - - {showExpandButton && ( - - - - )} - - - )} - - - - {video.rating ? `` : t('rateThisVideo')} - - - {video.viewCount || 0} {t('views')} - - + - {/* Tags Section */} - - - option === value} - onChange={(_, newValue) => onTagsUpdate(newValue)} - slotProps={{ - chip: { variant: 'outlined', size: 'small' }, - listbox: { - sx: { - display: 'flex', - flexWrap: 'wrap', - gap: 0.5, - p: 1 - } - } - }} - renderOption={(props, option, { selected }) => { - const { key, ...otherProps } = props; - return ( -
  • - -
  • - ); - }} - renderInput={(params) => ( - - )} - sx={{ flexGrow: 1 }} - /> -
    + + + = ({ spacing={2} sx={{ mb: 2 }} > - - - {video.author ? video.author.charAt(0).toUpperCase() : 'A'} - - - - {video.author} - - - {formatDate(video.date)} - - - + - - - - - - - - {t('playWith')} - - - - handlePlayInPlayer('iina')}> - IINA - - handlePlayInPlayer('vlc')}> - VLC - - handlePlayInPlayer('potplayer')}> - PotPlayer - - handlePlayInPlayer('mpv')}> - MPV - - handlePlayInPlayer('infuse')}> - Infuse - - - - - - - - - - - - + - {deleteError && ( {deleteError} )} - {video.description && ( - - - {video.description} - - {showDescriptionExpandButton && ( - - )} - - )} + - - - {video.sourceUrl && ( - - - - {t('originalLink')} - - - )} - {video.videoPath && ( - - - - {t('download')} - - - )} - {videoCollections.length > 0 && ( - - {videoCollections.map((c, index) => ( - - onCollectionClick(c.id)} - style={{ - cursor: 'pointer', - color: theme.palette.primary.main, - fontWeight: 'bold', - display: 'inline-flex', - alignItems: 'center', - verticalAlign: 'bottom' - }} - > - - {c.name} - - {index < videoCollections.length - 1 ? , : ''} - - ))} - - )} - - - {video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'} - - {video.addedAt && ( - - - {new Date(video.addedAt).toISOString().split('T')[0]} - - )} - {videoResolution && ( - - - {videoResolution && `${videoResolution}`} - - )} - - - - - - +
    ); }; diff --git a/frontend/src/components/VideoPlayer/VideoInfo/EditableTitle.tsx b/frontend/src/components/VideoPlayer/VideoInfo/EditableTitle.tsx new file mode 100644 index 0000000..f2d007f --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/EditableTitle.tsx @@ -0,0 +1,132 @@ +import { Check, Close, Edit, ExpandLess, ExpandMore } from '@mui/icons-material'; +import { Box, Button, TextField, Tooltip, Typography } from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; +import { useLanguage } from '../../../contexts/LanguageContext'; + +interface EditableTitleProps { + title: string; + onSave: (newTitle: string) => Promise; +} + +const EditableTitle: React.FC = ({ title, onSave }) => { + const { t } = useLanguage(); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editedTitle, setEditedTitle] = useState(''); + const [isTitleExpanded, setIsTitleExpanded] = useState(false); + const [showExpandButton, setShowExpandButton] = useState(false); + const titleRef = useRef(null); + + useEffect(() => { + const checkOverflow = () => { + const element = titleRef.current; + if (element && !isTitleExpanded) { + setShowExpandButton(element.scrollHeight > element.clientHeight); + } + }; + + checkOverflow(); + window.addEventListener('resize', checkOverflow); + return () => window.removeEventListener('resize', checkOverflow); + }, [title, isTitleExpanded]); + + const handleStartEditingTitle = () => { + setEditedTitle(title); + setIsEditingTitle(true); + }; + + const handleCancelEditingTitle = () => { + setIsEditingTitle(false); + setEditedTitle(''); + }; + + const handleSaveTitle = async () => { + if (!editedTitle.trim()) return; + await onSave(editedTitle); + setIsEditingTitle(false); + }; + + if (isEditingTitle) { + return ( + + setEditedTitle(e.target.value)} + variant="outlined" + size="small" + autoFocus + onKeyDown={(e) => { + if (e.key === 'Enter') { + handleSaveTitle(); + } + }} + /> + + + + ); + } + + return ( + + showExpandButton && setIsTitleExpanded(!isTitleExpanded)} + sx={{ + mr: 1, + display: '-webkit-box', + overflow: 'hidden', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: isTitleExpanded ? 'unset' : 2, + wordBreak: 'break-word', + flex: 1, + cursor: showExpandButton ? 'pointer' : 'default' + }} + > + {title} + + + + + + {showExpandButton && ( + + + + )} + + + ); +}; + +export default EditableTitle; + diff --git a/frontend/src/components/VideoPlayer/VideoInfo/VideoActionButtons.tsx b/frontend/src/components/VideoPlayer/VideoInfo/VideoActionButtons.tsx new file mode 100644 index 0000000..6de409b --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/VideoActionButtons.tsx @@ -0,0 +1,138 @@ +import { Add, Delete, PlayArrow, Share } from '@mui/icons-material'; +import { Button, Divider, ListItemText, Menu, MenuItem, Stack, Tooltip, Typography } from '@mui/material'; +import React, { useState } from 'react'; +import { useLanguage } from '../../../contexts/LanguageContext'; +import { useShareVideo } from '../../../hooks/useShareVideo'; +import { Video } from '../../../types'; + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +interface VideoActionButtonsProps { + video: Video; + onAddToCollection: () => void; + onDelete: () => void; + isDeleting: boolean; +} + +const VideoActionButtons: React.FC = ({ + video, + onAddToCollection, + onDelete, + isDeleting +}) => { + const { t } = useLanguage(); + const { handleShare } = useShareVideo(video); + const [playerMenuAnchor, setPlayerMenuAnchor] = useState(null); + + const handleOpenPlayerMenu = (event: React.MouseEvent) => { + setPlayerMenuAnchor(event.currentTarget); + }; + + const handleClosePlayerMenu = () => { + setPlayerMenuAnchor(null); + }; + + const handlePlayInPlayer = (scheme: string) => { + const videoUrl = `${BACKEND_URL}${video.videoPath || video.sourceUrl}`; + let url = ''; + + switch (scheme) { + case 'iina': + url = `iina://weblink?url=${videoUrl}`; + break; + case 'vlc': + url = `vlc://${videoUrl}`; + break; + case 'potplayer': + url = `potplayer://${videoUrl}`; + break; + case 'mpv': + url = `mpv://${videoUrl}`; + break; + case 'infuse': + url = `infuse://x-callback-url/play?url=${videoUrl}`; + break; + } + + if (url) { + window.location.href = url; + } + handleClosePlayerMenu(); + }; + + return ( + + + + + + + + {t('playWith')} + + + + handlePlayInPlayer('iina')}> + IINA + + handlePlayInPlayer('vlc')}> + VLC + + handlePlayInPlayer('potplayer')}> + PotPlayer + + handlePlayInPlayer('mpv')}> + MPV + + handlePlayInPlayer('infuse')}> + Infuse + + + + + + + + + + + + + ); +}; + +export default VideoActionButtons; + diff --git a/frontend/src/components/VideoPlayer/VideoInfo/VideoAuthorInfo.tsx b/frontend/src/components/VideoPlayer/VideoInfo/VideoAuthorInfo.tsx new file mode 100644 index 0000000..9b621cd --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/VideoAuthorInfo.tsx @@ -0,0 +1,47 @@ +import { Avatar, Box, Typography } from '@mui/material'; +import React from 'react'; + +interface VideoAuthorInfoProps { + author: string; + date: string | undefined; + onAuthorClick: () => void; +} + +// Format the date (assuming format YYYYMMDD from youtube-dl) +const formatDate = (dateString?: string) => { + if (!dateString || dateString.length !== 8) { + return 'Unknown date'; + } + + const year = dateString.substring(0, 4); + const month = dateString.substring(4, 6); + const day = dateString.substring(6, 8); + + return `${year}-${month}-${day}`; +}; + +const VideoAuthorInfo: React.FC = ({ author, date, onAuthorClick }) => { + return ( + + + {author ? author.charAt(0).toUpperCase() : 'A'} + + + + {author} + + + {formatDate(date)} + + + + ); +}; + +export default VideoAuthorInfo; + diff --git a/frontend/src/components/VideoPlayer/VideoInfo/VideoDescription.tsx b/frontend/src/components/VideoPlayer/VideoInfo/VideoDescription.tsx new file mode 100644 index 0000000..144a9b9 --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/VideoDescription.tsx @@ -0,0 +1,64 @@ +import { ExpandLess, ExpandMore } from '@mui/icons-material'; +import { Box, Button, Typography } from '@mui/material'; +import React, { useEffect, useRef, useState } from 'react'; +import { useLanguage } from '../../../contexts/LanguageContext'; + +interface VideoDescriptionProps { + description: string | undefined; +} + +const VideoDescription: React.FC = ({ description }) => { + const { t } = useLanguage(); + const [isDescriptionExpanded, setIsDescriptionExpanded] = useState(false); + const [showDescriptionExpandButton, setShowDescriptionExpandButton] = useState(false); + const descriptionRef = useRef(null); + + useEffect(() => { + const checkDescriptionOverflow = () => { + const element = descriptionRef.current; + if (element && !isDescriptionExpanded) { + setShowDescriptionExpandButton(element.scrollHeight > element.clientHeight); + } + }; + + checkDescriptionOverflow(); + window.addEventListener('resize', checkDescriptionOverflow); + return () => window.removeEventListener('resize', checkDescriptionOverflow); + }, [description, isDescriptionExpanded]); + + if (!description) { + return null; + } + + return ( + + + {description} + + {showDescriptionExpandButton && ( + + )} + + ); +}; + +export default VideoDescription; + diff --git a/frontend/src/components/VideoPlayer/VideoInfo/VideoMetadata.tsx b/frontend/src/components/VideoPlayer/VideoInfo/VideoMetadata.tsx new file mode 100644 index 0000000..4e680f8 --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/VideoMetadata.tsx @@ -0,0 +1,89 @@ +import { CalendarToday, Download, Folder, HighQuality, Link as LinkIcon, VideoLibrary } from '@mui/icons-material'; +import { Box, Typography, useTheme } from '@mui/material'; +import React from 'react'; +import { useLanguage } from '../../../contexts/LanguageContext'; +import { Collection, Video } from '../../../types'; + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +interface VideoMetadataProps { + video: Video; + videoCollections: Collection[]; + onCollectionClick: (id: string) => void; + videoResolution: string | null; +} + +const VideoMetadata: React.FC = ({ + video, + videoCollections, + onCollectionClick, + videoResolution +}) => { + const theme = useTheme(); + const { t } = useLanguage(); + + return ( + + + {video.sourceUrl && ( + + + + {t('originalLink')} + + + )} + {video.videoPath && ( + + + + {t('download')} + + + )} + {videoCollections.length > 0 && ( + + {videoCollections.map((c, index) => ( + + onCollectionClick(c.id)} + style={{ + cursor: 'pointer', + color: theme.palette.primary.main, + fontWeight: 'bold', + display: 'inline-flex', + alignItems: 'center', + verticalAlign: 'bottom' + }} + > + + {c.name} + + {index < videoCollections.length - 1 ? , : ''} + + ))} + + )} + + + {video.source ? video.source.charAt(0).toUpperCase() + video.source.slice(1) : 'Unknown'} + + {video.addedAt && ( + + + {new Date(video.addedAt).toISOString().split('T')[0]} + + )} + {videoResolution && ( + + + {videoResolution && `${videoResolution}`} + + )} + + + ); +}; + +export default VideoMetadata; + diff --git a/frontend/src/components/VideoPlayer/VideoInfo/VideoRating.tsx b/frontend/src/components/VideoPlayer/VideoInfo/VideoRating.tsx new file mode 100644 index 0000000..57997aa --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/VideoRating.tsx @@ -0,0 +1,37 @@ +import { Box, Rating, Typography } from '@mui/material'; +import React from 'react'; +import { useLanguage } from '../../../contexts/LanguageContext'; + +interface VideoRatingProps { + rating: number | undefined; + viewCount: number | undefined; + onRatingChange: (newRating: number) => Promise; +} + +const VideoRating: React.FC = ({ rating, viewCount, onRatingChange }) => { + const { t } = useLanguage(); + + const handleRatingChangeInternal = (_: React.SyntheticEvent, newValue: number | null) => { + if (newValue) { + onRatingChange(newValue); + } + }; + + return ( + + + + {rating ? `` : t('rateThisVideo')} + + + {viewCount || 0} {t('views')} + + + ); +}; + +export default VideoRating; + diff --git a/frontend/src/components/VideoPlayer/VideoInfo/VideoTags.tsx b/frontend/src/components/VideoPlayer/VideoInfo/VideoTags.tsx new file mode 100644 index 0000000..e66a853 --- /dev/null +++ b/frontend/src/components/VideoPlayer/VideoInfo/VideoTags.tsx @@ -0,0 +1,67 @@ +import { LocalOffer } from '@mui/icons-material'; +import { Autocomplete, Box, Chip, TextField } from '@mui/material'; +import React from 'react'; +import { useLanguage } from '../../../contexts/LanguageContext'; + +interface VideoTagsProps { + tags: string[] | undefined; + availableTags: string[]; + onTagsUpdate: (tags: string[]) => Promise; +} + +const VideoTags: React.FC = ({ tags, availableTags, onTagsUpdate }) => { + const { t } = useLanguage(); + + return ( + + + option === value} + onChange={(_, newValue) => onTagsUpdate(newValue)} + slotProps={{ + chip: { variant: 'outlined', size: 'small' }, + listbox: { + sx: { + display: 'flex', + flexWrap: 'wrap', + gap: 0.5, + p: 1 + } + } + }} + renderOption={(props, option, { selected }) => { + const { key, ...otherProps } = props; + return ( +
  • + +
  • + ); + }} + renderInput={(params) => ( + + )} + sx={{ flexGrow: 1 }} + /> +
    + ); +}; + +export default VideoTags; + diff --git a/frontend/src/hooks/useShareVideo.ts b/frontend/src/hooks/useShareVideo.ts new file mode 100644 index 0000000..d171523 --- /dev/null +++ b/frontend/src/hooks/useShareVideo.ts @@ -0,0 +1,58 @@ +import { useSnackbar } from '../contexts/SnackbarContext'; +import { useLanguage } from '../contexts/LanguageContext'; +import { Video } from '../types'; + +export const useShareVideo = (video: Video) => { + const { showSnackbar } = useSnackbar(); + const { t } = useLanguage(); + + const handleShare = async () => { + if (navigator.share) { + try { + await navigator.share({ + title: video.title, + text: `Check out this video: ${video.title}`, + url: window.location.href, + }); + } catch (error) { + console.error('Error sharing:', error); + } + } else { + const url = window.location.href; + if (navigator.clipboard && navigator.clipboard.writeText) { + try { + await navigator.clipboard.writeText(url); + showSnackbar(t('linkCopied'), 'success'); + } catch (error) { + console.error('Error copying to clipboard:', error); + showSnackbar(t('copyFailed'), 'error'); + } + } else { + // Fallback for secure context requirement or unsupported browsers + const textArea = document.createElement("textarea"); + textArea.value = url; + textArea.style.position = "fixed"; // Avoid scrolling to bottom + 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) { + console.error('Fallback: Unable to copy', err); + showSnackbar(t('copyFailed'), 'error'); + } + + document.body.removeChild(textArea); + } + } + }; + + return { handleShare }; +}; + diff --git a/frontend/src/hooks/useVideoResolution.ts b/frontend/src/hooks/useVideoResolution.ts new file mode 100644 index 0000000..b2ca921 --- /dev/null +++ b/frontend/src/hooks/useVideoResolution.ts @@ -0,0 +1,111 @@ +import { useEffect, useRef, useState } from 'react'; +import { Video } from '../types'; + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; + +// Format video resolution from video object +export const formatResolution = (video: Video): string | null => { + // Check if resolution is directly available + if (video.resolution) { + const res = String(video.resolution).toUpperCase(); + // If it's already formatted like "720P", return it + if (res.match(/^\d+P$/)) { + return res; + } + // If it's "4K" or similar, return it + if (res.match(/^\d+K$/)) { + return res; + } + } + + // Check if width and height are available + const width = (video as any).width; + const height = (video as any).height; + + if (width && height) { + const h = typeof height === 'number' ? height : parseInt(String(height)); + if (!isNaN(h)) { + if (h >= 2160) return '4K'; + if (h >= 1440) return '1440P'; + if (h >= 1080) return '1080P'; + if (h >= 720) return '720P'; + if (h >= 480) return '480P'; + if (h >= 360) return '360P'; + if (h >= 240) return '240P'; + if (h >= 144) return '144P'; + } + } + + // Check if there's a format_id or format that might contain resolution info + const formatId = (video as any).format_id; + if (formatId && typeof formatId === 'string') { + const match = formatId.match(/(\d+)p/i); + if (match) { + return match[1].toUpperCase() + 'P'; + } + } + + return null; +}; + +export const useVideoResolution = (video: Video) => { + const videoRef = useRef(null); + const [detectedResolution, setDetectedResolution] = useState(null); + + useEffect(() => { + const videoElement = videoRef.current; + const videoSrc = video.videoPath || video.sourceUrl; + if (!videoElement || !videoSrc) { + setDetectedResolution(null); + return; + } + + // Set the video source + const fullVideoUrl = video.videoPath ? `${BACKEND_URL}${video.videoPath}` : video.sourceUrl; + if (videoElement.src !== fullVideoUrl) { + videoElement.src = fullVideoUrl; + videoElement.load(); // Force reload + } + + const handleLoadedMetadata = () => { + const height = videoElement.videoHeight; + if (height && height > 0) { + if (height >= 2160) setDetectedResolution('4K'); + else if (height >= 1440) setDetectedResolution('1440P'); + else if (height >= 1080) setDetectedResolution('1080P'); + else if (height >= 720) setDetectedResolution('720P'); + else if (height >= 480) setDetectedResolution('480P'); + else if (height >= 360) setDetectedResolution('360P'); + else if (height >= 240) setDetectedResolution('240P'); + else if (height >= 144) setDetectedResolution('144P'); + else setDetectedResolution(null); + } else { + setDetectedResolution(null); + } + }; + + const handleError = () => { + setDetectedResolution(null); + }; + + videoElement.addEventListener('loadedmetadata', handleLoadedMetadata); + videoElement.addEventListener('error', handleError); + + // If metadata is already loaded + if (videoElement.readyState >= 1 && videoElement.videoHeight > 0) { + handleLoadedMetadata(); + } + + return () => { + videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata); + videoElement.removeEventListener('error', handleError); + }; + }, [video.videoPath, video.sourceUrl, video.id]); + + // Try to get resolution from video object first, fallback to detected resolution + const resolutionFromObject = formatResolution(video); + const videoResolution = resolutionFromObject || detectedResolution; + + return { videoRef, videoResolution }; +}; +