mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
Merge branch 'master' of https://github.com/4gray/iptvnator into claude/server-storage-pwa
# Conflicts: # docs/architecture/pwa-self-hosted.md
This commit is contained in:
commit
de87c1e4d3
78 files changed
+4367
-276
No files matched your search
+17
-1
@@ -1,6 +1,22 @@
|
||||
import { PortalRailSection } from '@iptvnator/portal/shared/util';
|
||||
|
||||
export const SEARCH_INPUT_DEBOUNCE_MS = 350;
|
||||
/**
|
||||
* The one debounce between a keystroke in the header search box and the
|
||||
* applied term; pages that search on the applied term (global search
|
||||
* included) run at once. 300 ms sits above the inter-key gap of steady
|
||||
* typing, so a word typed in one go is applied once.
|
||||
* See docs/architecture/performance-journeys.md (J4).
|
||||
*/
|
||||
export const SEARCH_INPUT_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* 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';
|
||||
|
||||
+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