Skip to content

Commit b35b754

Browse files
Implement Electronic Program Guide from ATSC PSIP Data (#228)
* Initial plan * feat: Implement EPG core components and utilities Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * feat: Integrate EPG with ATSCPlayer page Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * docs: Add EPG implementation documentation Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * style: Fix markdown formatting in EPG README * fix: Address PR review comments - Use slotDurationMinutes parameter consistently in ProgramGrid - Fix channel number matching logic to use exact formatted channel number - Remove unused channels dependency from useMemo hooks - Fix grid column calculation to use Math.ceil for better positioning - Implement basic US TV rating parsing from Content Advisory Descriptor - Fix SCSU mode handling to return empty string instead of attempting UTF-8 - Fix test expectations to match actual decoded output - Update test query to match exact aria-label Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * fix: Address remaining PR review comments - Convert EPGStorage from static class to namespace pattern - Fix handleTuneFromEPG callback signature to accept both channelNumber and startTime parameters Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * fix: Address additional PR review feedback - Cache GPS epoch constant for efficiency (avoid creating Date on every call) - Fix fallback behavior for unknown text modes to return empty string instead of attempting UTF-8 - Fix useEffect dependency array to avoid unnecessary re-renders - Add documentation for rating parsing limitation (only primary age-based rating) - Fix inconsistent startTime parameter for past programs (use program.startTime instead of new Date()) Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * fix: Address performance and UX issues from PR review - Update currentPrograms periodically (every 30s) to show live updates - Add channels as dependency for allGenres and searchResults memos - Memoize gridEndTime and timeSlots calculations to avoid re-creating on every render - Replace alert() calls with console.info() for better UX - Add aria-hidden to close button symbol for better accessibility Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * fix: Improve accessibility with aria-hidden on decorative elements - Add aria-hidden="true" to current time marker (visual indicator only) - Add aria-hidden="true" to channel header spacer (layout element) - Add aria-hidden="true" to HD badge and live indicator (duplicate info in aria-label) - Add aria-hidden="true" to clear search button symbol - Include loadEPGData in useEffect dependency array per React best practices Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> * fix: Resolve TypeScript type checking issues - Remove unused ExtendedTextTable import from test file - Add proper undefined checks for Uint8Array access in parseContentAdvisoryDescriptor - Replace non-null assertions with explicit undefined checks to satisfy linter rules Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexthemitchell <5687158+alexthemitchell@users.noreply.github.com>
1 parent d8edd1c commit b35b754

17 files changed

+3445
-42
lines changed

src/components/EPG/ATSCProgramGuide.tsx

Lines changed: 801 additions & 0 deletions
Large diffs are not rendered by default.

src/components/EPG/ChannelRow.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Channel Row Component
3+
*
4+
* Displays a single channel's programs in the EPG grid.
5+
*/
6+
7+
import React from "react";
8+
import { ProgramCell } from "./ProgramCell";
9+
import type { EPGChannelData, EPGProgram } from "../../utils/epgStorage";
10+
11+
export interface ChannelRowProps {
12+
channel: EPGChannelData;
13+
gridStartTime: Date;
14+
gridEndTime: Date;
15+
slotDurationMinutes: number;
16+
onProgramClick: (program: EPGProgram) => void;
17+
currentTime: Date;
18+
}
19+
20+
/**
21+
* Filter programs that appear in the current time window
22+
*/
23+
function getVisiblePrograms(
24+
programs: EPGProgram[],
25+
startTime: Date,
26+
endTime: Date,
27+
): EPGProgram[] {
28+
return programs.filter((program) => {
29+
// Include if program overlaps with the time window
30+
return program.endTime > startTime && program.startTime < endTime;
31+
});
32+
}
33+
34+
/**
35+
* Channel Row Component
36+
*/
37+
export function ChannelRow({
38+
channel,
39+
gridStartTime,
40+
gridEndTime,
41+
slotDurationMinutes,
42+
onProgramClick,
43+
currentTime,
44+
}: ChannelRowProps): React.JSX.Element {
45+
const visiblePrograms = getVisiblePrograms(
46+
channel.programs,
47+
gridStartTime,
48+
gridEndTime,
49+
);
50+
51+
return (
52+
<div className="epg-channel-row">
53+
<div className="epg-channel-header">
54+
<div className="channel-number">{channel.channelNumber}</div>
55+
<div className="channel-name">{channel.channelName}</div>
56+
</div>
57+
<div className="epg-channel-programs">
58+
{visiblePrograms.length === 0 ? (
59+
<div className="epg-no-programs">
60+
No program information available
61+
</div>
62+
) : (
63+
visiblePrograms.map((program) => {
64+
const isCurrentlyAiring =
65+
program.startTime <= currentTime && program.endTime > currentTime;
66+
67+
return (
68+
<ProgramCell
69+
key={program.eventId}
70+
program={program}
71+
slotDurationMinutes={slotDurationMinutes}
72+
gridStartTime={gridStartTime}
73+
onProgramClick={onProgramClick}
74+
isCurrentlyAiring={isCurrentlyAiring}
75+
/>
76+
);
77+
})
78+
)}
79+
</div>
80+
</div>
81+
);
82+
}

src/components/EPG/ProgramCell.tsx

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/**
2+
* Program Cell Component
3+
*
4+
* Individual program cell in the EPG grid, displaying program info
5+
* within a time-based layout.
6+
*/
7+
8+
import React from "react";
9+
import type { EPGProgram } from "../../utils/epgStorage";
10+
11+
export interface ProgramCellProps {
12+
program: EPGProgram;
13+
slotDurationMinutes: number;
14+
gridStartTime: Date;
15+
onProgramClick: (program: EPGProgram) => void;
16+
isCurrentlyAiring?: boolean;
17+
}
18+
19+
/**
20+
* Calculate program position and width in the grid
21+
*/
22+
function calculateProgramLayout(
23+
program: EPGProgram,
24+
gridStartTime: Date,
25+
slotDurationMinutes: number,
26+
): { left: number; width: number } {
27+
const gridStartMs = gridStartTime.getTime();
28+
const programStartMs = program.startTime.getTime();
29+
const programEndMs = program.endTime.getTime();
30+
31+
// Calculate offset from grid start in minutes
32+
const offsetMinutes = Math.max(0, (programStartMs - gridStartMs) / 60000);
33+
34+
// Calculate program duration in minutes
35+
const durationMinutes = (programEndMs - programStartMs) / 60000;
36+
37+
// Convert to grid units (slots)
38+
const left = offsetMinutes / slotDurationMinutes;
39+
const width = durationMinutes / slotDurationMinutes;
40+
41+
return { left, width };
42+
}
43+
44+
/**
45+
* Program Cell Component
46+
*/
47+
export function ProgramCell({
48+
program,
49+
slotDurationMinutes,
50+
gridStartTime,
51+
onProgramClick,
52+
isCurrentlyAiring = false,
53+
}: ProgramCellProps): React.JSX.Element {
54+
const { left, width } = calculateProgramLayout(
55+
program,
56+
gridStartTime,
57+
slotDurationMinutes,
58+
);
59+
60+
const handleClick = (): void => {
61+
onProgramClick(program);
62+
};
63+
64+
const handleKeyDown = (e: React.KeyboardEvent): void => {
65+
if (e.key === "Enter" || e.key === " ") {
66+
e.preventDefault();
67+
onProgramClick(program);
68+
}
69+
};
70+
71+
// Determine if program title should be displayed based on width
72+
const showFullTitle = width >= 2; // Show full title if at least 2 slots wide
73+
const showTime = width >= 3; // Show time if at least 3 slots wide
74+
75+
return (
76+
<div
77+
className={`epg-program-cell ${isCurrentlyAiring ? "airing" : ""}`}
78+
style={{
79+
gridColumn: `${Math.ceil(left) + 1} / span ${Math.max(1, Math.ceil(width))}`,
80+
}}
81+
onClick={handleClick}
82+
onKeyDown={handleKeyDown}
83+
role="button"
84+
tabIndex={0}
85+
aria-label={`${program.title}, ${program.startTime.toLocaleTimeString()} on channel ${program.channelNumber}`}
86+
title={`${program.title}\n${program.startTime.toLocaleTimeString()} - ${program.endTime.toLocaleTimeString()}`}
87+
>
88+
<div className="program-cell-content">
89+
{showTime && (
90+
<div className="program-cell-time">
91+
{program.startTime.toLocaleTimeString("en-US", {
92+
hour: "numeric",
93+
minute: "2-digit",
94+
hour12: true,
95+
})}
96+
</div>
97+
)}
98+
<div className="program-cell-title">
99+
{showFullTitle ? program.title : program.title.substring(0, 20)}
100+
</div>
101+
{program.isHD && width >= 2 && (
102+
<span className="program-cell-hd" aria-hidden="true">
103+
HD
104+
</span>
105+
)}
106+
{isCurrentlyAiring && (
107+
<span className="program-cell-live-indicator" aria-hidden="true">
108+
109+
</span>
110+
)}
111+
</div>
112+
</div>
113+
);
114+
}

0 commit comments

Comments
 (0)