refactor(runtime): gate settings sections by capability

This commit is contained in:
4gray committed 2026-05-22 13:41:24 +03:00
1 parent 09e2e08fc1
commit c9a0d5210e
8 files changed
+127 -33

No files matched your search

@@ -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();
+11 -5
View File
@@ -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',
@@ -52,7 +52,7 @@
(selectRecordingFolder)="selectRecordingFolder()"
/>
@if (isDesktop) {
@if (supportsEpg) {
<app-settings-epg-section
[form]="settingsForm"
[activeSection]="activeSection()"
@@ -64,7 +64,9 @@
(refreshAllEpg)="refreshAllEpg()"
(clearEpgData)="clearEpgData()"
/>
}
@if (supportsRemoteControl) {
<app-settings-remote-control-section
[form]="settingsForm"
[activeSection]="activeSection()"
@@ -250,12 +250,16 @@ describe('SettingsComponent', () => {
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, [
+24 -12
View File
@@ -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<EmbeddedMpvSupport | null>(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<FormControl<string | null>>([]) }
: {}),
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<SettingsPlaylistDeleteSummary>(
@@ -341,7 +348,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
* Fetches local IP addresses for remote control URL display
*/
async fetchLocalIpAddresses(): Promise<void> {
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<void> => {
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');
}
+7 -4
View File
@@ -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
@@ -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(),
@@ -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 {