diff --git a/frontend/src/components/ConfirmationDialog.jsx b/frontend/src/components/ConfirmationDialog.jsx index 94fb169c..86e7d7af 100644 --- a/frontend/src/components/ConfirmationDialog.jsx +++ b/frontend/src/components/ConfirmationDialog.jsx @@ -1,4 +1,4 @@ -import { Modal, Group, Text, Button, Checkbox, Box } from '@mantine/core'; +import { Modal, Group, Button, Checkbox, Box } from '@mantine/core'; import React, { useState } from 'react'; import useWarningsStore from '../store/warnings'; diff --git a/frontend/src/components/Field.jsx b/frontend/src/components/Field.jsx index 1293bf7b..335c55cd 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -4,6 +4,7 @@ import React from 'react'; export const Field = ({ field, value, onChange }) => { const common = { label: field.label, description: field.help_text }; const effective = value ?? field.default; + switch (field.type) { case 'boolean': return ( diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 557767ed..d25f7468 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -1,39 +1,21 @@ // frontend/src/components/FloatingVideo.js -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import Draggable from 'react-draggable'; import useVideoStore from '../store/useVideoStore'; import mpegts from 'mpegts.js'; -import { CloseButton, Flex, Loader, Text, Box } from '@mantine/core'; +import {Box, CloseButton, Flex, Loader, Text} from '@mantine/core'; +import { + applyConstraints, + calculateNewDimensions, + getClientCoordinates, + getLivePlayerErrorMessage, + getVODPlayerErrorMessage, +} from '../utils/components/FloatingVideoUtils.js'; -export default function FloatingVideo() { - const isVisible = useVideoStore((s) => s.isVisible); - const streamUrl = useVideoStore((s) => s.streamUrl); - const contentType = useVideoStore((s) => s.contentType); - const metadata = useVideoStore((s) => s.metadata); - const hideVideo = useVideoStore((s) => s.hideVideo); - const videoRef = useRef(null); - const playerRef = useRef(null); - const videoContainerRef = useRef(null); - const [isLoading, setIsLoading] = useState(false); - const [loadError, setLoadError] = useState(null); - const [showOverlay, setShowOverlay] = useState(true); - const [videoSize, setVideoSize] = useState({ width: 320, height: 180 }); - const [isResizing, setIsResizing] = useState(false); - const resizeStateRef = useRef(null); - const overlayTimeoutRef = useRef(null); - const aspectRatioRef = useRef(320 / 180); - const [dragPosition, setDragPosition] = useState(null); - const dragPositionRef = useRef(null); - const dragOffsetRef = useRef({ x: 0, y: 0 }); - const initialPositionRef = useRef(null); - - const MIN_WIDTH = 220; - const MIN_HEIGHT = 124; - const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging - const HEADER_HEIGHT = 38; // height of the close button header area - const ERROR_HEIGHT = 45; // approximate height of error message area when displayed +const ResizeHandles = ({ startResize }) => { const HANDLE_SIZE = 18; const HANDLE_OFFSET = 0; + const resizeHandleBaseStyle = { position: 'absolute', width: HANDLE_SIZE, @@ -43,6 +25,7 @@ export default function FloatingVideo() { zIndex: 8, touchAction: 'none', }; + const resizeHandles = [ { id: 'bottom-right', @@ -106,6 +89,56 @@ export default function FloatingVideo() { }, ]; + return ( + <> + {/* Resize handles */} + {resizeHandles.map((handle) => ( + startResize(event, handle)} + onTouchStart={(event) => startResize(event, handle)} + style={{ + ...resizeHandleBaseStyle, + ...handle.style, + cursor: handle.cursor, + }} + /> + ))} + + ); +} + +export default function FloatingVideo() { + const isVisible = useVideoStore((s) => s.isVisible); + const streamUrl = useVideoStore((s) => s.streamUrl); + const contentType = useVideoStore((s) => s.contentType); + const metadata = useVideoStore((s) => s.metadata); + const hideVideo = useVideoStore((s) => s.hideVideo); + + const videoRef = useRef(null); + const playerRef = useRef(null); + const videoContainerRef = useRef(null); + const resizeStateRef = useRef(null); + const overlayTimeoutRef = useRef(null); + const aspectRatioRef = useRef(320 / 180); + const dragPositionRef = useRef(null); + const dragOffsetRef = useRef({ x: 0, y: 0 }); + const initialPositionRef = useRef(null); + + const [isLoading, setIsLoading] = useState(false); + const [loadError, setLoadError] = useState(null); + const [showOverlay, setShowOverlay] = useState(true); + const [videoSize, setVideoSize] = useState({ width: 320, height: 180 }); + const [isResizing, setIsResizing] = useState(false); + const [dragPosition, setDragPosition] = useState(null); + + const MIN_WIDTH = 220; + const MIN_HEIGHT = 124; + const VISIBLE_MARGIN = 48; // keep part of the window visible when dragging + const HEADER_HEIGHT = 38; // height of the close button header area + const ERROR_HEIGHT = 45; // approximate height of error message area when displayed + // Safely destroy the mpegts player to prevent errors const safeDestroyPlayer = () => { try { @@ -190,41 +223,20 @@ export default function FloatingVideo() { }; const handleError = (e) => { setIsLoading(false); - const error = e.target.error; - let errorMessage = 'Video playback error'; - if (error) { - switch (error.code) { - case error.MEDIA_ERR_ABORTED: - errorMessage = 'Video playback was aborted'; - break; - case error.MEDIA_ERR_NETWORK: - errorMessage = 'Network error while loading video'; - break; - case error.MEDIA_ERR_DECODE: - errorMessage = 'Video codec not supported by your browser'; - break; - case error.MEDIA_ERR_SRC_NOT_SUPPORTED: - errorMessage = 'Video format not supported by your browser'; - break; - default: - errorMessage = error.message || 'Unknown video error'; - } - } - - setLoadError(errorMessage); + setLoadError(getVODPlayerErrorMessage(e.target.error)); }; // Enhanced progress tracking for VOD const handleProgress = () => { - if (video.buffered.length > 0) { - const bufferedEnd = video.buffered.end(video.buffered.length - 1); - const duration = video.duration; - if (duration > 0) { - const bufferedPercent = (bufferedEnd / duration) * 100; - // You could emit this to a store for UI feedback - } - } + // if (video.buffered.length > 0) { + // const bufferedEnd = video.buffered.end(video.buffered.length - 1); + // const duration = video.duration; + // if (duration > 0) { + // const bufferedPercent = (bufferedEnd / duration) * 100; + // // You could emit this to a store for UI feedback + // } + // } }; // Add event listeners @@ -301,37 +313,7 @@ export default function FloatingVideo() { if (errorType !== 'NetworkError' || !errorDetail?.includes('aborted')) { console.error('Player error:', errorType, errorDetail); - let errorMessage = `Error: ${errorType}`; - - if (errorType === 'MediaError') { - const errorString = errorDetail?.toLowerCase() || ''; - - if ( - errorString.includes('audio') || - errorString.includes('ac3') || - errorString.includes('ac-3') - ) { - errorMessage = - 'Audio codec not supported by your browser. Try Chrome or Edge for better audio codec support.'; - } else if ( - errorString.includes('video') || - errorString.includes('h264') || - errorString.includes('h.264') - ) { - errorMessage = - 'Video codec not supported by your browser. Try Chrome or Edge for better video codec support.'; - } else if (errorString.includes('mse')) { - errorMessage = - "Your browser doesn't support the codecs used in this stream. Try Chrome or Edge for better compatibility."; - } else { - errorMessage = - 'Media codec not supported by your browser. This may be due to unsupported audio (AC3) or video codecs. Try Chrome or Edge.'; - } - } else if (errorDetail) { - errorMessage += ` - ${errorDetail}`; - } - - setLoadError(errorMessage); + setLoadError(getLivePlayerErrorMessage(errorType, errorDetail)); } }); @@ -448,15 +430,7 @@ export default function FloatingVideo() { (event) => { if (!resizeStateRef.current) return; - const clientX = - event.touches && event.touches.length - ? event.touches[0].clientX - : event.clientX; - const clientY = - event.touches && event.touches.length - ? event.touches[0].clientY - : event.clientY; - + const { clientX, clientY } = getClientCoordinates(event); const { startX, startY, @@ -466,104 +440,62 @@ export default function FloatingVideo() { handle, aspectRatio, } = resizeStateRef.current; - const deltaX = clientX - startX; - const deltaY = clientY - startY; - const widthDelta = deltaX * handle.xDir; - const heightDelta = deltaY * handle.yDir; + const ratio = aspectRatio || aspectRatioRef.current; + const { width: nextWidth, height: nextHeight } = calculateNewDimensions( + clientX - startX, + clientY - startY, + startWidth, + startHeight, + handle, + ratio + ); - // Derive width/height while keeping the original aspect ratio - let nextWidth = startWidth + widthDelta; - let nextHeight = nextWidth / ratio; - - // Allow vertical-driven resize if the user drags mostly vertically - if (Math.abs(deltaY) > Math.abs(deltaX)) { - nextHeight = startHeight + heightDelta; - nextWidth = nextHeight * ratio; - } - - // Respect minimums while keeping the ratio - if (nextWidth < MIN_WIDTH) { - nextWidth = MIN_WIDTH; - nextHeight = nextWidth / ratio; - } - - if (nextHeight < MIN_HEIGHT) { - nextHeight = MIN_HEIGHT; - nextWidth = nextHeight * ratio; - } - - // Keep within viewport with a margin based on current position - const posX = startPos?.x ?? 0; - const posY = startPos?.y ?? 0; - const margin = VISIBLE_MARGIN; - let maxWidth = null; - let maxHeight = null; - - if (!handle.isLeft) { - maxWidth = Math.max(MIN_WIDTH, window.innerWidth - posX - margin); - } - - if (!handle.isTop) { - maxHeight = Math.max(MIN_HEIGHT, window.innerHeight - posY - margin); - } - - if (maxWidth != null && nextWidth > maxWidth) { - nextWidth = maxWidth; - nextHeight = nextWidth / ratio; - } - - if (maxHeight != null && nextHeight > maxHeight) { - nextHeight = maxHeight; - nextWidth = nextHeight * ratio; - } - - // Final pass to honor both bounds while keeping the ratio - if (maxWidth != null && nextWidth > maxWidth) { - nextWidth = maxWidth; - nextHeight = nextWidth / ratio; - } + const constrainedSize = applyConstraints( + nextWidth, + nextHeight, + ratio, + startPos, + handle, + MIN_WIDTH, + MIN_HEIGHT, + VISIBLE_MARGIN + ); setVideoSize({ - width: Math.round(nextWidth), - height: Math.round(nextHeight), + width: Math.round(constrainedSize.width), + height: Math.round(constrainedSize.height), }); - if (handle.isLeft || handle.isTop) { - let nextX = posX; - let nextY = posY; - - if (handle.isLeft) { - nextX = posX + (startWidth - nextWidth); - } - - if (handle.isTop) { - nextY = posY + (startHeight - nextHeight); - } - - const clamped = clampToVisibleWithSize( - nextX, - nextY, - nextWidth, - nextHeight - ); - - if (handle.isLeft) { - nextX = clamped.x; - } - - if (handle.isTop) { - nextY = clamped.y; - } - - const nextPos = { x: nextX, y: nextY }; - setDragPosition(nextPos); - dragPositionRef.current = nextPos; - } + updatePositionIfNeeded( + handle, + startPos, + startWidth, + startHeight, + constrainedSize + ); }, [MIN_HEIGHT, MIN_WIDTH, VISIBLE_MARGIN, clampToVisibleWithSize] ); + const updatePositionIfNeeded = (handle, startPos, startWidth, startHeight, newSize) => { + if (!handle.isLeft && !handle.isTop) return; + + const posX = startPos?.x ?? 0; + const posY = startPos?.y ?? 0; + let nextX = handle.isLeft ? posX + (startWidth - newSize.width) : posX; + let nextY = handle.isTop ? posY + (startHeight - newSize.height) : posY; + + const clamped = clampToVisibleWithSize(nextX, nextY, newSize.width, newSize.height); + const nextPos = { + x: handle.isLeft ? clamped.x : nextX, + y: handle.isTop ? clamped.y : nextY, + }; + + setDragPosition(nextPos); + dragPositionRef.current = nextPos; + }; + const endResize = useCallback(() => { setIsResizing(false); resizeStateRef.current = null; @@ -577,14 +509,7 @@ export default function FloatingVideo() { event.stopPropagation(); event.preventDefault(); - const clientX = - event.touches && event.touches.length - ? event.touches[0].clientX - : event.clientX; - const clientY = - event.touches && event.touches.length - ? event.touches[0].clientY - : event.clientY; + const { clientX, clientY } = getClientCoordinates(event); const aspectRatio = videoSize.height > 0 @@ -719,20 +644,15 @@ export default function FloatingVideo() { }} > {/* Simple header row with a close button */} - + e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} + mih={32} + miw={32} style={{ - minHeight: '32px', - minWidth: '32px', cursor: 'pointer', touchAction: 'manipulation', }} @@ -741,7 +661,7 @@ export default function FloatingVideo() { {/* Video container with relative positioning for the overlay */} { if (contentType === 'vod' && !isLoading) { setShowOverlay(true); @@ -781,17 +701,17 @@ export default function FloatingVideo() { {/* VOD title overlay when not loading - auto-hides after 4 seconds */} {!isLoading && metadata && contentType === 'vod' && showOverlay && ( {metadata.year} @@ -816,14 +736,14 @@ export default function FloatingVideo() { {/* Loading overlay - only show when loading */} {isLoading && ( - + Loading {contentType === 'vod' ? 'video' : 'stream'}... @@ -841,32 +761,19 @@ export default function FloatingVideo() { {/* Error message below video - doesn't block controls */} {!isLoading && loadError && ( - + {loadError} )} - {/* Resize handles */} - {resizeHandles.map((handle) => ( - startResize(event, handle)} - onTouchStart={(event) => startResize(event, handle)} - style={{ - ...resizeHandleBaseStyle, - ...handle.style, - cursor: handle.cursor, - }} - /> - ))} + ); diff --git a/frontend/src/components/M3URefreshNotification.jsx b/frontend/src/components/M3URefreshNotification.jsx index b9819b4e..5b40cadb 100644 --- a/frontend/src/components/M3URefreshNotification.jsx +++ b/frontend/src/components/M3URefreshNotification.jsx @@ -1,7 +1,6 @@ // frontend/src/components/FloatingVideo.js import React, { useEffect, useState } from 'react'; import usePlaylistsStore from '../store/playlists'; -import { notifications } from '@mantine/notifications'; import useStreamsStore from '../store/streams'; import useChannelsStore from '../store/channels'; import useEPGsStore from '../store/epgs'; @@ -10,6 +9,39 @@ import { Stack, Button, Group } from '@mantine/core'; import API from '../api'; import { useNavigate } from 'react-router-dom'; import { CircleCheck } from 'lucide-react'; +import { showNotification } from '../utils/notificationUtils.js'; + +const M3uSetupSuccess = (data) => { + const navigate = useNavigate(); + + const onClickRefresh = () => { + API.refreshPlaylist(data.account); + } + + const onClickConfigure = () => { + // Store the ID we want to edit in the store first + usePlaylistsStore.getState().setEditPlaylistId(data.account); + + // Then navigate to the content sources page + // Using the exact path that matches your app's routing structure + navigate('/sources'); + } + + return ( + + {data.message || + 'M3U groups loaded. Configure group filters and auto channel sync settings.'} + + + + + + ); +} export default function M3URefreshNotification() { const playlists = usePlaylistsStore((s) => s.playlists); @@ -22,146 +54,109 @@ export default function M3URefreshNotification() { const fetchCategories = useVODStore((s) => s.fetchCategories); const [notificationStatus, setNotificationStatus] = useState({}); - const navigate = useNavigate(); const handleM3UUpdate = (data) => { - if ( - JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data) - ) { + // Skip if status hasn't changed + if (JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data)) { return; } const playlist = playlists.find((pl) => pl.id == data.account); - if (!playlist) { - return; - } + if (!playlist) return; - // Store the updated status first - setNotificationStatus({ - ...notificationStatus, + // Update notification status + setNotificationStatus(prev => ({ + ...prev, [data.account]: data, - }); + })); - // Special handling for pending setup status + // Handle different status types if (data.status === 'pending_setup') { - fetchChannelGroups(); - fetchPlaylists(); - - notifications.show({ - title: `M3U Setup: ${playlist.name}`, - message: ( - - {data.message || - 'M3U groups loaded. Configure group filters and auto channel sync settings.'} - - - - - - ), - color: 'orange.5', - autoClose: 5000, // Keep visible a bit longer - }); + handlePendingSetup(playlist, data); return; } - // Check for error status FIRST before doing anything else if (data.status === 'error') { - // Only show the error notification if we have a complete task (progress=100) - // or if it's explicitly flagged as an error - if (data.progress === 100) { - notifications.show({ - title: `M3U Processing: ${playlist.name}`, - message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`, - color: 'red', - autoClose: 5000, // Keep error visible a bit longer - }); - } - return; // Exit early for any error status - } - - // Check if we already have an error stored for this account, and if so, don't show further notifications - const currentStatus = notificationStatus[data.account]; - if (currentStatus && currentStatus.status === 'error') { - // Don't show any other notifications once we've hit an error + handleError(playlist, data); return; } - const taskProgress = data.progress; - - // Only show start and completion notifications for normal operation - if (data.progress != 0 && data.progress != 100) { + // Skip if already errored + if (notificationStatus[data.account]?.status === 'error') { return; } - let message = ''; - switch (data.action) { - case 'downloading': - message = 'Downloading'; - break; + // Handle normal progress updates (0% start, 100% completion) + if (data.progress === 0 || data.progress === 100) { + handleProgressNotification(playlist, data); + } + }; - case 'parsing': - message = 'Stream parsing'; - break; + const handlePendingSetup = (playlist, data) => { + fetchChannelGroups(); + fetchPlaylists(); - case 'processing_groups': - message = 'Group parsing'; - break; + showNotification({ + title: `M3U Setup: ${playlist.name}`, + message: , + color: 'orange.5', + autoClose: 5000, + }); + }; - case 'vod_refresh': - message = 'VOD content refresh'; - break; + const handleError = (playlist, data) => { + if (data.progress === 100) { + showNotification({ + title: `M3U Processing: ${playlist.name}`, + message: `${data.action || 'Processing'} failed: ${data.error || 'Unknown error'}`, + color: 'red', + autoClose: 5000, + }); + } + }; + + const getActionMessage = (action) => { + const messages = { + downloading: 'Downloading', + parsing: 'Stream parsing', + processing_groups: 'Group parsing', + vod_refresh: 'VOD content refresh', + }; + return messages[action] || 'Processing'; + }; + + const triggerPostCompletionFetches = (action) => { + if (action == 'parsing') { + fetchStreams(); + API.requeryChannels(); + fetchChannels(); + } else if (action == 'processing_groups') { + fetchStreams(); + fetchChannelGroups(); + fetchEPGData(); + fetchPlaylists(); + } else if (action == 'vod_refresh') { + fetchPlaylists(); + fetchCategories(); + } + }; + + const handleProgressNotification = (playlist, data) => { + const baseMessage = getActionMessage(data.action); + const message = data.progress == 0 + ? `${baseMessage} starting...` + : `${baseMessage} complete!`; + + if (data.progress == 100) { + triggerPostCompletionFetches(data.action); } - if (taskProgress == 0) { - message = `${message} starting...`; - } else if (taskProgress == 100) { - message = `${message} complete!`; - - // Only trigger additional fetches on successful completion - if (data.action == 'parsing') { - fetchStreams(); - API.requeryChannels(); - fetchChannels(); - } else if (data.action == 'processing_groups') { - fetchStreams(); - fetchChannelGroups(); - fetchEPGData(); - fetchPlaylists(); - } else if (data.action == 'vod_refresh') { - // VOD refresh completed, trigger VOD categories refresh - fetchPlaylists(); // Refresh playlist data to show updated VOD info - fetchCategories(); // Refresh VOD categories to make them visible - } - } - - notifications.show({ + showNotification({ title: `M3U Processing: ${playlist.name}`, message, - loading: taskProgress == 0, + loading: data.progress == 0, autoClose: 2000, - icon: taskProgress == 100 ? : null, + icon: data.progress == 100 ? : null, }); }; diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx index c8e551af..367e92a1 100644 --- a/frontend/src/components/SeriesModal.jsx +++ b/frontend/src/components/SeriesModal.jsx @@ -1,128 +1,345 @@ -import React, { useState, useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { + ActionIcon, + Badge, Box, Button, + Divider, Flex, Group, Image, + Loader, + Modal, + Select, + Stack, + Table, + TableTbody, + TableTd, + TableTh, + TableThead, + TableTr, + Tabs, + TabsList, + TabsPanel, + TabsTab, Text, Title, - Select, - Badge, - Loader, - Stack, - ActionIcon, - Modal, - Tabs, - Table, - Divider, } from '@mantine/core'; -import { Play, Copy } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; +import { Copy, Play } from 'lucide-react'; import { copyToClipboard } from '../utils'; import useVODStore from '../store/useVODStore'; import useVideoStore from '../store/useVideoStore'; import useSettingsStore from '../store/settings'; +import { + formatDuration, + formatStreamLabel, + getEpisodeAirdate, + getEpisodeStreamUrl, + getTmdbUrlLink, + getYouTubeEmbedUrl, + groupEpisodesBySeason, + imdbUrl, + sortBySeasonNumber, + sortEpisodesList, + tmdbUrl, +} from '../utils/components/SeriesModalUtils.js'; +import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx'; -const imdbUrl = (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}` : ''; -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`; +const Series = ({ displaySeries, onClickYouTubeTrailer }) => { + return ( + + {displaySeries.series_image || displaySeries.logo?.url ? ( + + {displaySeries.name} + + ) : ( + + + + )} + + + {displaySeries.name} + + {/* Original name if different */} + {displaySeries.o_name && + displaySeries.o_name !== displaySeries.name && ( + + Original: {displaySeries.o_name} + + )} + + + {displaySeries.year && ( + {displaySeries.year} + )} + {displaySeries.rating && ( + {displaySeries.rating} + )} + {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} + + )} + + {displaySeries.genre && ( + + Genre: {displaySeries.genre} + + )} + + {displaySeries.director && ( + + Director: {displaySeries.director} + + )} + + {displaySeries.cast && ( + + Cast: {displaySeries.cast} + + )} + + {displaySeries.country && ( + + Country: {displaySeries.country} + + )} + + {/* Description */} + {displaySeries.description && ( + + + Description + + {displaySeries.description} + + )} + + {/* Watch Trailer button if available */} + {displaySeries.youtube_trailer && ( + + )} + + + ); }; -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; +const Episode = ({ episode, displaySeries }) => { + return ( + + {/* Episode Image and Description Row */} + + {/* Episode Image */} + {episode.movie_image && ( + + {episode.name} + + )} - // Try to extract quality info - prioritizing the new quality_info field from backend - let qualityInfo = ''; + {/* Episode Description */} + + {episode.description && ( + + + Description + + + {episode.description} + + + )} + + - // 1. Check the new quality_info field from backend (PRIMARY) - if (relation.quality_info) { - if (relation.quality_info.quality) { - qualityInfo = ` - ${relation.quality_info.quality}`; - } else if (relation.quality_info.resolution) { - qualityInfo = ` - ${relation.quality_info.resolution}`; - } else if (relation.quality_info.bitrate) { - qualityInfo = ` - ${relation.quality_info.bitrate}`; - } - } + {/* Additional Episode Details */} + + {episode.rating && ( + + + Rating + + + {episode.rating} + + + )} + {/* IMDb and TMDb badges for episode */} + {(episode.imdb_id || displaySeries.tmdb_id) && ( + + + Links + + {episode.imdb_id && ( + + IMDb + + )} + {displaySeries.tmdb_id && ( + + TMDb + + )} + + )} - // 2. Fallback: Check custom_properties detailed info structure - if (qualityInfo === '' && relation.custom_properties) { - const props = relation.custom_properties; + {episode.director && ( + + + Director + + {episode.director} + + )} - // Check detailed_info structure (where the real data is!) - if (qualityInfo === '' && props.detailed_info) { - const detailedInfo = props.detailed_info; + {episode.actors && ( + + + Cast + + + {episode.actors} + + + )} + - // Extract from video resolution - if ( - detailedInfo.video && - detailedInfo.video.width && - detailedInfo.video.height - ) { - const width = detailedInfo.video.width; - const height = detailedInfo.video.height; + {/* Technical Details */} + {(episode.bitrate || episode.video || episode.audio) && ( + + + Technical Details + + + {episode.bitrate && episode.bitrate > 0 && ( + + Bitrate: {episode.bitrate} kbps + + )} + {episode.video && Object.keys(episode.video).length > 0 && ( + + Video:{' '} + {episode.video.codec_long_name || episode.video.codec_name} + {episode.video.width && episode.video.height + ? `, ${episode.video.width}x${episode.video.height}` + : ''} + + )} + {episode.audio && Object.keys(episode.audio).length > 0 && ( + + Audio:{' '} + {episode.audio.codec_long_name || episode.audio.codec_name} + {episode.audio.channels + ? `, ${episode.audio.channels} channels` + : ''} + + )} + + + )} - // Prioritize width for quality detection (handles ultrawide/cinematic aspect ratios) - if (width >= 3840) { - qualityInfo = ' - 4K'; - } else if (width >= 1920) { - qualityInfo = ' - 1080p'; - } else if (width >= 1280) { - qualityInfo = ' - 720p'; - } else if (width >= 854) { - qualityInfo = ' - 480p'; - } else { - qualityInfo = ` - ${width}x${height}`; - } - } - - // Extract from movie name in detailed_info - if (qualityInfo === '' && detailedInfo.name) { - const name = detailedInfo.name; - if (name.includes('4K') || name.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (name.includes('1080p') || name.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (name.includes('720p') || name.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (name.includes('480p')) { - qualityInfo = ' - 480p'; - } - } - } - } - - // 3. Final fallback: Check stream name for quality markers - if (qualityInfo === '' && relation.stream_name) { - const streamName = relation.stream_name; - if (streamName.includes('4K') || streamName.includes('2160p')) { - qualityInfo = ' - 4K'; - } else if (streamName.includes('1080p') || streamName.includes('FHD')) { - qualityInfo = ' - 1080p'; - } else if (streamName.includes('720p') || streamName.includes('HD')) { - qualityInfo = ' - 720p'; - } else if (streamName.includes('480p')) { - qualityInfo = ' - 480p'; - } - } - - return `${provider}${qualityInfo}${streamId ? ` (Stream ${streamId})` : ''}`; + {/* Provider Information */} + {episode.m3u_account && ( + + + Provider: + + + {episode.m3u_account.name || episode.m3u_account} + + + )} + + ); }; 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); @@ -192,12 +409,7 @@ const SeriesModal = ({ series, opened, onClose }) => { // Try to get episodes from the fetched data if (detailedSeries.episodesList) { - return detailedSeries.episodesList.sort((a, b) => { - if (a.season_number !== b.season_number) { - return (a.season_number || 0) - (b.season_number || 0); - } - return (a.episode_number || 0) - (b.episode_number || 0); - }); + return sortEpisodesList(detailedSeries.episodesList); } // If no episodes in detailed series, return empty array @@ -206,22 +418,12 @@ const SeriesModal = ({ series, opened, onClose }) => { // Group episodes by season const episodesBySeason = React.useMemo(() => { - const grouped = {}; - seriesEpisodes.forEach((episode) => { - const season = episode.season_number || 1; - if (!grouped[season]) { - grouped[season] = []; - } - grouped[season].push(episode); - }); - return grouped; + return groupEpisodesBySeason(seriesEpisodes); }, [seriesEpisodes]); // Get available seasons sorted const seasons = React.useMemo(() => { - return Object.keys(episodesBySeason) - .map(Number) - .sort((a, b) => a - b); + return sortBySeasonNumber(episodesBySeason); }, [episodesBySeason]); // Update active tab when seasons change or modal opens @@ -244,49 +446,12 @@ const SeriesModal = ({ series, opened, onClose }) => { }, [opened]); 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) { - streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; - } else { - streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; - } - } - - if (env_mode === 'dev') { - streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; - } else { - streamUrl = `${window.location.origin}${streamUrl}`; - } + const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode); showVideo(streamUrl, 'vod', episode); }; - const getEpisodeStreamUrl = (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) { - streamUrl += `?stream_id=${encodeURIComponent(selectedProvider.stream_id)}`; - } else { - streamUrl += `?m3u_account_id=${selectedProvider.m3u_account.id}`; - } - } - - if (env_mode === 'dev') { - streamUrl = `${window.location.protocol}//${window.location.hostname}:5656${streamUrl}`; - } else { - streamUrl = `${window.location.origin}${streamUrl}`; - } - return streamUrl; - }; - const handleCopyEpisodeLink = async (episode) => { - const streamUrl = getEpisodeStreamUrl(episode); + const streamUrl = getEpisodeStreamUrl(episode, selectedProvider, env_mode); await copyToClipboard(streamUrl, { successTitle: 'Link Copied!', successMessage: 'Episode link copied to clipboard', @@ -297,13 +462,14 @@ const SeriesModal = ({ series, opened, onClose }) => { setExpandedEpisode(expandedEpisode === episode.id ? null : episode.id); }; - // 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}`; + const onClickYouTubeTrailer = () => { + setTrailerUrl(getYouTubeEmbedUrl(displaySeries.youtube_trailer)); + setTrailerModalOpened(true); + }; + + const onChangeSelectedProvider = (value) => { + const provider = providers.find((p) => p.id.toString() === value); + setSelectedProvider(provider); }; if (!series) return null; @@ -320,7 +486,7 @@ const SeriesModal = ({ series, opened, onClose }) => { size="xl" centered > - + {/* Backdrop image as background */} {displaySeries.backdrop_path && displaySeries.backdrop_path.length > 0 && ( @@ -329,203 +495,58 @@ const SeriesModal = ({ series, opened, onClose }) => { src={displaySeries.backdrop_path[0]} alt={`${displaySeries.name} backdrop`} fit="cover" + pos="absolute" + top={0} + left={0} + w={'100%'} + h={'100%'} + bdrs={8} style={{ - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', objectFit: 'cover', zIndex: 0, - borderRadius: 8, filter: 'blur(2px) brightness(0.5)', }} /> {/* Overlay for readability */} )} {/* Modal content above backdrop */} - + {loadingDetails && ( - + Loading series details and episodes... )} {/* Series poster and basic info */} - - {displaySeries.series_image || displaySeries.logo?.url ? ( - - {displaySeries.name} - - ) : ( - - - - )} - - - {displaySeries.name} - - {/* Original name if different */} - {displaySeries.o_name && - displaySeries.o_name !== displaySeries.name && ( - - Original: {displaySeries.o_name} - - )} - - - {displaySeries.year && ( - {displaySeries.year} - )} - {displaySeries.rating && ( - {displaySeries.rating} - )} - {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} - - )} - - {displaySeries.genre && ( - - Genre: {displaySeries.genre} - - )} - - {displaySeries.director && ( - - Director: {displaySeries.director} - - )} - - {displaySeries.cast && ( - - Cast: {displaySeries.cast} - - )} - - {displaySeries.country && ( - - Country: {displaySeries.country} - - )} - - {/* Description */} - {displaySeries.description && ( - - - Description - - {displaySeries.description} - - )} - - {/* Watch Trailer button if available */} - {displaySeries.youtube_trailer && ( - - )} - - + {/* Provider Information */} Stream Selection - {loadingProviders && ( - - )} + {loadingProviders && } {providers.length === 0 && !loadingProviders && @@ -553,14 +574,9 @@ const SeriesModal = ({ series, opened, onClose }) => { label: formatStreamLabel(provider), }))} value={selectedProvider?.id?.toString() || ''} - onChange={(value) => { - const provider = providers.find( - (p) => p.id.toString() === value - ); - setSelectedProvider(provider); - }} + onChange={(value) => onChangeSelectedProvider(value)} placeholder="Select stream..." - style={{ maxWidth: 350 }} + maw={350} disabled={loadingProviders} /> ) : null} @@ -579,69 +595,61 @@ const SeriesModal = ({ series, opened, onClose }) => { ) : seasons.length > 0 ? ( - + {seasons.map((season) => ( - + Season {season} - + ))} - + {seasons.map((season) => ( - + - - - Ep - Title - - Duration - - Date - - Action - - - - + + + Ep + Title + Duration + Date + Action + + + {episodesBySeason[season]?.map((episode) => ( - handleEpisodeRowClick(episode)} > - + {episode.episode_number || '?'} - - + + {episode.name} {episode.genre && ( - + {episode.genre} )} - - - + + + {formatDuration(episode.duration_secs)} - - - - {episode.air_date - ? new Date( - episode.air_date - ).toLocaleDateString() - : 'N/A'} + + + + {getEpisodeAirdate(episode)} - - + + { - - + + {expandedEpisode === episode.id && ( - - + - - {/* Episode Image and Description Row */} - - {/* Episode Image */} - {episode.movie_image && ( - - - - )} - - {/* Episode Description */} - - {episode.description && ( - - - Description - - - {episode.description} - - - )} - - - - {/* Additional Episode Details */} - - {episode.rating && ( - - - Rating - - - {episode.rating} - - - )} - {/* IMDb and TMDb badges for episode */} - {(episode.imdb_id || - displaySeries.tmdb_id) && ( - - - Links - - {episode.imdb_id && ( - - IMDb - - )} - {displaySeries.tmdb_id && ( - - TMDb - - )} - - )} - - {episode.director && ( - - - Director - - - {episode.director} - - - )} - - {episode.actors && ( - - - Cast - - - {episode.actors} - - - )} - - - {/* Technical Details */} - {(episode.bitrate || - episode.video || - episode.audio) && ( - - - Technical Details - - - {episode.bitrate && - episode.bitrate > 0 && ( - - Bitrate:{' '} - {episode.bitrate} kbps - - )} - {episode.video && - Object.keys(episode.video) - .length > 0 && ( - - Video:{' '} - {episode.video - .codec_long_name || - episode.video.codec_name} - {episode.video.width && - episode.video.height - ? `, ${episode.video.width}x${episode.video.height}` - : ''} - - )} - {episode.audio && - Object.keys(episode.audio) - .length > 0 && ( - - Audio:{' '} - {episode.audio - .codec_long_name || - episode.audio.codec_name} - {episode.audio.channels - ? `, ${episode.audio.channels} channels` - : ''} - - )} - - - )} - - {/* Provider Information */} - {episode.m3u_account && ( - - - Provider: - - - {episode.m3u_account.name || - episode.m3u_account} - - - )} - - - + + + )} ))} - +
-
+ ))}
) : ( - + No episodes found for this series. )} @@ -909,36 +714,11 @@ const SeriesModal = ({ series, opened, onClose }) => { {/* YouTube Trailer Modal */} - setTrailerModalOpened(false)} - title="Trailer" - size="xl" - centered - > - - {trailerUrl && ( -