mirror of
https://github.com/Stremio/stremio-web
synced 2026-04-21 13:37:26 +00:00
feat(Discover): EPG layout
This commit is contained in:
parent
6dec4fe907
commit
2eab4b4e98
8 changed files with 1058 additions and 72 deletions
|
|
@ -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 ?
|
||||
|
|
|
|||
350
src/routes/Discover/EpgGuide/EpgGuide.less
Normal file
350
src/routes/Discover/EpgGuide/EpgGuide.less
Normal 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; }
|
||||
}
|
||||
326
src/routes/Discover/EpgGuide/EpgGuide.tsx
Normal file
326
src/routes/Discover/EpgGuide/EpgGuide.tsx
Normal 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;
|
||||
137
src/routes/Discover/EpgGuide/EpgGuideRow/EpgGuideRow.less
Normal file
137
src/routes/Discover/EpgGuide/EpgGuideRow/EpgGuideRow.less
Normal 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;
|
||||
}
|
||||
}
|
||||
95
src/routes/Discover/EpgGuide/EpgGuideRow/EpgGuideRow.tsx
Normal file
95
src/routes/Discover/EpgGuide/EpgGuideRow/EpgGuideRow.tsx
Normal 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;
|
||||
6
src/routes/Discover/EpgGuide/EpgGuideRow/index.ts
Normal file
6
src/routes/Discover/EpgGuide/EpgGuideRow/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import EpgGuideRow, { EpgChannel } from './EpgGuideRow';
|
||||
|
||||
export {
|
||||
EpgGuideRow,
|
||||
EpgChannel
|
||||
};
|
||||
21
src/routes/Discover/EpgGuide/epgUtils.ts
Normal file
21
src/routes/Discover/EpgGuide/epgUtils.ts
Normal 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,
|
||||
};
|
||||
3
src/routes/Discover/EpgGuide/index.ts
Normal file
3
src/routes/Discover/EpgGuide/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import EpgGuide from './EpgGuide';
|
||||
|
||||
export default EpgGuide;
|
||||
Loading…
Reference in a new issue