mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(epg): add vertical list view for the live EPG panel (#1115)
* feat(epg): add vertical list view for the live EPG panel
Add an EPG list view — a vertical, single-day programme list — as an
alternative rendering of the live EPG panel, selectable via a new
Settings → EPG → "Guide view" toggle (epgViewMode: 'timeline' | 'list',
default 'timeline' so existing users see no change).
- New EpgListViewComponent (app-epg-list-view) mirrors
EpgTimelineComponent's input/output contract 1:1, so all four live
hosts (M3U player, unified live tab, Xtream, Stalker) swap the panel
with a plain @if and identical bindings.
- Reuses the shared view-agnostic EPG modules (classifyTimelineWhen,
hasProgramsForDateKey, epg-archive.util catch-up gating,
epg-summary.util collapsed-summary maths, epg-date helpers,
EpgProgrammeDialogService, app-epg-timeline-empty-state) — no
duplicated logic.
- Rows show time range, title, optional description, live progress on
the on-air row, catch-up "Watch" on past rows when archive playback
is available, and a details dialog; keyboard activation guards
nested buttons (target === currentTarget).
- Auto-focuses the on-air row on channel select, restores it across
collapse/expand remounts, and shows a sticky in-flow "On now" strip
(never overlaying rows) when the current programme is scrolled away;
all scroll maths is rect-based relative to the scroller.
- List mode raises only the inline panel height via an epg--list
modifier (--epg-inline-height clamp); timeline and collapsed heights
are unchanged.
- Setting flows end-to-end (Settings interface → DEFAULT_SETTINGS →
SettingsStore/StorageMap → segmented control in the EPG section);
Electron-only UI, PWA stays on the timeline default. i18n keys added
to all 18 locales.
- Tests: new component/row/utils/scroll-controller specs, settings
persistence spec, swap tests in all four host specs, and Electron
E2E for the settings round-trip and the rendered list view. Docs
updated (m3u-playlist-module.md).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): address list-view review findings from Codex and Greptile
- Reset the list view to today when a new channel's programme set
arrives while the user is parked on another day (timeline parity):
the scroll controller now keys by the full programme-set identity
(programsFocusKey) and commits today before focusing, instead of
silently stranding the new channel on the stale day. (Codex P2)
- Centralise the 'timeline' fallback as a resolvedEpgViewMode computed
on SettingsStore; the four live hosts consume the derived signal
instead of duplicating the `?? 'timeline'` expression. (Greptile P2)
- Extract the component's reactive plumbing into
registerEpgListViewEffects(), bringing the component back under the
300-line guideline (290). (Greptile P2)
- Controller spec rewritten around programme-set fixtures with new
coverage: return-to-today on channel switch, day navigation left
alone, no-takeover when today has no data, empty-set no-op.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(epg): drop malformed programmes from the list-view day filter
Reject programmes whose stop is not after their start in
buildEpgListRows — same as the timeline's buildTimelineBlocks. Bad
provider data would otherwise render impossible time ranges and could
even be offered as catch-up playable. (Codex P2 on e6fd0d08)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
f015ec1ca9
commit
0b526b119a
58 files changed
+3090
-91
No files matched your search
@@ -231,6 +231,38 @@ test.describe('Electron Settings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @persistence @electron persists the EPG view mode across app restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(firstLaunch.mainWindow);
|
||||
const listToggle = firstLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
);
|
||||
await expect(listToggle).toBeVisible();
|
||||
await listToggle.click();
|
||||
await expect(listToggle).toHaveAttribute('aria-checked', 'true');
|
||||
await saveSettings(firstLaunch.mainWindow);
|
||||
} finally {
|
||||
await closeElectronApp(firstLaunch);
|
||||
}
|
||||
|
||||
const secondLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await openSettings(secondLaunch.mainWindow);
|
||||
await expect(
|
||||
secondLaunch.mainWindow.locator(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
)
|
||||
).toHaveAttribute('aria-checked', 'true');
|
||||
} finally {
|
||||
await closeElectronApp(secondLaunch);
|
||||
}
|
||||
});
|
||||
|
||||
test('@settings @electron starts on sources when dashboard is disabled', async ({ dataDir }) => {
|
||||
const firstLaunch = await launchElectronApp(dataDir);
|
||||
|
||||
|
||||
@@ -4,9 +4,12 @@ import {
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
goToDashboard,
|
||||
launchElectronApp,
|
||||
openSettings,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
saveSettings,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
@@ -121,6 +124,64 @@ for (const timeZone of ['UTC', 'Europe/Berlin'] as const) {
|
||||
});
|
||||
}
|
||||
|
||||
test('@epg @xtream @electron renders the vertical list view when the setting is "list"', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const fixture = await fetchXtreamEpgFixture(request, epgCredentials);
|
||||
const currentProgram = fixture.shortEpg[0];
|
||||
if (!currentProgram) {
|
||||
throw new Error(
|
||||
'Expected the Xtream EPG fixture to include a current program.'
|
||||
);
|
||||
}
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// Opt into the list view first (from the fresh workspace) so the portal
|
||||
// → Live TV → channel flow afterwards mirrors the timeline test exactly.
|
||||
await openSettings(app.mainWindow);
|
||||
await app.mainWindow
|
||||
.locator('[data-test-id="epg-view-mode-list"]')
|
||||
.click();
|
||||
await saveSettings(app.mainWindow);
|
||||
await goToDashboard(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: `${epgPortalName} List`,
|
||||
username: epgCredentials.username,
|
||||
password: epgCredentials.password,
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(app.mainWindow, fixture.categoryName);
|
||||
const channelRow = channelItemByTitle(
|
||||
app.mainWindow,
|
||||
fixture.stream.name ?? ''
|
||||
).first();
|
||||
await expect(channelRow).toBeVisible({ timeout: 20000 });
|
||||
await channelRow.click();
|
||||
|
||||
// The list view renders instead of the timeline.
|
||||
await expect(app.mainWindow.locator('app-epg-list-view')).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow.locator('app-epg-timeline')
|
||||
).toHaveCount(0);
|
||||
|
||||
// The on-air programme is the highlighted "now" row.
|
||||
await expect(
|
||||
app.mainWindow
|
||||
.locator('app-epg-list-view .g-row[data-when="now"] .title')
|
||||
.first()
|
||||
).toHaveText(currentProgram.title);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
function formatTimeInZone(timestampSeconds: number, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
timeZone,
|
||||
|
||||
@@ -14,6 +14,40 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__meta">
|
||||
<h4>{{ 'SETTINGS.EPG_VIEW_MODE' | translate }}</h4>
|
||||
<p>{{ 'SETTINGS.EPG_VIEW_MODE_DESCRIPTION' | translate }}</p>
|
||||
</div>
|
||||
<div class="setting-item__control">
|
||||
<div
|
||||
class="theme-switcher"
|
||||
role="radiogroup"
|
||||
[attr.aria-label]="'SETTINGS.EPG_VIEW_MODE' | translate"
|
||||
data-test-id="select-epg-view-mode"
|
||||
>
|
||||
@for (option of epgViewModeOptions(); track option.value) {
|
||||
<button
|
||||
type="button"
|
||||
class="theme-switcher__option"
|
||||
[class.theme-switcher__option--selected]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.aria-checked]="
|
||||
form().value.epgViewMode === option.value
|
||||
"
|
||||
[attr.data-test-id]="'epg-view-mode-' + option.value"
|
||||
role="radio"
|
||||
(click)="selectEpgViewMode.emit(option.value)"
|
||||
>
|
||||
<mat-icon>{{ option.icon }}</mat-icon>
|
||||
<span>{{ option.labelKey | translate }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setting-item setting-item--full-control">
|
||||
<div class="setting-item__control">
|
||||
<ng-container formArrayName="epgUrl">
|
||||
|
||||
@@ -7,8 +7,10 @@ import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { EpgViewMode } from '@iptvnator/shared/interfaces';
|
||||
import { EpgSourceStatusComponent } from '@iptvnator/ui/epg';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { EpgViewModeOption } from './settings.models';
|
||||
|
||||
@Component({
|
||||
selector: 'app-settings-epg-section',
|
||||
@@ -33,10 +35,12 @@ export class SettingsEpgSectionComponent {
|
||||
readonly activeSection = input.required<string>();
|
||||
readonly epgUrl = input.required<FormArray>();
|
||||
readonly isClearingEpgData = input(false);
|
||||
readonly epgViewModeOptions = input.required<EpgViewModeOption[]>();
|
||||
|
||||
readonly refreshEpg = output<string>();
|
||||
readonly removeEpgSource = output<number>();
|
||||
readonly addEpgSource = output<void>();
|
||||
readonly refreshAllEpg = output<void>();
|
||||
readonly clearEpgData = output<void>();
|
||||
readonly selectEpgViewMode = output<EpgViewMode>();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
import {
|
||||
CoverSize,
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
normalizeDashboardRailsSettings,
|
||||
normalizeExternalPlayerArguments,
|
||||
@@ -70,7 +71,12 @@ export function createSettingsForm(
|
||||
],
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium' as CoverSize,
|
||||
...(supportsEpg ? { preferUploadedEpgOverXtream: false } : {}),
|
||||
...(supportsEpg
|
||||
? {
|
||||
preferUploadedEpgOverXtream: false,
|
||||
epgViewMode: 'timeline' as EpgViewMode,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,6 +135,8 @@ export function createSettingsFromFormValue(
|
||||
value.preferUploadedEpgOverXtream ??
|
||||
currentSettings.preferUploadedEpgOverXtream ??
|
||||
false,
|
||||
epgViewMode:
|
||||
value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline',
|
||||
trustedPrivateNetworkEpgUrls:
|
||||
currentSettings.trustedPrivateNetworkEpgUrls ?? [],
|
||||
trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [],
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import {
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
CoverSizeOption,
|
||||
EpgViewModeOption,
|
||||
SettingsPlayerOption,
|
||||
SettingsSection,
|
||||
StartupBehaviorOption,
|
||||
@@ -48,6 +50,19 @@ export const SETTINGS_COVER_SIZE_OPTIONS: CoverSizeOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_EPG_VIEW_MODE_OPTIONS: EpgViewModeOption[] = [
|
||||
{
|
||||
value: 'timeline' satisfies EpgViewMode,
|
||||
icon: 'view_timeline',
|
||||
labelKey: 'SETTINGS.EPG_VIEW_MODE_TIMELINE',
|
||||
},
|
||||
{
|
||||
value: 'list' satisfies EpgViewMode,
|
||||
icon: 'view_list',
|
||||
labelKey: 'SETTINGS.EPG_VIEW_MODE_LIST',
|
||||
},
|
||||
];
|
||||
|
||||
export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
|
||||
{
|
||||
value: StartupBehavior.FirstView,
|
||||
|
||||
@@ -59,11 +59,13 @@
|
||||
[activeSection]="activeSection()"
|
||||
[epgUrl]="epgUrl"
|
||||
[isClearingEpgData]="isClearingEpgData()"
|
||||
[epgViewModeOptions]="epgViewModeOptions"
|
||||
(refreshEpg)="refreshEpg($event)"
|
||||
(removeEpgSource)="removeEpgSource($event)"
|
||||
(addEpgSource)="addEpgSource()"
|
||||
(refreshAllEpg)="refreshAllEpg()"
|
||||
(clearEpgData)="clearEpgData()"
|
||||
(selectEpgViewMode)="selectEpgViewMode($event)"
|
||||
/>
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ const DEFAULT_SETTINGS = {
|
||||
coverSize: 'medium',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
epgViewMode: 'timeline',
|
||||
};
|
||||
|
||||
const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
|
||||
@@ -1295,6 +1296,23 @@ describe('SettingsComponent', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates the EPG view mode through the epg section output', () => {
|
||||
const mockStore = settingsStore as unknown as MockSettingsStore;
|
||||
const listButton = (
|
||||
fixture.nativeElement as HTMLElement
|
||||
).querySelector(
|
||||
'[data-test-id="epg-view-mode-list"]'
|
||||
) as HTMLButtonElement;
|
||||
|
||||
listButton.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.settingsForm.value.epgViewMode).toBe('list');
|
||||
expect(mockStore.updateSettings).toHaveBeenCalledWith({
|
||||
epgViewMode: 'list',
|
||||
});
|
||||
});
|
||||
|
||||
it('renders dashboard controls with the expected defaults', () => {
|
||||
const nativeElement = fixture.nativeElement as HTMLElement;
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
CoverSize,
|
||||
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
|
||||
ElectronBridgeAppUpdateStatus,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
StreamFormat,
|
||||
Theme,
|
||||
@@ -69,6 +70,7 @@ import {
|
||||
buildSettingsSectionNavItems,
|
||||
SETTINGS_COVER_SIZE_OPTIONS,
|
||||
SETTINGS_EMBEDDED_PLAYER_OPTIONS,
|
||||
SETTINGS_EPG_VIEW_MODE_OPTIONS,
|
||||
SETTINGS_OS_PLAYER_OPTIONS,
|
||||
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
|
||||
SETTINGS_THEME_OPTIONS,
|
||||
@@ -203,6 +205,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
readonly themeOptions = SETTINGS_THEME_OPTIONS;
|
||||
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
|
||||
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
|
||||
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
|
||||
|
||||
/** Settings form object */
|
||||
settingsForm = createSettingsForm(this.formBuilder, this.supportsEpg);
|
||||
@@ -507,6 +510,17 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
this.settingsStore.updateSettings({ coverSize: size });
|
||||
}
|
||||
|
||||
selectEpgViewMode(mode: EpgViewMode): void {
|
||||
if (this.settingsForm.value.epgViewMode === mode) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.settingsForm.patchValue({ epgViewMode: mode });
|
||||
this.settingsForm.get('epgViewMode')?.markAsDirty();
|
||||
this.settingsForm.markAsDirty();
|
||||
this.settingsStore.updateSettings({ epgViewMode: mode });
|
||||
}
|
||||
|
||||
async selectRecordingFolder(): Promise<void> {
|
||||
if (
|
||||
!this.isDesktop ||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
CoverSize,
|
||||
EpgViewMode,
|
||||
StartupBehavior,
|
||||
Theme,
|
||||
VideoPlayer,
|
||||
@@ -34,6 +35,12 @@ export interface CoverSizeOption {
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface EpgViewModeOption {
|
||||
value: EpgViewMode;
|
||||
icon: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export interface SettingsPlayerOption {
|
||||
id: VideoPlayer;
|
||||
labelKey: string;
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "صغير",
|
||||
"COVER_SIZE_MEDIUM": "متوسط",
|
||||
"COVER_SIZE_LARGE": "كبير",
|
||||
"EPG_VIEW_MODE": "عرض الدليل",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "اختر طريقة عرض دليل البرامج أسفل المشغل: شريط جدول زمني أفقي، أو قائمة رأسية ليوم واحد.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "الجدول الزمني",
|
||||
"EPG_VIEW_MODE_LIST": "قائمة",
|
||||
"STARTUP_BEHAVIOR": "عرض البدء",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "اختر ما إذا كان IPTVnator يفتح أول عرض متاح في مساحة العمل أو يستعيد آخر عرض على مستوى القسم. العرض الأول يعني لوحة التحكم عند تفعيلها، وإلا فالمصادر.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متاح",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "صغير",
|
||||
"COVER_SIZE_MEDIUM": "متوسط",
|
||||
"COVER_SIZE_LARGE": "كبير",
|
||||
"EPG_VIEW_MODE": "عرض الدليل",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "اختار كيفاش يبان دليل البرامج تحت المشغل: شريط زمني أفقي ولا لائحة عمودية ديال نهار واحد.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "الخط الزمني",
|
||||
"EPG_VIEW_MODE_LIST": "لائحة",
|
||||
"STARTUP_BEHAVIOR": "العرض عند البدء",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "اختار واش IPTVnator يحل أول عرض متوفر في مساحة العمل ولا يسترجع آخر عرض على مستوى القسم. أول عرض كيعني لوحة التحكم إلى كانت مفعلة، إلا فالا فهي Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متوفر",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Малы",
|
||||
"COVER_SIZE_MEDIUM": "Сярэдні",
|
||||
"COVER_SIZE_LARGE": "Вялікі",
|
||||
"EPG_VIEW_MODE": "Выгляд праграмы перадач",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Выберыце, як паказваць праграму перадач пад прайгравальнікам: гарызантальнай стужкай часу або вертыкальным спісам на адзін дзень.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Стужка часу",
|
||||
"EPG_VIEW_MODE_LIST": "Спіс",
|
||||
"STARTUP_BEHAVIOR": "Старт праграмы",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберыце, ці адкрывае IPTVnator першы даступны выгляд працоўнай прасторы, ці аднаўляе ваш апошні выгляд на ўзроўні раздзела. Першы выгляд азначае «Панэль», калі ўключана, інакш «Крыніцы».",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Першы даступны выгляд",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Klein",
|
||||
"COVER_SIZE_MEDIUM": "Mittel",
|
||||
"COVER_SIZE_LARGE": "Groß",
|
||||
"EPG_VIEW_MODE": "Programmführer-Ansicht",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Wählen Sie, wie der Programmführer unter dem Player angezeigt wird: als horizontales Zeitleisten-Band oder als vertikale Tagesliste.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Zeitleiste",
|
||||
"EPG_VIEW_MODE_LIST": "Liste",
|
||||
"STARTUP_BEHAVIOR": "Startansicht",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Μικρό",
|
||||
"COVER_SIZE_MEDIUM": "Μεσαίο",
|
||||
"COVER_SIZE_LARGE": "Μεγάλο",
|
||||
"EPG_VIEW_MODE": "Προβολή οδηγού",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Επιλέξτε πώς εμφανίζεται ο οδηγός προγράμματος κάτω από το πρόγραμμα αναπαραγωγής: ως οριζόντια λωρίδα χρονολογίου ή ως κατακόρυφη λίστα μίας ημέρας.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Χρονολόγιο",
|
||||
"EPG_VIEW_MODE_LIST": "Λίστα",
|
||||
"STARTUP_BEHAVIOR": "Προβολή εκκίνησης",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Επιλέξτε αν το IPTVnator ανοίγει την πρώτη διαθέσιμη προβολή του χώρου εργασίας ή επαναφέρει την τελευταία προβολή σας. Πρώτη προβολή σημαίνει ο πίνακας ελέγχου, όταν είναι ενεργοποιημένος, διαφορετικά οι Πηγές.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Πρώτη διαθέσιμη προβολή",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Small",
|
||||
"COVER_SIZE_MEDIUM": "Medium",
|
||||
"COVER_SIZE_LARGE": "Large",
|
||||
"EPG_VIEW_MODE": "Guide view",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Choose how the programme guide under the player is shown: a horizontal timeline ribbon or a vertical, single-day list.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Timeline",
|
||||
"EPG_VIEW_MODE_LIST": "List",
|
||||
"STARTUP_BEHAVIOR": "Startup view",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Pequeño",
|
||||
"COVER_SIZE_MEDIUM": "Mediano",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Vista de la guía",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Elige cómo se muestra la guía de programas bajo el reproductor: una cinta de cronología horizontal o una lista vertical de un solo día.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Cronología",
|
||||
"EPG_VIEW_MODE_LIST": "Lista",
|
||||
"STARTUP_BEHAVIOR": "Vista al iniciar",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Elige si IPTVnator abre la primera vista disponible del espacio de trabajo o restaura la última vista a nivel de sección. Primera vista significa Panel cuando está activado, de lo contrario Fuentes.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primera vista disponible",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Petite",
|
||||
"COVER_SIZE_MEDIUM": "Moyenne",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Vue du guide",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Choisissez comment le guide des programmes sous le lecteur est affiché : une frise chronologique horizontale ou une liste verticale d'une seule journée.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Chronologie",
|
||||
"EPG_VIEW_MODE_LIST": "Liste",
|
||||
"STARTUP_BEHAVIOR": "Vue de démarrage",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Choisissez si IPTVnator ouvre la première vue disponible de l'espace de travail ou restaure votre dernière vue de section. Première vue signifie Tableau de bord lorsqu'il est activé, sinon Sources.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Première vue disponible",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Piccola",
|
||||
"COVER_SIZE_MEDIUM": "Media",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Vista guida",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Scegli come mostrare la guida programmi sotto il lettore: una fascia con timeline orizzontale o un elenco verticale di un singolo giorno.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Timeline",
|
||||
"EPG_VIEW_MODE_LIST": "Elenco",
|
||||
"STARTUP_BEHAVIOR": "Vista all'avvio",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Scegli se IPTVnator apre la prima vista dell'area di lavoro disponibile o ripristina l'ultima vista a livello di sezione. La prima vista è la dashboard se attiva, altrimenti Sorgenti.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Prima vista disponibile",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"EPG_VIEW_MODE": "番組表の表示",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "プレーヤー下部の番組表の表示方法を選択します。横方向のタイムライン形式か、縦方向の1日分のリスト形式かを選べます。",
|
||||
"EPG_VIEW_MODE_TIMELINE": "タイムライン",
|
||||
"EPG_VIEW_MODE_LIST": "リスト",
|
||||
"STARTUP_BEHAVIOR": "起動時の表示",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnatorを起動したときに最初に利用可能なワークスペースビューを開くか、最後に表示していたセクションを復元するかを選択します。「最初のビュー」は、ダッシュボードが有効な場合はダッシュボード、無効な場合はソースを意味します。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "最初に利用可能なビュー",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "작게",
|
||||
"COVER_SIZE_MEDIUM": "중간",
|
||||
"COVER_SIZE_LARGE": "크게",
|
||||
"EPG_VIEW_MODE": "가이드 보기",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "플레이어 아래 프로그램 가이드를 표시하는 방식을 선택합니다: 가로 타임라인 리본 또는 하루 단위의 세로 목록입니다.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "타임라인",
|
||||
"EPG_VIEW_MODE_LIST": "목록",
|
||||
"STARTUP_BEHAVIOR": "시작 화면",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator를 처음 시작할 때 첫 번째로 사용 가능한 작업 공간 화면을 열지, 마지막으로 본 섹션 화면을 복원할지 선택합니다. 첫 번째 화면은 활성화된 경우 대시보드, 그렇지 않으면 소스를 의미합니다.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "첫 번째로 사용 가능한 화면",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Klein",
|
||||
"COVER_SIZE_MEDIUM": "Gemiddeld",
|
||||
"COVER_SIZE_LARGE": "Groot",
|
||||
"EPG_VIEW_MODE": "Gidsweergave",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Kies hoe de programmagids onder de speler wordt getoond: een horizontale tijdlijn of een verticale lijst voor één dag.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Tijdlijn",
|
||||
"EPG_VIEW_MODE_LIST": "Lijst",
|
||||
"STARTUP_BEHAVIOR": "Opstartweergave",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Kies of IPTVnator de eerste beschikbare werkruimteweergave opent of je laatste sectieweergave herstelt. Eerste weergave betekent het dashboard wanneer ingeschakeld, anders Bronnen.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Eerste beschikbare weergave",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Mały",
|
||||
"COVER_SIZE_MEDIUM": "Średni",
|
||||
"COVER_SIZE_LARGE": "Duży",
|
||||
"EPG_VIEW_MODE": "Widok przewodnika",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Wybierz sposób wyświetlania przewodnika po programach pod odtwarzaczem: pozioma oś czasu lub pionowa lista dla jednego dnia.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Oś czasu",
|
||||
"EPG_VIEW_MODE_LIST": "Lista",
|
||||
"STARTUP_BEHAVIOR": "Widok startowy",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Wybierz, czy IPTVnator otwiera pierwszy dostępny widok obszaru roboczego, czy przywraca ostatni widok na poziomie sekcji. Pierwszy widok oznacza pulpit, jeśli jest włączony, w przeciwnym razie Źródła.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Pierwszy dostępny widok",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Pequeno",
|
||||
"COVER_SIZE_MEDIUM": "Médio",
|
||||
"COVER_SIZE_LARGE": "Grande",
|
||||
"EPG_VIEW_MODE": "Visualização do guia",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Escolha como o guia de programação abaixo do reprodutor é exibido: uma faixa horizontal em linha do tempo ou uma lista vertical de um único dia.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Linha do tempo",
|
||||
"EPG_VIEW_MODE_LIST": "Lista",
|
||||
"STARTUP_BEHAVIOR": "Visualização inicial",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Escolha se o IPTVnator abre a primeira visualização disponível do espaço de trabalho ou restaura sua última visualização. Primeira visualização significa Dashboard quando ativada, caso contrário, Fontes.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primeira visualização disponível",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Маленький",
|
||||
"COVER_SIZE_MEDIUM": "Средний",
|
||||
"COVER_SIZE_LARGE": "Большой",
|
||||
"EPG_VIEW_MODE": "Вид программы передач",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Выберите, как отображается программа передач под плеером: горизонтальная лента-таймлайн или вертикальный список на один день.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Таймлайн",
|
||||
"EPG_VIEW_MODE_LIST": "Список",
|
||||
"STARTUP_BEHAVIOR": "Начальный экран",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "Küçük",
|
||||
"COVER_SIZE_MEDIUM": "Orta",
|
||||
"COVER_SIZE_LARGE": "Büyük",
|
||||
"EPG_VIEW_MODE": "Rehber görünümü",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "Oynatıcının altındaki program rehberinin nasıl gösterileceğini seçin: yatay bir zaman çizelgesi şeridi veya dikey, tek günlük bir liste.",
|
||||
"EPG_VIEW_MODE_TIMELINE": "Zaman çizelgesi",
|
||||
"EPG_VIEW_MODE_LIST": "Liste",
|
||||
"STARTUP_BEHAVIOR": "Başlangıç görünümü",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator'un ilk kullanılabilir çalışma alanı görünümünü mü açacağını yoksa bölüm düzeyindeki son görünümünüzü mü geri yükleyeceğini seçin. İlk görünüm, Pano etkinleştirildiğinde Pano, aksi takdirde Kaynaklar anlamına gelir.",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "İlk kullanılabilir görünüm",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"EPG_VIEW_MODE": "节目指南视图",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "选择播放器下方的节目指南如何显示:横向时间轴条,或纵向的单日列表。",
|
||||
"EPG_VIEW_MODE_TIMELINE": "时间轴",
|
||||
"EPG_VIEW_MODE_LIST": "列表",
|
||||
"STARTUP_BEHAVIOR": "启动视图",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "选择 IPTVnator 启动时是打开第一个可用的工作区视图,还是恢复您上次的章节级视图。第一个视图在启用时为仪表盘,否则为「源」。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "首个可用视图",
|
||||
|
||||
@@ -292,6 +292,10 @@
|
||||
"COVER_SIZE_SMALL": "小",
|
||||
"COVER_SIZE_MEDIUM": "中",
|
||||
"COVER_SIZE_LARGE": "大",
|
||||
"EPG_VIEW_MODE": "節目表檢視方式",
|
||||
"EPG_VIEW_MODE_DESCRIPTION": "選擇播放器下方節目表的顯示方式:橫向時間軸條,或縱向的單日清單。",
|
||||
"EPG_VIEW_MODE_TIMELINE": "時間軸",
|
||||
"EPG_VIEW_MODE_LIST": "清單",
|
||||
"STARTUP_BEHAVIOR": "啟動畫面",
|
||||
"STARTUP_BEHAVIOR_DESCRIPTION": "選擇 IPTVnator 啟動時要開啟第一個可用的工作區畫面,或還原您上次瀏覽的區段層級畫面。第一個畫面在啟用儀表板時為儀表板,否則為「來源」。",
|
||||
"STARTUP_BEHAVIOR_FIRST_VIEW": "第一個可用畫面",
|
||||
|
||||
@@ -266,16 +266,33 @@ per-tab EPG logic:
|
||||
favorites). Callers read their `progressTick()` signal first so the computed
|
||||
re-runs on the ~30s tick.
|
||||
|
||||
### EPG Timeline Panel
|
||||
### EPG Panel (Timeline & List views)
|
||||
|
||||
The programme guide under the player is a horizontal **timeline ribbon**
|
||||
(`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`) shared by all four live
|
||||
surfaces: the M3U video player, the unified live tab, and the Xtream and Stalker
|
||||
live-stream layouts. It replaces the former vertical `app-epg-list` /
|
||||
`app-epg-view`.
|
||||
The programme guide under the player renders in one of **two interchangeable
|
||||
views**, chosen by the **`epgViewMode`** setting (`'timeline'` default, or
|
||||
`'list'`; Settings → EPG → *Guide view*):
|
||||
|
||||
The component stays presentation-focused; the reusable, view-agnostic pieces
|
||||
(so a future list view can share them) are split out and re-exported from
|
||||
- **Timeline** — a horizontal **ribbon** (`app-epg-timeline`,
|
||||
`libs/ui/epg/src/lib/epg-timeline/`).
|
||||
- **List** — a vertical, single-day **programme list** (`app-epg-list-view`,
|
||||
`libs/ui/epg/src/lib/epg-list-view/`) with a prev/today/next stepper.
|
||||
|
||||
Both are shared by all four live surfaces: the M3U video player, the unified live
|
||||
tab, and the Xtream and Stalker live-stream layouts (replacing the former
|
||||
vertical `app-epg-list` / `app-epg-view`). `EpgListViewComponent` mirrors
|
||||
`EpgTimelineComponent`'s input/output contract **1:1**, so each host swaps them
|
||||
with a plain `@if (epgViewMode() === 'list') { <app-epg-list-view … /> } @else {
|
||||
<app-epg-timeline … /> }` — identical bindings in both branches. Hosts read
|
||||
`epgViewMode` from `SettingsStore` (a signal), so flipping the setting swaps the
|
||||
panel live. The setting flows end-to-end (`Settings.epgViewMode` →
|
||||
`DEFAULT_SETTINGS` → `SettingsStore`/`StorageMap` → the segmented control in
|
||||
`settings-epg-section`) and needs no backend change. The control is
|
||||
**Electron-only in practice** — the EPG settings section (and the form control)
|
||||
is gated behind `supportsEpg`, which is false in PWA; there the stored value
|
||||
simply stays at the `'timeline'` default.
|
||||
|
||||
Both components stay presentation-focused; the reusable, view-agnostic pieces
|
||||
(shared by the timeline and the list) are split out and re-exported from
|
||||
`@iptvnator/ui/epg`:
|
||||
|
||||
- `epg-timeline.utils.ts` (axis/blocks/date helpers) + `epg-timeline-render.util.ts`
|
||||
@@ -290,6 +307,19 @@ The component stays presentation-focused; the reusable, view-agnostic pieces
|
||||
scrolling + channel-select auto-focus); timeline-specific, kept out of the
|
||||
component so it stays under the line ceiling.
|
||||
|
||||
The **list view** (`epg-list-view/`) composes those same shared modules — it does
|
||||
**not** duplicate classification or gating logic. It reuses `classifyTimelineWhen`
|
||||
/ `hasProgramsForDateKey` / `nearestDateKeyWithPrograms`, `epg-archive.util`,
|
||||
`epg-summary.util`, the `epg-date` helpers, `EpgProgrammeDialogService`, and the
|
||||
shared `app-epg-timeline-empty-state` — and drops all ribbon geometry, zoom, and
|
||||
horizontal scroll. It filters the loaded window to the selected day (overlap-based,
|
||||
matching `hasProgramsForDateKey`), sorts, and deduplicates via a pure
|
||||
`buildEpgListRows` (`epg-list-view.utils.ts`); renders each row through the dumb
|
||||
`app-epg-list-view-row`; and delegates its own vertical auto-focus + sticky
|
||||
"now" strip to `EpgListScrollController` (`epg-list-scroll.controller.ts`). Render
|
||||
states, the collapsed inline summary, the date stepper, catch-up/timeshift
|
||||
activation, and the details dialog behave identically to the timeline.
|
||||
|
||||
- **One channel, preloaded window.** The panel always shows a single channel.
|
||||
Each provider returns a multi-day window in roughly one call (M3U
|
||||
`GET_CHANNEL_PROGRAMS`; Stalker `get_epg_info`; Xtream `get_simple_data_table`),
|
||||
@@ -370,7 +400,11 @@ The component stays presentation-focused; the reusable, view-agnostic pieces
|
||||
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
|
||||
a compact panel (`.epg.epg--inline` → `flex: 0 0 clamp(180px, 36vh, 264px)`
|
||||
in `_portal-layout.scss`) so the player stays dominant; with an external
|
||||
player the guide keeps `flex: 1` and fills the whole content area.
|
||||
player the guide keeps `flex: 1` and fills the whole content area. In **list
|
||||
mode** the hosts also set `.epg--list`, which raises only the inline clamp
|
||||
(`--epg-inline-height: clamp(280px, 46vh, 430px)`) — vertical rows need more
|
||||
height than the ribbon; the timeline height is unchanged, and the collapsed
|
||||
56px clamp still wins because the modifier sets just the CSS variable.
|
||||
- **Wide-tier description preview.** When a block is the `wide` tier (rendered
|
||||
width ≥ `132px`, i.e. long programmes and/or zoomed in) **and** the programme
|
||||
has a `desc`, a dimmed (`--text-secondary`) preview of the description renders
|
||||
|
||||
@@ -94,32 +94,65 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="canCollapseEpgPanel"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onTimelineProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="epgPrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="epgArchiveDays()"
|
||||
[activeProgram]="activeEpgProgramOrNull()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="
|
||||
onTimelineProgramActivated($event)
|
||||
"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+28
-1
@@ -121,8 +121,10 @@ class StubWebPlayerViewComponent {
|
||||
readonly externalFallbackRequested = output<PlaybackFallbackRequest>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
@@ -189,6 +191,7 @@ describe('VideoPlayerComponent', () => {
|
||||
|
||||
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
|
||||
const showCaptions = signal(false);
|
||||
const epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
const originalElectron = window.electron;
|
||||
|
||||
const overlayRef = {
|
||||
@@ -386,6 +389,7 @@ describe('VideoPlayerComponent', () => {
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions,
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -469,6 +473,29 @@ describe('VideoPlayerComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||
syncStoreState(sampleChannel);
|
||||
player.set(VideoPlayer.VideoJs);
|
||||
epgViewMode.set('list');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
|
||||
epgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('hides EPG controls and the multi-EPG header action in browser/PWA playback', () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
@@ -29,6 +29,7 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
COMPONENT_OVERLAY_REF,
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
@@ -122,6 +123,7 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
|
||||
AudioPlayerComponent,
|
||||
ChannelListLoadingStateComponent,
|
||||
CommonModule,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -273,6 +275,8 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
|
||||
restoreLiveEpgPanelState()
|
||||
);
|
||||
readonly selectedLiveEpgDate = signal(getTodayEpgDateKey());
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
|
||||
+42
-14
@@ -73,25 +73,53 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="shouldUseInlinePlayer()"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
shouldUseInlinePlayer() && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
shouldUseInlinePlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="timelinePrograms()"
|
||||
[channelName]="timelineChannelName()"
|
||||
[channelLogo]="timelineChannelLogo()"
|
||||
[archivePlaybackAvailable]="timelineArchiveAvailable()"
|
||||
[archiveDays]="timelineArchiveDays()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
shouldUseInlinePlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="
|
||||
onLiveEpgSelectedDateChange($event)
|
||||
"
|
||||
(collapsedChange)="
|
||||
onLiveEpgPanelCollapsedChange($event)
|
||||
"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+46
-1
@@ -17,6 +17,7 @@ import {
|
||||
WebPlayerViewComponent,
|
||||
} from '@iptvnator/ui/playback';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
@@ -78,8 +79,10 @@ class StubGlobalFavoritesListComponent {
|
||||
readonly removeRequested = output<UnifiedFavoriteChannel>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
template: '<div class="stub-epg-timeline"></div>',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
@@ -127,6 +130,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
let fixture: ComponentFixture<UnifiedLiveTabComponent>;
|
||||
let component: UnifiedLiveTabComponent;
|
||||
let player: ReturnType<typeof signal<VideoPlayer>>;
|
||||
let epgViewMode: ReturnType<typeof signal<'timeline' | 'list'>>;
|
||||
let streamResolver: {
|
||||
resolveLiveDetail: jest.Mock;
|
||||
resolveM3uPlaybackDetail: jest.Mock;
|
||||
@@ -160,6 +164,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
recordLivePlayback: jest.fn(),
|
||||
};
|
||||
player = signal(VideoPlayer.VideoJs);
|
||||
epgViewMode = signal<'timeline' | 'list'>('timeline');
|
||||
portalPlayer = {
|
||||
isEmbeddedPlayer: jest.fn().mockReturnValue(false),
|
||||
openResolvedPlayback: jest.fn(),
|
||||
@@ -184,6 +189,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
useValue: {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
player,
|
||||
resolvedEpgViewMode: epgViewMode,
|
||||
},
|
||||
},
|
||||
{ provide: PORTAL_PLAYER, useValue: portalPlayer },
|
||||
@@ -193,6 +199,7 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
remove: {
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
ResizableDirective,
|
||||
@@ -447,6 +454,44 @@ describe('UnifiedLiveTabComponent', () => {
|
||||
expect(timeline.selectedDate()).toBe(nextDate);
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', async () => {
|
||||
epgViewMode.set('list');
|
||||
const item = buildLiveItem('xtream');
|
||||
streamResolver.resolveLiveDetail.mockResolvedValue({
|
||||
epgMode: 'portal',
|
||||
playback: {
|
||||
streamUrl: 'https://example.com/xtream.m3u8',
|
||||
title: 'Xtream Live',
|
||||
},
|
||||
epgItems: [buildCurrentEpgItem('Xtream Now')],
|
||||
});
|
||||
recentData.recordLivePlayback.mockResolvedValue({
|
||||
...item,
|
||||
viewedAt: '2026-03-26T12:00:00.000Z',
|
||||
});
|
||||
|
||||
fixture.componentRef.setInput('items', [item]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
await component.onChannelSelected(component.channelsForList()[0]);
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('renders inline portal EPG in the timeline and flows collapse state through', async () => {
|
||||
portalPlayer.isEmbeddedPlayer.mockReturnValue(true);
|
||||
const item = buildLiveItem('xtream');
|
||||
|
||||
+4
@@ -36,6 +36,7 @@ import {
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
@@ -59,6 +60,7 @@ import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GlobalFavoritesListComponent,
|
||||
MatButtonModule,
|
||||
@@ -191,6 +193,8 @@ export class UnifiedLiveTabComponent {
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly liveEpgPanelSummary = computed(() => {
|
||||
this.progressTick();
|
||||
return this.getLiveEpgPanelSummary(this.activeDetail());
|
||||
|
||||
+46
-19
@@ -141,6 +141,7 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="canCollapseEpgPanel"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
@@ -158,24 +159,50 @@
|
||||
|
||||
<ng-template #epgPanelContent>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="activeEpgPrograms()"
|
||||
[channelName]="controlledChannel()?.name ?? activePlaybackTitle()"
|
||||
[channelLogo]="activePlaybackArtwork()"
|
||||
[sourceLabel]="'Stalker Portal'"
|
||||
[archivePlaybackAvailable]="false"
|
||||
[archiveDays]="0"
|
||||
[loading]="isLoadingEpg()"
|
||||
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
!!streamUrl() &&
|
||||
usesEmbeddedPlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="activeEpgPrograms()"
|
||||
[channelName]="
|
||||
controlledChannel()?.name ?? activePlaybackTitle()
|
||||
"
|
||||
[channelLogo]="activePlaybackArtwork()"
|
||||
[sourceLabel]="'Stalker Portal'"
|
||||
[archivePlaybackAvailable]="false"
|
||||
[archiveDays]="0"
|
||||
[loading]="isLoadingEpg()"
|
||||
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
!!streamUrl() &&
|
||||
usesEmbeddedPlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="activeEpgPrograms()"
|
||||
[channelName]="
|
||||
controlledChannel()?.name ?? activePlaybackTitle()
|
||||
"
|
||||
[channelLogo]="activePlaybackArtwork()"
|
||||
[sourceLabel]="'Stalker Portal'"
|
||||
[archivePlaybackAvailable]="false"
|
||||
[archiveDays]="0"
|
||||
[loading]="isLoadingEpg()"
|
||||
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="
|
||||
!!streamUrl() &&
|
||||
usesEmbeddedPlayer() &&
|
||||
isLiveEpgPanelCollapsed()
|
||||
"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
+33
-2
@@ -10,7 +10,10 @@ import {
|
||||
ResizableDirective,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { EpgTimelineComponent } from '@iptvnator/ui/epg';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
} from '@iptvnator/ui/epg';
|
||||
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { ChannelListItemComponent } from '@iptvnator/ui/components';
|
||||
@@ -69,8 +72,10 @@ class StubAudioPlayerComponent {
|
||||
readonly channelSwitchRequested = output<'next' | 'previous'>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="live-epg-panel-summary">{{ summary()?.title }}</div>
|
||||
@@ -230,10 +235,14 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'),
|
||||
};
|
||||
const originalElectron = window.electron;
|
||||
|
||||
beforeEach(async () => {
|
||||
// The store mock is module-scoped: reset so a failed test can't leak
|
||||
// 'list' into siblings.
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
window.electron = {
|
||||
platform: 'darwin',
|
||||
updateRemoteControlStatus: jest.fn(),
|
||||
@@ -341,6 +350,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
imports: [
|
||||
ChannelListItemComponent,
|
||||
AudioPlayerComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
@@ -386,6 +396,27 @@ describe('StalkerLiveStreamLayoutComponent', () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('list');
|
||||
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
|
||||
settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('does not request or render EPG in browser/PWA playback', async () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
+4
@@ -39,6 +39,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
@@ -81,6 +82,7 @@ type StalkerPlayableChannel = StalkerPortalItem & {
|
||||
AudioPlayerComponent,
|
||||
ChannelListItemComponent,
|
||||
ChannelListSkeletonComponent,
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
@@ -191,6 +193,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(this.currentProgram())
|
||||
|
||||
+42
-19
@@ -109,6 +109,7 @@
|
||||
<div
|
||||
class="epg"
|
||||
[class.epg--inline]="isEmbeddedPlayer"
|
||||
[class.epg--list]="epgViewMode() === 'list'"
|
||||
[class.epg-collapsed]="
|
||||
isEmbeddedPlayer && isLiveEpgPanelCollapsed()
|
||||
"
|
||||
@@ -172,24 +173,46 @@
|
||||
|
||||
<ng-template #epgPanelContent>
|
||||
<div class="epg-content">
|
||||
<app-epg-timeline
|
||||
[programs]="controlledEpgPrograms()"
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[loading]="isLoadingEpg()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
@if (epgViewMode() === 'list') {
|
||||
<app-epg-list-view
|
||||
[programs]="controlledEpgPrograms()"
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[loading]="isLoadingEpg()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
} @else {
|
||||
<app-epg-timeline
|
||||
[programs]="controlledEpgPrograms()"
|
||||
[channelName]="activePlayback()?.title ?? ''"
|
||||
[channelLogo]="activePlayback()?.thumbnail ?? ''"
|
||||
[sourceLabel]="'Xtream'"
|
||||
[archivePlaybackAvailable]="archivePlaybackAvailable()"
|
||||
[archiveDays]="controlledArchiveDays()"
|
||||
[activeProgram]="activeCatchupProgram()"
|
||||
[isLivePlayback]="!showReturnToLive()"
|
||||
[loading]="isLoadingEpg()"
|
||||
[selectedDate]="selectedLiveEpgDate()"
|
||||
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
|
||||
[summary]="liveEpgPanelSummary()"
|
||||
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
|
||||
(programActivated)="onProgramActivated($event)"
|
||||
(returnToLive)="returnToLivePlayback()"
|
||||
(selectedDateChange)="onLiveEpgSelectedDateChange($event)"
|
||||
(collapsedChange)="onLiveEpgPanelCollapsedChange($event)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
+31
-1
@@ -24,6 +24,7 @@ import {
|
||||
XtreamUrlService,
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
EpgTimelineSummary,
|
||||
@@ -83,8 +84,10 @@ class StubWebPlayerViewComponent {
|
||||
readonly externalFallbackRequested = output<unknown>();
|
||||
}
|
||||
|
||||
// Matches both live-panel selectors so the host's timeline ↔ list swap can be
|
||||
// asserted by tag name; both branches share the identical contract.
|
||||
@Component({
|
||||
selector: 'app-epg-timeline',
|
||||
selector: 'app-epg-timeline, app-epg-list-view',
|
||||
standalone: true,
|
||||
template: `
|
||||
<div class="live-epg-panel-label">{{ summaryLabelKey() }}</div>
|
||||
@@ -195,6 +198,9 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
};
|
||||
const settingsStore = {
|
||||
openStreamOnDoubleClick: signal(false),
|
||||
// Reset in beforeEach: the store is module-scoped, so a test failure
|
||||
// before an in-test restore must not leak 'list' into siblings.
|
||||
resolvedEpgViewMode: signal<'timeline' | 'list'>('timeline'),
|
||||
};
|
||||
|
||||
const originalElectron = window.electron;
|
||||
@@ -202,6 +208,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
beforeEach(async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(fixedNow);
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
@@ -301,6 +308,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
.overrideComponent(LiveStreamLayoutComponent, {
|
||||
remove: {
|
||||
imports: [
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
GridListComponent,
|
||||
PortalChannelsListComponent,
|
||||
@@ -359,6 +367,28 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('swaps the timeline for the list view when epgViewMode is "list"', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('list');
|
||||
|
||||
component.playLive(sampleChannel);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-list-view')
|
||||
).not.toBeNull();
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('app-epg-timeline')
|
||||
).toBeNull();
|
||||
// Taller inline panel for the list view (see _portal-layout.scss).
|
||||
expect(
|
||||
fixture.nativeElement
|
||||
.querySelector('.epg')
|
||||
?.classList.contains('epg--list')
|
||||
).toBe(true);
|
||||
|
||||
settingsStore.resolvedEpgViewMode.set('timeline'); // restore for sibling tests
|
||||
});
|
||||
|
||||
it('hides the EPG panel in browser/PWA playback', () => {
|
||||
fixture.destroy();
|
||||
window.electron = undefined as unknown as typeof window.electron;
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from '@iptvnator/portal/xtream/data-access';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
EpgListViewComponent,
|
||||
EpgProgramActivationEvent,
|
||||
EpgTimelineComponent,
|
||||
getTodayEpgDateKey,
|
||||
@@ -91,6 +92,7 @@ interface XtreamLiveChannelItem {
|
||||
styleUrls: ['./live-stream-layout.component.scss'],
|
||||
providers: [LiveStreamAutoOpenStateService],
|
||||
imports: [
|
||||
EpgListViewComponent,
|
||||
EpgTimelineComponent,
|
||||
MatButtonModule,
|
||||
MatIcon,
|
||||
@@ -208,6 +210,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { computed, inject } from '@angular/core';
|
||||
import {
|
||||
patchState,
|
||||
signalStore,
|
||||
withComputed,
|
||||
withHooks,
|
||||
withMethods,
|
||||
withState,
|
||||
@@ -11,6 +12,7 @@ import { firstValueFrom } from 'rxjs';
|
||||
import {
|
||||
DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
ElectronBridgeTrustOptions,
|
||||
EpgViewMode,
|
||||
Language,
|
||||
Settings,
|
||||
StartupBehavior,
|
||||
@@ -43,6 +45,7 @@ const DEFAULT_SETTINGS: Settings = {
|
||||
downloadFolder: '',
|
||||
recordingFolder: '',
|
||||
coverSize: 'medium',
|
||||
epgViewMode: 'timeline',
|
||||
dashboardRails: DEFAULT_DASHBOARD_RAILS_SETTINGS,
|
||||
preferUploadedEpgOverXtream: false,
|
||||
trustedPrivateNetworkEpgUrls: [],
|
||||
@@ -91,6 +94,16 @@ function scheduleEmbeddedMpvPrepare(): void {
|
||||
export const SettingsStore = signalStore(
|
||||
{ providedIn: 'root' },
|
||||
withState<Settings>(DEFAULT_SETTINGS),
|
||||
withComputed((store) => ({
|
||||
/**
|
||||
* Live EPG panel layout with the `'timeline'` default applied — the
|
||||
* single source of truth for the four live hosts, so the fallback is
|
||||
* not duplicated per call-site.
|
||||
*/
|
||||
resolvedEpgViewMode: computed<EpgViewMode>(
|
||||
() => store.epgViewMode?.() ?? 'timeline'
|
||||
),
|
||||
})),
|
||||
withMethods((store, storage = inject(StorageMap)) => ({
|
||||
async loadSettings() {
|
||||
try {
|
||||
@@ -173,6 +186,8 @@ export const SettingsStore = signalStore(
|
||||
store.recordingFolder?.() ??
|
||||
DEFAULT_SETTINGS.recordingFolder,
|
||||
coverSize: store.coverSize?.() ?? DEFAULT_SETTINGS.coverSize,
|
||||
epgViewMode:
|
||||
store.epgViewMode?.() ?? DEFAULT_SETTINGS.epgViewMode,
|
||||
dashboardRails: normalizeDashboardRailsSettings(
|
||||
store.dashboardRails?.()
|
||||
),
|
||||
|
||||
@@ -21,6 +21,9 @@ export enum StartupBehavior {
|
||||
|
||||
export type CoverSize = 'small' | 'medium' | 'large';
|
||||
|
||||
/** Rendering of the live EPG panel under the player. */
|
||||
export type EpgViewMode = 'timeline' | 'list';
|
||||
|
||||
export interface DashboardRailsSettings {
|
||||
hero: boolean;
|
||||
continueWatching: boolean;
|
||||
@@ -103,6 +106,8 @@ export interface Settings {
|
||||
recordingFolder?: string;
|
||||
/** Cover/poster sizing preset applied across grids and rails */
|
||||
coverSize?: CoverSize;
|
||||
/** Live EPG panel layout: horizontal timeline (default) or vertical list */
|
||||
epgViewMode?: EpgViewMode;
|
||||
/** Per-rail dashboard visibility preferences. Missing keys default on. */
|
||||
dashboardRails?: DashboardRailsSettings;
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
export * from './lib/epg-list/epg-list.component';
|
||||
export * from './lib/epg-list/epg-item-description/epg-item-description.component';
|
||||
export * from './lib/epg-list-view/epg-list-view.component';
|
||||
export * from './lib/epg-list-view/epg-list-view-row/epg-list-view-row.component';
|
||||
export * from './lib/epg-list-view/epg-list-view.utils';
|
||||
export * from './lib/epg-date';
|
||||
export * from './lib/epg-timeline/epg-timeline.component';
|
||||
export * from './lib/epg-timeline/epg-timeline-empty-state.component';
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||
|
||||
function programAt(
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
channel = 'ch'
|
||||
): EpgProgram {
|
||||
const start = new Date(Date.now() + startOffsetMin * 60_000);
|
||||
const stop = new Date(start.getTime() + durationMin * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel,
|
||||
title: 'P',
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgListScrollController (channel-select auto-scroll)', () => {
|
||||
let controller: EpgListScrollController;
|
||||
let scrollSpy: jest.SpyInstance;
|
||||
let rafSpy: jest.SpyInstance;
|
||||
let hasProgramsToday: jest.Mock;
|
||||
let commitToday: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
rafSpy = jest
|
||||
.spyOn(window, 'requestAnimationFrame')
|
||||
.mockImplementation((cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 1;
|
||||
});
|
||||
hasProgramsToday = jest.fn(() => true);
|
||||
commitToday = jest.fn();
|
||||
controller = new EpgListScrollController({
|
||||
list: () => undefined,
|
||||
isViewToday: () => true,
|
||||
setNowStripVisible: () => undefined,
|
||||
hasProgramsToday,
|
||||
commitToday,
|
||||
});
|
||||
scrollSpy = jest
|
||||
.spyOn(controller, 'scrollNowIntoView')
|
||||
.mockImplementation(() => undefined);
|
||||
jest.spyOn(controller, 'focusNowAfterRender');
|
||||
jest.spyOn(controller, 'updateNowStrip').mockImplementation(
|
||||
() => undefined
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rafSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('scrolls the now row into view instantly on first load', () => {
|
||||
controller.maybeAutoScroll(
|
||||
{} as HTMLElement,
|
||||
[programAt(0, 120)],
|
||||
true,
|
||||
'ch'
|
||||
);
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
expect(scrollSpy).toHaveBeenCalledWith(false); // instant, no animation
|
||||
});
|
||||
|
||||
it('does not re-scroll while the same set stays loaded (now-tick / rollover)', () => {
|
||||
// The 30s tick reclassifies past/now/future at every programme
|
||||
// boundary, but the programme SET is unchanged — the viewport must
|
||||
// stay put.
|
||||
const list = {} as HTMLElement;
|
||||
const programs = [programAt(-30, 60), programAt(30, 60)];
|
||||
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
// The dedup path still refreshes the now-strip — layout can change
|
||||
// without scroll events (e.g. the panel grew and nothing scrolls).
|
||||
expect(controller.updateNowStrip).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('restores the now row when the same channel list remounts (collapse then expand)', () => {
|
||||
const programs = [programAt(0, 120)];
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // mount
|
||||
controller.maybeAutoScroll(undefined, programs, true, 'ch'); // collapsed
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch'); // expand
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('re-scrolls when the channel changes', () => {
|
||||
const list = {} as HTMLElement;
|
||||
controller.maybeAutoScroll(list, [programAt(0, 120, 'a')], true, 'a');
|
||||
controller.maybeAutoScroll(list, [programAt(0, 120, 'b')], true, 'b');
|
||||
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('returns to today when a new channel arrives while parked on another day', () => {
|
||||
// Channel switch while the user navigated to yesterday: the new set
|
||||
// must reset the view to today (when today has data) — otherwise the
|
||||
// new channel opens on the stale day (timeline parity).
|
||||
controller.maybeAutoScroll(
|
||||
{} as HTMLElement,
|
||||
[programAt(0, 120, 'b')],
|
||||
false,
|
||||
'b'
|
||||
);
|
||||
|
||||
expect(commitToday).toHaveBeenCalledTimes(1);
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('leaves day navigation alone while the set is unchanged', () => {
|
||||
// Same channel, user steps to yesterday: same set key → no snap back.
|
||||
const list = {} as HTMLElement;
|
||||
const programs = [programAt(0, 120)];
|
||||
controller.maybeAutoScroll(list, programs, true, 'ch');
|
||||
controller.maybeAutoScroll(list, programs, false, 'ch');
|
||||
|
||||
expect(commitToday).not.toHaveBeenCalled();
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not take over when the new set has nothing airing today', () => {
|
||||
hasProgramsToday.mockReturnValue(false);
|
||||
const programs = [programAt(3 * 1440, 60)];
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, false, 'ch');
|
||||
|
||||
expect(commitToday).not.toHaveBeenCalled();
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The key was not stored — a later, fuller load retries the focus.
|
||||
hasProgramsToday.mockReturnValue(true);
|
||||
controller.maybeAutoScroll({} as HTMLElement, programs, true, 'ch');
|
||||
expect(scrollSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does nothing for an empty programme set', () => {
|
||||
controller.maybeAutoScroll({} as HTMLElement, [], true, 'ch');
|
||||
|
||||
expect(scrollSpy).not.toHaveBeenCalled();
|
||||
expect(commitToday).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpgListScrollController (now-strip visibility)', () => {
|
||||
function stripStateFor(list: Partial<HTMLElement>): boolean | null {
|
||||
let visible: boolean | null = null;
|
||||
const controller = new EpgListScrollController({
|
||||
list: () => list as HTMLElement,
|
||||
isViewToday: () => true,
|
||||
setNowStripVisible: (value) => (visible = value),
|
||||
hasProgramsToday: () => true,
|
||||
commitToday: () => undefined,
|
||||
});
|
||||
controller.updateNowStrip();
|
||||
return visible;
|
||||
}
|
||||
|
||||
/** Row 500–560px below the list's viewport top (out of a 0–200 view). */
|
||||
const offscreenRow = {
|
||||
getBoundingClientRect: () => ({ top: 500, bottom: 560, height: 60 }),
|
||||
};
|
||||
|
||||
it('hides the strip when the list does not scroll (nothing can be scrolled away)', () => {
|
||||
expect(
|
||||
stripStateFor({
|
||||
scrollHeight: 200,
|
||||
clientHeight: 200,
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
|
||||
querySelector: () => offscreenRow,
|
||||
} as unknown as HTMLElement)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the strip when the on-air row is scrolled out of view', () => {
|
||||
expect(
|
||||
stripStateFor({
|
||||
scrollHeight: 600,
|
||||
clientHeight: 200,
|
||||
getBoundingClientRect: () => ({ top: 0, bottom: 200 }),
|
||||
querySelector: () => offscreenRow,
|
||||
} as unknown as HTMLElement)
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('EpgListScrollController (scroll target maths)', () => {
|
||||
it('scrolls relative to the list scroller, not the offset parent', () => {
|
||||
// Regression: the rows have no positioned ancestor inside the list, so
|
||||
// offsetTop-based maths measured against a far ancestor (including the
|
||||
// player above) and scrolled to a wildly wrong position.
|
||||
const scrollTo = jest.fn();
|
||||
const list = {
|
||||
scrollTop: 100,
|
||||
scrollTo,
|
||||
getBoundingClientRect: () => ({ top: 50 }),
|
||||
querySelector: () => ({
|
||||
getBoundingClientRect: () => ({ top: 350 }),
|
||||
}),
|
||||
} as unknown as HTMLElement;
|
||||
const controller = new EpgListScrollController({
|
||||
list: () => list,
|
||||
isViewToday: () => true,
|
||||
setNowStripVisible: () => undefined,
|
||||
hasProgramsToday: () => true,
|
||||
commitToday: () => undefined,
|
||||
});
|
||||
|
||||
controller.scrollNowIntoView(false);
|
||||
|
||||
// 100 (current scroll) + (350 − 50) (row offset within view) − 8
|
||||
expect(scrollTo).toHaveBeenCalledWith({ top: 392, behavior: 'auto' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { programsFocusKey } from '../epg-timeline/epg-timeline-scroll.controller';
|
||||
|
||||
export interface EpgListScrollDeps {
|
||||
/** The scrollable `.g-list` element (undefined before first render). */
|
||||
readonly list: () => HTMLElement | undefined;
|
||||
/** Whether the viewed day is today (auto-focus/now-strip are today-only). */
|
||||
readonly isViewToday: () => boolean;
|
||||
/** Toggle the sticky now-strip's visibility. */
|
||||
readonly setNowStripVisible: (visible: boolean) => void;
|
||||
/** Whether the loaded window has any programme airing today. */
|
||||
readonly hasProgramsToday: () => boolean;
|
||||
/** Commit today as the viewed day (emits `selectedDateChange`). */
|
||||
readonly commitToday: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical-list scroll behaviour extracted from the component: auto-focus the
|
||||
* on-air row when a channel's EPG (re)loads, keep the sticky "now" strip in sync
|
||||
* with scroll position, and reset scroll on day changes. The vertical analogue
|
||||
* of the timeline's `TimelineScrollController`.
|
||||
*/
|
||||
export class EpgListScrollController {
|
||||
private autoScrollKey: string | null = null;
|
||||
private lastList: HTMLElement | null = null;
|
||||
|
||||
constructor(private readonly deps: EpgListScrollDeps) {}
|
||||
|
||||
/**
|
||||
* Scroll the on-air row into view once per channel/EPG (re)load. Keyed by
|
||||
* the FULL programme-set identity (`programsFocusKey`, like the timeline) —
|
||||
* stable across day navigation, 30s now-ticks, and programme rollovers, so
|
||||
* the viewport is never yanked out from under the user. A *new* list
|
||||
* element under an unchanged key means the body was unmounted and
|
||||
* remounted (the inline panel was collapsed and re-expanded), which resets
|
||||
* scrollTop to 0 — restore the now-row instead of stranding the user at
|
||||
* the top of the day.
|
||||
*
|
||||
* When a NEW programme set arrives while the user is parked on another day
|
||||
* (a channel switch), commit today first — otherwise the new channel opens
|
||||
* on the stale day, possibly with nothing to show (timeline parity).
|
||||
*/
|
||||
maybeAutoScroll(
|
||||
list: HTMLElement | undefined,
|
||||
programs: readonly EpgProgram[],
|
||||
today: boolean,
|
||||
channel: string
|
||||
): void {
|
||||
const setKey = programsFocusKey(programs);
|
||||
if (!setKey) {
|
||||
return;
|
||||
}
|
||||
const key = `${channel}|${setKey}`;
|
||||
if (key === this.autoScrollKey) {
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
if (list !== this.lastList) {
|
||||
this.lastList = list;
|
||||
this.focusNowAfterRender();
|
||||
} else {
|
||||
// No scroll — but keep the now-strip honest: layout can change
|
||||
// without scroll events (panel resize, rows re-render), and a
|
||||
// non-scrollable list never fires scroll to self-correct.
|
||||
this.updateNowStrip();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// New programme set. Only take over when today actually has
|
||||
// programmes; otherwise leave the user's day navigation alone (and
|
||||
// don't store the key, so a later fuller load retries).
|
||||
if (!this.deps.hasProgramsToday()) {
|
||||
return;
|
||||
}
|
||||
this.autoScrollKey = key;
|
||||
this.lastList = list ?? null;
|
||||
if (!today) {
|
||||
this.deps.commitToday();
|
||||
}
|
||||
this.focusNowAfterRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to the now-row after the next render. Public for the toolbar
|
||||
* "Now" jump: committing today re-renders the rows, so a synchronous DOM
|
||||
* query would still see the previous day's rows and find no now-row —
|
||||
* the deferral lets change detection paint today first.
|
||||
*/
|
||||
focusNowAfterRender(animate = false): void {
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => {
|
||||
this.scrollNowIntoView(animate);
|
||||
this.updateNowStrip();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
scrollNowIntoView(animate: boolean): void {
|
||||
const list = this.deps.list();
|
||||
const nowRow = this.nowRowElement(list);
|
||||
if (list && nowRow) {
|
||||
// Rect-based, relative to the scroller itself: `offsetTop` is
|
||||
// measured against the nearest *positioned* ancestor, which is
|
||||
// outside the list (the rows/list are not positioned), so it would
|
||||
// include the player above and scroll to a wildly wrong spot.
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const rowRect = nowRow.getBoundingClientRect();
|
||||
const top = list.scrollTop + (rowRect.top - listRect.top) - 8;
|
||||
list.scrollTo({
|
||||
top: Math.max(0, top),
|
||||
behavior: animate ? 'smooth' : 'auto',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
resetListScroll(): void {
|
||||
const list = this.deps.list();
|
||||
if (list) {
|
||||
list.scrollTop = 0;
|
||||
}
|
||||
this.deps.setNowStripVisible(false);
|
||||
}
|
||||
|
||||
/** Show the condensed now-strip only when the on-air row is scrolled away. */
|
||||
updateNowStrip(): void {
|
||||
const list = this.deps.list();
|
||||
const nowRow = this.nowRowElement(list);
|
||||
if (!list || !nowRow || !this.deps.isViewToday()) {
|
||||
this.deps.setNowStripVisible(false);
|
||||
return;
|
||||
}
|
||||
// A list that doesn't scroll can't have scrolled the row away — and it
|
||||
// also never fires scroll events to clear a stale strip.
|
||||
if (list.scrollHeight <= list.clientHeight) {
|
||||
this.deps.setNowStripVisible(false);
|
||||
return;
|
||||
}
|
||||
// Rect-based for the same reason as scrollNowIntoView.
|
||||
const listRect = list.getBoundingClientRect();
|
||||
const rowRect = nowRow.getBoundingClientRect();
|
||||
if (rowRect.height <= 0) {
|
||||
this.deps.setNowStripVisible(false);
|
||||
return;
|
||||
}
|
||||
const visible =
|
||||
Math.min(rowRect.bottom, listRect.bottom) -
|
||||
Math.max(rowRect.top, listRect.top);
|
||||
this.deps.setNowStripVisible(visible / rowRect.height < 0.5);
|
||||
}
|
||||
|
||||
private nowRowElement(
|
||||
list: HTMLElement | undefined
|
||||
): HTMLElement | null {
|
||||
return (
|
||||
list?.querySelector<HTMLElement>('.g-row[data-when="now"]') ?? null
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
@let r = row();
|
||||
|
||||
<div class="time">
|
||||
<span class="time-range"
|
||||
>{{ r.startMs | date: 'HH:mm' }} – {{ r.stopMs | date: 'HH:mm' }}</span
|
||||
>
|
||||
@if (r.when === 'now') {
|
||||
<span class="time-now">{{ 'EPG.TIMELINE.ON_NOW' | translate }}</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<div class="title">{{ r.program.title }}</div>
|
||||
@if (r.program.desc) {
|
||||
<div class="desc">{{ r.program.desc }}</div>
|
||||
}
|
||||
|
||||
@if (r.when === 'now' && r.progress !== null) {
|
||||
<div class="g-now-bar" aria-hidden="true">
|
||||
<i [style.width.%]="r.progress"></i>
|
||||
</div>
|
||||
}
|
||||
@if (isPlaying()) {
|
||||
<div class="playtag">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'EPG.TIMELINE.FROM_ARCHIVE' | translate }}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="aux">
|
||||
@if (r.when === 'now') {
|
||||
@if (minutesLeft() !== null) {
|
||||
<span class="nowtag"
|
||||
>{{ minutesLeft() }}
|
||||
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
|
||||
>
|
||||
}
|
||||
} @else if (r.canCatchUp) {
|
||||
<button
|
||||
type="button"
|
||||
class="watch"
|
||||
(click)="onWatch($event)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.WATCH_FROM_START' | translate"
|
||||
>
|
||||
<mat-icon>replay</mat-icon>
|
||||
{{ 'EPG.TIMELINE.WATCH' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icbtn"
|
||||
(click)="onInfo($event)"
|
||||
[attr.aria-label]="
|
||||
'EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS' | translate
|
||||
"
|
||||
>
|
||||
<mat-icon>info_outline</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
@@ -0,0 +1,224 @@
|
||||
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
|
||||
$surface-1: var(--surface-1, #0f141c);
|
||||
$surface-2: var(--surface-2, #161d28);
|
||||
$surface-3: var(--surface-3, #1d2533);
|
||||
$text-primary: var(--text-primary, #e7ecf3);
|
||||
$text-secondary: var(--text-secondary, #9aa3b2);
|
||||
$text-tertiary: var(--text-tertiary, #6b7384);
|
||||
$accent-blue: var(--accent-blue, #4f8eff);
|
||||
$accent-cyan: var(--accent-cyan, #5cd6ff);
|
||||
$accent-live: var(--accent-live, #ff5b6e);
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
:host {
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr auto;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 11px 18px 11px 22px;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: background 120ms ease;
|
||||
outline: none;
|
||||
}
|
||||
:host *,
|
||||
:host *::before {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
:host(:hover) {
|
||||
background: $surface-1;
|
||||
}
|
||||
|
||||
.time {
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
color: $text-tertiary;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.time-now {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 7px;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-live;
|
||||
}
|
||||
.time-now::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 6px $accent-live;
|
||||
}
|
||||
|
||||
.body {
|
||||
min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.desc {
|
||||
font-size: 12.5px;
|
||||
color: $text-tertiary;
|
||||
margin-top: 3px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 54ch;
|
||||
}
|
||||
.g-now-bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
margin-top: 8px;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.g-now-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-cyan;
|
||||
}
|
||||
.playtag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-blue;
|
||||
}
|
||||
.playtag mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.aux {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.nowtag {
|
||||
font-size: 12px;
|
||||
color: $text-tertiary;
|
||||
padding-right: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.icbtn {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 120ms,
|
||||
background 120ms,
|
||||
color 120ms;
|
||||
}
|
||||
.icbtn mat-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
font-size: 18px;
|
||||
}
|
||||
:host(:hover) .icbtn,
|
||||
:host(:focus-within) .icbtn {
|
||||
opacity: 1;
|
||||
}
|
||||
.icbtn:hover {
|
||||
background: $surface-3;
|
||||
color: $text-primary;
|
||||
}
|
||||
.watch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 30px;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid $line-strong;
|
||||
background: $surface-2;
|
||||
color: $text-primary;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.watch mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.watch:hover {
|
||||
border-color: $accent-blue;
|
||||
color: $accent-blue;
|
||||
}
|
||||
|
||||
/* ── PAST — dimmed ── */
|
||||
:host([data-when='past']) .time,
|
||||
:host([data-when='past']) .title {
|
||||
color: $text-secondary;
|
||||
}
|
||||
:host([data-when='past']) .title {
|
||||
font-weight: 400;
|
||||
}
|
||||
:host([data-when='past']:hover) .time,
|
||||
:host([data-when='past']:hover) .title {
|
||||
color: $text-primary;
|
||||
}
|
||||
|
||||
/* ── NOW — accent bar + fill ── */
|
||||
:host([data-when='now']) {
|
||||
align-items: start;
|
||||
background: rgba(79, 142, 255, 0.08);
|
||||
border-left-color: #4f8eff;
|
||||
}
|
||||
:host([data-when='now']) .time {
|
||||
color: $accent-blue;
|
||||
}
|
||||
:host([data-when='now']) .title {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
:host([data-when='now']) .desc {
|
||||
color: $text-secondary;
|
||||
}
|
||||
|
||||
/* ── PLAYING from archive — distinct from now ── */
|
||||
:host(.playing) {
|
||||
background: rgba(79, 142, 255, 0.13);
|
||||
border-left-color: #4f8eff;
|
||||
}
|
||||
:host(.playing) .title {
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
:host(.playing) .watch {
|
||||
border-color: $accent-blue;
|
||||
background: rgba(79, 142, 255, 0.18);
|
||||
color: $accent-blue;
|
||||
}
|
||||
|
||||
/* ── SELECTED (focus) — ring only, independent of temporal/playing state ── */
|
||||
:host(.sel) {
|
||||
box-shadow: inset 0 0 0 1px rgba(79, 142, 255, 0.55);
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListRow } from '../epg-list-view.utils';
|
||||
import { EpgListViewRowComponent } from './epg-list-view-row.component';
|
||||
|
||||
function program(title = 'P', desc: string | null = null): EpgProgram {
|
||||
return {
|
||||
start: new Date().toISOString(),
|
||||
stop: new Date().toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function row(overrides: Partial<EpgListRow> = {}): EpgListRow {
|
||||
return {
|
||||
program: program(),
|
||||
key: 'k',
|
||||
startMs: Date.now(),
|
||||
stopMs: Date.now() + 60_000,
|
||||
when: 'future',
|
||||
progress: null,
|
||||
isActive: false,
|
||||
canCatchUp: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('EpgListViewRowComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListViewRowComponent>;
|
||||
let component: EpgListViewRowComponent;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgListViewRowComponent, TranslateModule.forRoot()],
|
||||
});
|
||||
fixture = TestBed.createComponent(EpgListViewRowComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
it('marks an active past programme as playing (archive), not a now row', () => {
|
||||
fixture.componentRef.setInput('row', row({ when: 'past', isActive: true }));
|
||||
expect(component.isPlaying()).toBe(true);
|
||||
|
||||
fixture.componentRef.setInput('row', row({ when: 'now', isActive: true }));
|
||||
expect(component.isPlaying()).toBe(false);
|
||||
});
|
||||
|
||||
it('computes minutes left for a now row from the tick', () => {
|
||||
const stopMs = Date.now() + 25 * 60_000;
|
||||
fixture.componentRef.setInput('row', row({ when: 'now', stopMs }));
|
||||
fixture.componentRef.setInput('nowMs', Date.now());
|
||||
expect(component.minutesLeft()).toBeGreaterThanOrEqual(24);
|
||||
expect(component.minutesLeft()).toBeLessThanOrEqual(25);
|
||||
});
|
||||
|
||||
it('activates on keyboard only when the row itself is the target', () => {
|
||||
fixture.componentRef.setInput('row', row());
|
||||
const events: string[] = [];
|
||||
component.activate.subscribe(() => events.push('activate'));
|
||||
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
const nested = document.createElement('button');
|
||||
|
||||
// Enter bubbling up from a nested watch/info button must NOT activate
|
||||
// the row (and must not preventDefault, which would suppress the
|
||||
// button's own native click).
|
||||
const fromButton = {
|
||||
target: nested,
|
||||
currentTarget: host,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as Event;
|
||||
component.onKeydown(fromButton);
|
||||
expect(events).toEqual([]);
|
||||
expect(
|
||||
(fromButton as unknown as { preventDefault: jest.Mock })
|
||||
.preventDefault
|
||||
).not.toHaveBeenCalled();
|
||||
|
||||
// Enter on the focused row itself activates it.
|
||||
const fromRow = {
|
||||
target: host,
|
||||
currentTarget: host,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown as Event;
|
||||
component.onKeydown(fromRow);
|
||||
expect(events).toEqual(['activate']);
|
||||
});
|
||||
|
||||
it('emits activate on row click and stops propagation on watch/info', () => {
|
||||
fixture.componentRef.setInput('row', row());
|
||||
const events: string[] = [];
|
||||
component.activate.subscribe(() => events.push('activate'));
|
||||
component.watch.subscribe(() => events.push('watch'));
|
||||
component.info.subscribe(() => events.push('info'));
|
||||
|
||||
component.onRowClick();
|
||||
const stop = jest.fn();
|
||||
component.onWatch({ stopPropagation: stop } as unknown as Event);
|
||||
component.onInfo({ stopPropagation: stop } as unknown as Event);
|
||||
|
||||
expect(events).toEqual(['activate', 'watch', 'info']);
|
||||
expect(stop).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('renders the temporal state and catch-up Watch affordance', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'row',
|
||||
row({ when: 'past', canCatchUp: true })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.getAttribute('data-when')).toBe('past');
|
||||
expect(host.querySelector('.watch')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the live progress bar for a now row and hides Watch', () => {
|
||||
fixture.componentRef.setInput(
|
||||
'row',
|
||||
row({ when: 'now', progress: 40, canCatchUp: false })
|
||||
);
|
||||
fixture.detectChanges();
|
||||
const host = fixture.nativeElement as HTMLElement;
|
||||
expect(host.getAttribute('data-when')).toBe('now');
|
||||
expect(host.querySelector('.g-now-bar')).not.toBeNull();
|
||||
expect(host.querySelector('.watch')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { EpgListRow } from '../epg-list-view.utils';
|
||||
|
||||
/**
|
||||
* One programme row in the vertical EPG list. Dumb/presentational: it renders
|
||||
* the precomputed `EpgListRow` and emits semantic intents; the list component
|
||||
* owns activation policy (live vs timeshift vs open-details). Mirrors the
|
||||
* mockup's `.g-row[data-when]` template.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-list-view-row',
|
||||
templateUrl: './epg-list-view-row.component.html',
|
||||
styleUrl: './epg-list-view-row.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DatePipe, MatIcon, TranslatePipe],
|
||||
host: {
|
||||
class: 'g-row',
|
||||
role: 'button',
|
||||
tabindex: '0',
|
||||
'[attr.data-when]': 'row().when',
|
||||
'[class.sel]': 'selected()',
|
||||
'[class.playing]': 'isPlaying()',
|
||||
'(click)': 'onRowClick()',
|
||||
'(keydown.enter)': 'onKeydown($event)',
|
||||
'(keydown.space)': 'onKeydown($event)',
|
||||
},
|
||||
})
|
||||
export class EpgListViewRowComponent {
|
||||
readonly row = input.required<EpgListRow>();
|
||||
readonly selected = input(false);
|
||||
readonly currentLocale = input('en');
|
||||
/** Current wall-clock ms from the list's 30s tick (drives minutes-left). */
|
||||
readonly nowMs = input(0);
|
||||
|
||||
readonly activate = output<void>();
|
||||
readonly watch = output<void>();
|
||||
readonly info = output<void>();
|
||||
|
||||
/** Archive-playback highlight: the active programme is a past one. */
|
||||
readonly isPlaying = computed(() => {
|
||||
const row = this.row();
|
||||
return row.isActive && row.when === 'past';
|
||||
});
|
||||
|
||||
/** Minutes remaining on the on-air programme (for the `now` row tag). */
|
||||
readonly minutesLeft = computed(() => {
|
||||
const row = this.row();
|
||||
if (row.when !== 'now') {
|
||||
return null;
|
||||
}
|
||||
return Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000));
|
||||
});
|
||||
|
||||
onRowClick(): void {
|
||||
this.activate.emit();
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard Enter/Space activates the row only when the event target is the
|
||||
* row itself — keydown from the nested watch/info buttons bubbles up here,
|
||||
* and hijacking it would swap the button's action for row activation (the
|
||||
* `preventDefault()` would also suppress the button's native click).
|
||||
*/
|
||||
onKeydown(event: Event): void {
|
||||
if (event.target !== event.currentTarget) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.activate.emit();
|
||||
}
|
||||
|
||||
onWatch(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.watch.emit();
|
||||
}
|
||||
|
||||
onInfo(event: Event): void {
|
||||
event.stopPropagation();
|
||||
this.info.emit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
@let state = renderState();
|
||||
|
||||
<div
|
||||
class="guide"
|
||||
[class.guide--no-archive]="!archivePlaybackAvailable()"
|
||||
[class.is-collapsed]="collapsed()"
|
||||
>
|
||||
<!-- toolbar: collapse + channel on the left, live/jump/stepper on the right -->
|
||||
<div class="g-head">
|
||||
<button
|
||||
type="button"
|
||||
class="g-collapse"
|
||||
(click)="toggleCollapsed()"
|
||||
[attr.aria-expanded]="!collapsed()"
|
||||
[attr.aria-label]="
|
||||
(collapsed() ? 'EPG.EXPAND_PANEL' : 'EPG.COLLAPSE_PANEL')
|
||||
| translate
|
||||
"
|
||||
>
|
||||
<span class="cc"><mat-icon>keyboard_arrow_down</mat-icon></span>
|
||||
<span class="gt">
|
||||
<b>{{ channelName() }}</b>
|
||||
<span class="src">{{ sourceLabel() }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<span class="g-spacer"></span>
|
||||
|
||||
@if (collapsed()) {
|
||||
<!-- collapsed inline summary lives IN the toolbar row: hosts clamp
|
||||
the collapsed panel to 56px, so a block below it would be
|
||||
clipped (mirrors the timeline's collapsed layout). -->
|
||||
@if (loading()) {
|
||||
<span class="cn-title cn-title--muted">{{
|
||||
'EPG.LOADING' | translate
|
||||
}}</span>
|
||||
} @else if (hasSummary()) {
|
||||
@if (isLivePlayback()) {
|
||||
<span class="cn-tag live">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
<span class="cn-title">{{ summary()?.title }}</span>
|
||||
@if (progress() !== null) {
|
||||
<span class="cn-bar" aria-hidden="true"
|
||||
><i [style.width.%]="progress()"></i
|
||||
></span>
|
||||
}
|
||||
@if (minutesLeft() !== null) {
|
||||
<span class="cn-meta"
|
||||
>{{ minutesLeft() }}
|
||||
{{ 'EPG.TIMELINE.MINUTES_LEFT' | translate }}</span
|
||||
>
|
||||
}
|
||||
} @else {
|
||||
<span class="cn-tag arch">
|
||||
<mat-icon>play_arrow</mat-icon>
|
||||
{{ 'EPG.ARCHIVE_PLAYBACK' | translate }}
|
||||
</span>
|
||||
<span class="cn-title">{{ summary()?.title }}</span>
|
||||
@if (hasTimeRange()) {
|
||||
<span class="cn-meta"
|
||||
>{{ summary()?.start | date: 'HH:mm' }} –
|
||||
{{ summary()?.stop | date: 'HH:mm' }}</span
|
||||
>
|
||||
}
|
||||
}
|
||||
} @else {
|
||||
<span class="cn-title cn-title--muted">{{
|
||||
'EPG.NO_PROGRAM_INFO' | translate
|
||||
}}</span>
|
||||
}
|
||||
} @else {
|
||||
@if (!isLivePlayback()) {
|
||||
<button
|
||||
type="button"
|
||||
class="g-livebtn"
|
||||
(click)="returnToLive.emit()"
|
||||
>
|
||||
{{ 'EPG.TIMELINE.RETURN_TO_LIVE' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (showJump()) {
|
||||
<button
|
||||
type="button"
|
||||
class="g-jump"
|
||||
(click)="jumpToNow()"
|
||||
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
>
|
||||
<mat-icon>my_location</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</button>
|
||||
}
|
||||
@if (showDateStepper()) {
|
||||
<div class="g-date">
|
||||
<button
|
||||
type="button"
|
||||
class="nav"
|
||||
[attr.aria-label]="'EPG.PREVIOUS_DAY' | translate"
|
||||
(click)="stepDay('prev')"
|
||||
>
|
||||
<mat-icon>chevron_left</mat-icon>
|
||||
</button>
|
||||
<div class="d">
|
||||
<span>
|
||||
@if (isViewToday()) {
|
||||
{{ 'EPG.TIMELINE.TODAY' | translate }}
|
||||
} @else {
|
||||
{{
|
||||
viewDate()
|
||||
| date: 'EEE' : '' : currentLocale()
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
<small>{{
|
||||
viewDate() | date: 'EEE, d MMM' : '' : currentLocale()
|
||||
}}</small>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="nav"
|
||||
[attr.aria-label]="'EPG.NEXT_DAY' | translate"
|
||||
(click)="stepDay('next')"
|
||||
>
|
||||
<mat-icon>chevron_right</mat-icon>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!collapsed()) {
|
||||
<!-- schedule-only notice (provider exposes history but no catch-up) -->
|
||||
@if (state === 'list' && !archivePlaybackAvailable()) {
|
||||
<div class="g-notice" role="note">
|
||||
<mat-icon>info</mat-icon>
|
||||
<span>{{ 'EPG.TIMELINE.SCHEDULE_ONLY_NOTICE' | translate }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@switch (state) {
|
||||
@case ('loading') {
|
||||
<div class="g-list g-list--skeleton">
|
||||
@for (row of skeletonRows; track $index) {
|
||||
<div class="sk-row">
|
||||
<span class="sk-time"></span>
|
||||
<span class="sk-title"></span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@case ('list') {
|
||||
<div class="g-listwrap">
|
||||
<!-- In normal flow (not an overlay): when it appears it
|
||||
pushes the list down instead of covering the topmost
|
||||
programme row. -->
|
||||
@if (nowStripVisible() && nowRow(); as now) {
|
||||
<button
|
||||
type="button"
|
||||
class="g-nowstrip"
|
||||
(click)="jumpToNow()"
|
||||
>
|
||||
<span class="ns-dot" aria-hidden="true"></span>
|
||||
<span class="ns-lbl">{{
|
||||
'EPG.TIMELINE.ON_NOW' | translate
|
||||
}}</span>
|
||||
<span class="ns-title">{{ now.program.title }}</span>
|
||||
@if (now.progress !== null) {
|
||||
<span class="ns-bar" aria-hidden="true"
|
||||
><i [style.width.%]="now.progress"></i
|
||||
></span>
|
||||
}
|
||||
@if (nowRowMinutesLeft() !== null) {
|
||||
<span class="ns-left"
|
||||
>{{ nowRowMinutesLeft() }}
|
||||
{{
|
||||
'EPG.TIMELINE.MINUTES_SHORT' | translate
|
||||
}}</span
|
||||
>
|
||||
}
|
||||
<span class="ns-jump">
|
||||
<mat-icon>arrow_upward</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
<div
|
||||
#list
|
||||
class="g-list app-scrollbar"
|
||||
(scroll)="onListScroll()"
|
||||
>
|
||||
@for (row of rows(); track row.key) {
|
||||
<app-epg-list-view-row
|
||||
[row]="row"
|
||||
[selected]="row.key === selectedKey()"
|
||||
[currentLocale]="currentLocale()"
|
||||
[nowMs]="nowMs()"
|
||||
(activate)="onRowActivate(row)"
|
||||
(watch)="onWatch(row)"
|
||||
(info)="openDetails(row)"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<app-epg-timeline-empty-state
|
||||
[reason]="emptyStateReason()"
|
||||
[hasOtherDays]="hasOtherDays()"
|
||||
(jumpToday)="jumpToNow()"
|
||||
(jumpNearest)="jumpToNearestDay()"
|
||||
(openSettings)="openEpgSettings.emit()"
|
||||
(retry)="retry.emit()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
@@ -0,0 +1,440 @@
|
||||
$line: var(--line, rgba(255, 255, 255, 0.06));
|
||||
$line-strong: var(--line-strong, rgba(255, 255, 255, 0.1));
|
||||
$surface-1: var(--surface-1, #0f141c);
|
||||
$surface-2: var(--surface-2, #161d28);
|
||||
$surface-3: var(--surface-3, #1d2533);
|
||||
$text-primary: var(--text-primary, #e7ecf3);
|
||||
$text-secondary: var(--text-secondary, #9aa3b2);
|
||||
$text-tertiary: var(--text-tertiary, #6b7384);
|
||||
$accent-blue: var(--accent-blue, #4f8eff);
|
||||
$accent-cyan: var(--accent-cyan, #5cd6ff);
|
||||
$accent-live: var(--accent-live, #ff5b6e);
|
||||
$font-mono: var(--font-mono, ui-monospace, 'SF Mono', Menlo, monospace);
|
||||
|
||||
/* This app has no global border-box reset; the list relies on it. */
|
||||
.guide,
|
||||
.guide * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: #0a0e15;
|
||||
}
|
||||
|
||||
.guide {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* ── toolbar ── */
|
||||
.g-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid $line;
|
||||
background: $surface-1;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-collapse {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: $text-primary;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
.g-collapse:hover {
|
||||
background: $surface-2;
|
||||
}
|
||||
.cc {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: $text-secondary;
|
||||
transition: transform 200ms ease;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cc mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.is-collapsed .cc {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.gt {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.15;
|
||||
text-align: left;
|
||||
min-width: 0;
|
||||
}
|
||||
.gt b {
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.gt .src {
|
||||
font-family: $font-mono;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: $text-tertiary;
|
||||
}
|
||||
.g-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.g-livebtn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 15px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 91, 110, 0.14);
|
||||
border: 1px solid rgba(255, 91, 110, 0.32);
|
||||
color: $accent-live;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-livebtn::before {
|
||||
content: '';
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 7px $accent-live;
|
||||
}
|
||||
|
||||
.g-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line-strong;
|
||||
color: $text-secondary;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-jump:hover {
|
||||
color: $text-primary;
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
.g-jump mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.g-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 34px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line;
|
||||
border-radius: 999px;
|
||||
padding: 2px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-date .nav {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: $text-secondary;
|
||||
cursor: pointer;
|
||||
}
|
||||
.g-date .nav:hover {
|
||||
background: $surface-3;
|
||||
color: $text-primary;
|
||||
}
|
||||
.g-date .nav mat-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 20px;
|
||||
}
|
||||
.g-date .d {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: $text-primary;
|
||||
padding: 0 8px;
|
||||
min-width: 116px;
|
||||
text-align: center;
|
||||
}
|
||||
.g-date .d span {
|
||||
display: block;
|
||||
}
|
||||
.g-date .d small {
|
||||
display: block;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: $text-tertiary;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
/* ── schedule-only notice ── */
|
||||
.g-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 10px 18px;
|
||||
background: rgba(79, 142, 255, 0.07);
|
||||
border-bottom: 1px solid $line;
|
||||
color: $text-secondary;
|
||||
font-size: 12.5px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.g-notice mat-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
font-size: 16px;
|
||||
color: $accent-blue;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ── collapsed inline summary (rendered inside the .g-head toolbar row:
|
||||
hosts clamp the collapsed panel to 56px, so a separate block below the
|
||||
toolbar would be clipped — mirrors the timeline's collapsed layout) ── */
|
||||
.cn-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cn-tag.live {
|
||||
color: $accent-live;
|
||||
}
|
||||
.cn-tag.live::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 6px $accent-live;
|
||||
}
|
||||
.cn-tag.arch {
|
||||
color: $accent-blue;
|
||||
}
|
||||
.cn-tag mat-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cn-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.cn-title--muted {
|
||||
color: $text-tertiary;
|
||||
font-weight: 500;
|
||||
}
|
||||
.cn-bar {
|
||||
width: 84px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.cn-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-cyan;
|
||||
}
|
||||
.cn-meta {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* ── list ── */
|
||||
.g-listwrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.g-list {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* ── condensed now-strip: in normal flow above the list, so appearing pushes
|
||||
the rows down instead of overlaying the topmost programme ── */
|
||||
.g-nowstrip {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
padding: 9px 18px;
|
||||
border: none;
|
||||
border-bottom: 1px solid $line-strong;
|
||||
background: rgba(13, 18, 26, 0.93);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
animation: epg-nowstrip-in 180ms ease;
|
||||
}
|
||||
|
||||
@keyframes epg-nowstrip-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.g-nowstrip {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
.ns-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: $accent-live;
|
||||
box-shadow: 0 0 7px $accent-live;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-lbl {
|
||||
font-family: $font-mono;
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: $accent-live;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-title {
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: $text-primary;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.ns-bar {
|
||||
width: 84px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
overflow: hidden;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-bar > i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: $accent-cyan;
|
||||
}
|
||||
.ns-left {
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-jump {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: $accent-blue;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.ns-jump mat-icon {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ── loading skeleton ── */
|
||||
.g-list--skeleton {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 12px 22px;
|
||||
}
|
||||
.sk-row {
|
||||
display: grid;
|
||||
grid-template-columns: 92px 1fr;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
padding: 11px 0;
|
||||
}
|
||||
.sk-time,
|
||||
.sk-title {
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.06),
|
||||
rgba(255, 255, 255, 0.12),
|
||||
rgba(255, 255, 255, 0.06)
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: epg-list-shimmer 1.4s linear infinite;
|
||||
}
|
||||
.sk-title {
|
||||
max-width: 60%;
|
||||
}
|
||||
|
||||
@keyframes epg-list-shimmer {
|
||||
from {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
to {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sk-time,
|
||||
.sk-title {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { EpgListViewComponent } from './epg-list-view.component';
|
||||
import { EpgListRow } from './epg-list-view.utils';
|
||||
|
||||
function programAt(
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
title = 'P'
|
||||
): EpgProgram {
|
||||
const start = new Date(Date.now() + startOffsetMin * 60_000);
|
||||
const stop = new Date(start.getTime() + durationMin * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
};
|
||||
}
|
||||
|
||||
function localDateKey(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
describe('EpgListViewComponent', () => {
|
||||
let fixture: ComponentFixture<EpgListViewComponent>;
|
||||
let component: EpgListViewComponent;
|
||||
let dialogResult: BehaviorSubject<'live' | 'timeshift' | undefined>;
|
||||
|
||||
beforeEach(() => {
|
||||
dialogResult = new BehaviorSubject<'live' | 'timeshift' | undefined>(
|
||||
undefined
|
||||
);
|
||||
TestBed.configureTestingModule({
|
||||
imports: [EpgListViewComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: MatDialog,
|
||||
useValue: {
|
||||
open: () => ({ afterClosed: () => dialogResult }),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: TranslateService,
|
||||
useValue: {
|
||||
currentLang: 'en',
|
||||
defaultLang: 'en',
|
||||
onLangChange: new BehaviorSubject(null),
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
fixture = TestBed.createComponent(EpgListViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
});
|
||||
|
||||
function setInputs(inputs: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
fixture.componentRef.setInput(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function rowWhen(when: EpgListRow['when']): EpgListRow {
|
||||
const row = component.rows().find((r) => r.when === when);
|
||||
expect(row).toBeDefined();
|
||||
return row as EpgListRow;
|
||||
}
|
||||
|
||||
it('renders the list when programmes exist for the current day', () => {
|
||||
setInputs({ programs: [programAt(0, 120, 'Now')] });
|
||||
expect(component.renderState()).toBe('list');
|
||||
expect(component.emptyStateReason()).toBe('none');
|
||||
});
|
||||
|
||||
it('shows the loading state regardless of programmes', () => {
|
||||
setInputs({ programs: [programAt(0, 60)], loading: true });
|
||||
expect(component.renderState()).toBe('loading');
|
||||
});
|
||||
|
||||
it('falls back to channel-unmapped when there are no programmes', () => {
|
||||
setInputs({ programs: [] });
|
||||
expect(component.renderState()).toBe('channel-unmapped');
|
||||
});
|
||||
|
||||
it('honours an explicit empty reason from the host', () => {
|
||||
setInputs({ programs: [], emptyReason: 'provider-no-epg' });
|
||||
expect(component.renderState()).toBe('provider-no-epg');
|
||||
});
|
||||
|
||||
it('reports empty-day when programmes exist but not for the viewed day', () => {
|
||||
setInputs({ programs: [programAt(3 * 1440, 60)] });
|
||||
expect(component.renderState()).toBe('empty-day');
|
||||
});
|
||||
|
||||
it('honours a controlled non-today selectedDate', () => {
|
||||
const future = programAt(3 * 1440, 60);
|
||||
setInputs({
|
||||
programs: [future],
|
||||
selectedDate: localDateKey(future.start),
|
||||
});
|
||||
expect(component.renderState()).toBe('list');
|
||||
});
|
||||
|
||||
it('follows a programmatic selectedDate change from the host', () => {
|
||||
const future = programAt(3 * 1440, 60);
|
||||
setInputs({ programs: [future] });
|
||||
expect(component.renderState()).toBe('empty-day');
|
||||
setInputs({ selectedDate: localDateKey(future.start) });
|
||||
expect(component.renderState()).toBe('list');
|
||||
});
|
||||
|
||||
it('shows the date stepper for list + empty-day, hides it with no EPG anywhere', () => {
|
||||
setInputs({ programs: [programAt(0, 120)] });
|
||||
expect(component.showDateStepper()).toBe(true);
|
||||
|
||||
setInputs({ programs: [programAt(3 * 1440, 60)] });
|
||||
expect(component.renderState()).toBe('empty-day');
|
||||
expect(component.showDateStepper()).toBe(true);
|
||||
|
||||
setInputs({ programs: [] });
|
||||
expect(component.showDateStepper()).toBe(false);
|
||||
|
||||
setInputs({ programs: [], emptyReason: 'provider-no-epg' });
|
||||
expect(component.showDateStepper()).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the Now jump only off-today or while watching archive', () => {
|
||||
setInputs({ programs: [programAt(0, 120)], isLivePlayback: true });
|
||||
expect(component.showJump()).toBe(false); // today + live
|
||||
|
||||
setInputs({ isLivePlayback: false });
|
||||
expect(component.showJump()).toBe(true); // watching archive
|
||||
});
|
||||
|
||||
it('emits returnToLive when the on-now row is activated', () => {
|
||||
setInputs({ programs: [programAt(-30, 60, 'Live')] });
|
||||
let returned = false;
|
||||
component.returnToLive.subscribe(() => (returned = true));
|
||||
|
||||
component.onRowActivate(rowWhen('now'));
|
||||
expect(returned).toBe(true);
|
||||
});
|
||||
|
||||
it('emits a timeshift activation when a catch-up row is activated', () => {
|
||||
// Anchor the viewed day to the fixture's own day: a "3 hours ago"
|
||||
// programme belongs to *yesterday* when the suite runs 00:00–02:00
|
||||
// local, and today's row filter would drop it (midnight flakiness).
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
|
||||
component.onRowActivate(rowWhen('past'));
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('does not activate a past row when catch-up is unavailable', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: false,
|
||||
});
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
let returned = false;
|
||||
component.returnToLive.subscribe(() => (returned = true));
|
||||
|
||||
component.onRowActivate(rowWhen('past'));
|
||||
expect(events).toEqual([]);
|
||||
expect(returned).toBe(false);
|
||||
});
|
||||
|
||||
it('emits timeshift from the explicit Watch affordance', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
|
||||
component.onWatch(rowWhen('past'));
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('opens details and reacts to a timeshift dialog result', () => {
|
||||
const past = programAt(-180, 60, 'Earlier');
|
||||
setInputs({
|
||||
programs: [past],
|
||||
selectedDate: localDateKey(past.start),
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
});
|
||||
dialogResult.next('timeshift');
|
||||
const events: string[] = [];
|
||||
component.programActivated.subscribe((e) => events.push(e.type));
|
||||
|
||||
component.openDetails(rowWhen('past'));
|
||||
expect(events).toEqual(['timeshift']);
|
||||
});
|
||||
|
||||
it('emits the day when stepping forward', () => {
|
||||
setInputs({ programs: [programAt(-30, 60)] });
|
||||
const emitted: string[] = [];
|
||||
component.selectedDateChange.subscribe((d) => emitted.push(d));
|
||||
|
||||
component.stepDay('next');
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted[0]).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
});
|
||||
|
||||
it('toggles the collapsed state through the output', () => {
|
||||
setInputs({ collapsed: false });
|
||||
const emitted: boolean[] = [];
|
||||
component.collapsedChange.subscribe((v) => emitted.push(v));
|
||||
|
||||
component.toggleCollapsed();
|
||||
expect(emitted).toEqual([true]);
|
||||
});
|
||||
|
||||
it('derives a live progress percentage from the summary when collapsed', () => {
|
||||
const start = new Date(Date.now() - 30 * 60_000).toISOString();
|
||||
const stop = new Date(Date.now() + 30 * 60_000).toISOString();
|
||||
setInputs({ collapsed: true, summary: { title: 'Now', start, stop } });
|
||||
|
||||
expect(component.hasSummary()).toBe(true);
|
||||
expect(component.progress()).toBeGreaterThan(40);
|
||||
expect(component.progress()).toBeLessThan(60);
|
||||
expect(component.minutesLeft()).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,290 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
linkedSignal,
|
||||
output,
|
||||
signal,
|
||||
viewChild,
|
||||
} from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
|
||||
import { startWith } from 'rxjs';
|
||||
import {
|
||||
EpgDateNavigationDirection,
|
||||
getTodayEpgDateKey,
|
||||
parseEpgDateKey,
|
||||
shiftEpgDateKey,
|
||||
} from '../epg-date';
|
||||
import { EpgItemDialogAction } from '../epg-list/epg-item-description/epg-item-description.component';
|
||||
import type { EpgProgramActivationEvent } from '../epg-list/epg-list.component';
|
||||
import { EpgProgrammeDialogService } from '../epg-programme-dialog.service';
|
||||
import { epgDialogActionFor } from '../epg-timeline/epg-archive.util';
|
||||
import {
|
||||
EpgTimelineSummary,
|
||||
summaryHasTimeRange,
|
||||
summaryHasTitle,
|
||||
summaryMinutesLeft,
|
||||
summaryProgress,
|
||||
} from '../epg-timeline/epg-summary.util';
|
||||
import {
|
||||
EpgTimelineEmptyReason,
|
||||
EpgTimelineEmptyStateComponent,
|
||||
} from '../epg-timeline/epg-timeline-empty-state.component';
|
||||
import {
|
||||
hasProgramsForDateKey,
|
||||
nearestDateKeyWithPrograms,
|
||||
} from '../epg-timeline/epg-timeline.utils';
|
||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||
import { EpgListViewRowComponent } from './epg-list-view-row/epg-list-view-row.component';
|
||||
import { registerEpgListViewEffects } from './epg-list-view.effects';
|
||||
import { buildEpgListRows, EpgListRow } from './epg-list-view.utils';
|
||||
|
||||
type RenderState = 'loading' | 'list' | EpgTimelineEmptyReason;
|
||||
|
||||
/**
|
||||
* Vertical, single-day EPG list — a drop-in alternative to `app-epg-timeline`
|
||||
* with an identical controlled input/output contract, so hosts swap the two
|
||||
* with a plain `@if`. Reuses the timeline's view-agnostic modules (archive
|
||||
* gating, summary maths, date helpers, empty-state, details dialog); drops all
|
||||
* ribbon geometry, zoom, and horizontal scroll.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-epg-list-view',
|
||||
templateUrl: './epg-list-view.component.html',
|
||||
styleUrl: './epg-list-view.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
DatePipe,
|
||||
EpgListViewRowComponent,
|
||||
EpgTimelineEmptyStateComponent,
|
||||
MatIcon,
|
||||
MatTooltip,
|
||||
TranslatePipe,
|
||||
],
|
||||
})
|
||||
export class EpgListViewComponent {
|
||||
readonly programs = input<EpgProgram[]>([]);
|
||||
readonly channelName = input('');
|
||||
readonly channelLogo = input('');
|
||||
/** Same input as the timeline's, but a list-appropriate default: hosts
|
||||
* that don't pass a label (M3U, unified tab) would otherwise show the
|
||||
* timeline's literal "Timeline" while in list mode. */
|
||||
readonly sourceLabel = input('Programme guide');
|
||||
readonly archivePlaybackAvailable = input(false);
|
||||
readonly archiveDays = input(0);
|
||||
readonly activeProgram = input<EpgProgram | null>(null);
|
||||
readonly isLivePlayback = input(true);
|
||||
readonly loading = input(false);
|
||||
readonly emptyReason = input<EpgTimelineEmptyReason>('none');
|
||||
readonly selectedDate = input<string | null>(null);
|
||||
readonly collapsed = input(false);
|
||||
readonly summary = input<EpgTimelineSummary | null>(null);
|
||||
readonly summaryLabelKey = input('EPG.CURRENT_PROGRAM');
|
||||
|
||||
readonly programActivated = output<EpgProgramActivationEvent>();
|
||||
readonly returnToLive = output<void>();
|
||||
readonly selectedDateChange = output<string>();
|
||||
readonly openEpgSettings = output<void>();
|
||||
readonly retry = output<void>();
|
||||
readonly collapsedChange = output<boolean>();
|
||||
|
||||
private readonly programmeDialog = inject(EpgProgrammeDialogService);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly list = viewChild<ElementRef<HTMLElement>>('list');
|
||||
readonly nowMs = signal(Date.now());
|
||||
readonly selectedKey = signal<string | null>(null);
|
||||
readonly nowStripVisible = signal(false);
|
||||
|
||||
/** Day shown, seeded from the controlled `selectedDate` so a non-today
|
||||
* date survives (re)mount and follows host changes — same as the timeline. */
|
||||
private readonly viewDayKey = linkedSignal(() => {
|
||||
const key = this.selectedDate()?.trim();
|
||||
return key ? key : getTodayEpgDateKey();
|
||||
});
|
||||
|
||||
private readonly languageTick = toSignal(
|
||||
this.translate.onLangChange.pipe(startWith(null)),
|
||||
{ initialValue: null }
|
||||
);
|
||||
readonly currentLocale = computed(() => {
|
||||
this.languageTick();
|
||||
return normalizeDateLocale(
|
||||
this.translate.currentLang || this.translate.defaultLang
|
||||
);
|
||||
});
|
||||
|
||||
readonly rows = computed<EpgListRow[]>(() =>
|
||||
buildEpgListRows(this.programs(), this.viewDayKey(), this.nowMs(), {
|
||||
archivePlaybackAvailable: this.archivePlaybackAvailable(),
|
||||
archiveDays: this.archiveDays(),
|
||||
activeProgram: this.activeProgram(),
|
||||
})
|
||||
);
|
||||
readonly nowRow = computed<EpgListRow | null>(
|
||||
() => this.rows().find((row) => row.when === 'now') ?? null
|
||||
);
|
||||
readonly nowRowMinutesLeft = computed(() => {
|
||||
const row = this.nowRow();
|
||||
return row ? Math.max(0, Math.round((row.stopMs - this.nowMs()) / 60_000)) : null;
|
||||
});
|
||||
|
||||
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
|
||||
readonly isViewToday = computed(
|
||||
() => this.viewDayKey() === getTodayEpgDateKey()
|
||||
);
|
||||
|
||||
readonly renderState = computed<RenderState>(() => {
|
||||
if (this.loading()) {
|
||||
return 'loading';
|
||||
}
|
||||
const reason = this.emptyReason();
|
||||
if (reason !== 'none') {
|
||||
return reason;
|
||||
}
|
||||
if (this.programs().length === 0) {
|
||||
return 'channel-unmapped';
|
||||
}
|
||||
if (!hasProgramsForDateKey(this.programs(), this.viewDayKey())) {
|
||||
return 'empty-day';
|
||||
}
|
||||
return 'list';
|
||||
});
|
||||
readonly emptyStateReason = computed<EpgTimelineEmptyReason>(() => {
|
||||
const state = this.renderState();
|
||||
return state === 'loading' || state === 'list' ? 'none' : state;
|
||||
});
|
||||
readonly hasOtherDays = computed(() => this.programs().length > 0);
|
||||
readonly showDateStepper = computed(() => {
|
||||
const state = this.renderState();
|
||||
return state === 'list' || state === 'empty-day';
|
||||
});
|
||||
/** "Now" jump: only useful off-today or while watching archive. */
|
||||
readonly showJump = computed(
|
||||
() =>
|
||||
this.renderState() === 'list' &&
|
||||
(!this.isViewToday() || !this.isLivePlayback())
|
||||
);
|
||||
readonly skeletonRows = [0, 1, 2, 3, 4, 5];
|
||||
|
||||
// ── collapsed-summary state (reused from the timeline) ──
|
||||
readonly hasSummary = computed(() => summaryHasTitle(this.summary()));
|
||||
readonly hasTimeRange = computed(() => summaryHasTimeRange(this.summary()));
|
||||
readonly progress = computed(() =>
|
||||
summaryProgress(this.summary(), this.nowMs())
|
||||
);
|
||||
readonly minutesLeft = computed(() =>
|
||||
summaryMinutesLeft(this.summary(), this.nowMs())
|
||||
);
|
||||
|
||||
private readonly scroll = new EpgListScrollController({
|
||||
list: () => this.list()?.nativeElement,
|
||||
isViewToday: () => this.isViewToday(),
|
||||
setNowStripVisible: (visible) => this.nowStripVisible.set(visible),
|
||||
hasProgramsToday: () =>
|
||||
hasProgramsForDateKey(this.programs(), getTodayEpgDateKey()),
|
||||
commitToday: () => this.commitDay(getTodayEpgDateKey()),
|
||||
});
|
||||
|
||||
constructor() {
|
||||
registerEpgListViewEffects({
|
||||
nowMs: this.nowMs,
|
||||
list: () => this.list()?.nativeElement,
|
||||
rows: () => this.rows(),
|
||||
programs: () => this.programs(),
|
||||
isViewToday: () => this.isViewToday(),
|
||||
channelName: () => this.channelName(),
|
||||
scroll: this.scroll,
|
||||
});
|
||||
}
|
||||
|
||||
toggleCollapsed(): void {
|
||||
this.collapsedChange.emit(!this.collapsed());
|
||||
}
|
||||
|
||||
stepDay(direction: EpgDateNavigationDirection): void {
|
||||
this.commitDay(shiftEpgDateKey(this.viewDayKey(), direction));
|
||||
this.scroll.resetListScroll();
|
||||
}
|
||||
|
||||
jumpToNow(): void {
|
||||
this.commitDay(getTodayEpgDateKey());
|
||||
// Deferred: when jumping from another day, today's rows only exist
|
||||
// after the next render. Deliberate user action → animate.
|
||||
this.scroll.focusNowAfterRender(true);
|
||||
}
|
||||
|
||||
jumpToNearestDay(): void {
|
||||
const nearest = nearestDateKeyWithPrograms(
|
||||
this.programs(),
|
||||
this.nowMs()
|
||||
);
|
||||
if (nearest) {
|
||||
this.commitDay(nearest);
|
||||
this.scroll.resetListScroll();
|
||||
}
|
||||
}
|
||||
|
||||
onRowActivate(row: EpgListRow): void {
|
||||
this.selectedKey.set(row.key);
|
||||
if (row.when === 'now') {
|
||||
this.returnToLive.emit();
|
||||
return;
|
||||
}
|
||||
if (row.canCatchUp) {
|
||||
this.programActivated.emit({
|
||||
program: row.program,
|
||||
type: 'timeshift',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onWatch(row: EpgListRow): void {
|
||||
this.selectedKey.set(row.key);
|
||||
this.programActivated.emit({ program: row.program, type: 'timeshift' });
|
||||
}
|
||||
|
||||
openDetails(row: EpgListRow): void {
|
||||
this.programmeDialog
|
||||
.open({
|
||||
...row.program,
|
||||
channelName: this.channelName(),
|
||||
channelLogo: this.channelLogo(),
|
||||
primaryAction: epgDialogActionFor(row.when, row.canCatchUp),
|
||||
archiveUnavailableNote:
|
||||
row.when === 'past' && !this.archivePlaybackAvailable(),
|
||||
})
|
||||
.subscribe((result: EpgItemDialogAction | undefined) => {
|
||||
if (result === 'live') {
|
||||
this.returnToLive.emit();
|
||||
} else if (result === 'timeshift') {
|
||||
this.selectedKey.set(row.key);
|
||||
this.programActivated.emit({
|
||||
program: row.program,
|
||||
type: 'timeshift',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
onListScroll(): void {
|
||||
this.scroll.updateNowStrip();
|
||||
}
|
||||
|
||||
private commitDay(dayKey: string): void {
|
||||
if (dayKey === this.viewDayKey()) {
|
||||
return;
|
||||
}
|
||||
this.viewDayKey.set(dayKey);
|
||||
this.selectedDateChange.emit(dayKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { effect, untracked, WritableSignal } from '@angular/core';
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { EpgListScrollController } from './epg-list-scroll.controller';
|
||||
import { EpgListRow } from './epg-list-view.utils';
|
||||
|
||||
export interface EpgListViewEffectsContext {
|
||||
/** Wall-clock signal driving past/now/future classification + progress. */
|
||||
readonly nowMs: WritableSignal<number>;
|
||||
readonly list: () => HTMLElement | undefined;
|
||||
readonly rows: () => readonly EpgListRow[];
|
||||
readonly programs: () => readonly EpgProgram[];
|
||||
readonly isViewToday: () => boolean;
|
||||
readonly channelName: () => string;
|
||||
readonly scroll: EpgListScrollController;
|
||||
}
|
||||
|
||||
/**
|
||||
* The list view's reactive plumbing, kept out of the component so it stays
|
||||
* within the file-size guideline. Must be called from the component's
|
||||
* constructor (an injection context — `effect()` requires one).
|
||||
*/
|
||||
export function registerEpgListViewEffects(
|
||||
ctx: EpgListViewEffectsContext
|
||||
): void {
|
||||
// 30s tick reclassifies past/now/future and refreshes progress. The list
|
||||
// is a controlled component (activeProgram/isLivePlayback come from the
|
||||
// host), so the tick never clobbers active archive playback.
|
||||
effect((onCleanup) => {
|
||||
const intervalId = window.setInterval(
|
||||
() => ctx.nowMs.set(Date.now()),
|
||||
30_000
|
||||
);
|
||||
onCleanup(() => clearInterval(intervalId));
|
||||
});
|
||||
|
||||
// Auto-focus the on-air row when a channel's EPG (re)loads or the list
|
||||
// (re)mounts (collapse → expand) — the vertical analogue of the ribbon's
|
||||
// auto-focus. Tracks the `list` viewChild so a remount re-triggers, and
|
||||
// `rows` so day changes and 30s ticks refresh the now-strip; the
|
||||
// controller dedupes by programme-set identity so neither re-jumps the
|
||||
// viewport.
|
||||
effect(() => {
|
||||
const list = ctx.list();
|
||||
ctx.rows();
|
||||
const programs = ctx.programs();
|
||||
const today = ctx.isViewToday();
|
||||
const channel = ctx.channelName();
|
||||
untracked(() =>
|
||||
ctx.scroll.maybeAutoScroll(list, programs, today, channel)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { buildEpgListRows } from './epg-list-view.utils';
|
||||
|
||||
// Anchor "now" at local noon so ±3h fixtures stay inside the same local day,
|
||||
// keeping the overlap-based day filter deterministic regardless of run time.
|
||||
const NOON = new Date();
|
||||
NOON.setHours(12, 0, 0, 0);
|
||||
const NOW = NOON.getTime();
|
||||
|
||||
function programAt(
|
||||
startOffsetMin: number,
|
||||
durationMin: number,
|
||||
title = 'P',
|
||||
extra: Partial<EpgProgram> = {}
|
||||
): EpgProgram {
|
||||
const start = new Date(NOW + startOffsetMin * 60_000);
|
||||
const stop = new Date(start.getTime() + durationMin * 60_000);
|
||||
return {
|
||||
start: start.toISOString(),
|
||||
stop: stop.toISOString(),
|
||||
channel: 'ch',
|
||||
title,
|
||||
desc: null,
|
||||
category: null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function dayKey(ms: number): string {
|
||||
const d = new Date(ms);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
const TODAY = dayKey(NOW);
|
||||
const DEFAULT_OPTS = {
|
||||
archivePlaybackAvailable: false,
|
||||
archiveDays: 0,
|
||||
activeProgram: null,
|
||||
};
|
||||
|
||||
describe('buildEpgListRows', () => {
|
||||
it('keeps only programmes overlapping the selected day, sorted by start', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[
|
||||
programAt(90, 60, 'Later'),
|
||||
programAt(-30, 60, 'Now'),
|
||||
programAt(3 * 1440, 60, 'ThreeDaysOut'),
|
||||
],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows.map((r) => r.program.title)).toEqual(['Now', 'Later']);
|
||||
});
|
||||
|
||||
it('classifies past / now / future relative to nowMs', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[
|
||||
programAt(-180, 60, 'Past'),
|
||||
programAt(-30, 60, 'Now'),
|
||||
programAt(120, 60, 'Future'),
|
||||
],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows.map((r) => r.when)).toEqual(['past', 'now', 'future']);
|
||||
});
|
||||
|
||||
it('computes live progress only for the now row', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[programAt(-30, 60, 'Now'), programAt(120, 60, 'Future')],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
const now = rows.find((r) => r.when === 'now');
|
||||
const future = rows.find((r) => r.when === 'future');
|
||||
expect(now?.progress).toBeGreaterThan(40);
|
||||
expect(now?.progress).toBeLessThan(60);
|
||||
expect(future?.progress).toBeNull();
|
||||
});
|
||||
|
||||
it('gates catch-up on archive availability + window', () => {
|
||||
const past = [programAt(-180, 60, 'Past')];
|
||||
|
||||
const withArchive = buildEpgListRows(past, TODAY, NOW, {
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
activeProgram: null,
|
||||
});
|
||||
expect(withArchive[0].canCatchUp).toBe(true);
|
||||
|
||||
const noArchive = buildEpgListRows(past, TODAY, NOW, DEFAULT_OPTS);
|
||||
expect(noArchive[0].canCatchUp).toBe(false);
|
||||
});
|
||||
|
||||
it('marks the active programme', () => {
|
||||
const active = programAt(-180, 60, 'Past');
|
||||
const rows = buildEpgListRows(
|
||||
[active, programAt(-30, 60, 'Now')],
|
||||
TODAY,
|
||||
NOW,
|
||||
{
|
||||
archivePlaybackAvailable: true,
|
||||
archiveDays: 7,
|
||||
activeProgram: active,
|
||||
}
|
||||
);
|
||||
|
||||
expect(rows.find((r) => r.program.title === 'Past')?.isActive).toBe(
|
||||
true
|
||||
);
|
||||
expect(rows.find((r) => r.program.title === 'Now')?.isActive).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('drops malformed programmes whose stop is not after their start', () => {
|
||||
// Same as the timeline's buildTimelineBlocks: zero/negative durations
|
||||
// from bad provider data must not render (or become catch-up playable).
|
||||
const zeroDuration = programAt(-60, 0, 'Zero');
|
||||
const negativeDuration = {
|
||||
...programAt(-60, 60, 'Negative'),
|
||||
stop: new Date(NOW - 120 * 60_000).toISOString(),
|
||||
};
|
||||
const rows = buildEpgListRows(
|
||||
[zeroDuration, negativeDuration, programAt(-30, 60, 'Now')],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows.map((r) => r.program.title)).toEqual(['Now']);
|
||||
});
|
||||
|
||||
it('deduplicates programmes sharing a time slot, keeping the richer one', () => {
|
||||
const rows = buildEpgListRows(
|
||||
[
|
||||
programAt(-30, 60, 'Now'),
|
||||
programAt(-30, 60, 'Now', { desc: 'With description' }),
|
||||
],
|
||||
TODAY,
|
||||
NOW,
|
||||
DEFAULT_OPTS
|
||||
);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].program.desc).toBe('With description');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { EpgProgram } from '@iptvnator/shared/interfaces';
|
||||
import { addDays } from 'date-fns';
|
||||
import { parseEpgDateKey } from '../epg-date';
|
||||
import {
|
||||
areProgramsSame,
|
||||
deduplicateProgramsByTimeSlot,
|
||||
getProgramTimeMs,
|
||||
} from '../epg-list/epg-list.utils';
|
||||
import { canCatchUpProgramme } from '../epg-timeline/epg-archive.util';
|
||||
import {
|
||||
classifyTimelineWhen,
|
||||
TimelineWhen,
|
||||
} from '../epg-timeline/epg-timeline.utils';
|
||||
|
||||
/**
|
||||
* One vertical list row. View-agnostic classification/gating is precomputed so
|
||||
* the row component stays dumb and the list re-derives everything off the 30s
|
||||
* `nowMs` tick. Mirrors the timeline's `TimelineBlock` semantics without the
|
||||
* ribbon geometry (offset/duration in px).
|
||||
*/
|
||||
export interface EpgListRow {
|
||||
readonly program: EpgProgram;
|
||||
readonly key: string;
|
||||
readonly startMs: number;
|
||||
readonly stopMs: number;
|
||||
readonly when: TimelineWhen;
|
||||
/** Live progress 0–100 for the `now` row, else null. */
|
||||
readonly progress: number | null;
|
||||
/** Matches the host's active programme (archive playback highlight). */
|
||||
readonly isActive: boolean;
|
||||
/** Past programme playable from the catch-up archive. */
|
||||
readonly canCatchUp: boolean;
|
||||
}
|
||||
|
||||
export interface BuildEpgListRowsOptions {
|
||||
readonly archivePlaybackAvailable: boolean;
|
||||
readonly archiveDays: number;
|
||||
readonly activeProgram: EpgProgram | null;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.min(100, Math.max(0, value));
|
||||
}
|
||||
|
||||
function liveProgress(
|
||||
when: TimelineWhen,
|
||||
startMs: number,
|
||||
stopMs: number,
|
||||
nowMs: number
|
||||
): number | null {
|
||||
if (when !== 'now' || stopMs <= startMs) {
|
||||
return null;
|
||||
}
|
||||
return clampPercent(((nowMs - startMs) / (stopMs - startMs)) * 100);
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmes overlapping the given local day, sorted by start and deduplicated.
|
||||
* Overlap-based (not start-date-only) so a cross-midnight programme airing now
|
||||
* still shows for *today* — matching `hasProgramsForDateKey`, which the list's
|
||||
* render-state uses. A start-date filter would drop the on-air programme and
|
||||
* leave an empty list while the state says `list`.
|
||||
*/
|
||||
export function buildEpgListRows(
|
||||
programs: readonly EpgProgram[],
|
||||
selectedDateKey: string,
|
||||
nowMs: number,
|
||||
options: BuildEpgListRowsOptions
|
||||
): EpgListRow[] {
|
||||
const dayStart = parseEpgDateKey(selectedDateKey);
|
||||
const dayStartMs = dayStart.getTime();
|
||||
const dayEndMs = addDays(dayStart, 1).getTime(); // exclusive
|
||||
|
||||
const forDay = programs.filter((program) => {
|
||||
const startMs = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(stopMs)) {
|
||||
return false;
|
||||
}
|
||||
// Drop malformed programmes (stop not after start) — same as the
|
||||
// timeline's buildTimelineBlocks; they would render impossible time
|
||||
// ranges and could even be offered as catch-up playable.
|
||||
if (stopMs <= startMs) {
|
||||
return false;
|
||||
}
|
||||
return startMs < dayEndMs && stopMs > dayStartMs;
|
||||
});
|
||||
|
||||
const deduped = deduplicateProgramsByTimeSlot(
|
||||
[...forDay].sort(
|
||||
(left, right) =>
|
||||
getProgramTimeMs(left.start, left.startTimestamp) -
|
||||
getProgramTimeMs(right.start, right.startTimestamp)
|
||||
)
|
||||
);
|
||||
|
||||
const activeProgram = options.activeProgram;
|
||||
|
||||
return deduped.map((program, index) => {
|
||||
const startMs = getProgramTimeMs(program.start, program.startTimestamp);
|
||||
const stopMs = getProgramTimeMs(program.stop, program.stopTimestamp);
|
||||
const when = classifyTimelineWhen(startMs, stopMs, nowMs);
|
||||
|
||||
return {
|
||||
program,
|
||||
key: `${startMs}-${stopMs}-${index}`,
|
||||
startMs,
|
||||
stopMs,
|
||||
when,
|
||||
progress: liveProgress(when, startMs, stopMs, nowMs),
|
||||
isActive: activeProgram
|
||||
? areProgramsSame(program, activeProgram)
|
||||
: false,
|
||||
canCatchUp: canCatchUpProgramme(
|
||||
when,
|
||||
startMs,
|
||||
options.archivePlaybackAvailable,
|
||||
options.archiveDays,
|
||||
nowMs
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -133,13 +133,21 @@
|
||||
flex: 0 0 var(--epg-inline-height, clamp(180px, 36vh, 264px));
|
||||
}
|
||||
|
||||
// Vertical list view (epgViewMode = 'list'): rows need more height to
|
||||
// be useful — raise the inline clamp only. Sets just the variable, so
|
||||
// the later `.epg-collapsed` flex rule still wins when collapsed.
|
||||
&.epg--list {
|
||||
--epg-inline-height: clamp(280px, 46vh, 430px);
|
||||
}
|
||||
|
||||
&.epg-collapsed {
|
||||
flex: 0 0 56px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
> app-live-epg-panel,
|
||||
> app-epg-timeline {
|
||||
> app-epg-timeline,
|
||||
> app-epg-list-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -154,7 +162,8 @@
|
||||
|
||||
> app-epg-view,
|
||||
> app-live-epg-panel,
|
||||
> app-epg-timeline {
|
||||
> app-epg-timeline,
|
||||
> app-epg-list-view {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user