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 {