Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa

# Conflicts:
#	docs/architecture/pwa-self-hosted.md
This commit is contained in:
4gray committed 2026-10-13 09:14:50 +02:00
commit de87c1e4d3
78 files changed
+4367 -276

No files matched your search

@@ -1,6 +1,22 @@
import { PortalRailSection } from '@iptvnator/portal/shared/util';
export const SEARCH_INPUT_DEBOUNCE_MS = 350;
/**
* The one debounce between a keystroke in the header search box and the
* applied term; pages that search on the applied term (global search
* included) run at once. 300 ms sits above the inter-key gap of steady
* typing, so a word typed in one go is applied once.
* See docs/architecture/performance-journeys.md (J4).
*/
export const SEARCH_INPUT_DEBOUNCE_MS = 300;
/**
* How long a purely local Xtream load (reading the saved catalog from SQLite)
* may run before the full-screen import overlay appears. A warm catalog
* finishes in tens of milliseconds; showing the overlay for that only flashes
* it between the skeleton and the loaded page. Remote imports and refresh
* preparation are not delayed.
*/
export const XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS = 300;
export const SEARCH_PLAYLIST_PLACEHOLDER =
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
@@ -4,6 +4,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS } from './helpers/workspace-shell-constants';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
@@ -51,4 +52,120 @@ describe('WorkspaceShellXtreamImportService', () => {
expect(service.xtreamImportTitleLabel()).toBe('Импорт');
});
describe('import overlay visibility', () => {
const activeImportSessionId = signal<string | null>(null);
const isImporting = signal(false);
const refreshPreparation = signal<{ playlistId: string } | null>(
null
);
let service: WorkspaceShellXtreamImportService;
beforeEach(() => {
jest.useFakeTimers();
activeImportSessionId.set(null);
isImporting.set(false);
refreshPreparation.set(null);
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [
WorkspaceShellXtreamImportService,
{
provide: XtreamStore,
useValue: {
activeImportSessionId,
isImporting,
contentInitBlockReason: signal(null),
},
},
{
provide: PlaylistRefreshActionService,
useValue: { refreshPreparation },
},
{ provide: RuntimeCapabilitiesService, useValue: {} },
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentRoute: signal({ kind: 'xtreams' }),
currentContext: signal({
provider: 'xtreams',
playlistId: 'portal-1',
}),
currentSection: signal('vod'),
},
},
],
});
service = TestBed.inject(WorkspaceShellXtreamImportService);
});
afterEach(() => {
jest.useRealTimers();
});
/** Lets the delay effect react, then advances the fake clock. */
function advance(ms: number): void {
TestBed.tick();
jest.advanceTimersByTime(ms);
TestBed.tick();
}
it('never shows the overlay for a saved-catalog load that finishes quickly', () => {
// A warm open: the session starts and only reads SQLite
// ('loading-cached' leaves isImporting false).
activeImportSessionId.set('session-1');
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS - 1);
expect(service.showXtreamImportOverlay()).toBe(false);
activeImportSessionId.set(null);
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(service.showXtreamImportOverlay()).toBe(false);
});
it('shows the overlay once a saved-catalog load outlasts the delay', () => {
activeImportSessionId.set('session-1');
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(service.showXtreamImportOverlay()).toBe(true);
activeImportSessionId.set(null);
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
});
it('restarts the delay for the next load', () => {
activeImportSessionId.set('session-1');
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
activeImportSessionId.set(null);
advance(0);
activeImportSessionId.set('session-2');
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
});
it('shows the overlay at once for a remote import', () => {
activeImportSessionId.set('session-1');
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
// A remote phase marks the session as a real import.
isImporting.set(true);
expect(service.showXtreamImportOverlay()).toBe(true);
});
it('keeps the delay while another playlist prepares a refresh', () => {
refreshPreparation.set({ playlistId: 'other-playlist' });
activeImportSessionId.set('session-1');
advance(0);
expect(service.showXtreamImportOverlay()).toBe(false);
advance(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(service.showXtreamImportOverlay()).toBe(true);
});
it('shows the overlay at once while a refresh prepares the playlist', () => {
refreshPreparation.set({ playlistId: 'portal-1' });
expect(service.showXtreamImportOverlay()).toBe(true);
});
});
});
@@ -1,4 +1,4 @@
import { computed, inject, Injectable } from '@angular/core';
import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -6,7 +6,10 @@ import {
RuntimeCapabilitiesService,
injectTranslationTick,
} from '@iptvnator/services';
import type { XtreamImportPhaseTone } from './helpers/workspace-shell-constants';
import {
XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS,
type XtreamImportPhaseTone,
} from './helpers/workspace-shell-constants';
import {
buildXtreamImportDetailLabel,
buildXtreamImportPhaseLabel,
@@ -76,7 +79,8 @@ export class WorkspaceShellXtreamImportService {
Boolean(this.xtreamStore.activeImportSessionId()) &&
!this.xtreamStore.isCancellingImport()
);
readonly showXtreamImportOverlay = computed(() => {
/** Whether the current route is waiting on an import or refresh. */
private readonly isOverlayWanted = computed(() => {
const route = this.routeState.currentRoute();
const context = this.routeState.currentContext();
const section = this.routeState.currentSection();
@@ -103,6 +107,35 @@ export class WorkspaceShellXtreamImportService {
);
});
/**
* Remote imports (the store flags `isImporting` for every phase except
* `loading-cached`) and refresh preparation of the shown playlist (any
* on the dashboard) show the overlay at once. A session that has only
* read the saved catalog so far waits `XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS`,
* so a warm open never flashes it, even while another playlist prepares
* a refresh.
*/
private readonly isOverlayImmediate = computed(() => {
if (this.xtreamStore.isImporting()) {
return true;
}
if (this.routeState.currentRoute().kind === 'dashboard') {
return Boolean(this.refreshPreparation());
}
const playlistId = this.routeState.currentContext()?.playlistId;
return (
playlistId !== undefined &&
this.isRefreshPreparationRunningForPlaylist(playlistId)
);
});
private readonly localLoadDelayElapsed = signal(false);
readonly showXtreamImportOverlay = computed(
() =>
this.isOverlayWanted() &&
(this.isOverlayImmediate() || this.localLoadDelayElapsed())
);
readonly xtreamImportTitleLabel = computed(() => {
this.languageTick();
if (this.activeRefreshPreparation()) {
@@ -198,6 +231,22 @@ export class WorkspaceShellXtreamImportService {
);
});
constructor() {
// Restart the delay for every wait: it runs from the moment the
// route starts waiting and resets as soon as the wait ends.
effect((onCleanup) => {
if (!this.isOverlayWanted()) {
this.localLoadDelayElapsed.set(false);
return;
}
const timer = setTimeout(
() => this.localLoadDelayElapsed.set(true),
XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS
);
onCleanup(() => clearTimeout(timer));
});
}
readonly isImportRunning = computed(
() =>
!this.xtreamStore.contentInitBlockReason() &&
@@ -30,6 +30,7 @@ import {
} from '@iptvnator/workspace/shell/util';
import { RecentCommandsService } from '../../recent-commands';
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-commands';
import { XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS } from './helpers/workspace-shell-constants';
import { WorkspaceShellFacade } from './workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
@@ -503,16 +504,31 @@ describe('WorkspaceShellFacade', () => {
expect(facade.showXtreamImportOverlay()).toBe(true);
});
it('keeps a cache-only Xtream import session blocking and cancellable', () => {
const xtreamStore = TestBed.inject(
XtreamStore
) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService);
xtreamStore.activeImportSessionId.set('xtream-import-session');
it('keeps a slow cache-only Xtream import session blocking and cancellable', () => {
jest.useFakeTimers();
try {
const xtreamStore = TestBed.inject(
XtreamStore
) as unknown as MockXtreamStore;
const xtreamImport = TestBed.inject(
WorkspaceShellXtreamImportService
);
xtreamStore.activeImportSessionId.set('xtream-import-session');
TestBed.tick();
expect(xtreamStore.isImporting()).toBe(false);
expect(facade.showXtreamImportOverlay()).toBe(true);
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
// A saved-catalog read waits out the show delay so a quick one
// never flashes the overlay, but stays cancellable meanwhile.
expect(xtreamStore.isImporting()).toBe(false);
expect(facade.showXtreamImportOverlay()).toBe(false);
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
jest.advanceTimersByTime(XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS);
expect(facade.showXtreamImportOverlay()).toBe(true);
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
} finally {
jest.useRealTimers();
}
});
it('shows the Xtream overlay during refresh preparation on the dashboard', () => {