UNPKG

unified-video-framework

Version:

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

483 lines (472 loc) 19.8 kB
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { DEFAULT_EPG_THEME } from '../types/EPGTypes.js'; import { hexToRgba } from '../utils/ColorUtils.js'; 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, showFavoriteButton: false, showRecordButton: false, showReminderButton: false, showCatchupButton: false, }; const calculateContainerWidth = (visibleHours) => { const pixelsPerHour = visibleHours <= 4 ? 250 : visibleHours <= 8 ? 180 : 150; return visibleHours * pixelsPerHour; }; export const EPGOverlay = ({ data, config: userConfig = {}, theme: userTheme = {}, 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 theme = useMemo(() => ({ ...DEFAULT_EPG_THEME, ...userTheme, }), [userTheme]); 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: calculateContainerWidth(config.visibleHours), 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(() => calculateContainerWidth(state.visibleHours), [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: calculateContainerWidth(prev.visibleHours), 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: calculateContainerWidth(hours), })); 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]); const canNavigateLeft = true; const canNavigateRight = true; return (React.createElement("div", { ref: overlayRef, className: `epg-overlay epg-overlay-smarttv ${visible ? 'epg-visible' : ''} ${className}`, style: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(10, 10, 10, 0.65)', backdropFilter: 'blur(12px)', WebkitBackdropFilter: 'blur(12px)', color: '#fff', zIndex: 2147483647, display: visible ? 'flex' : 'none', flexDirection: 'column', overflow: 'hidden', animation: visible ? 'epgFadeIn 300ms ease-out' : 'none', visibility: visible ? 'visible' : 'hidden', opacity: visible ? 1 : 0, pointerEvents: visible ? 'auto' : 'none', ...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: hexToRgba(theme.primaryColor, 0.9), color: '#fff', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: '24px', fontWeight: '600', transition: 'all 200ms ease', boxShadow: `0 4px 12px ${hexToRgba(theme.primaryColor, 0.3)}`, zIndex: 999, '&:hover': { backgroundColor: theme.primaryColor, boxShadow: `0 6px 16px ${hexToRgba(theme.primaryColor, 0.5)}`, transform: 'scale(1.1)', }, }, onMouseEnter: (e) => { e.currentTarget.style.backgroundColor = theme.primaryColor; e.currentTarget.style.boxShadow = `0 6px 16px ${hexToRgba(theme.primaryColor, 0.5)}`; e.currentTarget.style.transform = 'scale(1.1)'; }, onMouseLeave: (e) => { e.currentTarget.style.backgroundColor = hexToRgba(theme.primaryColor, 0.9); e.currentTarget.style.boxShadow = `0 4px 12px ${hexToRgba(theme.primaryColor, 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, theme: theme }), 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, theme: theme }), 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, showThumbnails: config.showProgramThumbnailsInGrid, thumbnailMinWidth: config.thumbnailMinWidth, theme: theme, 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, config: config, theme: theme })))), 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 ${theme.selectionColor} !important; outline-offset: 4px !important; box-shadow: 0 0 20px ${hexToRgba(theme.selectionColor, 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; } /* Fullscreen Mode Support - Ensure EPG is visible in fullscreen ONLY when epg-visible class is present */ :fullscreen .epg-overlay.epg-visible, :-webkit-full-screen .epg-overlay.epg-visible, :-moz-full-screen .epg-overlay.epg-visible, :-ms-fullscreen .epg-overlay.epg-visible { position: absolute !important; top: 0 !important; left: 0 !important; right: 0 !important; bottom: 0 !important; z-index: 2147483647 !important; display: flex !important; visibility: visible !important; opacity: 1 !important; } `))); }; export default EPGOverlay; //# sourceMappingURL=EPGOverlay.js.map