diff --git a/apps/web/src/app/settings/settings-options.spec.ts b/apps/web/src/app/settings/settings-options.spec.ts
index 7ac76cf97..856d0566d 100644
--- a/apps/web/src/app/settings/settings-options.spec.ts
+++ b/apps/web/src/app/settings/settings-options.spec.ts
@@ -44,24 +44,34 @@ describe('buildSettingsSectionNavItems', () => {
return ids;
}
- it('exposes desktop-only items only when isDesktop is true', () => {
- const desktopItems = buildSettingsSectionNavItems(true);
- const pwaItems = buildSettingsSectionNavItems(false);
+ it('exposes feature-specific items only when their runtime capabilities are supported', () => {
+ const supportedItems = buildSettingsSectionNavItems({
+ supportsEpg: true,
+ supportsRemoteControl: true,
+ });
+ const unsupportedItems = buildSettingsSectionNavItems({
+ supportsEpg: false,
+ supportsRemoteControl: false,
+ });
- expect(desktopItems.map((item) => item.id)).toEqual(
+ expect(supportedItems.map((item) => item.id)).toEqual(
expect.arrayContaining(['epg', 'remote-control'])
);
- expect(pwaItems.find((item) => item.id === 'epg')?.visible).toBe(
- false
- );
expect(
- pwaItems.find((item) => item.id === 'remote-control')?.visible
+ unsupportedItems.find((item) => item.id === 'epg')?.visible
+ ).toBe(false);
+ expect(
+ unsupportedItems.find((item) => item.id === 'remote-control')
+ ?.visible
).toBe(false);
});
it('every nav id matches an existing section template id (regression: remote-control nav no longer maps to the Nx lib name)', () => {
const navIds = new Set(
- buildSettingsSectionNavItems(true).map((item) => item.id)
+ buildSettingsSectionNavItems({
+ supportsEpg: true,
+ supportsRemoteControl: true,
+ }).map((item) => item.id)
);
const templateIds = collectSectionTemplateIds();
diff --git a/apps/web/src/app/settings/settings-options.ts b/apps/web/src/app/settings/settings-options.ts
index 93e8bb84d..a2275a994 100644
--- a/apps/web/src/app/settings/settings-options.ts
+++ b/apps/web/src/app/settings/settings-options.ts
@@ -85,9 +85,15 @@ export const SETTINGS_EMBEDDED_PLAYER_OPTIONS: SettingsPlayerOption[] = [
},
];
-export function buildSettingsSectionNavItems(
- isDesktop: boolean
-): SettingsSection[] {
+export interface SettingsSectionVisibility {
+ supportsEpg: boolean;
+ supportsRemoteControl: boolean;
+}
+
+export function buildSettingsSectionNavItems({
+ supportsEpg,
+ supportsRemoteControl,
+}: SettingsSectionVisibility): SettingsSection[] {
return [
{
id: 'general',
@@ -105,7 +111,7 @@ export function buildSettingsSectionNavItems(
id: 'epg',
label: 'SETTINGS.NAV_EPG',
icon: 'calendar_month',
- visible: isDesktop,
+ visible: supportsEpg,
},
{
// Must match the section's HTML id (`remote-control`) so the
@@ -116,7 +122,7 @@ export function buildSettingsSectionNavItems(
id: 'remote-control',
label: 'SETTINGS.NAV_REMOTE',
icon: 'smartphone',
- visible: isDesktop,
+ visible: supportsRemoteControl,
},
{
id: 'backup',
diff --git a/apps/web/src/app/settings/settings.component.html b/apps/web/src/app/settings/settings.component.html
index 9f2abe4b5..649fca38c 100644
--- a/apps/web/src/app/settings/settings.component.html
+++ b/apps/web/src/app/settings/settings.component.html
@@ -52,7 +52,7 @@
(selectRecordingFolder)="selectRecordingFolder()"
/>
- @if (isDesktop) {
+ @if (supportsEpg) {
+ }
+ @if (supportsRemoteControl) {
{
staleUrls: [],
}),
clearEpgData: jest.fn().mockResolvedValue({ success: true }),
+ fetchEpg: jest.fn().mockResolvedValue({ success: true }),
forceFetchEpg: jest.fn().mockResolvedValue({ success: true }),
getAppVersion: jest.fn().mockResolvedValue('1.0.0'),
+ getChannelPrograms: jest.fn().mockResolvedValue([]),
+ getEpgChannelsByRange: jest.fn().mockResolvedValue([]),
getLocalIpAddresses: jest.fn().mockResolvedValue([]),
openInMpv: jest.fn(),
openInVlc: jest.fn(),
platform: 'linux',
+ searchEpgPrograms: jest.fn().mockResolvedValue([]),
saveFileDialog: jest.fn().mockResolvedValue('/tmp/backup.json'),
setMpvPlayerPath: jest.fn().mockResolvedValue(undefined),
setVlcPlayerPath: jest.fn().mockResolvedValue(undefined),
@@ -496,6 +500,21 @@ describe('SettingsComponent', () => {
expect(
partialBridgeComponent.supportsExternalPlayerPathSettings
).toBe(false);
+ expect(partialBridgeComponent.supportsEpg).toBe(false);
+ expect(partialBridgeComponent.supportsRemoteControl).toBe(false);
+ expect(
+ partialBridgeComponent.settingsForm.get('epgUrl')
+ ).toBeNull();
+ expect(
+ partialBridgeComponent.sectionNav.find(
+ (section) => section.id === 'epg'
+ )
+ ).toBeUndefined();
+ expect(
+ partialBridgeComponent.sectionNav.find(
+ (section) => section.id === 'remote-control'
+ )
+ ).toBeUndefined();
expect(
partialBridgeComponent
.players()
@@ -748,6 +767,8 @@ describe('SettingsComponent', () => {
expect(browserComponent.isDesktop).toBe(false);
expect(browserComponent.isPwa).toBe(true);
+ expect(browserComponent.supportsEpg).toBe(false);
+ expect(browserComponent.supportsRemoteControl).toBe(false);
expect(browserComponent.settingsForm.get('epgUrl')).toBeNull();
mockStore.overrideSelector(selectAllPlaylistsMeta, [
diff --git a/apps/web/src/app/settings/settings.component.ts b/apps/web/src/app/settings/settings.component.ts
index 7afed4d31..5d6839ba7 100644
--- a/apps/web/src/app/settings/settings.component.ts
+++ b/apps/web/src/app/settings/settings.component.ts
@@ -137,8 +137,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Flag that indicates whether the app runs in electron environment */
readonly isDesktop = this.runtime.isElectron;
+ readonly supportsEpg = this.runtime.supportsEpg;
readonly supportsExternalPlayerPathSettings =
this.runtime.supportsExternalPlayerPathSettings;
+ readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly embeddedMpvSupport = signal(null);
readonly supportsEmbeddedMpv = computed(
() => this.isDesktop && !!this.embeddedMpvSupport()?.supported
@@ -186,7 +188,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** Settings form object */
settingsForm = this.formBuilder.group({
player: [VideoPlayer.VideoJs],
- ...(this.isDesktop
+ ...(this.supportsEpg
? { epgUrl: new FormArray>([]) }
: {}),
streamFormat: StreamFormat.M3u8StreamFormat,
@@ -215,7 +217,9 @@ export class SettingsComponent implements OnInit, OnDestroy {
],
recordingFolder: '',
coverSize: 'medium' as CoverSize,
- ...(this.isDesktop ? { preferUploadedEpgOverXtream: false } : {}),
+ ...(this.supportsEpg
+ ? { preferUploadedEpgOverXtream: false }
+ : {}),
});
/** Form array with epg sources */
@@ -233,7 +237,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
private settingsStore = inject(SettingsStore);
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems(
- this.isDesktop
+ {
+ supportsEpg: this.supportsEpg,
+ supportsRemoteControl: this.supportsRemoteControl,
+ }
);
readonly playlistDeleteSummary = computed(
@@ -341,7 +348,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
* Fetches local IP addresses for remote control URL display
*/
async fetchLocalIpAddresses(): Promise {
- if (window.electron?.getLocalIpAddresses) {
+ if (
+ this.supportsRemoteControl &&
+ window.electron?.getLocalIpAddresses
+ ) {
const addresses = await window.electron.getLocalIpAddresses();
this.localIpAddresses.set(addresses);
}
@@ -365,7 +375,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
const currentSettings = this.settingsStore.getSettings();
this.settingsForm.patchValue(currentSettings);
- if (this.isDesktop && currentSettings.epgUrl) {
+ if (this.supportsEpg && currentSettings.epgUrl) {
this.epgUrl.clear();
this.setEpgUrls(currentSettings.epgUrl);
}
@@ -553,7 +563,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
*/
applyChangedSettings(): void {
this.settingsForm.markAsPristine();
- if (this.isDesktop) {
+ if (this.supportsEpg) {
let epgUrls = this.settingsForm.value.epgUrl;
if (epgUrls) {
if (!Array.isArray(epgUrls)) {
@@ -598,8 +608,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
* intends when clicking "Refresh".
*/
refreshEpg(url: string): void {
- if (!url || !window.electron?.forceFetchEpg) return;
- void window.electron.forceFetchEpg(url);
+ if (!this.supportsEpg || !url) {
+ return;
+ }
+ void window.electron!.forceFetchEpg!(url);
}
/**
@@ -608,11 +620,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
* gets visible per-URL feedback.
*/
refreshAllEpg(): void {
- if (!window.electron?.forceFetchEpg) return;
+ if (!this.supportsEpg) return;
const urls = (this.epgUrl.value as string[])
.map((url) => url?.trim())
.filter((url): url is string => Boolean(url));
- urls.forEach((url) => window.electron.forceFetchEpg(url));
+ urls.forEach((url) => void window.electron!.forceFetchEpg!(url));
}
/**
@@ -645,7 +657,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
),
onConfirm: async (): Promise => {
if (
- !window.electron?.clearEpgData ||
+ !this.supportsEpg ||
this.isClearingEpgData()
) {
return;
@@ -653,7 +665,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.isClearingEpgData.set(true);
try {
- const result = await window.electron.clearEpgData();
+ const result = await window.electron!.clearEpgData!();
if (result && result.success === false) {
throw new Error('Clear EPG returned success=false');
}
diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md
index 9ed479317..5965ff39a 100644
--- a/docs/architecture/pwa-self-hosted.md
+++ b/docs/architecture/pwa-self-hosted.md
@@ -73,10 +73,13 @@ feature decisions expressed as capabilities such as `supportsEpg`,
from one shared boundary. `supportsSqlite` requires the complete playlist
storage preload API surface used by `PlaylistsService`, `supportsDownloads`
requires the complete downloads preload API surface used by `DownloadsService`,
-`supportsPlaylistRefresh` requires the native playlist refresh/cancel/progress
-bridge, `supportsXtreamSectionNavigation` is available in PWA and in Electron
-when either the SQLite Xtream data source or the Xtream API transport is
-available, and
+`supportsEpg` requires the Electron EPG preload methods used by the shared EPG
+panels (`fetchEpg`, `getChannelPrograms`, `checkEpgFreshness`,
+`forceFetchEpg`, `clearEpgData`, `getEpgChannelsByRange`, and
+`searchEpgPrograms`), `supportsPlaylistRefresh` requires the native playlist
+refresh/cancel/progress bridge, `supportsXtreamSectionNavigation` is available
+in PWA and in Electron when either the SQLite Xtream data source or the Xtream
+API transport is available, and
`supportsManagedExternalPlayers` requires the MPV and VLC preload launch and
path-setting methods (`openInMpv`, `openInVlc`, `setMpvPlayerPath`, and
`setVlcPlayerPath`); a partial Electron bridge must not expose desktop-only
diff --git a/libs/services/src/lib/runtime-capabilities.service.spec.ts b/libs/services/src/lib/runtime-capabilities.service.spec.ts
index 7ed2f8562..a1d634b49 100644
--- a/libs/services/src/lib/runtime-capabilities.service.spec.ts
+++ b/libs/services/src/lib/runtime-capabilities.service.spec.ts
@@ -107,6 +107,13 @@ describe('RuntimeCapabilitiesService', () => {
onChannelChange: jest.fn(),
onRemoteControlCommand: jest.fn(),
xtreamRequest: jest.fn(),
+ fetchEpg: jest.fn(),
+ getChannelPrograms: jest.fn(),
+ checkEpgFreshness: jest.fn(),
+ forceFetchEpg: jest.fn(),
+ clearEpgData: jest.fn(),
+ getEpgChannelsByRange: jest.fn(),
+ searchEpgPrograms: jest.fn(),
};
const service = new RuntimeCapabilitiesService();
@@ -140,7 +147,7 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.environment).toBe('electron');
expect(service.platform).toBeUndefined();
expect(service.isMacOS).toBe(false);
- expect(service.supportsEpg).toBe(true);
+ expect(service.supportsEpg).toBe(false);
expect(service.supportsSqlite).toBe(false);
expect(service.supportsXtreamSqliteDataSource).toBe(false);
expect(service.supportsDownloads).toBe(false);
@@ -198,6 +205,31 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsExternalPlayerPathSettings).toBe(true);
});
+ it('requires the complete EPG preload surface', () => {
+ testWindow.electron = {
+ fetchEpg: jest.fn(),
+ getChannelPrograms: jest.fn(),
+ checkEpgFreshness: jest.fn(),
+ };
+
+ const service = new RuntimeCapabilitiesService();
+
+ expect(service.isElectron).toBe(true);
+ expect(service.supportsEpg).toBe(false);
+
+ testWindow.electron = {
+ fetchEpg: jest.fn(),
+ getChannelPrograms: jest.fn(),
+ checkEpgFreshness: jest.fn(),
+ forceFetchEpg: jest.fn(),
+ clearEpgData: jest.fn(),
+ getEpgChannelsByRange: jest.fn(),
+ searchEpgPrograms: jest.fn(),
+ };
+
+ expect(service.supportsEpg).toBe(true);
+ });
+
it('requires the complete downloads preload surface', () => {
testWindow.electron = {
downloadsGetList: jest.fn(),
diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts
index 8d47c1cdd..374a70197 100644
--- a/libs/services/src/lib/runtime-capabilities.service.ts
+++ b/libs/services/src/lib/runtime-capabilities.service.ts
@@ -32,7 +32,15 @@ export class RuntimeCapabilitiesService {
}
get supportsEpg(): boolean {
- return this.isElectron;
+ return [
+ 'fetchEpg',
+ 'getChannelPrograms',
+ 'checkEpgFreshness',
+ 'forceFetchEpg',
+ 'clearEpgData',
+ 'getEpgChannelsByRange',
+ 'searchEpgPrograms',
+ ].every((methodName) => this.hasElectronMethod(methodName));
}
get supportsSqlite(): boolean {