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