UNPKG

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