unified-video-framework
Version:
Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more
323 lines • 17.6 kB
JavaScript
import React, { useMemo, useRef, useEffect, useState, useCallback } from 'react';
import { DEFAULT_EPG_THEME } from '../types/EPGTypes.js';
import { hexToRgba } from '../utils/ColorUtils.js';
import { calculateProgramBlock, isProgramLive, getProgramProgress, formatTime, throttle } from '../utils/EPGUtils.js';
const THUMBNAIL_MIN_WIDTH = 120;
const THUMBNAIL_SIDE_LAYOUT_WIDTH = 200;
const THUMBNAIL_HEIGHT_RATIO = 0.6;
const ProgramBlockComponent = ({ block, isSelected, isLive, progress, onClick, channelHeight, showThumbnails = true, thumbnailMinWidth = THUMBNAIL_MIN_WIDTH, theme: themeProp, }) => {
const theme = { ...DEFAULT_EPG_THEME, ...themeProp };
const [isHovered, setIsHovered] = useState(false);
const showThumbnail = !!block.program.image && showThumbnails && block.width >= thumbnailMinWidth;
const useSideLayout = showThumbnail && block.width >= THUMBNAIL_SIDE_LAYOUT_WIDTH;
const useBackgroundMode = showThumbnail && !useSideLayout;
const thumbnailWidth = useSideLayout ? Math.min(100, block.width * 0.3) : 0;
const thumbnailHeight = useSideLayout ? channelHeight - 4 - 12 : channelHeight - 4;
return (React.createElement("div", { className: `epg-program-block ${isSelected ? 'selected' : ''} ${isLive ? 'live' : ''}`, style: {
position: 'absolute',
left: `${block.left}px`,
width: `${block.width}px`,
height: `${channelHeight - 4}px`,
top: '2px',
boxSizing: 'border-box',
backgroundColor: isLive ? theme.primaryColor : isSelected ? theme.selectionColor : 'rgba(42, 42, 42, 0.8)',
border: `${isHovered ? '2px' : '1px'} solid ${isSelected ? theme.selectionColor : isHovered ? '#fff' : isLive ? theme.primaryColor : 'rgba(255, 255, 255, 0.15)'}`,
borderRadius: '3px',
cursor: 'pointer',
overflow: 'hidden',
transition: 'all 200ms ease',
zIndex: isSelected ? 10 : isHovered ? 8 : isLive ? 5 : 1,
boxShadow: isSelected ? `0 0 20px ${hexToRgba(theme.selectionColor, 0.4)}` : isHovered ? '0 4px 12px rgba(255, 255, 255, 0.1)' : '0 2px 4px rgba(0,0,0,0.3)',
transform: isHovered ? 'scale(1.02)' : 'scale(1)',
backgroundImage: useBackgroundMode ? `url(${block.program.image})` : 'none',
backgroundSize: 'cover',
backgroundPosition: 'center',
display: 'flex',
flexDirection: useSideLayout ? 'row' : 'column',
gap: useSideLayout ? '8px' : '0',
padding: useSideLayout ? '6px' : '0',
}, onClick: onClick, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false) },
useBackgroundMode && (React.createElement("div", { style: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(to bottom, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.85) 100%)',
borderRadius: '6px',
} })),
isLive && progress > 0 && (React.createElement("div", { className: "epg-program-progress", style: {
position: 'absolute',
top: '0',
left: '0',
height: '2px',
width: `${progress}%`,
backgroundColor: '#fff',
transition: 'width 0.3s ease',
zIndex: 2,
} })),
useSideLayout && block.program.image && (React.createElement("div", { style: {
width: `${thumbnailWidth}px`,
height: `${thumbnailHeight}px`,
borderRadius: '4px',
overflow: 'hidden',
flexShrink: 0,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
} },
React.createElement("img", { src: block.program.image, alt: block.program.title, loading: "lazy", style: {
width: '100%',
height: '100%',
objectFit: 'cover',
willChange: 'transform',
}, onError: (e) => {
e.currentTarget.style.display = 'none';
} }))),
React.createElement("div", { style: {
padding: useSideLayout ? '0' : '4px 8px',
height: '100%',
flex: useSideLayout ? 1 : 'initial',
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-start',
color: isLive || isSelected ? '#fff' : '#e0e0e0',
position: 'relative',
zIndex: 1,
minWidth: 0,
} },
isLive && (React.createElement("div", { className: "epg-live-indicator", style: {
position: 'absolute',
top: useSideLayout ? '0' : '4px',
right: useSideLayout ? '0' : '4px',
fontSize: '8px',
fontWeight: '700',
color: '#fff',
backgroundColor: '#ff0000',
padding: '2px 6px',
borderRadius: '4px',
letterSpacing: '0.5px',
textTransform: 'uppercase',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
zIndex: 2,
} }, "LIVE")),
React.createElement("div", { className: "epg-program-title", style: {
fontSize: block.width > 120 ? '13px' : '11px',
fontWeight: '600',
lineHeight: '1.2',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
marginBottom: '2px',
textShadow: useBackgroundMode ? '0 1px 3px rgba(0,0,0,0.8)' : 'none',
}, title: block.program.title }, block.program.title),
block.width > 80 && (React.createElement("div", { className: "epg-program-time", style: {
fontSize: '10px',
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
lineHeight: '1.2',
textShadow: useBackgroundMode ? '0 1px 2px rgba(0,0,0,0.8)' : 'none',
} },
formatTime(block.start),
" - ",
formatTime(block.end))),
block.width > 160 && (block.program.category || block.program.rating) && (React.createElement("div", { className: "epg-program-meta", style: {
fontSize: '9px',
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 'auto',
display: 'flex',
gap: '6px',
textShadow: useBackgroundMode ? '0 1px 2px rgba(0,0,0,0.8)' : 'none',
} },
block.program.category && (React.createElement("span", { className: "category" }, block.program.category)),
block.program.category && block.program.rating && React.createElement("span", null, "\u2022"),
block.program.rating && (React.createElement("span", { className: "rating" }, block.program.rating)))))));
};
export const EPGProgramGrid = ({ data, timelineStart, timelineEnd, containerWidth, currentTime = Date.now(), selectedProgram, onProgramSelect, onChannelSelect, onTimelineScroll, timelineScrollLeft = 0, channelHeight = 80, visibleChannels = 6, showThumbnails = true, thumbnailMinWidth = THUMBNAIL_MIN_WIDTH, maxProgramWidth = 280, programGap = 4, className = '', style = {}, theme: themeProp, }) => {
const theme = { ...DEFAULT_EPG_THEME, ...themeProp };
const gridRef = useRef(null);
const channelNamesRef = useRef(null);
const [scrollTop, setScrollTop] = useState(0);
const [scrollLeft, setScrollLeft] = useState(0);
const programBlocks = useMemo(() => {
const blocks = new Map();
data.forEach(channel => {
const channelBlocks = [];
channel.data.forEach(program => {
const block = calculateProgramBlock(program, channel, timelineStart, timelineEnd, containerWidth, channelHeight, maxProgramWidth, programGap);
if (block) {
channelBlocks.push(block);
}
});
blocks.set(channel.programTitle, channelBlocks);
});
return blocks;
}, [data, timelineStart, timelineEnd, containerWidth, channelHeight]);
const handleProgramSelect = useCallback((program, channel) => {
if (onProgramSelect) {
onProgramSelect(program, channel);
}
}, [onProgramSelect]);
const handleChannelSelect = useCallback((channel) => {
if (onChannelSelect) {
onChannelSelect(channel);
}
}, [onChannelSelect]);
const handleScroll = useMemo(() => throttle((e) => {
const target = e.currentTarget;
setScrollTop(target.scrollTop);
setScrollLeft(target.scrollLeft);
if (onTimelineScroll) {
onTimelineScroll(target.scrollLeft);
}
}, 16), [onTimelineScroll]);
useEffect(() => {
if (channelNamesRef.current) {
const channelNamesContainer = channelNamesRef.current.querySelector('.epg-channel-names-content');
if (channelNamesContainer) {
channelNamesContainer.style.transform = `translateY(-${scrollTop}px)`;
}
}
}, [scrollTop]);
useEffect(() => {
if (gridRef.current && timelineScrollLeft !== undefined) {
gridRef.current.scrollLeft = timelineScrollLeft;
}
}, [timelineScrollLeft]);
const visibleChannelRange = useMemo(() => {
const startIndex = Math.floor(scrollTop / channelHeight);
const endIndex = Math.min(startIndex + visibleChannels + 2, data.length);
return { startIndex, endIndex };
}, [scrollTop, channelHeight, visibleChannels, data.length]);
return (React.createElement("div", { className: `epg-program-grid ${className}`, style: {
position: 'relative',
height: '100%',
overflow: 'hidden',
backgroundColor: 'transparent',
...style,
} },
React.createElement("div", { ref: channelNamesRef, className: "epg-channel-names", style: {
position: 'absolute',
left: '0',
top: '0',
bottom: '0',
width: '200px',
backgroundColor: 'rgba(20, 20, 20, 0.6)',
borderRight: '1px solid rgba(255, 255, 255, 0.1)',
zIndex: 10,
overflow: 'hidden',
backdropFilter: 'blur(8px)',
} },
React.createElement("div", { className: "epg-channel-names-content", style: {
height: `${data.length * channelHeight}px`,
position: 'relative',
} }, data.slice(visibleChannelRange.startIndex, visibleChannelRange.endIndex).map((channel, index) => {
const actualIndex = visibleChannelRange.startIndex + index;
return (React.createElement("div", { key: channel.programTitle, className: "epg-channel-name", style: {
position: 'absolute',
top: `${actualIndex * channelHeight}px`,
left: '0',
right: '0',
height: `${channelHeight}px`,
display: 'flex',
alignItems: 'center',
padding: '0 12px',
borderBottom: '1px solid rgba(255, 255, 255, 0.1)',
cursor: 'pointer',
backgroundColor: 'rgba(20, 20, 20, 0.3)',
transition: 'all 200ms ease',
backdropFilter: 'blur(6px)',
}, onClick: () => handleChannelSelect(channel), onMouseEnter: (e) => {
e.currentTarget.style.backgroundColor = 'rgba(20, 20, 20, 0.5)';
}, onMouseLeave: (e) => {
e.currentTarget.style.backgroundColor = 'rgba(20, 20, 20, 0.3)';
} },
channel.channelLogo && (React.createElement("img", { src: channel.channelLogo, alt: channel.programTitle, style: {
width: '32px',
height: '32px',
borderRadius: '4px',
marginRight: '8px',
objectFit: 'cover',
}, onError: (e) => {
e.currentTarget.style.display = 'none';
} })),
React.createElement("div", { style: {
color: '#fff',
fontSize: '14px',
fontWeight: '600',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
flex: 1,
} }, channel.programTitle)));
}))),
React.createElement("div", { ref: gridRef, className: "epg-programs-container", style: {
position: 'absolute',
left: '200px',
top: '0',
right: '0',
bottom: '0',
overflow: 'auto',
}, onScroll: handleScroll },
React.createElement("div", { className: "epg-programs-grid", style: {
position: 'relative',
width: `${containerWidth}px`,
height: `${data.length * channelHeight}px`,
} },
data.map((_, index) => (React.createElement("div", { key: `h-grid-line-${index}`, style: {
position: 'absolute',
top: `${(index + 1) * channelHeight}px`,
left: '0',
right: '0',
height: '1px',
backgroundColor: 'rgba(255, 255, 255, 0.1)',
opacity: 1,
} }))),
(() => {
const lines = [];
const timeRange = timelineEnd - timelineStart;
const slotDuration = 60 * 60 * 1000;
const numSlots = Math.ceil(timeRange / slotDuration);
for (let i = 0; i <= numSlots; i++) {
const lineTime = timelineStart + (i * slotDuration);
const linePosition = ((lineTime - timelineStart) / timeRange) * containerWidth;
lines.push(React.createElement("div", { key: `v-grid-line-${i}`, style: {
position: 'absolute',
left: `${linePosition}px`,
top: '0',
bottom: '0',
width: '1px',
backgroundColor: 'rgba(255, 255, 255, 0.08)',
opacity: 1,
} }));
}
return lines;
})(),
data.slice(visibleChannelRange.startIndex, visibleChannelRange.endIndex).map((channel, index) => {
const actualIndex = visibleChannelRange.startIndex + index;
const channelBlocks = programBlocks.get(channel.programTitle) || [];
return (React.createElement("div", { key: channel.programTitle, className: "epg-channel-programs", style: {
position: 'absolute',
top: `${actualIndex * channelHeight}px`,
left: '0',
right: '0',
height: `${channelHeight}px`,
} }, channelBlocks.map((block, blockIndex) => {
const isSelected = selectedProgram?.id === block.program.id;
const isLive = isProgramLive(block.program, currentTime);
const progress = isLive ? getProgramProgress(block.program, currentTime) : 0;
return (React.createElement(ProgramBlockComponent, { key: `${block.program.id}-${blockIndex}`, block: block, isSelected: isSelected, isLive: isLive, progress: progress, channelHeight: channelHeight, showThumbnails: showThumbnails, thumbnailMinWidth: thumbnailMinWidth, theme: theme, onClick: () => handleProgramSelect(block.program, channel) }));
})));
}),
currentTime >= timelineStart && currentTime <= timelineEnd && (React.createElement("div", { className: "epg-current-time-line", style: {
position: 'absolute',
left: `${((currentTime - timelineStart) / (timelineEnd - timelineStart)) * containerWidth}px`,
top: '0',
bottom: '0',
width: '2px',
backgroundColor: theme.primaryColor,
zIndex: 20,
pointerEvents: 'none',
boxShadow: `0 0 4px ${hexToRgba(theme.primaryColor, 0.5)}`,
} }))))));
};
export default EPGProgramGrid;
//# sourceMappingURL=EPGProgramGrid.js.map