From 8e5ab7d6e61531c11bd891d58ee12a294b20593b Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 12 Oct 2026 08:07:41 +0200 Subject: [PATCH] fix(workspace): don't flash the import overlay on a cached Xtream open Opening an already-imported Xtream portal reads the saved catalog in tens of milliseconds, but the session made the shell mount the full-screen import overlay right away, so it flashed for about two frames between the skeleton and the loaded page (J2 open-source). A session that has only read the saved catalog (loading-cached, store isImporting still false) now shows the overlay after a 300 ms delay. Remote import phases and refresh preparation still show it immediately, and a slow or stuck cache read still blocks and stays cancellable. Co-Authored-By: Claude Opus 5.5 --- .../workspace-cached-open-no-overlay-flash.md | 9 ++ docs/architecture/performance-journeys.md | 5 + docs/architecture/workspace-shell.md | 7 +- .../helpers/workspace-shell-constants.ts | 9 ++ ...kspace-shell-xtream-import.service.spec.ts | 107 ++++++++++++++++++ .../workspace-shell-xtream-import.service.ts | 45 +++++++- .../services/workspace-shell.facade.spec.ts | 34 ++++-- 7 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 .changes/workspace-cached-open-no-overlay-flash.md diff --git a/.changes/workspace-cached-open-no-overlay-flash.md b/.changes/workspace-cached-open-no-overlay-flash.md new file mode 100644 index 000000000..9a5b222ea --- /dev/null +++ b/.changes/workspace-cached-open-no-overlay-flash.md @@ -0,0 +1,9 @@ +--- +type: fix +area: workspace +--- + +Opening an already-imported Xtream portal no longer flashes the "Syncing +playlist" overlay for a split second before the catalog appears. The overlay +still shows for real downloads and for saved catalogs that take a while to +load. diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 12d9c575b..c2bf25bdf 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -704,6 +704,11 @@ source's default section, which is VOD (`getPlaylistLink` links to and the first page is the "All items" grid. The plan's "live category list" would need a second click and is not measured; the landed section is recorded under `evidence.firstPage.section`. +The seeded portal is already imported, so the click only reads the saved +catalog and finishes well inside the shell's show delay for the import +overlay ([workspace shell](workspace-shell.md)): a J2 click must not mount +`app-workspace-shell-import-overlay`, and before that delay it flashed for +about two frames between the skeleton and the first page. **HTTP requests to the mock.** The J2 profile is seeded with the origin of a loopback proxy in the test process diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index a161a3eee..43604be59 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -144,7 +144,12 @@ services: 5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle and recent-command recording. 6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay - state and labels. + state and labels. Remote import phases and refresh preparation show the + overlay at once. A session that has only read the saved catalog + (`loading-cached`, `isImporting` still false) shows it only after + `XTREAM_LOCAL_LOAD_OVERLAY_DELAY_MS` (300 ms), so a warm open goes from + the skeleton to the page without flashing the overlay, while a slow or + stuck cache read still blocks and stays cancellable. When adding shell behavior, prefer placing it in the service that owns the nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts index 7464a2552..c1e38a4c3 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/helpers/workspace-shell-constants.ts @@ -2,6 +2,15 @@ import { PortalRailSection } from '@iptvnator/portal/shared/util'; export const SEARCH_INPUT_DEBOUNCE_MS = 350; +/** + * 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'; export const SEARCH_SECTION_PLACEHOLDER = diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts index 3802adb96..b59611792 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.spec.ts @@ -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,110 @@ describe('WorkspaceShellXtreamImportService', () => { expect(service.xtreamImportTitleLabel()).toBe('Импорт'); }); + + describe('import overlay visibility', () => { + const activeImportSessionId = signal(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('shows the overlay at once while a refresh prepares the playlist', () => { + refreshPreparation.set({ playlistId: 'portal-1' }); + expect(service.showXtreamImportOverlay()).toBe(true); + }); + }); }); diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts index 8ada0d20d..e02fd10d2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell-xtream-import.service.ts @@ -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,25 @@ export class WorkspaceShellXtreamImportService { ); }); + /** + * Remote imports (the store flags `isImporting` for every phase except + * `loading-cached`) and refresh preparation 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. + */ + private readonly isOverlayImmediate = computed( + () => + Boolean(this.refreshPreparation()) || + this.xtreamStore.isImporting() + ); + private readonly localLoadDelayElapsed = signal(false); + + readonly showXtreamImportOverlay = computed( + () => + this.isOverlayWanted() && + (this.isOverlayImmediate() || this.localLoadDelayElapsed()) + ); + readonly xtreamImportTitleLabel = computed(() => { this.languageTick(); if (this.activeRefreshPreparation()) { @@ -198,6 +221,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() && diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts index 7d76bd215..8064d6b96 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/services/workspace-shell.facade.spec.ts @@ -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', () => {