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"
|
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
|
would need a second click and is not measured; the landed section is
|
||||||
recorded under `evidence.firstPage.section`.
|
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
|
**HTTP requests to the mock.** The J2 profile is seeded with the origin of a
|
||||||
loopback proxy in the test process
|
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
|
buttons appearing when the categories arrive. The skeletons of the category
|
||||||
list and the grid are replaced by content without any shift. Nothing was
|
list and the grid are replaced by content without any shift. Nothing was
|
||||||
hidden behind the startup overlay: the "Syncing playlist" overlay of the
|
hidden behind the startup overlay: the "Syncing playlist" overlay of the
|
||||||
cached catalog load flashes for about two frames after the shift and covers
|
cached catalog load flashed for about two frames after the shift and covered
|
||||||
no further one.
|
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
|
The content frame and the header controls now slide into place instead of
|
||||||
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
|
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
|
||||||
|
|||||||
@@ -195,7 +195,13 @@ services:
|
|||||||
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
|
5. `WorkspaceShellCommandPaletteService` owns command-palette dialog lifecycle
|
||||||
and recent-command recording.
|
and recent-command recording.
|
||||||
6. `WorkspaceShellXtreamImportService` owns Xtream import/refresh overlay
|
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
|
When adding shell behavior, prefer placing it in the service that owns the
|
||||||
nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer
|
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;
|
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 =
|
export const SEARCH_PLAYLIST_PLACEHOLDER =
|
||||||
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
'WORKSPACE.SHELL.SEARCH_PLAYLIST_PLACEHOLDER';
|
||||||
export const SEARCH_SECTION_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 { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
|
||||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
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 { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
|
||||||
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
|
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
|
||||||
|
|
||||||
@@ -51,4 +52,120 @@ describe('WorkspaceShellXtreamImportService', () => {
|
|||||||
|
|
||||||
expect(service.xtreamImportTitleLabel()).toBe('Импорт');
|
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 { TranslateService } from '@ngx-translate/core';
|
||||||
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
|
import { PlaylistRefreshActionService } from '@iptvnator/playlist/shared/ui';
|
||||||
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
|
||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
RuntimeCapabilitiesService,
|
RuntimeCapabilitiesService,
|
||||||
injectTranslationTick,
|
injectTranslationTick,
|
||||||
} from '@iptvnator/services';
|
} 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 {
|
import {
|
||||||
buildXtreamImportDetailLabel,
|
buildXtreamImportDetailLabel,
|
||||||
buildXtreamImportPhaseLabel,
|
buildXtreamImportPhaseLabel,
|
||||||
@@ -76,7 +79,8 @@ export class WorkspaceShellXtreamImportService {
|
|||||||
Boolean(this.xtreamStore.activeImportSessionId()) &&
|
Boolean(this.xtreamStore.activeImportSessionId()) &&
|
||||||
!this.xtreamStore.isCancellingImport()
|
!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 route = this.routeState.currentRoute();
|
||||||
const context = this.routeState.currentContext();
|
const context = this.routeState.currentContext();
|
||||||
const section = this.routeState.currentSection();
|
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(() => {
|
readonly xtreamImportTitleLabel = computed(() => {
|
||||||
this.languageTick();
|
this.languageTick();
|
||||||
if (this.activeRefreshPreparation()) {
|
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(
|
readonly isImportRunning = computed(
|
||||||
() =>
|
() =>
|
||||||
!this.xtreamStore.contentInitBlockReason() &&
|
!this.xtreamStore.contentInitBlockReason() &&
|
||||||
|
|||||||
+25
-9
@@ -30,6 +30,7 @@ import {
|
|||||||
} from '@iptvnator/workspace/shell/util';
|
} from '@iptvnator/workspace/shell/util';
|
||||||
import { RecentCommandsService } from '../../recent-commands';
|
import { RecentCommandsService } from '../../recent-commands';
|
||||||
import { WorkspacePlayerCommandsContributor } from '../../workspace-player-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 { WorkspaceShellFacade } from './workspace-shell.facade';
|
||||||
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
|
import { WorkspaceShellXtreamImportService } from './workspace-shell-xtream-import.service';
|
||||||
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
|
import { WorkspaceShellCommandPaletteService } from './workspace-shell-command-palette.service';
|
||||||
@@ -503,16 +504,31 @@ describe('WorkspaceShellFacade', () => {
|
|||||||
expect(facade.showXtreamImportOverlay()).toBe(true);
|
expect(facade.showXtreamImportOverlay()).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a cache-only Xtream import session blocking and cancellable', () => {
|
it('keeps a slow cache-only Xtream import session blocking and cancellable', () => {
|
||||||
const xtreamStore = TestBed.inject(
|
jest.useFakeTimers();
|
||||||
XtreamStore
|
try {
|
||||||
) as unknown as MockXtreamStore;
|
const xtreamStore = TestBed.inject(
|
||||||
const xtreamImport = TestBed.inject(WorkspaceShellXtreamImportService);
|
XtreamStore
|
||||||
xtreamStore.activeImportSessionId.set('xtream-import-session');
|
) as unknown as MockXtreamStore;
|
||||||
|
const xtreamImport = TestBed.inject(
|
||||||
|
WorkspaceShellXtreamImportService
|
||||||
|
);
|
||||||
|
xtreamStore.activeImportSessionId.set('xtream-import-session');
|
||||||
|
TestBed.tick();
|
||||||
|
|
||||||
expect(xtreamStore.isImporting()).toBe(false);
|
// A saved-catalog read waits out the show delay so a quick one
|
||||||
expect(facade.showXtreamImportOverlay()).toBe(true);
|
// never flashes the overlay, but stays cancellable meanwhile.
|
||||||
expect(xtreamImport.canCancelXtreamImport()).toBe(true);
|
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', () => {
|
it('shows the Xtream overlay during refresh preparation on the dashboard', () => {
|
||||||
|
|||||||
Reference in new issue
Block a user