mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 20:16:15 -08:00
perf(settings): keep the PIN dialog and the Stalker enforcement step off the initial path
Master (#1712) moved the UI component barrel and the Stalker data layer out of main.js and tightened the initial budget to 2 MB. The parental-lock prompt imported the PIN dialog through the ui/components barrel and the enforcement service injected the Stalker store at startup, which pulled both back in (2.55 MB, over budget). The PIN dialog now loads through a local lazy file on the first prompt, and the Stalker step loads only while a Stalker route is open: initial total 1.65 MB. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c035085edf
commit
5c3e675b41
9 files changed
+206
-68
No files matched your search
@@ -120,6 +120,19 @@ describe('ParentalLockEnforcementService', () => {
|
||||
});
|
||||
|
||||
describe('Stalker', () => {
|
||||
it('does not load the Stalker step outside a Stalker route', async () => {
|
||||
router.url = '/workspace/xtreams/xtream-1/live';
|
||||
const load = jest.spyOn(
|
||||
service as unknown as { loadStalkerEnforcement: () => unknown },
|
||||
'loadStalkerEnforcement'
|
||||
);
|
||||
|
||||
await service.applyStalker();
|
||||
|
||||
expect(load).not.toHaveBeenCalled();
|
||||
expect(stalkerStore.clearSelectedItem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears a detail opened from All whose own genre is withheld', async () => {
|
||||
router.url = '/workspace/stalker/stalker-1/vod/42';
|
||||
lockedStalkerIds.add('9');
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { effect, inject, Injectable, untracked } from '@angular/core';
|
||||
import {
|
||||
effect,
|
||||
EnvironmentInjector,
|
||||
inject,
|
||||
Injectable,
|
||||
untracked,
|
||||
} from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { ChannelActions, selectActive } from '@iptvnator/m3u-state';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import {
|
||||
toParentalLockStalkerCategoryType,
|
||||
toParentalLockXtreamCategoryType,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { toParentalLockXtreamCategoryType } from '@iptvnator/shared/interfaces';
|
||||
import { PlaybackKeepAwakeService } from './playback-keep-awake.service';
|
||||
|
||||
const XTREAM_ROUTE =
|
||||
/^\/workspace\/xtreams\/([^/?#]+)\/(live|vod|series)(?:\/(\d+))?/;
|
||||
const STALKER_ROUTE =
|
||||
export const STALKER_ROUTE =
|
||||
/^\/workspace\/stalker\/([^/?#]+)\/(itv|vod|series|radio)(?:\/([^/?#]+))?/;
|
||||
|
||||
/**
|
||||
@@ -27,7 +29,7 @@ const STALKER_ROUTE =
|
||||
export class ParentalLockEnforcementService {
|
||||
private readonly parentalLock = inject(ParentalLockService);
|
||||
private readonly xtreamStore = inject(XtreamStore);
|
||||
private readonly stalkerStore = inject(StalkerStore);
|
||||
private readonly injector = inject(EnvironmentInjector);
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly keepAwake = inject(PlaybackKeepAwakeService);
|
||||
@@ -221,61 +223,27 @@ export class ParentalLockEnforcementService {
|
||||
}
|
||||
}
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadStalkerEnforcement = () =>
|
||||
import('./parental-lock-stalker-enforcement');
|
||||
|
||||
/**
|
||||
* The Stalker data layer stays off the initial path: the step is loaded
|
||||
* only while a Stalker route is open. Outside one there is nothing to
|
||||
* step off — the Stalker route session clears the selection (and the
|
||||
* live layout its playback) when the route is left.
|
||||
*/
|
||||
private async applyStalker(): Promise<void> {
|
||||
const playlist = this.stalkerStore.currentPlaylist();
|
||||
const playlistId = playlist?._id;
|
||||
if (!playlistId) {
|
||||
if (!STALKER_ROUTE.test(this.router.url)) {
|
||||
return;
|
||||
}
|
||||
const contentType = toParentalLockStalkerCategoryType(
|
||||
this.stalkerStore.selectedContentType()
|
||||
const { applyParentalLockToStalker } =
|
||||
await this.loadStalkerEnforcement();
|
||||
applyParentalLockToStalker(
|
||||
this.injector,
|
||||
this.parentalLock,
|
||||
this.router
|
||||
);
|
||||
if (!contentType) {
|
||||
return;
|
||||
}
|
||||
const selectedCategoryId = this.stalkerStore.selectedCategoryId();
|
||||
const categoryWithheld =
|
||||
!!selectedCategoryId &&
|
||||
this.parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
contentType,
|
||||
selectedCategoryId
|
||||
);
|
||||
// An item opened from "All" (`*`) or search has its own genre to be
|
||||
// judged by; the list dropping its row is not enough. Live and radio
|
||||
// rows carry that genre in `tv_genre_id`, VOD and series rows in
|
||||
// `category_id` (the store's withheld filter applies the same rule).
|
||||
const selectedItem = this.stalkerStore.selectedItem?.() as {
|
||||
category_id?: string | number;
|
||||
tv_genre_id?: string | number;
|
||||
} | null;
|
||||
const itemCategoryId =
|
||||
contentType === 'itv' || contentType === 'radio'
|
||||
? selectedItem?.tv_genre_id
|
||||
: selectedItem?.category_id;
|
||||
const itemWithheld =
|
||||
!!selectedItem &&
|
||||
this.parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
contentType,
|
||||
itemCategoryId
|
||||
);
|
||||
if (!categoryWithheld && !itemWithheld) {
|
||||
return;
|
||||
}
|
||||
this.stalkerStore.clearSelectedItem();
|
||||
if (categoryWithheld) {
|
||||
this.stalkerStore.setSelectedCategory(null);
|
||||
}
|
||||
const match = STALKER_ROUTE.exec(this.router.url);
|
||||
if (match && match[1] === playlistId) {
|
||||
void this.router.navigate([
|
||||
'/workspace',
|
||||
'stalker',
|
||||
match[1],
|
||||
match[2],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
private applyM3u(): void {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lazy boundary for the parental-lock prompt: the prompt service imports
|
||||
* this file dynamically, so the PIN dialog and the @angular/forms and
|
||||
* Material form-field modules it brings stay off the initial path. The
|
||||
* boundary is a local file backed by a file-level alias, like the other
|
||||
* root-shell lazy dialogs (see docs/architecture/nx-workspace-boundaries.md).
|
||||
*/
|
||||
export { ParentalLockPinDialogComponent } from '@iptvnator/ui/components/parental-lock-pin-dialog';
|
||||
@@ -0,0 +1,44 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { of } from 'rxjs';
|
||||
import { AppParentalLockPromptService } from './parental-lock-prompt.service';
|
||||
|
||||
describe('AppParentalLockPromptService', () => {
|
||||
const dialog = { open: jest.fn() };
|
||||
let service: AppParentalLockPromptService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
AppParentalLockPromptService,
|
||||
{ provide: MatDialog, useValue: dialog },
|
||||
],
|
||||
});
|
||||
service = TestBed.inject(AppParentalLockPromptService);
|
||||
});
|
||||
|
||||
it('loads the PIN dialog on demand and returns the entered PIN', async () => {
|
||||
const open = jest.fn(() => ({ afterClosed: () => of('1234') }));
|
||||
service.loadPinDialog = jest.fn(async () => ({
|
||||
ParentalLockPinDialogComponent: { open },
|
||||
})) as never;
|
||||
|
||||
await expect(service.requestPin({ mode: 'set' })).resolves.toBe('1234');
|
||||
expect(open).toHaveBeenCalledWith(
|
||||
dialog,
|
||||
expect.objectContaining({ mode: 'set' })
|
||||
);
|
||||
});
|
||||
|
||||
it('treats a failed dialog chunk load as a cancelled prompt', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
service.loadPinDialog = jest.fn(async () => {
|
||||
throw new Error('ChunkLoadError');
|
||||
}) as never;
|
||||
|
||||
await expect(
|
||||
service.requestPin({ mode: 'unlock' })
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,26 +6,45 @@ import {
|
||||
ParentalLockPrompt,
|
||||
ParentalLockPromptRequest,
|
||||
} from '@iptvnator/services';
|
||||
import { ParentalLockPinDialogComponent } from '@iptvnator/ui/components';
|
||||
|
||||
/**
|
||||
* Application implementation of the parental lock prompt: the shared PIN
|
||||
* dialog from `@iptvnator/ui/components`, which the data-access lock
|
||||
* service cannot import itself.
|
||||
* service cannot import itself. The dialog is loaded on first use (the
|
||||
* service is constructed at startup, the dialog is not needed until a PIN
|
||||
* is asked for).
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppParentalLockPromptService implements ParentalLockPrompt {
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
/** The dynamic import; a field so specs can substitute it. */
|
||||
loadPinDialog = () => import('./parental-lock-pin-dialog.lazy');
|
||||
|
||||
async requestPin(
|
||||
request: ParentalLockPromptRequest
|
||||
): Promise<string | null> {
|
||||
const dialogRef = ParentalLockPinDialogComponent.open(this.dialog, {
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
});
|
||||
let module: Awaited<ReturnType<typeof this.loadPinDialog>>;
|
||||
try {
|
||||
module = await this.loadPinDialog();
|
||||
} catch (error) {
|
||||
// A failed chunk load reads as a cancelled prompt: the gate it
|
||||
// guards stays closed.
|
||||
console.error(
|
||||
'The parental lock PIN dialog could not be loaded.',
|
||||
error
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const dialogRef = module.ParentalLockPinDialogComponent.open(
|
||||
this.dialog,
|
||||
{
|
||||
mode: request.mode,
|
||||
verify: request.verify,
|
||||
titleKey: request.titleKey,
|
||||
descriptionKey: request.descriptionKey,
|
||||
}
|
||||
);
|
||||
const pin = await firstValueFrom(dialogRef.afterClosed());
|
||||
return typeof pin === 'string' ? pin : null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Injector } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
|
||||
import { ParentalLockService } from '@iptvnator/services';
|
||||
import { toParentalLockStalkerCategoryType } from '@iptvnator/shared/interfaces';
|
||||
import { STALKER_ROUTE } from './parental-lock-enforcement.service';
|
||||
|
||||
/**
|
||||
* The Stalker step of `ParentalLockEnforcementService`, in its own file so
|
||||
* the enforcement service can import it dynamically: a static import of the
|
||||
* Stalker store would put the whole Stalker data layer back on the initial
|
||||
* path (see docs/architecture/nx-workspace-boundaries.md).
|
||||
*/
|
||||
export function applyParentalLockToStalker(
|
||||
injector: Injector,
|
||||
parentalLock: ParentalLockService,
|
||||
router: Router
|
||||
): void {
|
||||
const stalkerStore = injector.get(StalkerStore);
|
||||
const playlist = stalkerStore.currentPlaylist();
|
||||
const playlistId = playlist?._id;
|
||||
if (!playlistId) {
|
||||
return;
|
||||
}
|
||||
const contentType = toParentalLockStalkerCategoryType(
|
||||
stalkerStore.selectedContentType()
|
||||
);
|
||||
if (!contentType) {
|
||||
return;
|
||||
}
|
||||
const selectedCategoryId = stalkerStore.selectedCategoryId();
|
||||
const categoryWithheld =
|
||||
!!selectedCategoryId &&
|
||||
parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
contentType,
|
||||
selectedCategoryId
|
||||
);
|
||||
// An item opened from "All" (`*`) or search has its own genre to be
|
||||
// judged by; the list dropping its row is not enough. Live and radio
|
||||
// rows carry that genre in `tv_genre_id`, VOD and series rows in
|
||||
// `category_id` (the store's withheld filter applies the same rule).
|
||||
const selectedItem = stalkerStore.selectedItem?.() as {
|
||||
category_id?: string | number;
|
||||
tv_genre_id?: string | number;
|
||||
} | null;
|
||||
const itemCategoryId =
|
||||
contentType === 'itv' || contentType === 'radio'
|
||||
? selectedItem?.tv_genre_id
|
||||
: selectedItem?.category_id;
|
||||
const itemWithheld =
|
||||
!!selectedItem &&
|
||||
parentalLock.isStalkerCategoryLocked(
|
||||
playlistId,
|
||||
contentType,
|
||||
itemCategoryId
|
||||
);
|
||||
if (!categoryWithheld && !itemWithheld) {
|
||||
return;
|
||||
}
|
||||
stalkerStore.clearSelectedItem();
|
||||
if (categoryWithheld) {
|
||||
stalkerStore.setSelectedCategory(null);
|
||||
}
|
||||
const match = STALKER_ROUTE.exec(router.url);
|
||||
if (match && match[1] === playlistId) {
|
||||
void router.navigate(['/workspace', 'stalker', match[1], match[2]]);
|
||||
}
|
||||
}
|
||||
@@ -248,7 +248,9 @@ entries: `@iptvnator/ui/components/window-controls`,
|
||||
`@iptvnator/playlist/shared/ui/stalker-connection-editor` (like the existing
|
||||
`@iptvnator/ui/epg/progress-panel`), and loads anything used only on demand
|
||||
through a local file it imports dynamically (the Stalker connection editor,
|
||||
the release-notes dialog, the external-player info dialog). A local file,
|
||||
the release-notes dialog, the external-player info dialog, the parental-lock
|
||||
PIN dialog and the Stalker step of the parental-lock enforcement, which runs
|
||||
only while a Stalker route is open). A local file,
|
||||
not the library alias, is the dynamic-import target because
|
||||
`@nx/enforce-module-boundaries` forbids static imports of a library the same
|
||||
project also loads dynamically. `renderer.initialBytes` in
|
||||
|
||||
@@ -344,6 +344,18 @@ on either side.
|
||||
- Header lock/unlock button and the `parental-lock-now` /
|
||||
`parental-unlock` palette commands.
|
||||
|
||||
## Startup footprint
|
||||
|
||||
The lock service, lock store and enforcement are on the renderer's initial
|
||||
path by necessity (the workspace resolver and the catalog data sources gate
|
||||
on them). Everything else stays lazy: the PIN dialog loads through
|
||||
`parental-lock-pin-dialog.lazy.ts` on the first prompt (a failed chunk load
|
||||
reads as a cancelled prompt), and the enforcement's Stalker step loads
|
||||
through `parental-lock-stalker-enforcement.ts` only while a Stalker route is
|
||||
open — a static import of the Stalker store would put the whole Stalker
|
||||
data layer back into `main.js`. The feature costs about 30 KB of
|
||||
`renderer.initialBytes`.
|
||||
|
||||
## Lock store lifetime
|
||||
|
||||
Every lock-store mutation runs through one write queue in
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
"@iptvnator/ui/components/confirm-dialog": [
|
||||
"libs/ui/components/src/lib/confirm-dialog/dialog.service.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/parental-lock-pin-dialog": [
|
||||
"libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts"
|
||||
],
|
||||
"@iptvnator/ui/components/window-controls": [
|
||||
"libs/ui/components/src/lib/window-controls/window-controls.component.ts"
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user