Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions apps/electron-backend-e2e/src/settings.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
61 changes: 61 additions & 0 deletions apps/electron-backend-e2e/src/xtream-epg.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ import {
clickCategoryByNameExact,
closeElectronApp,
expect,
goToDashboard,
launchElectronApp,
openSettings,
openWorkspaceSection,
resetMockServers,
saveSettings,
test,
waitForXtreamWorkspaceReady,
} from './electron-test-fixtures';
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/app/settings/settings-epg-section.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,40 @@ <h3>{{ 'SETTINGS.EPG_SOURCES' | translate }}</h3>
</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">
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app/settings/settings-epg-section.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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>();
}
10 changes: 9 additions & 1 deletion apps/web/src/app/settings/settings-form.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
import {
CoverSize,
DEFAULT_DASHBOARD_RAILS_SETTINGS,
EpgViewMode,
Language,
normalizeDashboardRailsSettings,
normalizeExternalPlayerArguments,
Expand Down Expand Up @@ -70,7 +71,12 @@ export function createSettingsForm(
],
recordingFolder: '',
coverSize: 'medium' as CoverSize,
...(supportsEpg ? { preferUploadedEpgOverXtream: false } : {}),
...(supportsEpg
? {
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline' as EpgViewMode,
}
: {}),
});
}

Expand Down Expand Up @@ -129,6 +135,8 @@ export function createSettingsFromFormValue(
value.preferUploadedEpgOverXtream ??
currentSettings.preferUploadedEpgOverXtream ??
false,
epgViewMode:
value.epgViewMode ?? currentSettings.epgViewMode ?? 'timeline',
trustedPrivateNetworkEpgUrls:
currentSettings.trustedPrivateNetworkEpgUrls ?? [],
trustedInsecureTlsHosts: currentSettings.trustedInsecureTlsHosts ?? [],
Expand Down
15 changes: 15 additions & 0 deletions apps/web/src/app/settings/settings-options.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
CoverSize,
EpgViewMode,
StartupBehavior,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import {
CoverSizeOption,
EpgViewModeOption,
SettingsPlayerOption,
SettingsSection,
StartupBehaviorOption,
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/settings/settings.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,13 @@ <h2 mat-dialog-title>{{ 'SETTINGS.GENERAL' | translate }}</h2>
[activeSection]="activeSection()"
[epgUrl]="epgUrl"
[isClearingEpgData]="isClearingEpgData()"
[epgViewModeOptions]="epgViewModeOptions"
(refreshEpg)="refreshEpg($event)"
(removeEpgSource)="removeEpgSource($event)"
(addEpgSource)="addEpgSource()"
(refreshAllEpg)="refreshAllEpg()"
(clearEpgData)="clearEpgData()"
(selectEpgViewMode)="selectEpgViewMode($event)"
/>
}

Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/app/settings/settings.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ const DEFAULT_SETTINGS = {
coverSize: 'medium',
dashboardRails: DEFAULT_DASHBOARD_RAILS,
preferUploadedEpgOverXtream: false,
epgViewMode: 'timeline',
};

const DEFAULT_APP_UPDATE_STATUS: ElectronBridgeAppUpdateStatus = {
Expand Down Expand Up @@ -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;

Expand Down
14 changes: 14 additions & 0 deletions apps/web/src/app/settings/settings.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
CoverSize,
ELECTRON_BRIDGE_APP_UPDATE_STATUSES,
ElectronBridgeAppUpdateStatus,
EpgViewMode,
Language,
StreamFormat,
Theme,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 ||
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/app/settings/settings.models.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
CoverSize,
EpgViewMode,
StartupBehavior,
Theme,
VideoPlayer,
Expand Down Expand Up @@ -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;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/assets/i18n/ar.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "أول عرض متاح",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/assets/i18n/ary.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "أول عرض متوفر",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/assets/i18n/by.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "Першы даступны выгляд",
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/assets/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading