diff --git a/CHANGELOG.md b/CHANGELOG.md index 04ca4ead..42f2f89b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Unit tests for `NotificationCenter`, `NotificationCenterUtils`, and `M3URefreshNotification` components. — Thanks [@nick4810](https://github.com/nick4810) - Floating video player now displays the channel, stream, or VOD title in the player header bar. Title is passed through from all preview entry points: channel table, stream table, stream connection card, guide, DVR, recording cards, recording details modal, VOD modal, and series modal. - New Client Buffer proxy setting: new clients joining an active channel are now positioned a configurable number of seconds behind live rather than a fixed chunk count. The start position is determined by wall-clock chunk receive time (stored as a Redis sorted set alongside the buffer), so the buffer depth is consistent in seconds regardless of stream bitrate. Setting the value to `0` starts clients at live with no buffer. Defaults to 5 seconds. Existing chunk-count gating for the first client connecting to a channel is unchanged. The setting is exposed in Settings → Proxy as "New Client Buffer (seconds)". - Channel table filter for channels that have stale streams: A new "Has Stale Streams" filter option in the channel table header menu highlights and filters channels containing at least one stale stream. Channels with stale streams are visually distinguished with an orange tint. The filter is mutually exclusive with "Only Empty Channels". - Thanks [@JCBird1012](https://github.com/JCBird1012) @@ -25,6 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Frontend component refactoring and cleanup — Thanks [@nick4810](https://github.com/nick4810) + - `FloatingVideo`, `SeriesModal`, `VODModal`, `SystemEvents`, `M3URefreshNotification`, and `NotificationCenter` significantly reduced in size by separating business logic into dedicated utility modules under `utils/components/` (`FloatingVideoUtils.js`, `SeriesModalUtils.js`, `VODModalUtils.js`, `NotificationCenterUtils.js`). + - `FloatingVideo` resize handle elements extracted into a standalone `ResizeHandles` sub-component. + - `YouTubeTrailerModal` extracted into a standalone component (`components/modals/YouTubeTrailerModal.jsx`). + - `NotificationCenter` and `Sidebar` updated from Mantine dot-notation sub-components (`Popover.Target`, `Popover.Dropdown`, `ScrollArea.Autosize`, `AppShell.Navbar`) to Mantine v7 named imports (`PopoverTarget`, `PopoverDropdown`, `ScrollAreaAutosize`, `AppShellNavbar`). + - `M3URefreshNotification` now uses the centralized `showNotification()` utility (from `notificationUtils.js`) instead of calling `notifications.show()` directly, bringing it in line with the rest of the app. State updates also converted to functional updater form (`prev => ...`) to eliminate potential stale-closure bugs. + - `SystemEvents` now imports `format` from `dateTimeUtils` for consistent date/time formatting. + - Removed a dead `onLogout` handler in `Sidebar` that called `logout()` and `window.location.reload()` but was never wired to any UI element. - EPG output when no `days` parameter is specified now excludes already-ended programs instead of returning all historical data. ### Fixed 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 6ee94243..e8f71c68 100644 --- a/frontend/src/components/Field.jsx +++ b/frontend/src/components/Field.jsx @@ -12,6 +12,7 @@ export const Field = ({ field, value, onChange }) => { const description = field.help_text ?? field.description ?? field.value; const common = { label: field.label, description }; const effective = value ?? field.default; + switch (field.type) { case 'info': return ( diff --git a/frontend/src/components/FloatingVideo.jsx b/frontend/src/components/FloatingVideo.jsx index 529a605c..d06273b6 100644 --- a/frontend/src/components/FloatingVideo.jsx +++ b/frontend/src/components/FloatingVideo.jsx @@ -4,36 +4,18 @@ 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 { + 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 @@ -876,20 +801,7 @@ export default function FloatingVideo() { )} - {/* 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 371def3b..bd6319bf 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,9 +54,9 @@ export default function M3URefreshNotification() { const fetchCategories = useVODStore((s) => s.fetchCategories); const [notificationStatus, setNotificationStatus] = useState({}); - const navigate = useNavigate(); const handleM3UUpdate = (data) => { + // Skip if status hasn't changed if ( JSON.stringify(notificationStatus[data.account]) == JSON.stringify(data) ) { @@ -36,132 +68,101 @@ export default function M3URefreshNotification() { 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 + handleError(playlist, data); + return; } - // Check if we already have an error stored for this account, and if so, don't show further notifications + // Skip if already errored const currentStatus = notificationStatus[data.account]; if (currentStatus && currentStatus.status === 'error') { - // Don't show any other notifications once we've hit an error return; } - const taskProgress = data.progress; + // Handle normal progress updates (0% start, 100% completion) + if (data.progress === 0 || data.progress === 100) { + handleProgressNotification(playlist, data); + } + }; - // Only show start and completion notifications for normal operation - if (data.progress != 0 && data.progress != 100) { - return; + const handlePendingSetup = (playlist, data) => { + fetchChannelGroups(); + fetchPlaylists(); + + showNotification({ + title: `M3U Setup: ${playlist.name}`, + message: , + color: 'orange.5', + autoClose: 5000, + }); + }; + + 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(); + fetchChannelIds(); + } 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); } - let message = ''; - switch (data.action) { - case 'downloading': - message = 'Downloading'; - break; - - case 'parsing': - message = 'Stream parsing'; - break; - - case 'processing_groups': - message = 'Group parsing'; - break; - - case 'vod_refresh': - message = 'VOD content refresh'; - break; - } - - 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(); - fetchChannelIds(); - } 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/NotificationCenter.jsx b/frontend/src/components/NotificationCenter.jsx index f7da56ad..252e7da0 100644 --- a/frontend/src/components/NotificationCenter.jsx +++ b/frontend/src/components/NotificationCenter.jsx @@ -10,7 +10,9 @@ import { Group, Indicator, Popover, - ScrollArea, + PopoverDropdown, + PopoverTarget, + ScrollAreaAutosize, Stack, Text, ThemeIcon, @@ -33,7 +35,11 @@ import { ArrowRight, } from 'lucide-react'; import useNotificationsStore from '../store/notifications'; -import API from '../api'; +import { + dismissAllNotifications, + dismissNotification, + getNotifications, +} from '../utils/components/NotificationCenterUtils.js'; // Get icon for notification type const getNotificationIcon = (type) => { @@ -249,7 +255,7 @@ const NotificationCenter = ({ onSettingAction }) => { // Fetch notifications on mount and periodically const fetchNotifications = useCallback(async () => { try { - await API.getNotifications(showDismissed); + await getNotifications(showDismissed); } catch (error) { console.error('Failed to fetch notifications:', error); } @@ -265,7 +271,7 @@ const NotificationCenter = ({ onSettingAction }) => { const handleDismiss = async (notificationId, actionTaken = null) => { try { - await API.dismissNotification(notificationId, actionTaken); + await dismissNotification(notificationId, actionTaken); } catch (error) { console.error('Failed to dismiss notification:', error); } @@ -273,7 +279,7 @@ const NotificationCenter = ({ onSettingAction }) => { const handleDismissAll = async () => { try { - await API.dismissAllNotifications(); + await dismissAllNotifications(); } catch (error) { console.error('Failed to dismiss all notifications:', error); } @@ -302,7 +308,7 @@ const NotificationCenter = ({ onSettingAction }) => { shadow="lg" withArrow > - + { - + - + {/* Header */} @@ -367,7 +373,7 @@ const NotificationCenter = ({ onSettingAction }) => { {/* Notification list */} - + {displayedNotifications.length === 0 ? ( { ))} )} - + {/* Footer with info text */} {!showDismissed && @@ -421,7 +427,7 @@ const NotificationCenter = ({ onSettingAction }) => { )} - + ); }; diff --git a/frontend/src/components/SeriesModal.jsx b/frontend/src/components/SeriesModal.jsx index c8e551af..d22e6630 100644 --- a/frontend/src/components/SeriesModal.jsx +++ b/frontend/src/components/SeriesModal.jsx @@ -16,113 +16,330 @@ import { Tabs, Table, Divider, + TableTbody, + TableTd, + TableTh, + TableThead, + TableTr, + TabsList, + TabsPanel, + TabsTab, } from '@mantine/core'; import { Play, Copy } from 'lucide-react'; -import { notifications } from '@mantine/notifications'; 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; @@ -371,153 +537,10 @@ const SeriesModal = ({ series, opened, onClose }) => { )} {/* 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 */} @@ -553,12 +576,7 @@ 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 }} disabled={loadingProviders} @@ -579,43 +597,39 @@ 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} @@ -626,22 +640,18 @@ const SeriesModal = ({ series, opened, onClose }) => { )} - - + + {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} - - - )} - - - + + + )} ))} - +
-
+ ))}
) : ( @@ -909,36 +716,11 @@ const SeriesModal = ({ series, opened, onClose }) => { {/* YouTube Trailer Modal */} - setTrailerModalOpened(false)} - title="Trailer" - size="xl" - centered - > - - {trailerUrl && ( -