mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
refactor(runtime): gate settings sections by capability
This commit is contained in:
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();
|
||||
|
||||
|
||||
@@ -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, [
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user