mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-22 09:37:57 +00:00
Component refactoring and cleanup
This commit is contained in:
parent
8f11ef417b
commit
fec4caf4d0
8 changed files with 1084 additions and 1631 deletions
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue