feat(Discover): EPG layout

This commit is contained in:
Botzy 2026-03-26 16:25:04 +02:00
parent 6dec4fe907
commit 2eab4b4e98
8 changed files with 1058 additions and 72 deletions

View file

@ -11,6 +11,7 @@ const { AddonDetailsModal, Button, DelayedRenderer, Image, MainNavBars, MetaItem
const useDiscover = require('./useDiscover');
const useSelectableInputs = require('./useSelectableInputs');
const useInstalledAddons = require('../Addons/useInstalledAddons');
const {default: EpgGuide} = require('./EpgGuide');
const styles = require('./styles');
const SCROLL_TO_BOTTOM_THRESHOLD = 400;
@ -27,6 +28,7 @@ const Discover = ({ urlParams, queryParams }) => {
const metasContainerRef = React.useRef();
const metaPreviewRef = React.useRef();
const [selectedProgram, setSelectedProgram] = React.useState(null);
const installedAddons = useInstalledAddons({ transportUrl: null, catalogId: null });
const selectedAddon = React.useMemo(() => {
const selected = discover.selectable.catalogs.find(({ selected }) => selected)?.addon ?? null;
@ -34,6 +36,9 @@ const Discover = ({ urlParams, queryParams }) => {
return addon;
}, [discover.selectable.catalogs, installedAddons]);
const isEpgLayout = React.useMemo(() => !!selectedAddon?.manifest?.behaviorHints?.epgEndpoint, [selectedAddon]);
const onProgramSelect = React.useCallback((program) => {
setSelectedProgram(program);
}, [setSelectedProgram]);
React.useEffect(() => {
if (!isEpgLayout && discover.catalog?.content.type === 'Loading' && metasContainerRef.current) {
@ -105,7 +110,120 @@ const Discover = ({ urlParams, queryParams }) => {
closeInputsModal();
closeAddonModal();
setSelectedMetaItemIndex(0);
setSelectedProgram(null);
}, [discover.selected]);
const renderEmptyState = () => (
<DelayedRenderer delay={500}>
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{t('NO_CATALOG_SELECTED')}</div>
</div>
</DelayedRenderer>
);
const renderErrorState = (msg) => (
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{msg}</div>
</div>
);
const renderCatalogContent = () => {
if (discover.catalog === null) return renderEmptyState();
if (discover.catalog.content.type === 'Err') return renderErrorState(discover.catalog.content.content);
if (isEpgLayout) {
return (
<EpgGuide
addon={selectedAddon}
onProgramSelect={onProgramSelect}
/>
);
}
if (discover.catalog.content.type === 'Loading') {
return (
<div ref={metasContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')}>
{Array(CONSTANTS.CATALOG_PAGE_SIZE).fill(null).map((_, index) => (
<div key={index} className={styles['meta-item-placeholder']}>
<div className={styles['poster-container']} />
<div className={styles['title-bar-container']}>
<div className={styles['title-label']} />
</div>
</div>
))}
</div>
);
}
return (
<div ref={metasContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')} onScroll={onScroll} onFocusCapture={metaItemsOnFocusCapture}>
{discover.catalog.content.content.map((metaItem, index) => (
<MetaItem
key={index}
className={classnames({ 'selected': selectedMetaItemIndex === index })}
type={metaItem.type}
name={metaItem.name}
poster={metaItem.poster}
posterShape={metaItem.posterShape}
playname={selectedMetaItemIndex === index}
deepLinks={metaItem.deepLinks}
watched={metaItem.watched}
data-index={index}
onClick={metaItemOnClick}
/>
))}
</div>
);
};
const renderMetaPreview = () => {
if (isEpgLayout) {
if (selectedProgram === null) return null;
return (
<MetaPreview
className={styles['meta-preview-container']}
compact={true}
name={selectedProgram.title}
background={selectedProgram.thumbnail}
released={selectedProgram.start ? new Date(selectedProgram.start) : null}
description={selectedProgram.description}
/>
);
}
if (selectedMetaItem !== null) {
return (
<MetaPreview
className={styles['meta-preview-container']}
compact={true}
ref={metaPreviewRef}
name={selectedMetaItem.name}
logo={selectedMetaItem.logo}
background={selectedMetaItem.poster}
runtime={selectedMetaItem.runtime}
releaseInfo={selectedMetaItem.releaseInfo}
released={selectedMetaItem.released}
description={selectedMetaItem.description}
links={selectedMetaItem.links}
deepLinks={selectedMetaItem.deepLinks}
trailerStreams={selectedMetaItem.trailerStreams}
inLibrary={selectedMetaItem.inLibrary}
toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary}
metaId={selectedMetaItem.id}
like={selectedMetaItem.like}
/>
);
}
if (discover.catalog !== null && discover.catalog.content.type === 'Loading') {
return <div className={styles['meta-preview-container']} />;
}
return null;
};
return (
<MainNavBars className={styles['discover-container']} route={'discover'}>
<div className={styles['discover-content']}>
@ -138,79 +256,9 @@ const Discover = ({ urlParams, queryParams }) => {
:
null
}
{
discover.catalog === null ?
<DelayedRenderer delay={500}>
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{t('NO_CATALOG_SELECTED')}</div>
</div>
</DelayedRenderer>
:
discover.catalog.content.type === 'Err' ?
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{discover.catalog.content.content}</div>
</div>
:
discover.catalog.content.type === 'Loading' ?
<div ref={metasContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')}>
{Array(CONSTANTS.CATALOG_PAGE_SIZE).fill(null).map((_, index) => (
<div key={index} className={styles['meta-item-placeholder']}>
<div className={styles['poster-container']} />
<div className={styles['title-bar-container']}>
<div className={styles['title-label']} />
</div>
</div>
))}
</div>
:
<div ref={metasContainerRef} className={classnames(styles['meta-items-container'], 'animation-fade-in')} onScroll={onScroll} onFocusCapture={metaItemsOnFocusCapture}>
{discover.catalog.content.content.map((metaItem, index) => (
<MetaItem
key={index}
className={classnames({ 'selected': selectedMetaItemIndex === index })}
type={metaItem.type}
name={metaItem.name}
poster={metaItem.poster}
posterShape={metaItem.posterShape}
playname={selectedMetaItemIndex === index}
deepLinks={metaItem.deepLinks}
watched={metaItem.watched}
data-index={index}
onClick={metaItemOnClick}
/>
))}
</div>
}
{renderCatalogContent()}
</div>
{
selectedMetaItem !== null ?
<MetaPreview
className={styles['meta-preview-container']}
compact={true}
ref={metaPreviewRef}
name={selectedMetaItem.name}
logo={selectedMetaItem.logo}
background={selectedMetaItem.poster}
runtime={selectedMetaItem.runtime}
releaseInfo={selectedMetaItem.releaseInfo}
released={selectedMetaItem.released}
description={selectedMetaItem.description}
links={selectedMetaItem.links}
deepLinks={selectedMetaItem.deepLinks}
trailerStreams={selectedMetaItem.trailerStreams}
inLibrary={selectedMetaItem.inLibrary}
toggleInLibrary={selectedMetaItem.inLibrary ? removeFromLibrary : addToLibrary}
metaId={selectedMetaItem.id}
like={selectedMetaItem.like}
/>
:
discover.catalog !== null && discover.catalog.content.type === 'Loading' ?
<div className={styles['meta-preview-container']} />
:
null
}
{renderMetaPreview()}
</div>
{
inputsModalOpen ?

View file

@ -0,0 +1,350 @@
// Copyright (C) 2017-2026 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
@keyframes epg-skeleton-shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
.epg-guide {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
padding: 0 0 1.5rem 0;
overflow: hidden;
}
.epg-day-selector {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.8rem 1.5rem 1rem 1.5rem;
overflow-x: auto;
scrollbar-width: none;
&::-webkit-scrollbar { display: none; }
}
.epg-day-arrow {
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 2.8rem;
height: 2.8rem;
border-radius: 50%;
font-size: 2rem;
line-height: 1;
color: var(--primary-foreground-color);
background: transparent;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s, background-color 0.15s;
&:hover:not(:disabled) {
opacity: 1;
background-color: var(--overlay-color);
}
&:disabled {
opacity: 0.2;
cursor: default;
}
}
.epg-day-btn {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.1rem;
min-width: 5rem;
padding: 0.5rem 0.8rem;
border-radius: 0.8rem;
color: var(--primary-foreground-color);
background-color: transparent;
opacity: 0.5;
cursor: pointer;
transition: background-color 0.15s, opacity 0.15s;
&:hover {
opacity: 0.85;
background-color: var(--overlay-color);
}
&-active {
opacity: 1;
border: 1.5px solid rgba(255, 255, 255, 0.55);
background-color: rgba(255, 255, 255, 0.07);
&:hover { background-color: rgba(255, 255, 255, 0.1); }
}
}
.epg-day-weekday {
font-size: 1.1rem;
opacity: 0.65;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.epg-day-date {
font-size: 1.6rem;
font-weight: 600;
line-height: 1.2;
}
.epg-header-row {
flex: none;
display: flex;
flex-direction: row;
border-bottom: 1px solid var(--overlay-color);
margin-left: 1.5rem;
}
.epg-channel-column-header {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
padding-bottom: 0.4rem;
border-right: 1px solid rgba(255, 255, 255, 0.07);
background-color: rgba(0, 0, 0, 0.25);
position: relative;
z-index: 20;
}
.epg-time-picker {
position: relative;
}
.epg-now-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.35rem 0.9rem;
border-radius: 2rem;
background-color: #5e4fa2;
font-size: 1.3rem;
font-weight: 600;
color: #fff;
cursor: pointer;
transition: background-color 0.15s;
&:hover {
background-color: var(--primary-accent-color);
}
}
.epg-now-badge-arrow {
font-size: 1rem;
opacity: 0.8;
}
.epg-time-dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 50%;
transform: translateX(-50%);
width: 10rem;
max-height: 24rem;
overflow-y: auto;
background-color: rgb(28, 26, 44);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.8rem;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
z-index: 100;
scrollbar-width: thin;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-thumb { background-color: rgba(255, 255, 255, 0.2); border-radius: 2px; }
}
.epg-time-dropdown-item {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 0.6rem 1rem;
font-size: 1.2rem;
color: var(--primary-foreground-color);
opacity: 0.65;
cursor: pointer;
transition: background-color 0.1s, opacity 0.1s;
&:hover { background-color: rgba(255, 255, 255, 0.07); opacity: 1; }
}
.epg-time-dropdown-item-active {
opacity: 1;
font-weight: 600;
color: rgb(170, 154, 230);
background-color: rgba(94, 79, 162, 0.25);
}
.epg-header-viewport {
flex: 1;
min-width: 0;
overflow-x: hidden;
overflow-y: hidden;
scrollbar-width: none;
pointer-events: none;
&::-webkit-scrollbar { display: none; }
}
.epg-header-time-slots {
display: flex;
flex-shrink: 0;
padding-bottom: 0.4rem;
}
.epg-time-slot {
flex-shrink: 0;
padding: 0.3rem 0.5rem;
border-left: 1px solid rgba(255, 255, 255, 0.1);
font-size: 1.05rem;
color: var(--primary-foreground-color);
opacity: 0.45;
white-space: nowrap;
box-sizing: border-box;
}
// Body layout ─────────────────────────────────────────────────────────────────
.epg-body-row {
display: flex;
flex-direction: row;
flex: 1;
min-height: 0;
margin-left: 1.5rem;
}
.epg-channel-column {
flex-shrink: 0;
overflow: hidden;
background-color: rgba(0, 0, 0, 0.25);
border-right: 1px solid rgba(255, 255, 255, 0.07);
}
.epg-channel-column-inner {
will-change: transform;
}
.epg-channel-cell {
display: flex;
align-items: center;
justify-content: center;
padding: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
box-sizing: border-box;
}
.epg-channel-logo {
width: 5.5rem;
height: 2.6rem;
object-fit: contain;
object-position: center;
}
.epg-channel-name {
font-size: 1.1rem;
font-weight: 600;
color: var(--primary-foreground-color);
text-align: center;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.3;
}
.epg-viewport {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
}
.epg-program-grid {
display: block;
}
.epg-empty {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem;
font-size: 1.6rem;
color: var(--primary-foreground-color);
opacity: 0.5;
}
.epg-load-more {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 1rem;
font-size: 1.2rem;
color: var(--primary-foreground-color);
background-color: rgba(255, 255, 255, 0.05);
cursor: pointer;
&:hover { background-color: rgba(255, 255, 255, 0.09); }
}
.epg-skeleton {
display: block;
border-radius: 0.3rem;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.09) 50%,
rgba(255, 255, 255, 0.04) 100%
);
background-size: 200% 100%;
animation: epg-skeleton-shimmer 1.5s ease-in-out infinite;
}
.epg-skeleton-row {
display: flex;
box-sizing: border-box;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.epg-skeleton-programs {
flex: 1;
display: flex;
align-items: stretch;
gap: 0.4rem;
padding: 0.6rem;
.epg-skeleton {
flex: 1;
min-width: 6rem;
border-radius: 0.6rem;
}
}
@media only screen and (max-width: @xsmall) {
.epg-channel-logo {
width: 3.5rem;
height: 1.8rem;
}
}
@media only screen and (max-width: @minimum) {
.epg-day-selector { padding: 0.5rem 1rem 0.75rem 1rem; }
.epg-header-row { margin-left: 1rem; }
.epg-body-row { margin-left: 1rem; }
.epg-channel-logo { width: 3rem; height: 1.6rem; }
}

View file

@ -0,0 +1,326 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import { EpgGuideRow, EpgChannel } from './EpgGuideRow';
import { useEPG, EPGProgram } from './useEPG';
import { programStartMs, programEndMs } from './epgUtils';
import styles from './EpgGuide.less';
const BASE_PIXELS_PER_HOUR = 120; // minimum scale
const MAX_PIXELS_PER_HOUR = 720; // cap — at 720 a 10-min show is 120 px (~17 000 px total grid)
const MIN_PROGRAM_WIDTH = 120; // px — wide enough to show a thumbnail + label
const CHANNEL_COLUMN_WIDTH = 130;
const ROW_HEIGHT = 68;
const HOUR_IN_MS = 60 * 60 * 1000;
const DAY_IN_MS = 24 * HOUR_IN_MS;
const MIN_PROGRAM_DURATION_MS = 10 * 60 * 1000; // ignore sub-10-min filler when choosing scale
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const TIME_SLOTS = Array.from({ length: 48 }, (_, i) => ({
index: i,
label: `${String(Math.floor(i / 2)).padStart(2, '0')}:${i % 2 === 0 ? '00' : '30'}`,
}));
function isSameDay(a: Date, b: Date): boolean {
return a.getFullYear() === b.getFullYear()
&& a.getMonth() === b.getMonth()
&& a.getDate() === b.getDate();
}
function getCurrentHalfHourIndex(): number {
const now = new Date();
return Math.floor((now.getHours() * 60 + now.getMinutes()) / 30);
}
function generateDayRange(before: number, after: number): Date[] {
const today = new Date();
today.setHours(0, 0, 0, 0);
return Array.from({ length: before + after + 1 }, (_, i) => {
const d = new Date(today);
d.setDate(today.getDate() + i - before);
return d;
});
}
type Props = {
addon: Addon | null;
onProgramSelect: (program: EPGProgram, channel: EpgChannel) => void;
};
const EpgGuide = ({ addon, onProgramSelect }: Props) => {
const viewportRef = useRef<HTMLDivElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
// The inner wrapper of the channel column is what we translate — the outer clips it
const channelColumnInnerRef = useRef<HTMLDivElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const dropdownListRef = useRef<HTMLDivElement>(null);
const [selectedDay, setSelectedDay] = useState<Date | null>(null);
const [dropdownOpen, setDropdownOpen] = useState(false);
const [selectedSlot, setSelectedSlot] = useState(getCurrentHalfHourIndex);
const [nowLabel, setNowLabel] = useState(() => {
const d = new Date();
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
});
const days = useMemo(() => generateDayRange(3, 3), []);
const effectiveDay = useMemo(() => {
if (selectedDay) return selectedDay;
const todayStart = new Date();
todayStart.setHours(0, 0, 0, 0);
return days.find((d) => d.getTime() === todayStart.getTime()) ?? days[0];
}, [selectedDay, days]);
const dateStr = useMemo(() => {
const y = effectiveDay.getFullYear();
const m = String(effectiveDay.getMonth() + 1).padStart(2, '0');
const d = String(effectiveDay.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}, [effectiveDay]);
const { channels, programs, loading } = useEPG(addon, dateStr);
// Compute a scale that guarantees every program (≥ 5 min) is at least
// MIN_PROGRAM_WIDTH pixels wide, so thumbnails always fit.
const pixelsPerHour = useMemo(() => {
const allPrograms = Object.values(programs).flat();
if (allPrograms.length === 0) return BASE_PIXELS_PER_HOUR;
let minDurationHours = Infinity;
for (const p of allPrograms) {
const dur = programEndMs(p) - programStartMs(p);
if (dur >= MIN_PROGRAM_DURATION_MS) {
minDurationHours = Math.min(minDurationHours, dur / HOUR_IN_MS);
}
}
if (!isFinite(minDurationHours)) return BASE_PIXELS_PER_HOUR;
const required = MIN_PROGRAM_WIDTH / minDurationHours;
return Math.min(MAX_PIXELS_PER_HOUR, Math.max(BASE_PIXELS_PER_HOUR, required));
}, [programs]);
const halfHourPx = pixelsPerHour / 2;
const totalGridWidth = 24 * pixelsPerHour;
// Attach a native passive scroll listener and sync via requestAnimationFrame +
// CSS transform (compositor-thread, no layout cost → zero visible lag).
useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;
let rafId: number | null = null;
const onScroll = () => {
if (rafId !== null) return;
rafId = requestAnimationFrame(() => {
rafId = null;
if (channelColumnInnerRef.current) {
channelColumnInnerRef.current.style.transform =
`translateY(-${viewport.scrollTop}px)`;
}
if (headerRef.current) {
headerRef.current.scrollLeft = viewport.scrollLeft;
}
});
};
viewport.addEventListener('scroll', onScroll, { passive: true });
return () => {
viewport.removeEventListener('scroll', onScroll);
if (rafId !== null) cancelAnimationFrame(rafId);
};
}, []);
// Reset channel column translation when the day changes (viewport scroll resets)
useEffect(() => {
if (channelColumnInnerRef.current) {
channelColumnInnerRef.current.style.transform = 'translateY(0)';
}
}, [effectiveDay]);
const handleWheel = useCallback((e: React.WheelEvent) => {
if (e.shiftKey && viewportRef.current) {
e.preventDefault();
viewportRef.current.scrollLeft += e.deltaY;
}
}, []);
// Scroll to current time when the effective day is today
useEffect(() => {
if (!viewportRef.current) return;
const now = new Date();
const start = new Date(effectiveDay);
start.setHours(0, 0, 0, 0);
if (now >= start && now.getTime() < start.getTime() + DAY_IN_MS) {
viewportRef.current.scrollLeft = Math.max(0, ((now.getTime() - start.getTime()) / HOUR_IN_MS) * pixelsPerHour - 200);
}
}, [effectiveDay, pixelsPerHour]);
const handleSlotSelect = useCallback((index: number) => {
setSelectedSlot(index);
setDropdownOpen(false);
if (viewportRef.current) {
viewportRef.current.scrollLeft = index * halfHourPx;
}
}, [halfHourPx]);
// Reset selected day when the addon changes
useEffect(() => {
setSelectedDay(null);
}, [addon]);
const selectedDayIndex = useMemo(
() => days.findIndex((d) => effectiveDay && d.getTime() === effectiveDay.getTime()),
[days, effectiveDay],
);
return (
<div className={styles['epg-guide']} onWheel={handleWheel}>
{/* Day selector */}
<div className={styles['epg-day-selector']}>
<button
className={styles['epg-day-arrow']}
disabled={selectedDayIndex <= 0}
onClick={() => selectedDayIndex > 0 && setSelectedDay(days[selectedDayIndex - 1])}
>
</button>
{days.map((day) => {
const active = effectiveDay.getTime() === day.getTime();
const today = isSameDay(day, new Date());
return (
<button
key={day.getTime()}
className={`${styles['epg-day-btn']}${active ? ` ${styles['epg-day-btn-active']}` : ''}`}
onClick={() => setSelectedDay(day)}
>
{today
? <><span className={styles['epg-day-weekday']}>Today</span><span className={styles['epg-day-date']}>{MONTHS[day.getMonth()]} {day.getDate()}</span></>
: <><span className={styles['epg-day-weekday']}>{WEEKDAYS[day.getDay()]}</span><span className={styles['epg-day-date']}>{day.getDate()}</span></>
}
</button>
);
})}
<button
className={styles['epg-day-arrow']}
disabled={selectedDayIndex >= days.length - 1}
onClick={() => selectedDayIndex < days.length - 1 && setSelectedDay(days[selectedDayIndex + 1])}
>
</button>
</div>
{/* Time header */}
<div className={styles['epg-header-row']}>
<div
className={styles['epg-channel-column-header']}
style={{ width: `${CHANNEL_COLUMN_WIDTH}px` }}
>
<div ref={dropdownRef} className={styles['epg-time-picker']}>
<button
className={styles['epg-now-badge']}
onClick={() => setDropdownOpen((o) => !o)}
>
{nowLabel}
<span className={styles['epg-now-badge-arrow']}></span>
</button>
{dropdownOpen && (
<div ref={dropdownListRef} className={styles['epg-time-dropdown']}>
{TIME_SLOTS.map((slot) => (
<button
key={slot.index}
data-active={slot.index === selectedSlot ? 'true' : undefined}
className={`${styles['epg-time-dropdown-item']}${slot.index === selectedSlot ? ` ${styles['epg-time-dropdown-item-active']}` : ''}`}
onClick={() => handleSlotSelect(slot.index)}
>
{slot.label}
</button>
))}
</div>
)}
</div>
</div>
<div ref={headerRef} className={styles['epg-header-viewport']}>
<div className={styles['epg-header-time-slots']} style={{ width: `${totalGridWidth}px` }}>
{TIME_SLOTS.map((slot) => (
<div
key={slot.index}
className={styles['epg-time-slot']}
style={{ width: `${halfHourPx}px` }}
>
{slot.label}
</div>
))}
</div>
</div>
</div>
{/* Body */}
<div className={styles['epg-body-row']}>
{/* Channel column — outer clips, inner translates via rAF transform */}
<div
className={styles['epg-channel-column']}
style={{ width: `${CHANNEL_COLUMN_WIDTH}px` }}
>
<div ref={channelColumnInnerRef} className={styles['epg-channel-column-inner']}>
{loading
? Array.from({ length: 6 }, (_, i) => (
<div key={i} className={styles['epg-channel-cell']} style={{ height: `${ROW_HEIGHT}px` }}>
<div className={styles['epg-skeleton']} style={{ width: '60%', height: '18px', borderRadius: '4px' }} />
</div>
))
: channels.map((channel) => (
<div key={channel.id} className={styles['epg-channel-cell']} style={{ height: `${ROW_HEIGHT}px` }}>
{channel.logo ? (
<img
className={styles['epg-channel-logo']}
src={channel.logo}
alt={channel.name}
/>
) : (
<div className={styles['epg-channel-name']}>{channel.name}</div>
)}
</div>
))
}
</div>
</div>
{/* Scrollable program grid */}
<div ref={viewportRef} className={styles['epg-viewport']}>
<div className={styles['epg-program-grid']} style={{ width: `${totalGridWidth}px` }}>
{loading
? Array.from({ length: 6 }, (_, i) => (
<div key={i} className={styles['epg-skeleton-row']} style={{ height: `${ROW_HEIGHT}px` }}>
<div className={styles['epg-skeleton-programs']}>
<div className={styles['epg-skeleton']} style={{ flex: 2 }} />
<div className={styles['epg-skeleton']} style={{ flex: 3 }} />
<div className={styles['epg-skeleton']} style={{ flex: 1 }} />
</div>
</div>
))
: channels.length > 0
? channels.map((channel) => (
<EpgGuideRow
key={channel.id}
channel={channel}
programs={programs[channel.id] ?? []}
selectedDay={effectiveDay}
onProgramClick={onProgramSelect}
pixelsPerHour={pixelsPerHour}
/>
))
: (
<div className={styles['epg-empty']}>
No schedule data available for this catalog
</div>
)
}
</div>
</div>
</div>
</div>
);
};
export default EpgGuide;

View file

@ -0,0 +1,137 @@
// Copyright (C) 2017-2026 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
.epg-row {
position: relative;
display: flex;
height: 68px;
flex-shrink: 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
box-sizing: border-box;
}
.epg-program-list {
position: relative;
flex: 1;
min-width: 0;
height: 100%;
}
.epg-program-block {
position: absolute;
top: 4px;
bottom: 4px;
padding-right: 3px;
box-sizing: border-box;
&:hover {
z-index: 10;
}
&-inner {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
height: 100%;
padding: 0 0.8rem 0 0.6rem;
border-radius: 0.6rem;
background-color: rgba(255, 255, 255, 0.07);
cursor: pointer;
overflow: hidden;
box-sizing: border-box;
transition: background-color 0.12s;
&:hover {
background-color: rgba(255, 255, 255, 0.12);
}
}
&-current {
.epg-program-block-inner {
background-color: rgba(94, 79, 162, 0.35);
border-left: 2.5px solid rgb(94, 79, 162);
&:hover {
background-color: rgba(94, 79, 162, 0.5);
}
}
}
}
.epg-program-thumb {
flex: none;
width: 3rem;
height: 3rem;
border-radius: 0.4rem;
background-size: cover;
background-position: center;
background-color: var(--overlay-color);
}
.epg-program-content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 0.15rem;
}
.epg-program-title {
font-size: 1.2rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
}
.epg-program-time {
font-size: 1.05rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
opacity: 0.45;
}
.epg-now-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background-color: #e53935;
z-index: 20;
pointer-events: none;
&::before {
content: '';
position: absolute;
top: 0;
left: 50%;
transform: translateX(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #e53935;
}
}
.epg-no-programs {
display: flex;
align-items: center;
padding: 0 1rem;
height: 100%;
font-size: 1.2rem;
color: var(--primary-foreground-color);
opacity: 0.4;
}
@media only screen and (max-width: @xsmall) {
.epg-program-thumb {
display: none;
}
}

View file

@ -0,0 +1,95 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useMemo } from 'react';
import { type EPGProgram } from '../useEPG';
import { programStartMs, programEndMs, programTitle } from '../epgUtils';
import styles from './EpgGuideRow.less';
const DAY_IN_MS = 24 * 60 * 60 * 1000;
const DEFAULT_PIXELS_PER_HOUR = 120;
export type EpgChannel = {
id: string;
name: string;
logo: string | null;
};
type Props = {
channel: EpgChannel;
programs: EPGProgram[];
selectedDay: Date;
onProgramClick: (program: EPGProgram, channel: EpgChannel) => void;
pixelsPerHour?: number;
};
const EpgGuideRow = ({ channel, programs: allPrograms, selectedDay, onProgramClick, pixelsPerHour = DEFAULT_PIXELS_PER_HOUR }: Props) => {
const timeRange = useMemo(() => {
const start = new Date(selectedDay);
start.setHours(0, 0, 0, 0);
return { start: start.getTime(), end: start.getTime() + DAY_IN_MS };
}, [selectedDay]);
const programs = useMemo(() =>
allPrograms.filter((p) => {
const s = programStartMs(p);
const e = programEndMs(p);
return !isNaN(s) && !isNaN(e) && e > timeRange.start && s < timeRange.end;
}),
[allPrograms, timeRange],
);
const now = Date.now();
const totalPx = 24 * pixelsPerHour;
return (
<div className={styles['epg-row']}>
<div className={styles['epg-program-list']} style={{ width: `${totalPx}px` }}>
{programs.map((program) => {
const startMs = programStartMs(program);
const endMs = programEndMs(program);
const left = Math.max(0, ((startMs - timeRange.start) / DAY_IN_MS) * totalPx);
const width = Math.max(4, ((endMs - startMs) / DAY_IN_MS) * totalPx);
const isCurrent = startMs <= now && now < endMs;
const label = programTitle(program);
return (
<button
key={`${startMs}-${label}`}
className={`${styles['epg-program-block']}${isCurrent ? ` ${styles['epg-program-block-current']}` : ''}`}
style={{ left: `${left}px`, width: `${width}px` }}
onClick={() => onProgramClick(program, channel)}
title={label}
>
<div className={styles['epg-program-block-inner']}>
{program.thumbnail && (
<div
className={styles['epg-program-thumb']}
style={{ backgroundImage: `url('${program.thumbnail}')` }}
/>
)}
<div className={styles['epg-program-content']}>
<div className={styles['epg-program-title']}>{label}</div>
<div className={styles['epg-program-time']}>
{new Date(startMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
{' '}
{new Date(endMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
</div>
</div>
</button>
);
})}
{programs.length === 0 && (
<div className={styles['epg-no-programs']}>No programs available</div>
)}
{timeRange.start <= now && now < timeRange.end && (
<div
className={styles['epg-now-line']}
style={{ left: `${((now - timeRange.start) / DAY_IN_MS) * totalPx}px` }}
/>
)}
</div>
</div>
);
};
export default EpgGuideRow;

View file

@ -0,0 +1,6 @@
import EpgGuideRow, { EpgChannel } from './EpgGuideRow';
export {
EpgGuideRow,
EpgChannel
};

View file

@ -0,0 +1,21 @@
// Copyright (C) 2017-2026 Smart code 203358507
import type { EPGProgram } from './useEPG';
const programStartMs = (program: EPGProgram): number => {
return new Date(program.start).getTime();
};
const programEndMs = (program: EPGProgram): number => {
return new Date(program.stop).getTime();
};
const programTitle = (program: EPGProgram): string => {
return program.title;
};
export {
programStartMs,
programEndMs,
programTitle,
};

View file

@ -0,0 +1,3 @@
import EpgGuide from './EpgGuide';
export default EpgGuide;