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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: 4gray <fourgray@proton.me>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-12 23:32:50 +02:00
1 parent 434cbf3681
commit f60d797011
7 files changed
+226 -15

No files matched your search

@@ -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.
+7 -2
View File
@@ -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
+7 -1
View File
@@ -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
@@ -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 =
@@ -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', () => {