From f60d797011bf0245dd6c6e8bff6827eb8eb7c50b Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Mon, 12 Oct 2026 23:32:50 +0200 Subject: [PATCH] fix(workspace): don't flash the import overlay on a cached Xtream open (#1906) * 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 * fix(workspace): keep the overlay delay while another playlist refreshes Refresh preparation skipped the cached-load delay for any playlist, so opening a cached portal while another playlist prepared a refresh still flashed the overlay. Only preparation of the shown playlist (any on the dashboard) now shows it at once. Also words the J2 layout-shift history from #1895 in the past tense: the cached-load overlay no longer mounts on a J2 click. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: 4gray Co-authored-by: Claude Opus 5.5 --- .../workspace-cached-open-no-overlay-flash.md | 9 ++ docs/architecture/performance-journeys.md | 9 +- docs/architecture/workspace-shell.md | 8 +- .../helpers/workspace-shell-constants.ts | 9 ++ ...kspace-shell-xtream-import.service.spec.ts | 117 ++++++++++++++++++ .../workspace-shell-xtream-import.service.ts | 55 +++++++- .../services/workspace-shell.facade.spec.ts | 34 +++-- 7 files changed, 226 insertions(+), 15 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 cfbaecc47..b83dbfc00 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 @@ -1317,8 +1322,8 @@ largest distance in it, the content pane's 323 px, which is why Back added buttons appearing when the categories arrive. The skeletons of the category list and the grid are replaced by content without any shift. Nothing was hidden behind the startup overlay: the "Syncing playlist" overlay of the -cached catalog load flashes for about two frames after the shift and covers -no further one. +cached catalog load flashed for about two frames after the shift and covered +no further one. It no longer mounts on a J2 click (see the J2 section). The content frame and the header controls now slide into place instead of jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 243842ad3..58b09ed0c 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -195,7 +195,13 @@ 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 of the + shown playlist (of any playlist on the dashboard) 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..31fe6c19f 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,120 @@ 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('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); + }); + }); }); 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..056bb82d4 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,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() && 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', () => {