diff --git a/frontend/src/pages/VODs.jsx b/frontend/src/pages/VODs.jsx index 303cd147..5fa99afd 100644 --- a/frontend/src/pages/VODs.jsx +++ b/frontend/src/pages/VODs.jsx @@ -1,26 +1,26 @@ import React, { useState, useEffect } from 'react'; import { -Box, -Button, -Card, -Flex, -Group, -Image, -Text, -Title, -Select, -TextInput, -Pagination, -Badge, -Grid, -Loader, -Stack, -SegmentedControl, -ActionIcon, -Modal, -Tabs, -Table, -Divider, + Box, + Button, + Card, + Flex, + Group, + Image, + Text, + Title, + Select, + TextInput, + Pagination, + Badge, + Grid, + Loader, + Stack, + SegmentedControl, + ActionIcon, + Modal, + Tabs, + Table, + Divider, } from '@mantine/core'; import { Search, Play, Calendar, Clock, Star } from 'lucide-react'; import { useDisclosure } from '@mantine/hooks'; @@ -29,575 +29,575 @@ import useVideoStore from '../store/useVideoStore'; import useSettingsStore from '../store/settings'; const imdbUrl = (imdb_id) => -imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; + imdb_id ? `https://www.imdb.com/title/${imdb_id}` : ''; const tmdbUrl = (tmdb_id, type = 'movie') => -tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; + tmdb_id ? `https://www.themoviedb.org/${type}/${tmdb_id}` : ''; const formatDuration = (seconds) => { -if (!seconds) return ''; -const hours = Math.floor(seconds / 3600); -const mins = Math.floor((seconds % 3600) / 60); -const secs = seconds % 60; -return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; + if (!seconds) return ''; + const hours = Math.floor(seconds / 3600); + const mins = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + return hours > 0 ? `${hours}h ${mins}m` : `${mins}m ${secs}s`; }; const formatStreamLabel = (relation) => { -// Create a label for the stream that includes provider name and stream-specific info -const provider = relation.m3u_account.name; -const streamId = relation.stream_id; + // Create a label for the stream that includes provider name and stream-specific info + const provider = relation.m3u_account.name; + const streamId = relation.stream_id; -// Try to extract quality info - prioritizing the new quality_info field from backend -let qualityInfo = ''; + // Try to extract quality info - prioritizing the new quality_info field from backend + let qualityInfo = ''; -// 1. Check the new quality_info field from backend (PRIMARY) -if (relation.quality_info) { + // 1. Check the new quality_info field from backend (PRIMARY) + if (relation.quality_info) { if (relation.quality_info.quality) { - qualityInfo = ` - ${relation.quality_info.quality}`; + qualityInfo = ` - ${relation.quality_info.quality}`; } else if (relation.quality_info.resolution) { - qualityInfo = ` - ${relation.quality_info.resolution}`; + qualityInfo = ` - ${relation.quality_info.resolution}`; } else if (relation.quality_info.bitrate) { - qualityInfo = ` - ${relation.quality_info.bitrate}`; + qualityInfo = ` - ${relation.quality_info.bitrate}`; } -} + } -// 2. Fallback: Check custom_properties detailed info structure -if (qualityInfo === '' && relation.custom_properties) { + // 2. Fallback: Check custom_properties detailed info structure + if (qualityInfo === '' && relation.custom_properties) { const props = relation.custom_properties; // Check detailed_info structure (where the real data is!) if (qualityInfo === '' && props.detailed_info) { - const detailedInfo = props.detailed_info; + const detailedInfo = props.detailed_info; - // Extract from video resolution - if ( + // Extract from video resolution + if ( detailedInfo.video && detailedInfo.video.width && detailedInfo.video.height - ) { + ) { const width = detailedInfo.video.width; const height = detailedInfo.video.height; // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) if (width >= 3840) { - qualityInfo = ' - 4K'; + qualityInfo = ' - 4K'; } else if (width >= 1920) { - qualityInfo = ' - 1080p'; + qualityInfo = ' - 1080p'; } else if (width >= 1280) { - qualityInfo = ' - 720p'; + qualityInfo = ' - 720p'; } else if (width >= 854) { - qualityInfo = ' - 480p'; + qualityInfo = ' - 480p'; } else { - qualityInfo = ` - ${width}x${height}`; + qualityInfo = ` - ${width}x${height}`; } - } + } - // Extract from movie name in detailed_info - if (qualityInfo === '' && detailedInfo.name) { + // Extract from movie name in detailed_info + if (qualityInfo === '' && detailedInfo.name) { const name = detailedInfo.name; if (name.includes('4K') || name.includes('2160p')) { - qualityInfo = ' - 4K'; + qualityInfo = ' - 4K'; } else if (name.includes('1080p') || name.includes('FHD')) { - qualityInfo = ' - 1080p'; + qualityInfo = ' - 1080p'; } else if (name.includes('720p') || name.includes('HD')) { - qualityInfo = ' - 720p'; + qualityInfo = ' - 720p'; } else if (name.includes('480p')) { - qualityInfo = ' - 480p'; + qualityInfo = ' - 480p'; } - } + } - // Extract from bitrate in detailed_info - if ( + // Extract from bitrate in detailed_info + if ( qualityInfo === '' && detailedInfo.bitrate && detailedInfo.bitrate > 0 - ) { + ) { const bitrate = detailedInfo.bitrate; if (bitrate >= 6000) { - qualityInfo = ' - 4K'; + qualityInfo = ' - 4K'; } else if (bitrate >= 3000) { - qualityInfo = ' - 1080p'; + qualityInfo = ' - 1080p'; } else if (bitrate >= 1500) { - qualityInfo = ' - 720p'; + qualityInfo = ' - 720p'; } else { - qualityInfo = ` - ${Math.round(bitrate / 1000)}Mbps`; + qualityInfo = ` - ${Math.round(bitrate / 1000)}Mbps`; } - } + } } // Check basic_data structure as another fallback if (qualityInfo === '' && props.basic_data && props.basic_data.name) { - const name = props.basic_data.name; - if (name.includes('4K') || name.includes('2160p')) { + const name = props.basic_data.name; + if (name.includes('4K') || name.includes('2160p')) { qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { + } else if (name.includes('1080p') || name.includes('FHD')) { qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { + } else if (name.includes('720p') || name.includes('HD')) { qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { + } else if (name.includes('480p')) { qualityInfo = ' - 480p'; + } } - } -} + } -// 3. Final fallback: Try to extract from movie/episode name -if (qualityInfo === '') { + // 3. Final fallback: Try to extract from movie/episode name + if (qualityInfo === '') { const content = relation.movie || relation.episode; if (content && content.name) { - const name = content.name; - if (name.includes('4K') || name.includes('2160p')) { + const name = content.name; + if (name.includes('4K') || name.includes('2160p')) { qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { + } else if (name.includes('1080p') || name.includes('FHD')) { qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { + } else if (name.includes('720p') || name.includes('HD')) { qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { + } else if (name.includes('480p')) { qualityInfo = ' - 480p'; + } } - } -} + } -// If no quality info and multiple streams from same provider, show stream ID -const finalLabel = `${provider}${qualityInfo}${qualityInfo === '' && streamId ? ` - Stream ${streamId}` : ''}`; -return finalLabel; + // If no quality info and multiple streams from same provider, show stream ID + const finalLabel = `${provider}${qualityInfo}${qualityInfo === '' && streamId ? ` - Stream ${streamId}` : ''}`; + return finalLabel; }; // Helper function to get technical details from selected provider or fallback to default VOD const getTechnicalDetails = (selectedProvider, defaultVOD) => { -let source = defaultVOD; // Default fallback + let source = defaultVOD; // Default fallback -// If a provider is selected, try to get technical details from various locations -if (selectedProvider) { + // If a provider is selected, try to get technical details from various locations + if (selectedProvider) { // 1. First try the movie/episode relation content const content = selectedProvider.movie || selectedProvider.episode; if (content && (content.bitrate || content.video || content.audio)) { - source = content; + source = content; } // 2. Try technical details directly on the relation object else if ( - selectedProvider.bitrate || - selectedProvider.video || - selectedProvider.audio + selectedProvider.bitrate || + selectedProvider.video || + selectedProvider.audio ) { - source = selectedProvider; + source = selectedProvider; } // 3. Try to extract from custom_properties detailed_info (where quality data is stored) else if (selectedProvider.custom_properties?.detailed_info) { - const detailedInfo = selectedProvider.custom_properties.detailed_info; + const detailedInfo = selectedProvider.custom_properties.detailed_info; - // Create a synthetic source from detailed_info - const syntheticSource = { + // Create a synthetic source from detailed_info + const syntheticSource = { bitrate: detailedInfo.bitrate || null, video: detailedInfo.video || null, audio: detailedInfo.audio || null, - }; + }; - if ( + if ( syntheticSource.bitrate || syntheticSource.video || syntheticSource.audio - ) { + ) { source = syntheticSource; + } } - } -} + } -return { + return { bitrate: source?.bitrate, video: source?.video, audio: source?.audio, -}; + }; }; const VODCard = ({ vod, onClick }) => { -const isEpisode = vod.type === 'episode'; + const isEpisode = vod.type === 'episode'; -const getDisplayTitle = () => { + const getDisplayTitle = () => { if (isEpisode && vod.series) { - const seasonEp = + const seasonEp = vod.season_number && vod.episode_number - ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}` - : ''; - return ( + ? `S${vod.season_number.toString().padStart(2, '0')}E${vod.episode_number.toString().padStart(2, '0')}` + : ''; + return ( - + {vod.series.name} - - + + {seasonEp} - {vod.name} - + - ); + ); } return {vod.name}; -}; + }; -const handleCardClick = async () => { + const handleCardClick = async () => { // Just pass the basic vod info to the parent handler onClick(vod); -}; + }; -return ( + return ( - + - {vod.logo?.url ? ( + {vod.logo?.url ? ( {vod.name} - ) : ( + ) : ( - + - )} + )} - { - e.stopPropagation(); - onClick(vod); + e.stopPropagation(); + onClick(vod); }} - > + > - + - + > {isEpisode ? 'Episode' : 'Movie'} - + - + - + {getDisplayTitle()} - {vod.year && ( + {vod.year && ( - - + + {vod.year} - + - )} + )} - {vod.duration && ( + {vod.duration && ( - - + + {formatDuration(vod.duration_secs)} - + - )} + )} - {vod.rating && ( + {vod.rating && ( - - + + {vod.rating} - + - )} + )} {vod.genre && ( - + {vod.genre} - + )} - + -); + ); }; const SeriesCard = ({ series, onClick }) => { -return ( + return ( onClick(series)} + shadow="sm" + padding="md" + radius="md" + withBorder + style={{ cursor: 'pointer', backgroundColor: '#27272A' }} + onClick={() => onClick(series)} > - + - {series.logo?.url ? ( + {series.logo?.url ? ( {series.name} - ) : ( + ) : ( - + - )} - {/* Add Series badge in the same position as Movie badge */} - + > Series - + - + - + {series.name} - {series.year && ( + {series.year && ( - - + + {series.year} - + - )} - {series.rating && ( + )} + {series.rating && ( - - + + {series.rating} - + - )} + )} {series.genre && ( - + {series.genre} - + )} - + -); + ); }; const SeriesModal = ({ series, opened, onClose }) => { -const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore(); -const showVideo = useVideoStore((s) => s.showVideo); -const env_mode = useSettingsStore((s) => s.environment.env_mode); -const [detailedSeries, setDetailedSeries] = useState(null); -const [loadingDetails, setLoadingDetails] = useState(false); -const [activeTab, setActiveTab] = useState(null); -const [expandedEpisode, setExpandedEpisode] = useState(null); -const [trailerModalOpened, setTrailerModalOpened] = useState(false); -const [trailerUrl, setTrailerUrl] = useState(''); -const [providers, setProviders] = useState([]); -const [selectedProvider, setSelectedProvider] = useState(null); -const [loadingProviders, setLoadingProviders] = useState(false); + const { fetchSeriesInfo, fetchSeriesProviders } = useVODStore(); + const showVideo = useVideoStore((s) => s.showVideo); + const env_mode = useSettingsStore((s) => s.environment.env_mode); + const [detailedSeries, setDetailedSeries] = useState(null); + const [loadingDetails, setLoadingDetails] = useState(false); + const [activeTab, setActiveTab] = useState(null); + const [expandedEpisode, setExpandedEpisode] = useState(null); + const [trailerModalOpened, setTrailerModalOpened] = useState(false); + const [trailerUrl, setTrailerUrl] = useState(''); + const [providers, setProviders] = useState([]); + const [selectedProvider, setSelectedProvider] = useState(null); + const [loadingProviders, setLoadingProviders] = useState(false); -useEffect(() => { + useEffect(() => { if (opened && series) { - // Fetch detailed series info which now includes episodes - setLoadingDetails(true); - fetchSeriesInfo(series.id) + // Fetch detailed series info which now includes episodes + setLoadingDetails(true); + fetchSeriesInfo(series.id) .then((details) => { - setDetailedSeries(details); - // Check if episodes were fetched - if (!details.episodes_fetched) { + setDetailedSeries(details); + // Check if episodes were fetched + if (!details.episodes_fetched) { // Episodes not yet fetched, may need to wait for background fetch - } + } }) .catch((error) => { - console.warn( + console.warn( 'Failed to fetch series details, using basic info:', error - ); - setDetailedSeries(series); // Fallback to basic data + ); + setDetailedSeries(series); // Fallback to basic data }) .finally(() => { - setLoadingDetails(false); + setLoadingDetails(false); }); - // Fetch available providers - setLoadingProviders(true); - fetchSeriesProviders(series.id) + // Fetch available providers + setLoadingProviders(true); + fetchSeriesProviders(series.id) .then((providersData) => { - setProviders(providersData); - // Set the first provider as default if none selected - if (providersData.length > 0 && !selectedProvider) { + setProviders(providersData); + // Set the first provider as default if none selected + if (providersData.length > 0 && !selectedProvider) { setSelectedProvider(providersData[0]); - } + } }) .catch((error) => { - console.error('Failed to fetch series providers:', error); - setProviders([]); + console.error('Failed to fetch series providers:', error); + setProviders([]); }) .finally(() => { - setLoadingProviders(false); + setLoadingProviders(false); }); } -}, [opened, series, fetchSeriesInfo, fetchSeriesProviders, selectedProvider]); + }, [opened, series, fetchSeriesInfo, fetchSeriesProviders, selectedProvider]); -useEffect(() => { + useEffect(() => { if (!opened) { - setDetailedSeries(null); - setLoadingDetails(false); - setProviders([]); - setSelectedProvider(null); - setLoadingProviders(false); + setDetailedSeries(null); + setLoadingDetails(false); + setProviders([]); + setSelectedProvider(null); + setLoadingProviders(false); } -}, [opened]); + }, [opened]); -// Get episodes from the store based on the series ID -const seriesEpisodes = React.useMemo(() => { + // Get episodes from the store based on the series ID + const seriesEpisodes = React.useMemo(() => { if (!detailedSeries) return []; // Try to get episodes from the fetched data if (detailedSeries.episodesList) { - return detailedSeries.episodesList.sort((a, b) => { + return detailedSeries.episodesList.sort((a, b) => { if (a.season_number !== b.season_number) { - return (a.season_number || 0) - (b.season_number || 0); + return (a.season_number || 0) - (b.season_number || 0); } return (a.episode_number || 0) - (b.episode_number || 0); - }); + }); } // If no episodes in detailed series, return empty array return []; -}, [detailedSeries]); + }, [detailedSeries]); -// Group episodes by season -const episodesBySeason = React.useMemo(() => { + // Group episodes by season + const episodesBySeason = React.useMemo(() => { const grouped = {}; seriesEpisodes.forEach((episode) => { - const season = episode.season_number || 1; - if (!grouped[season]) { + const season = episode.season_number || 1; + if (!grouped[season]) { grouped[season] = []; - } - grouped[season].push(episode); + } + grouped[season].push(episode); }); return grouped; -}, [seriesEpisodes]); + }, [seriesEpisodes]); -// Get available seasons sorted -const seasons = React.useMemo(() => { + // Get available seasons sorted + const seasons = React.useMemo(() => { return Object.keys(episodesBySeason) - .map(Number) - .sort((a, b) => a - b); -}, [episodesBySeason]); + .map(Number) + .sort((a, b) => a - b); + }, [episodesBySeason]); -// Update active tab when seasons change or modal opens -React.useEffect(() => { + // Update active tab when seasons change or modal opens + React.useEffect(() => { if (seasons.length > 0) { - if ( + if ( !activeTab || !seasons.includes(parseInt(activeTab.replace('season-', ''))) - ) { + ) { setActiveTab(`season-${seasons[0]}`); + } } - } -}, [seasons, activeTab]); + }, [seasons, activeTab]); -// Reset tab when modal closes -React.useEffect(() => { + // Reset tab when modal closes + React.useEffect(() => { if (!opened) { - setActiveTab(null); + setActiveTab(null); } -}, [opened]); + }, [opened]); -const handlePlayEpisode = (episode) => { + const handlePlayEpisode = (episode) => { let streamUrl = `/proxy/vod/episode/${episode.uuid}`; // Add selected provider as query parameter if available if (selectedProvider) { - // Use stream_id for most specific selection, fallback to account_id - if (selectedProvider.stream_id) { + // Use stream_id for most specific selection, fallback to account_id + if (selectedProvider.stream_id) { streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; - } else { + } else { streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; - } + } } if (env_mode === 'dev') { - streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; + streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; } else { - streamUrl = `${window.location.origin}${streamUrl}`; + streamUrl = `${window.location.origin}${streamUrl}`; } showVideo(streamUrl, 'vod', episode); -}; + }; -const handleEpisodeRowClick = (episode) => { + const handleEpisodeRowClick = (episode) => { setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id); -}; + }; -// Helper to get embeddable YouTube URL -const getEmbedUrl = (url) => { + // Helper to get embeddable YouTube URL + const getEmbedUrl = (url) => { if (!url) return ''; // Accepts full YouTube URLs or just IDs const match = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([\w-]+)/); const videoId = match ? match[1] : url; return `https://www.youtube.com/embed/${videoId}`; -}; + }; -if (!series) return null; + if (!series) return null; -// Use detailed data if available, otherwise use basic series data -const displaySeries = detailedSeries || series; + // Use detailed data if available, otherwise use basic series data + const displaySeries = detailedSeries || series; -return ( + return ( <> - + > - {/* Backdrop image as background */} - {displaySeries.backdrop_path && + {/* Backdrop image as background */} + {displaySeries.backdrop_path && displaySeries.backdrop_path.length > 0 && ( - <> + <> {`${displaySeries.name} {/* Overlay for readability */} - + )} - {/* Modal content above backdrop */} - + {/* Modal content above backdrop */} + - {loadingDetails && ( + {loadingDetails && ( - - + + Loading series details and episodes... - + - )} + )} - {/* Series poster and basic info */} - + {/* Series poster and basic info */} + {displaySeries.series_image || displaySeries.logo?.url ? ( - + {displaySeries.name} - + ) : ( - + > - + )} - {displaySeries.name} + {displaySeries.name} - {/* Original name if different */} - {displaySeries.o_name && + {/* Original name if different */} + {displaySeries.o_name && displaySeries.o_name !== displaySeries.name && ( - + > Original: {displaySeries.o_name} - + )} - + {displaySeries.year && ( - {displaySeries.year} + {displaySeries.year} )} {displaySeries.rating && ( - {displaySeries.rating} + {displaySeries.rating} )} {displaySeries.age && ( - {displaySeries.age} + {displaySeries.age} )} Series {displaySeries.episode_count && ( - + {displaySeries.episode_count} episodes - + )} {/* imdb_id and tmdb_id badges */} {displaySeries.imdb_id && ( - + > IMDb - + )} {displaySeries.tmdb_id && ( - + > TMDb - + )} - + - {/* Release date */} - {displaySeries.release_date && ( + {/* Release date */} + {displaySeries.release_date && ( - Release Date:{' '} - {displaySeries.release_date} + Release Date:{' '} + {displaySeries.release_date} - )} + )} - {displaySeries.genre && ( + {displaySeries.genre && ( - Genre: {displaySeries.genre} + Genre: {displaySeries.genre} - )} + )} - {displaySeries.director && ( + {displaySeries.director && ( - Director: {displaySeries.director} + Director: {displaySeries.director} - )} + )} - {displaySeries.cast && ( + {displaySeries.cast && ( - Cast: {displaySeries.cast} + Cast: {displaySeries.cast} - )} + )} - {displaySeries.country && ( + {displaySeries.country && ( - Country: {displaySeries.country} + Country: {displaySeries.country} - )} + )} - {/* Description */} - {displaySeries.description && ( + {/* Description */} + {displaySeries.description && ( - + Description - - {displaySeries.description} + + {displaySeries.description} - )} + )} - {/* Watch Trailer button if available */} - {displaySeries.youtube_trailer && ( + {/* Watch Trailer button if available */} + {displaySeries.youtube_trailer && ( - )} + )} - + - {/* Provider Information */} - + {/* Provider Information */} + - Stream Selection - {loadingProviders && ( + Stream Selection + {loadingProviders && ( - )} + )} {providers.length === 0 && !loadingProviders && displaySeries.m3u_account ? ( - + - {displaySeries.m3u_account.name} + {displaySeries.m3u_account.name} {displaySeries.m3u_account.account_type && ( - + {displaySeries.m3u_account.account_type === 'XC' - ? 'Xtream Codes' - : 'Standard M3U'} - + ? 'Xtream Codes' + : 'Standard M3U'} + )} - + ) : providers.length === 1 ? ( - + - {providers[0].m3u_account.name} + {providers[0].m3u_account.name} {providers[0].m3u_account.account_type && ( - + {providers[0].m3u_account.account_type === 'XC' - ? 'Xtream Codes' - : 'Standard M3U'} - + ? 'Xtream Codes' + : 'Standard M3U'} + )} {providers[0].stream_id && ( - + Stream {providers[0].stream_id} - + )} - + ) : providers.length > 1 ? ( - ({ - value: provider.id.toString(), - label: formatStreamLabel(provider), + value: provider.id.toString(), + label: formatStreamLabel(provider), }))} value={selectedProvider?.id?.toString() || ''} onChange={(value) => { - const provider = providers.find( + const provider = providers.find( (p) => p.id.toString() === value - ); - setSelectedProvider(provider); + ); + setSelectedProvider(provider); }} placeholder="Select stream..." style={{ minWidth: 250 }} disabled={loadingProviders} - /> + /> )} - + )} {/* Fallback provider info if no providers loaded yet */} {providers.length === 0 && - !loadingProviders && - vod?.m3u_account && ( + !loadingProviders && + vod?.m3u_account && ( - + Stream Selection - - + + - {vod.m3u_account.name} + {vod.m3u_account.name} {vod.m3u_account.account_type && ( - + {vod.m3u_account.account_type === 'XC' - ? 'Xtream Codes' - : 'Standard M3U'} - + ? 'Xtream Codes' + : 'Standard M3U'} + )} - + - )} + )} {/* Play Button */} - + - {/* Technical Details */} - {(() => { + {/* Technical Details */} + {(() => { const techDetails = getTechnicalDetails( - selectedProvider, - displayVOD + selectedProvider, + displayVOD ); const hasDetails = - techDetails.bitrate || techDetails.video || techDetails.audio; + techDetails.bitrate || techDetails.video || techDetails.audio; return ( - hasDetails && ( + hasDetails && ( - + Technical Details: {selectedProvider && ( - + > (from {selectedProvider.m3u_account.name} {selectedProvider.stream_id && - ` - Stream ${selectedProvider.stream_id}`} + ` - Stream ${selectedProvider.stream_id}`} ) - + )} - - {techDetails.bitrate && techDetails.bitrate > 0 && ( + + {techDetails.bitrate && techDetails.bitrate > 0 && ( - Bitrate: {techDetails.bitrate} kbps + Bitrate: {techDetails.bitrate} kbps - )} - {techDetails.video && + )} + {techDetails.video && Object.keys(techDetails.video).length > 0 && ( - + Video:{' '} {techDetails.video.codec_long_name && techDetails.video.codec_long_name !== 'unknown' - ? techDetails.video.codec_long_name - : techDetails.video.codec_name} + ? techDetails.video.codec_long_name + : techDetails.video.codec_name} {techDetails.video.profile - ? ` (${techDetails.video.profile})` - : ''} + ? ` (${techDetails.video.profile})` + : ''} {techDetails.video.width && techDetails.video.height - ? `, ${techDetails.video.width}x${techDetails.video.height}` - : ''} + ? `, ${techDetails.video.width}x${techDetails.video.height}` + : ''} {techDetails.video.display_aspect_ratio - ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}` - : ''} + ? `, Aspect Ratio: ${techDetails.video.display_aspect_ratio}` + : ''} {techDetails.video.bit_rate - ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps` - : ''} + ? `, Bitrate: ${Math.round(Number(techDetails.video.bit_rate) / 1000)} kbps` + : ''} {techDetails.video.r_frame_rate - ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps` - : ''} + ? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps` + : ''} {techDetails.video.tags?.encoder - ? `, Encoder: ${techDetails.video.tags.encoder}` - : ''} - + ? `, Encoder: ${techDetails.video.tags.encoder}` + : ''} + )} - {techDetails.audio && + {techDetails.audio && Object.keys(techDetails.audio).length > 0 && ( - + Audio:{' '} {techDetails.audio.codec_long_name && techDetails.audio.codec_long_name !== 'unknown' - ? techDetails.audio.codec_long_name - : techDetails.audio.codec_name} + ? techDetails.audio.codec_long_name + : techDetails.audio.codec_name} {techDetails.audio.profile - ? ` (${techDetails.audio.profile})` - : ''} + ? ` (${techDetails.audio.profile})` + : ''} {techDetails.audio.channel_layout - ? `, Channels: ${techDetails.audio.channel_layout}` - : techDetails.audio.channels + ? `, Channels: ${techDetails.audio.channel_layout}` + : techDetails.audio.channels ? `, Channels: ${techDetails.audio.channels}` : ''} {techDetails.audio.sample_rate - ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz` - : ''} + ? `, Sample Rate: ${techDetails.audio.sample_rate} Hz` + : ''} {techDetails.audio.bit_rate - ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps` - : ''} + ? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps` + : ''} {techDetails.audio.tags?.handler_name - ? `, Handler: ${techDetails.audio.tags.handler_name}` - : ''} - + ? `, Handler: ${techDetails.audio.tags.handler_name}` + : ''} + )} - ) + ) ); - })()} - {/* YouTube trailer if available */} + })()} + {/* YouTube trailer if available */} + - - - {/* YouTube Trailer Modal */} - + {/* YouTube Trailer Modal */} + setTrailerModalOpened(false)} title="Trailer" size="xl" centered withCloseButton - > + > - {trailerUrl && ( + {trailerUrl && (