mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
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:
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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+9
@@ -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 =
|
||||
|
||||
+117
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+52
-3
@@ -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() &&
|
||||
|
||||
+25
-9
@@ -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', () => {
|
||||
|
||||
Reference in new issue
Block a user