Component refactoring and cleanup

This commit is contained in:
Nick Sandstrom 2026-02-24 01:02:26 -08:00
parent 8f11ef417b
commit fec4caf4d0
8 changed files with 1084 additions and 1631 deletions

View file

@ -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';

View file

@ -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 (

View file

@ -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) => (
<Box
key={handle.id}
className="floating-video-no-drag"
onMouseDown={(event) => 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 */}
<Flex
justify="flex-end"
style={{
padding: 3,
}}
>
<Flex justify="flex-end" p={3}>
<CloseButton
onClick={handleClose}
onTouchEnd={handleClose}
onMouseDown={(e) => 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 */}
<Box
style={{ position: 'relative' }}
pos="relative"
onMouseEnter={() => {
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 && (
<Box
pos="absolute"
top={0}
left={0}
right={0}
p={'10px 10px 20px'}
color={'white'}
opacity={showOverlay ? 1 : 0}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
background: 'linear-gradient(rgba(0,0,0,0.8), transparent)',
padding: '10px 10px 20px',
color: 'white',
pointerEvents: 'none', // Allow clicks to pass through to video controls
transition: 'opacity 0.3s ease-in-out',
opacity: showOverlay ? 1 : 0,
}}
>
<Text
@ -804,7 +724,7 @@ export default function FloatingVideo() {
{metadata.year && (
<Text
size="xs"
color="dimmed"
c="dimmed"
style={{ textShadow: '1px 1px 2px rgba(0,0,0,0.8)' }}
>
{metadata.year}
@ -816,14 +736,14 @@ export default function FloatingVideo() {
{/* Loading overlay - only show when loading */}
{isLoading && (
<Box
pos="absolute"
top={0}
left={0}
w={'100%'}
h={'100%'}
display="flex"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
@ -831,7 +751,7 @@ export default function FloatingVideo() {
}}
>
<Loader color="cyan" size="md" />
<Text color="white" size="sm" mt={10}>
<Text c="white" size="sm" mt={10}>
Loading {contentType === 'vod' ? 'video' : 'stream'}...
</Text>
</Box>
@ -841,32 +761,19 @@ export default function FloatingVideo() {
{/* Error message below video - doesn't block controls */}
{!isLoading && loadError && (
<Box
p={10}
style={{
padding: '10px',
backgroundColor: '#2d1b2e',
borderTop: '1px solid #444',
}}
>
<Text color="red" size="xs" style={{ textAlign: 'center' }}>
<Text c="red" size="xs" ta="center">
{loadError}
</Text>
</Box>
)}
{/* Resize handles */}
{resizeHandles.map((handle) => (
<Box
key={handle.id}
className="floating-video-no-drag"
onMouseDown={(event) => startResize(event, handle)}
onTouchStart={(event) => startResize(event, handle)}
style={{
...resizeHandleBaseStyle,
...handle.style,
cursor: handle.cursor,
}}
/>
))}
<ResizeHandles startResize={startResize}/>
</div>
</Draggable>
);

View file

@ -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 (
<Stack>
{data.message ||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button size="xs" variant="default" onClick={onClickRefresh}>
Refresh Now
</Button>
<Button size="xs" variant="outline" onClick={onClickConfigure}>
Configure Groups
</Button>
</Group>
</Stack>
);
}
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: (
<Stack>
{data.message ||
'M3U groups loaded. Configure group filters and auto channel sync settings.'}
<Group grow>
<Button
size="xs"
variant="default"
onClick={() => {
API.refreshPlaylist(data.account);
}}
>
Refresh Now
</Button>
<Button
size="xs"
variant="outline"
onClick={() => {
// 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');
}}
>
Configure Groups
</Button>
</Group>
</Stack>
),
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: <M3uSetupSuccess data={data} />,
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 ? <CircleCheck /> : null,
icon: data.progress == 100 ? <CircleCheck /> : null,
});
};

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,6 @@ import {
ListOrdered,
Play,
Database,
SlidersHorizontal,
LayoutGrid,
Settings as LucideSettings,
Copy,
@ -18,7 +17,6 @@ import {
} from 'lucide-react';
import {
Avatar,
AppShell,
Group,
Stack,
Box,
@ -26,9 +24,8 @@ import {
UnstyledButton,
TextInput,
ActionIcon,
Menu,
AppShellNavbar,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
import logo from '../images/logo.png';
import useChannelsStore from '../store/channels';
import './sidebar.css';
@ -61,7 +58,7 @@ const NavLink = ({ item, isActive, collapsed }) => {
</Text>
)}
{!collapsed && item.badge && (
<Text size="sm" style={{ color: '#D4D4D8', whiteSpace: 'nowrap' }}>
<Text size="sm" c='#D4D4D8' style={{ whiteSpace: 'nowrap' }}>
{item.badge}
</Text>
)}
@ -150,21 +147,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
});
};
const onLogout = async () => {
await logout();
window.location.reload();
};
return (
<AppShell.Navbar
<AppShellNavbar
width={{ base: collapsed ? miniDrawerWidth : drawerWidth }}
p="xs"
mih="100vh"
display='flex'
style={{
backgroundColor: '#1A1A1E',
// transition: 'width 0.3s ease',
borderRight: '1px solid #2A2A2E',
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
}}
>
@ -172,15 +163,15 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
<Group
onClick={toggleDrawer}
spacing="sm"
display="flex"
p={'16px 12px'}
fz={18}
fw={600}
color="#FFFFFF"
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '16px 12px',
fontSize: 18,
fontWeight: 600,
color: '#FFFFFF',
justifyContent: collapsed ? 'center' : 'flex-start',
whiteSpace: 'nowrap',
}}
@ -221,10 +212,10 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{/* Profile Section */}
<Box
mt='auto'
p={16}
display='flex'
style={{
marginTop: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
gap: 10,
borderTop: '1px solid #2A2A2E',
@ -286,14 +277,14 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
{/* Version is always shown when sidebar is expanded, regardless of auth status */}
{!collapsed && (
<Text size="xs" style={{ padding: '0 16px 16px' }} c="dimmed">
<Text size="xs" p={'0 16px 16px'} c="dimmed">
v{appVersion?.version || '0.0.0'}
{appVersion?.timestamp ? `-${appVersion.timestamp}` : ''}
</Text>
)}
<UserForm user={authUser} isOpen={userFormOpen} onClose={closeUserForm} />
</AppShell.Navbar>
</AppShellNavbar>
);
};

View file

@ -19,11 +19,9 @@ import {
Download,
Gauge,
HardDriveDownload,
List,
LogIn,
LogOut,
RefreshCw,
Shield,
ShieldAlert,
SquareX,
Timer,
@ -31,25 +29,159 @@ import {
Video,
XCircle,
} from 'lucide-react';
import dayjs from 'dayjs';
import API from '../api';
import useLocalStorage from '../hooks/useLocalStorage';
import { format } from '../utils/dateTimeUtils.js';
const getEventIcon = (eventType) => {
switch (eventType) {
case 'channel_start':
return <CirclePlay size={16} />;
case 'channel_stop':
return <SquareX size={16} />;
case 'channel_reconnect':
return <RefreshCw size={16} />;
case 'channel_buffering':
return <Timer size={16} />;
case 'channel_failover':
return <HardDriveDownload size={16} />;
case 'client_connect':
return <Users size={16} />;
case 'client_disconnect':
return <Users size={16} />;
case 'recording_start':
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
return <RefreshCw size={16} />;
case 'm3u_download':
return <Download size={16} />;
case 'epg_refresh':
return <RefreshCw size={16} />;
case 'epg_download':
return <Download size={16} />;
case 'login_success':
return <LogIn size={16} />;
case 'login_failed':
return <ShieldAlert size={16} />;
case 'logout':
return <LogOut size={16} />;
case 'm3u_blocked':
return <XCircle size={16} />;
case 'epg_blocked':
return <XCircle size={16} />;
default:
return <Gauge size={16} />;
}
};
const getEventColor = (eventType) => {
switch (eventType) {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
case 'channel_failover':
case 'channel_error':
return 'orange';
case 'stream_switch':
return 'blue';
case 'm3u_refresh':
case 'epg_refresh':
return 'cyan';
case 'm3u_download':
case 'epg_download':
return 'teal';
case 'login_failed':
case 'm3u_blocked':
case 'epg_blocked':
return 'red';
default:
return 'gray';
}
};
const getSystemEvents = (eventsLimit, offset) => {
return API.getSystemEvents(eventsLimit, offset);
}
const Event = ({ event }) => {
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
return (
<Box
p="xs"
bdrs={4}
style={{
backgroundColor: '#1A1B1E',
borderLeft: `3px solid var(--mantine-color-${getEventColor(event.event_type)}-6)`,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" flex={1} miw={0}>
<Box c={`${getEventColor(event.event_type)}.6`}>
{getEventIcon(event.event_type)}
</Box>
<Stack gap={2} flex={1} miw={0}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{event.event_type_display || event.event_type}
</Text>
{event.channel_name && (
<Text size="sm" c="dimmed" truncate maw={300}>
{event.channel_name}
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(([key]) =>
!['stream_url', 'new_url'].includes(key))
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{format(event.timestamp, `${dateFormat} HH:mm:ss`)}
</Text>
</Group>
</Box>
);
};
const SystemEvents = () => {
const [events, setEvents] = useState([]);
const [totalEvents, setTotalEvents] = useState(0);
const [isExpanded, setIsExpanded] = useState(false);
const { ref: cardRef, width: cardWidth } = useElementSize();
const isNarrow = cardWidth < 650;
const [isLoading, setIsLoading] = useState(false);
const [dateFormatSetting] = useLocalStorage('date-format', 'mdy');
const dateFormat = dateFormatSetting === 'mdy' ? 'MM/DD' : 'DD/MM';
const [currentPage, setCurrentPage] = useState(1);
const [eventsRefreshInterval, setEventsRefreshInterval] = useLocalStorage(
'events-refresh-interval',
0
);
const [eventsLimit, setEventsLimit] = useLocalStorage('events-limit', 100);
const [currentPage, setCurrentPage] = useState(1);
const { ref: cardRef, width: cardWidth } = useElementSize();
const isNarrow = cardWidth < 650;
// Calculate offset based on current page and limit
const offset = (currentPage - 1) * eventsLimit;
@ -58,7 +190,7 @@ const SystemEvents = () => {
const fetchEvents = useCallback(async () => {
try {
setIsLoading(true);
const response = await API.getSystemEvents(eventsLimit, offset);
const response = await getSystemEvents(eventsLimit, offset);
if (response && response.events) {
setEvents(response.events);
setTotalEvents(response.total || 0);
@ -86,87 +218,6 @@ const SystemEvents = () => {
setCurrentPage(1);
}, [eventsLimit]);
const getEventIcon = (eventType) => {
switch (eventType) {
case 'channel_start':
return <CirclePlay size={16} />;
case 'channel_stop':
return <SquareX size={16} />;
case 'channel_reconnect':
return <RefreshCw size={16} />;
case 'channel_buffering':
return <Timer size={16} />;
case 'channel_failover':
return <HardDriveDownload size={16} />;
case 'client_connect':
return <Users size={16} />;
case 'client_disconnect':
return <Users size={16} />;
case 'recording_start':
return <Video size={16} />;
case 'recording_end':
return <Video size={16} />;
case 'stream_switch':
return <HardDriveDownload size={16} />;
case 'm3u_refresh':
return <RefreshCw size={16} />;
case 'm3u_download':
return <Download size={16} />;
case 'epg_refresh':
return <RefreshCw size={16} />;
case 'epg_download':
return <Download size={16} />;
case 'login_success':
return <LogIn size={16} />;
case 'login_failed':
return <ShieldAlert size={16} />;
case 'logout':
return <LogOut size={16} />;
case 'm3u_blocked':
return <XCircle size={16} />;
case 'epg_blocked':
return <XCircle size={16} />;
default:
return <Gauge size={16} />;
}
};
const getEventColor = (eventType) => {
switch (eventType) {
case 'channel_start':
case 'client_connect':
case 'recording_start':
case 'login_success':
return 'green';
case 'channel_reconnect':
return 'yellow';
case 'channel_stop':
case 'client_disconnect':
case 'recording_end':
case 'logout':
return 'gray';
case 'channel_buffering':
return 'yellow';
case 'channel_failover':
case 'channel_error':
return 'orange';
case 'stream_switch':
return 'blue';
case 'm3u_refresh':
case 'epg_refresh':
return 'cyan';
case 'm3u_download':
case 'epg_download':
return 'teal';
case 'login_failed':
case 'm3u_blocked':
case 'epg_blocked':
return 'red';
default:
return 'gray';
}
};
return (
<Card
ref={cardRef}
@ -174,13 +225,13 @@ const SystemEvents = () => {
padding="sm"
radius="md"
withBorder
color="#fff"
w={'100%'}
maw={isExpanded ? '100%' : 800}
ml="auto"
mr="auto"
style={{
color: '#fff',
backgroundColor: '#27272A',
width: '100%',
maxWidth: isExpanded ? '100%' : '800px',
marginLeft: 'auto',
marginRight: 'auto',
transition: 'max-width 0.3s ease',
}}
>
@ -200,7 +251,7 @@ const SystemEvents = () => {
min={10}
max={1000}
step={10}
style={{ width: 130 }}
w={130}
/>
<Select
size="xs"
@ -214,14 +265,14 @@ const SystemEvents = () => {
{ value: '30', label: '30s' },
{ value: '60', label: '1m' },
]}
style={{ width: 120 }}
w={120}
/>
<Button
size="xs"
variant="subtle"
onClick={fetchEvents}
loading={isLoading}
style={{ marginTop: 'auto' }}
mt="auto"
>
Refresh
</Button>
@ -261,8 +312,8 @@ const SystemEvents = () => {
<Stack
gap="xs"
mt="sm"
mah="60vh"
style={{
maxHeight: '60vh',
overflowY: 'auto',
}}
>
@ -272,55 +323,7 @@ const SystemEvents = () => {
</Text>
) : (
events.map((event) => (
<Box
key={event.id}
p="xs"
style={{
backgroundColor: '#1A1B1E',
borderRadius: '4px',
borderLeft: `3px solid var(--mantine-color-${getEventColor(event.event_type)}-6)`,
}}
>
<Group justify="space-between" wrap="nowrap">
<Group gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Box c={`${getEventColor(event.event_type)}.6`}>
{getEventIcon(event.event_type)}
</Box>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Group gap="xs" wrap="nowrap">
<Text size="sm" fw={500}>
{event.event_type_display || event.event_type}
</Text>
{event.channel_name && (
<Text
size="sm"
c="dimmed"
truncate
style={{ maxWidth: '300px' }}
>
{event.channel_name}
</Text>
)}
</Group>
{event.details &&
Object.keys(event.details).length > 0 && (
<Text size="xs" c="dimmed">
{Object.entries(event.details)
.filter(
([key]) =>
!['stream_url', 'new_url'].includes(key)
)
.map(([key, value]) => `${key}: ${value}`)
.join(', ')}
</Text>
)}
</Stack>
</Group>
<Text size="xs" c="dimmed" style={{ whiteSpace: 'nowrap' }}>
{dayjs(event.timestamp).format(`${dateFormat} HH:mm:ss`)}
</Text>
</Group>
</Box>
<Event key={event.id} event={event} />
))
)}
</Stack>

View file

@ -1,165 +1,233 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
Flex,
Group,
Image,
Text,
Title,
Select,
Badge,
Loader,
Stack,
Modal,
} from '@mantine/core';
import { Play, Copy } from 'lucide-react';
import { notifications } from '@mantine/notifications';
import React, { useEffect, useState } from 'react';
import { Badge, Box, Button, Flex, Group, Image, Loader, Modal, Select, Stack, Text, Title, } from '@mantine/core';
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,
getYouTubeEmbedUrl,
imdbUrl,
tmdbUrl
} from '../utils/components/SeriesModalUtils.js';
import { YouTubeTrailerModal } from './modals/YouTubeTrailerModal.jsx';
import {
formatAudioDetails,
formatVideoDetails,
getMovieStreamUrl,
getTechnicalDetails,
} from '../utils/components/VODModalUtils.js';
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 Movie = ({
onClickYouTubeTrailer,
hasMultipleProviders,
selectedProvider,
detailedVOD,
vod
}) => {
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
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 displayVOD = detailedVOD || vod;
// Try to extract quality info - prioritizing the new quality_info field from backend
let qualityInfo = '';
const getStreamUrl = () => {
if (!displayVOD) return null;
// 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}`;
}
}
// 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;
// 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';
} 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})` : ''}`;
};
const getTechnicalDetails = (selectedProvider, defaultVOD) => {
let source = defaultVOD; // Default fallback
// 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;
}
// 2. Try technical details directly on the relation object
else if (
selectedProvider.bitrate ||
selectedProvider.video ||
selectedProvider.audio
) {
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;
// Create a synthetic source from detailed_info
const syntheticSource = {
bitrate: detailedInfo.bitrate || null,
video: detailedInfo.video || null,
audio: detailedInfo.audio || null,
};
if (
syntheticSource.bitrate ||
syntheticSource.video ||
syntheticSource.audio
) {
source = syntheticSource;
}
}
}
return {
bitrate: source?.bitrate,
video: source?.video,
audio: source?.audio,
return getMovieStreamUrl(vod, selectedProvider, env_mode);
};
const handlePlayVOD = () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
showVideo(streamUrl, 'vod', displayVOD);
};
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Stream link copied to clipboard',
});
};
return (
<Stack spacing="md" flex={1}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text size="sm" c="dimmed" fs="italic">
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
)}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displayVOD.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displayVOD.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displayVOD.tmdb_id, 'movie')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" c="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" c="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" c="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" c="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" c="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displayVOD.description}</Text>
</Box>
)}
{/* Play and Watch Trailer buttons */}
<Group spacing="xs" mt="sm">
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="sm"
onClick={handlePlayVOD}
disabled={hasMultipleProviders && !selectedProvider}
style={{ alignSelf: 'flex-start' }}
>
Play Movie
</Button>
{displayVOD.youtube_trailer && (
<Button
variant="outline"
color="red"
size="sm"
onClick={onClickYouTubeTrailer}
style={{ alignSelf: 'flex-start' }}
>
Watch Trailer
</Button>
)}
<Button
leftSection={<Copy size={16} />}
variant="outline"
color="gray"
size="sm"
onClick={handleCopyLink}
style={{ alignSelf: 'flex-start' }}
>
Copy Link
</Button>
</Group>
</Stack>
);
};
const MovieTechnicalDetails = ({ selectedProvider, displayVOD }) => {
const techDetails = getTechnicalDetails(selectedProvider, displayVOD);
const hasDetails = techDetails.bitrate || techDetails.video || techDetails.audio;
if (!hasDetails) return null;
const hasVideo = techDetails.video && Object.keys(techDetails.video).length > 0;
const hasAudio = techDetails.audio && Object.keys(techDetails.audio).length > 0;
return (
<Stack spacing={4} mt="xs">
<Text size="sm" weight={500}>
Technical Details:
{selectedProvider && (
<Text size="xs" c="dimmed" weight="normal" span ml={8}>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id && ` - Stream ${selectedProvider.stream_id}`})
</Text>
)}
</Text>
{techDetails.bitrate && techDetails.bitrate > 0 && (
<Text size="xs" c="dimmed">
<strong>Bitrate:</strong> {techDetails.bitrate} kbps
</Text>
)}
{hasVideo && (
<Text size="xs" c="dimmed">
<strong>Video:</strong> {formatVideoDetails(techDetails.video)}
</Text>
)}
{hasAudio && (
<Text size="xs" c="dimmed">
<strong>Audio:</strong> {formatAudioDetails(techDetails.audio)}
</Text>
)}
</Stack>
);
};
const VODModal = ({ vod, opened, onClose }) => {
@ -170,9 +238,8 @@ const VODModal = ({ vod, opened, onClose }) => {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState(null);
const [loadingProviders, setLoadingProviders] = useState(false);
const { fetchMovieDetailsFromProvider, fetchMovieProviders } = useVODStore();
const showVideo = useVideoStore((s) => s.showVideo);
const env_mode = useSettingsStore((s) => s.environment.env_mode);
useEffect(() => {
if (opened && vod) {
@ -234,54 +301,17 @@ const VODModal = ({ vod, opened, onClose }) => {
}
}, [opened]);
const getStreamUrl = () => {
const vodToPlay = detailedVOD || vod;
if (!vodToPlay) return null;
const onClickYouTubeTrailer = () => {
setTrailerUrl(
getYouTubeEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerModalOpened(true);
}
let streamUrl = `/proxy/vod/movie/${vod.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 handlePlayVOD = () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
const vodToPlay = detailedVOD || vod;
showVideo(streamUrl, 'vod', vodToPlay);
};
const handleCopyLink = async () => {
const streamUrl = getStreamUrl();
if (!streamUrl) return;
await copyToClipboard(streamUrl, {
successTitle: 'Link Copied!',
successMessage: 'Stream link copied to clipboard',
});
};
// 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 onChangeSelectedProvider = (value) => {
const provider = providers.find((p) => p.id.toString() === value);
setSelectedProvider(provider);
}
if (!vod) return null;
@ -297,7 +327,7 @@ const VODModal = ({ vod, opened, onClose }) => {
size="xl"
centered
>
<Box style={{ position: 'relative', minHeight: 400 }}>
<Box pos="relative" mih={400}>
{/* Backdrop image as background */}
{displayVOD.backdrop_path && displayVOD.backdrop_path.length > 0 && (
<>
@ -305,41 +335,41 @@ const VODModal = ({ vod, opened, onClose }) => {
src={displayVOD.backdrop_path[0]}
alt={`${displayVOD.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 */}
<Box
pos="absolute"
top={0}
left={0}
w="100%"
h="100%"
bdrs={8}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
background:
'linear-gradient(180deg, rgba(24,24,27,0.85) 60%, rgba(24,24,27,1) 100%)',
zIndex: 1,
borderRadius: 8,
}}
/>
</>
)}
{/* Modal content above backdrop */}
<Box style={{ position: 'relative', zIndex: 2 }}>
<Box pos="relative" style={{ zIndex: 2 }}>
<Stack spacing="md">
{loadingDetails && (
<Group spacing="xs" mb={8}>
<Loader size="xs" />
<Text size="xs" color="dimmed">
<Text size="xs" c="dimmed">
Loading additional details...
</Text>
</Group>
@ -356,19 +386,19 @@ const VODModal = ({ vod, opened, onClose }) => {
height={300}
alt={displayVOD.name}
fit="contain"
style={{ borderRadius: '8px' }}
bdrs={8}
/>
</Box>
) : (
<Box
w={200}
h={300}
display="flex"
bdrs={8}
style={{
width: 200,
height: 300,
backgroundColor: '#404040',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '8px',
flexShrink: 0,
}}
>
@ -376,158 +406,23 @@ const VODModal = ({ vod, opened, onClose }) => {
</Box>
)}
<Stack spacing="md" style={{ flex: 1 }}>
<Title order={3}>{displayVOD.name}</Title>
{/* Original name if different */}
{displayVOD.o_name &&
displayVOD.o_name !== displayVOD.name && (
<Text
size="sm"
color="dimmed"
style={{ fontStyle: 'italic' }}
>
Original: {displayVOD.o_name}
</Text>
)}
<Group spacing="md">
{displayVOD.year && (
<Badge color="blue">{displayVOD.year}</Badge>
)}
{displayVOD.duration_secs && (
<Badge color="gray">
{formatDuration(displayVOD.duration_secs)}
</Badge>
)}
{displayVOD.rating && (
<Badge color="yellow">{displayVOD.rating}</Badge>
)}
{displayVOD.age && (
<Badge color="orange">{displayVOD.age}</Badge>
)}
<Badge color="green">Movie</Badge>
{/* imdb_id and tmdb_id badges */}
{displayVOD.imdb_id && (
<Badge
color="yellow"
component="a"
href={imdbUrl(displayVOD.imdb_id)}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
IMDb
</Badge>
)}
{displayVOD.tmdb_id && (
<Badge
color="cyan"
component="a"
href={tmdbUrl(displayVOD.tmdb_id, 'movie')}
target="_blank"
rel="noopener noreferrer"
style={{ cursor: 'pointer' }}
>
TMDb
</Badge>
)}
</Group>
{/* Release date */}
{displayVOD.release_date && (
<Text size="sm" color="dimmed">
<strong>Release Date:</strong> {displayVOD.release_date}
</Text>
)}
{displayVOD.genre && (
<Text size="sm" color="dimmed">
<strong>Genre:</strong> {displayVOD.genre}
</Text>
)}
{displayVOD.director && (
<Text size="sm" color="dimmed">
<strong>Director:</strong> {displayVOD.director}
</Text>
)}
{displayVOD.actors && (
<Text size="sm" color="dimmed">
<strong>Cast:</strong> {displayVOD.actors}
</Text>
)}
{displayVOD.country && (
<Text size="sm" color="dimmed">
<strong>Country:</strong> {displayVOD.country}
</Text>
)}
{/* Description */}
{displayVOD.description && (
<Box>
<Text size="sm" weight={500} mb={8}>
Description
</Text>
<Text size="sm">{displayVOD.description}</Text>
</Box>
)}
{/* Play and Watch Trailer buttons */}
<Group spacing="xs" mt="sm">
<Button
leftSection={<Play size={16} />}
variant="filled"
color="blue"
size="sm"
onClick={handlePlayVOD}
disabled={providers.length > 0 && !selectedProvider}
style={{ alignSelf: 'flex-start' }}
>
Play Movie
</Button>
{displayVOD.youtube_trailer && (
<Button
variant="outline"
color="red"
size="sm"
onClick={() => {
setTrailerUrl(
getEmbedUrl(displayVOD.youtube_trailer)
);
setTrailerModalOpened(true);
}}
style={{ alignSelf: 'flex-start' }}
>
Watch Trailer
</Button>
)}
<Button
leftSection={<Copy size={16} />}
variant="outline"
color="gray"
size="sm"
onClick={handleCopyLink}
style={{ alignSelf: 'flex-start' }}
>
Copy Link
</Button>
</Group>
</Stack>
<Movie
detailedVOD={detailedVOD}
vod={vod}
hasMultipleProviders={providers.length > 0}
selectedProvider={selectedProvider}
onClickYouTubeTrailer={onClickYouTubeTrailer}
/>
</Flex>
{/* Provider Information & Play Button Row */}
<Group spacing="md" align="flex-end" mt="md">
{/* Provider Selection */}
{providers.length > 0 && (
<Box style={{ minWidth: 200 }}>
<Box miw={200}>
<Text size="sm" weight={500} mb={8}>
Stream Selection
{loadingProviders && (
<Loader size="xs" style={{ marginLeft: 8 }} />
)}
{loadingProviders && <Loader size="xs" ml={8} />}
</Text>
{providers.length === 1 ? (
<Group spacing="md">
@ -542,14 +437,9 @@ const VODModal = ({ vod, 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={{ minWidth: 250 }}
miw={250}
disabled={loadingProviders}
/>
)}
@ -576,135 +466,21 @@ const VODModal = ({ vod, opened, onClose }) => {
</Group>
{/* Technical Details */}
{(() => {
const techDetails = getTechnicalDetails(
selectedProvider,
displayVOD
);
const hasDetails =
techDetails.bitrate || techDetails.video || techDetails.audio;
return (
hasDetails && (
<Stack spacing={4} mt="xs">
<Text size="sm" weight={500}>
Technical Details:
{selectedProvider && (
<Text
size="xs"
color="dimmed"
weight="normal"
span
style={{ marginLeft: 8 }}
>
(from {selectedProvider.m3u_account.name}
{selectedProvider.stream_id &&
` - Stream ${selectedProvider.stream_id}`}
)
</Text>
)}
</Text>
{techDetails.bitrate && techDetails.bitrate > 0 && (
<Text size="xs" color="dimmed">
<strong>Bitrate:</strong> {techDetails.bitrate} kbps
</Text>
)}
{techDetails.video &&
Object.keys(techDetails.video).length > 0 && (
<Text size="xs" color="dimmed">
<strong>Video:</strong>{' '}
{techDetails.video.codec_long_name &&
techDetails.video.codec_long_name !== 'unknown'
? techDetails.video.codec_long_name
: techDetails.video.codec_name}
{techDetails.video.profile
? ` (${techDetails.video.profile})`
: ''}
{techDetails.video.width && techDetails.video.height
? `, ${techDetails.video.width}x${techDetails.video.height}`
: ''}
{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`
: ''}
{techDetails.video.r_frame_rate
? `, Frame Rate: ${techDetails.video.r_frame_rate.replace('/', '/')} fps`
: ''}
{techDetails.video.tags?.encoder
? `, Encoder: ${techDetails.video.tags.encoder}`
: ''}
</Text>
)}
{techDetails.audio &&
Object.keys(techDetails.audio).length > 0 && (
<Text size="xs" color="dimmed">
<strong>Audio:</strong>{' '}
{techDetails.audio.codec_long_name &&
techDetails.audio.codec_long_name !== 'unknown'
? techDetails.audio.codec_long_name
: techDetails.audio.codec_name}
{techDetails.audio.profile
? ` (${techDetails.audio.profile})`
: ''}
{techDetails.audio.channel_layout
? `, Channels: ${techDetails.audio.channel_layout}`
: techDetails.audio.channels
? `, Channels: ${techDetails.audio.channels}`
: ''}
{techDetails.audio.sample_rate
? `, Sample Rate: ${techDetails.audio.sample_rate} Hz`
: ''}
{techDetails.audio.bit_rate
? `, Bitrate: ${Math.round(Number(techDetails.audio.bit_rate) / 1000)} kbps`
: ''}
{techDetails.audio.tags?.handler_name
? `, Handler: ${techDetails.audio.tags.handler_name}`
: ''}
</Text>
)}
</Stack>
)
);
})()}
{/* YouTube trailer if available */}
<MovieTechnicalDetails
selectedProvider={selectedProvider}
displayVOD={displayVOD}
/>
</Stack>
</Box>
</Box>
</Modal>
{/* YouTube Trailer Modal */}
<Modal
<YouTubeTrailerModal
opened={trailerModalOpened}
onClose={() => setTrailerModalOpened(false)}
title="Trailer"
size="xl"
centered
withCloseButton
>
<Box
style={{ position: 'relative', paddingBottom: '56.25%', height: 0 }}
>
{trailerUrl && (
<iframe
src={trailerUrl}
title="YouTube Trailer"
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
borderRadius: 8,
}}
/>
)}
</Box>
</Modal>
trailerUrl={trailerUrl}
/>
</>
);
};