UNPKG

unified-video-framework

Version:

Cross-platform video player framework supporting iOS, Android, Web, Smart TVs (Samsung/LG), Roku, and more

453 lines (443 loc) 18.4 kB
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { calculateOptimalTimeRange, getProgramsInRange } from "../utils/EPGUtils.js"; import EPGNavigationControls from "./EPGNavigationControls.js"; import EPGTimelineHeader from "./EPGTimelineHeader.js"; import EPGProgramGrid from "./EPGProgramGrid.js"; import EPGProgramDetails from "./EPGProgramDetails.js"; const DEFAULT_CONFIG = { timeSlotDuration: 60, visibleHours: 4, enableInfiniteScroll: true, lazyLoadThreshold: 200, showChannelLogos: true, showProgramImages: true, compactMode: false, }; export const EPGOverlay = ({ data, config: userConfig = {}, visible = true, onToggle, className = '', style = {}, }) => { const overlayRef = useRef(null); const [updateTrigger, setUpdateTrigger] = useState(0); const [timelineScrollLeft, setTimelineScrollLeft] = useState(0); const config = useMemo(() => ({ ...DEFAULT_CONFIG, ...userConfig, }), [userConfig]); const [state, setState] = useState(() => { const currentTime = Date.now(); const { start, end } = calculateOptimalTimeRange(currentTime, config.visibleHours); return { selectedProgram: null, selectedChannel: null, timelineStart: start, timelineEnd: end, containerWidth: 2400, visibleHours: config.visibleHours, currentTime, isLoading: false, error: null, }; }); useEffect(() => { const interval = setInterval(() => { setState(prev => ({ ...prev, currentTime: Date.now(), })); }, 60000); return () => clearInterval(interval); }, []); const containerWidth = useMemo(() => { return state.visibleHours * 600; }, [state.visibleHours]); const filteredData = useMemo(() => { if (!data?.timeline) return []; return getProgramsInRange(data.timeline, state.timelineStart, state.timelineEnd, 1); }, [data?.timeline, state.timelineStart, state.timelineEnd]); const handleNavigate = useCallback(async (direction) => { setState(prev => ({ ...prev, isLoading: true, error: null })); try { let newStart; let newEnd; if (direction === 'today') { const { start, end } = calculateOptimalTimeRange(Date.now(), state.visibleHours); newStart = start; newEnd = end; } else { const timeShift = state.visibleHours * 60 * 60 * 1000; if (direction === 'left') { newStart = state.timelineStart - timeShift; newEnd = state.timelineEnd - timeShift; } else { newStart = state.timelineStart + timeShift; newEnd = state.timelineEnd + timeShift; } } setState(prev => ({ ...prev, timelineStart: newStart, timelineEnd: newEnd, containerWidth: prev.visibleHours * 600, isLoading: false, })); setUpdateTrigger(prev => prev + 1); } catch (error) { setState(prev => ({ ...prev, error: error instanceof Error ? error.message : 'Navigation failed', isLoading: false, })); } }, [state.visibleHours, state.timelineStart, state.timelineEnd]); const handleTimeRangeChange = useCallback((hours) => { const { start, end } = calculateOptimalTimeRange(state.currentTime, hours); setState(prev => ({ ...prev, visibleHours: hours, timelineStart: start, timelineEnd: end, containerWidth: hours * 600, })); setUpdateTrigger(prev => prev + 1); }, [state.currentTime]); const getModalPosition = useCallback(() => { if (!overlayRef.current) { return { top: '20px', right: '20px' }; } const overlayRect = overlayRef.current.getBoundingClientRect(); const modalWidth = 400; const modalHeight = 400; const rightSpace = overlayRect.width - modalWidth - 40; const topSpace = overlayRect.height - modalHeight - 40; let position = { top: Math.max(20, Math.min(topSpace / 2, 80)), right: rightSpace > 0 ? '20px' : 'auto', left: rightSpace > 0 ? 'auto' : '20px', }; return position; }, []); const handleProgramSelect = useCallback((program, channel) => { setState(prev => ({ ...prev, selectedProgram: program, selectedChannel: channel, })); if (config.onProgramSelect) { config.onProgramSelect(program, channel); } }, [config]); const handleChannelSelect = useCallback((channel) => { setState(prev => ({ ...prev, selectedChannel: channel, })); if (config.onChannelSelect) { config.onChannelSelect(channel); } }, [config]); const handleAction = useCallback(async (action) => { setState(prev => ({ ...prev, isLoading: true, error: null })); try { switch (action.type) { case 'favorite': if (config.onFavorite) { await config.onFavorite(action.program, action.channel); } break; case 'record': if (config.onRecord) { await config.onRecord(action.program, action.channel); } break; case 'reminder': if (config.onSetReminder) { await config.onSetReminder(action.program, action.channel); } break; case 'catchup': if (config.onCatchup) { await config.onCatchup(action.program, action.channel); } break; } if (filteredData && state.selectedProgram) { const updatedProgram = { ...state.selectedProgram }; switch (action.type) { case 'favorite': updatedProgram.isFavorite = !updatedProgram.isFavorite; break; case 'record': updatedProgram.isRecording = !updatedProgram.isRecording; break; case 'reminder': updatedProgram.hasReminder = !updatedProgram.hasReminder; break; } setState(prev => ({ ...prev, selectedProgram: updatedProgram, isLoading: false, })); } else { setState(prev => ({ ...prev, isLoading: false })); } } catch (error) { setState(prev => ({ ...prev, error: error instanceof Error ? error.message : 'Action failed', isLoading: false, })); } }, [config, filteredData, state.selectedProgram]); const handleTimelineScroll = useCallback((scrollLeft) => { setTimelineScrollLeft(scrollLeft); }, []); const handleProgramGridScroll = useCallback((scrollLeft) => { setTimelineScrollLeft(scrollLeft); }, []); const handleTimeClick = useCallback((timestamp) => { const newStart = timestamp - (state.visibleHours * 30 * 60 * 1000); const newEnd = newStart + (state.visibleHours * 60 * 60 * 1000); setState(prev => ({ ...prev, timelineStart: newStart, timelineEnd: newEnd, })); setUpdateTrigger(prev => prev + 1); }, [state.visibleHours]); const handleCloseDetails = useCallback(() => { setState(prev => ({ ...prev, selectedProgram: null, })); }, []); useEffect(() => { const handleKeyPress = (e) => { if (!visible) return; if ((e.key === 'g' || e.key === 'G') && e.ctrlKey) { e.preventDefault(); onToggle?.(false); return; } switch (e.key) { case 'Home': e.preventDefault(); handleNavigate('today'); break; case 'Escape': if (state.selectedProgram) { e.preventDefault(); handleCloseDetails(); } else { e.preventDefault(); onToggle?.(false); } break; } }; if (visible) { document.addEventListener('keydown', handleKeyPress); } return () => { document.removeEventListener('keydown', handleKeyPress); }; }, [visible, handleNavigate, handleCloseDetails, state.selectedProgram, onToggle]); if (!visible) return null; const canNavigateLeft = true; const canNavigateRight = true; return (React.createElement("div", { ref: overlayRef, className: `epg-overlay epg-overlay-smarttv ${className}`, style: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(10, 10, 10, 0.65)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', color: '#fff', zIndex: 1000, display: 'flex', flexDirection: 'column', overflow: 'hidden', animation: 'epgFadeIn 300ms ease-out', ...style, } }, React.createElement("button", { onClick: () => onToggle?.(false), className: "epg-close-button", title: "Close EPG (Ctrl+G or Esc)", "aria-label": "Close EPG", style: { position: 'absolute', top: '16px', right: '16px', width: '40px', height: '40px', borderRadius: '50%', border: 'none', backgroundColor: 'rgba(255, 107, 53, 0.9)', color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24px', fontWeight: '600', transition: 'all 200ms ease', boxShadow: '0 4px 12px rgba(255, 107, 53, 0.3)', zIndex: 999, '&:hover': { backgroundColor: 'rgba(255, 107, 53, 1)', boxShadow: '0 6px 16px rgba(255, 107, 53, 0.5)', transform: 'scale(1.1)', }, }, onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = 'rgba(255, 107, 53, 1)'; e.currentTarget.style.boxShadow = '0 6px 16px rgba(255, 107, 53, 0.5)'; e.currentTarget.style.transform = 'scale(1.1)'; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = 'rgba(255, 107, 53, 0.9)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(255, 107, 53, 0.3)'; e.currentTarget.style.transform = 'scale(1)'; } }, "\u2715"), React.createElement(EPGNavigationControls, { onNavigate: handleNavigate, onTimeRangeChange: handleTimeRangeChange, canNavigateLeft: canNavigateLeft, canNavigateRight: canNavigateRight, currentTime: state.currentTime, timelineStart: state.timelineStart, timelineEnd: state.timelineEnd, visibleHours: state.visibleHours }), React.createElement(EPGTimelineHeader, { timelineStart: state.timelineStart, timelineEnd: state.timelineEnd, containerWidth: containerWidth, currentTime: state.currentTime, visibleHours: state.visibleHours, slotDuration: config.timeSlotDuration, onTimeClick: handleTimeClick, scrollLeft: timelineScrollLeft, onScroll: handleTimelineScroll }), React.createElement("div", { className: "epg-main-content", style: { flex: 1, display: 'flex', overflow: 'hidden', position: 'relative', } }, React.createElement(EPGProgramGrid, { data: filteredData, timelineStart: state.timelineStart, timelineEnd: state.timelineEnd, containerWidth: containerWidth, currentTime: state.currentTime, selectedProgram: state.selectedProgram, onProgramSelect: handleProgramSelect, onChannelSelect: handleChannelSelect, onTimelineScroll: handleProgramGridScroll, timelineScrollLeft: timelineScrollLeft, channelHeight: 80, visibleChannels: 6, style: { flex: 1 } }), state.selectedProgram && (React.createElement("div", { className: "epg-details-panel", style: { position: 'absolute', top: '20px', right: '20px', width: '400px', maxHeight: 'calc(100vh - 45vh - 40px)', zIndex: 200, animation: 'slideInFromTop 0.3s ease-out', boxShadow: '0 12px 48px rgba(0, 0, 0, 0.7)', borderRadius: '12px', overflow: 'hidden', display: 'flex', flexDirection: 'column', } }, React.createElement(EPGProgramDetails, { program: state.selectedProgram, channel: state.selectedChannel || undefined, onClose: handleCloseDetails, onAction: handleAction, isModal: false, currentTime: state.currentTime })))), state.isLoading && (React.createElement("div", { className: "epg-loading-overlay", style: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0, 0, 0, 0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 300, } }, React.createElement("div", { style: { color: '#fff', fontSize: '18px', fontWeight: '600', } }, "Loading..."))), state.error && (React.createElement("div", { className: "epg-error-message", style: { position: 'absolute', top: '20px', left: '50%', transform: 'translateX(-50%)', backgroundColor: '#e74c3c', color: '#fff', padding: '12px 20px', borderRadius: '8px', fontSize: '14px', fontWeight: '600', zIndex: 400, maxWidth: '400px', textAlign: 'center', boxShadow: '0 4px 12px rgba(231, 76, 60, 0.3)', } }, state.error, React.createElement("button", { onClick: () => setState(prev => ({ ...prev, error: null })), style: { marginLeft: '12px', backgroundColor: 'transparent', border: 'none', color: '#fff', cursor: 'pointer', fontSize: '16px', padding: '0', } }, "\u00D7"))), React.createElement("style", null, ` /* SmartTV Glassmorphic Animations */ @keyframes epgFadeIn { from { opacity: 0; } to { opacity: 1; } } @keyframes slideInRight { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } } @keyframes slideInFromTop { from { transform: translateY(-20px); opacity: 0; scale: 0.95; } to { transform: translateY(0); opacity: 1; scale: 1; } } /* SmartTV Focus Styles */ .epg-focus-ring { outline: 2px solid #4a90e2 !important; outline-offset: 4px !important; box-shadow: 0 0 20px rgba(74, 144, 226, 0.5) !important; } /* Glassmorphic Effect */ .epg-glass-effect { background: rgba(255, 255, 255, 0.05); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid rgba(255, 255, 255, 0.1); } .epg-overlay * { box-sizing: border-box; } .epg-overlay::-webkit-scrollbar { width: 8px; height: 8px; } .epg-overlay::-webkit-scrollbar-track { background: rgba(26, 26, 26, 0.8); } .epg-overlay::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.2); border-radius: 4px; } .epg-overlay::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } /* TV-Optimized Typography */ .epg-overlay-smarttv { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; font-weight: 500; letter-spacing: 0.3px; } `))); }; export default EPGOverlay; //# sourceMappingURL=EPGOverlay.js.map