From 15d964ef887619a9be3617aeb3a663c29a27ca74 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:08:41 +0200 Subject: [PATCH] fix(collections): non-destructive reload feedback for the scope switch (#1636) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switching the unified Favorites/Recent page between "This playlist" and "All playlists" kept the old items on screen with no feedback until the new query resolved. The skeleton cannot be reused there: it unmounts a playing channel and drops focus from the toggle. Add a separate reload state that keeps content mounted: after a 180 ms grace period an indeterminate progress bar overlays the header separator, the content region carries aria-busy, and the grid or the live tab's channel rail dims — the player never dims. Fast IndexedDB/SQLite answers show nothing. Only the latest request settles the state. Also bind Clear and drag reorder to the request that loaded the rows still on screen (`loadedRequest`) instead of `effectiveScope()`, which moves ahead the moment the toggle is clicked. Co-Authored-By: Claude Fable 5.1 --- .changes/collections-scope-switch-feedback.md | 10 + docs/architecture/iptvnator-ui-guidelines.md | 42 +++ .../collection-reload-indicator.spec.ts | 88 ++++++ .../collection-reload-indicator.ts | 64 +++++ .../unified-collection-page.component.html | 156 ++++++----- .../unified-collection-page.component.scss | 33 +++ .../unified-collection-page.component.spec.ts | 260 ++++++++++++++++++ .../unified-collection-page.component.ts | 52 +++- .../unified-live-tab.component.html | 1 + .../unified-live-tab.component.scss | 7 + .../unified-live-tab.component.ts | 2 + 11 files changed, 637 insertions(+), 78 deletions(-) create mode 100644 .changes/collections-scope-switch-feedback.md create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts diff --git a/.changes/collections-scope-switch-feedback.md b/.changes/collections-scope-switch-feedback.md new file mode 100644 index 000000000..8e29d48a9 --- /dev/null +++ b/.changes/collections-scope-switch-feedback.md @@ -0,0 +1,10 @@ +--- +type: fix +area: collections +--- + +Switching Favorites or Recently Viewed between "This playlist" and "All +playlists" now shows a thin progress bar under the header and dims the list +while the new items load, instead of leaving the old list on screen with no +sign that anything happened. The toggle reflects your choice at once, a +playing channel keeps playing, and quick switches show no indicator at all. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 45f4e052c..9bc74167f 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -531,6 +531,48 @@ The progress bar should clearly communicate: Avoid making the track too faint, especially in dark theme. +## Loading States + +Two loading states exist, chosen by whether content is already on screen. + +### First load: skeleton + +Nothing is rendered yet, so the skeleton replaces the whole content area and +mirrors the row/card geometry it precedes (see Channel List Item and Cover +Grids). The unified Favorites/Recent page gates this on `isLoading`, set only +while its item list is empty. + +### Reload with content on screen: non-destructive indicator + +A reload of a list that is already rendered (the collection page's +"This playlist ↔ All playlists" scope switch, a favorites reload) must never +swap back to the skeleton: that unmounts a playing channel, drops focus from +the toggle the user just clicked, and flashes on fast IndexedDB/SQLite answers. +Instead keep everything mounted and layer feedback on top: + +- an indeterminate `mat-progress-bar` (2 px track, `--app-selection-color` + fill) absolutely positioned over the header's bottom separator, so its + appearance never shifts content; +- `aria-busy="true"` on the content region for the whole reload; +- the list or grid dims to opacity `0.6` with a 160 ms transition (`0ms` under + `prefers-reduced-motion: reduce`). The player is never dimmed — the live tab + dims only its channel rail. + +**Grace period.** The bar and dimming render only once the reload has run for +`COLLECTION_RELOAD_INDICATOR_DELAY_MS` (180 ms); a reload that settles sooner +shows nothing. `aria-busy` is set immediately, since it does not paint. A +superseded reload keeps the earliest deadline and never clears the indicator; +only the latest request's completion does. Reference implementation: +`createCollectionReloadIndicator` in +`libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts`. +Controls that triggered the reload stay enabled and reflect the requested +value at once (`scope.set()` runs synchronously before the load starts). +Because the rows on screen then belong to the PREVIOUS request, actions on +them (Clear, drag reorder) must bind to the request that loaded those rows, +never to the toggle's current value — "This playlist" applied to still-mounted +global rows would delete other playlists' favorites or write foreign URLs +into this playlist (`loadedRequest` on the collection page). + ## Navigation Lists Use the shared `nav-list.scss` treatment for sidebar and context-panel list items. diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts new file mode 100644 index 000000000..a36c7bdb4 --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.spec.ts @@ -0,0 +1,88 @@ +import { DestroyRef } from '@angular/core'; +import { + COLLECTION_RELOAD_INDICATOR_DELAY_MS, + createCollectionReloadIndicator, +} from './collection-reload-indicator'; + +describe('createCollectionReloadIndicator', () => { + let destroyCallbacks: (() => void)[]; + let destroyRef: DestroyRef; + + beforeEach(() => { + jest.useFakeTimers(); + destroyCallbacks = []; + destroyRef = { + onDestroy: (callback: () => void) => { + destroyCallbacks.push(callback); + return () => undefined; + }, + } as unknown as DestroyRef; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('is active at once but visible only after the grace period', () => { + const indicator = createCollectionReloadIndicator(destroyRef); + + indicator.begin(); + + expect(indicator.active()).toBe(true); + expect(indicator.visible()).toBe(false); + + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS - 1); + expect(indicator.visible()).toBe(false); + + jest.advanceTimersByTime(1); + expect(indicator.visible()).toBe(true); + }); + + it('never becomes visible when the reload settles inside the grace period', () => { + const indicator = createCollectionReloadIndicator(destroyRef); + + indicator.begin(); + indicator.settle(); + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS * 2); + + expect(indicator.active()).toBe(false); + expect(indicator.visible()).toBe(false); + }); + + it('keeps the earliest deadline when a reload is superseded before it shows', () => { + const indicator = createCollectionReloadIndicator(destroyRef); + + indicator.begin(); + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS - 20); + indicator.begin(); + jest.advanceTimersByTime(20); + + expect(indicator.visible()).toBe(true); + }); + + it('stays visible across a superseding begin and clears on settle', () => { + const indicator = createCollectionReloadIndicator(destroyRef); + + indicator.begin(); + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS); + indicator.begin(); + + expect(indicator.visible()).toBe(true); + + indicator.settle(); + + expect(indicator.active()).toBe(false); + expect(indicator.visible()).toBe(false); + expect(jest.getTimerCount()).toBe(0); + }); + + it('cancels the pending timer when the host is destroyed', () => { + const indicator = createCollectionReloadIndicator(destroyRef); + + indicator.begin(); + destroyCallbacks.forEach((callback) => callback()); + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS); + + expect(indicator.visible()).toBe(false); + }); +}); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts new file mode 100644 index 000000000..37ea5b58c --- /dev/null +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/collection-reload-indicator.ts @@ -0,0 +1,64 @@ +import { DestroyRef, Signal, signal } from '@angular/core'; + +/** + * Grace period before a reload with content already on screen shows its + * indicator. IndexedDB/SQLite collection reads usually settle inside this + * window; painting a progress bar for them would only flash. + */ +export const COLLECTION_RELOAD_INDICATOR_DELAY_MS = 180; + +export interface CollectionReloadIndicator { + /** A reload is in flight while the previous items stay on screen. */ + readonly active: Signal; + /** The reload has outlived the grace period: render the indicator. */ + readonly visible: Signal; + /** Mark a reload as started. Repeated calls keep the earliest deadline. */ + begin(): void; + /** The latest reload settled (success or failure); hide everything. */ + settle(): void; +} + +/** + * Non-destructive loading state for reloads of a list that is already + * rendered. Unlike the first-load skeleton it never unmounts content, so a + * playing channel, the toggle the user just clicked and its focus survive. + */ +export function createCollectionReloadIndicator( + destroyRef: DestroyRef, + delayMs = COLLECTION_RELOAD_INDICATOR_DELAY_MS +): CollectionReloadIndicator { + const active = signal(false); + const visible = signal(false); + let timer: ReturnType | null = null; + + const clearTimer = (): void => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + }; + + destroyRef.onDestroy(clearTimer); + + return { + active: active.asReadonly(), + visible: visible.asReadonly(), + begin(): void { + active.set(true); + if (visible() || timer !== null) { + return; + } + timer = setTimeout(() => { + timer = null; + if (active()) { + visible.set(true); + } + }, delayMs); + }, + settle(): void { + clearTimer(); + active.set(false); + visible.set(false); + }, + }; +} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index 4fc26257f..9cfda9b39 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -64,10 +64,7 @@ @for (option of favSortOptions; track option.mode) { - } - - @switch (selectedContentType()) { - @case ('live') { - @if (hasLive()) { - - } - } - @case ('movie') { - @if (hasMovies()) { - - } - } - @case ('series') { - @if (hasSeries()) { - - } - } - } - - @if (!hasLive() && !hasMovies() && !hasSeries()) { - @if (workspaceSearchTerm()) { -
- search_off -

- {{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }} -

-
- } @else { - } - } + + +
+ @switch (selectedContentType()) { + @case ('live') { + @if (hasLive()) { + + } + } + @case ('movie') { + @if (hasMovies()) { + + } + } + @case ('series') { + @if (hasSeries()) { + + } + } + } + + @if (!hasLive() && !hasMovies() && !hasSeries()) { + @if (workspaceSearchTerm()) { +
+ search_off +

+ {{ + 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate + }} +

+
+ } @else { + + } + } +
} diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss index 9898557b4..2f4a35c8b 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.scss @@ -26,6 +26,7 @@ } .collection-header { + position: relative; display: flex; align-items: center; gap: 12px; @@ -34,6 +35,37 @@ border-bottom: 1px solid var(--app-separator); } +// Reload feedback for a list that is already on screen. The bar overlays the +// header separator so appearing/disappearing never shifts the content below. +.collection-reload-bar { + position: absolute; + left: 0; + right: 0; + bottom: -1px; + --mdc-linear-progress-track-height: 2px; + --mdc-linear-progress-active-indicator-height: 2px; + --mdc-linear-progress-active-indicator-color: var(--app-selection-color); +} + +.collection-content { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +// The grid dims as a whole; the live tab dims only its channel rail +// (see unified-live-tab.component.scss), so a playing channel stays bright. +.collection-content--reloading app-unified-grid-tab { + opacity: var(--collection-reload-dim-opacity, 0.6); +} + +@media (prefers-reduced-motion: reduce) { + :host { + --collection-reload-dim-duration: 0ms; + } +} + .collection-title { font-size: 0.94rem; font-weight: 600; @@ -86,6 +118,7 @@ app-unified-grid-tab { app-unified-grid-tab { flex-direction: column; + transition: opacity var(--collection-reload-dim-duration, 160ms) ease; } app-unified-live-tab { diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts index e25f799de..f41b3207d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.spec.ts @@ -7,6 +7,7 @@ import { ViewChild, } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MatButtonToggleGroup } from '@angular/material/button-toggle'; import { By } from '@angular/platform-browser'; import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -34,6 +35,7 @@ import { import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { BehaviorSubject } from 'rxjs'; import { PlaylistMeta } from '@iptvnator/shared/interfaces'; +import { COLLECTION_RELOAD_INDICATOR_DELAY_MS } from './collection-reload-indicator'; import { UnifiedCollectionPageComponent } from './unified-collection-page.component'; import { UnifiedCollectionDetailDirective } from './unified-collection-detail.directive'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; @@ -52,6 +54,7 @@ class StubUnifiedLiveTabComponent { readonly favoriteUids = input>(new Set()); readonly sortMode = input('custom'); readonly isSidebarCollapsed = input(false); + readonly reloading = input(false); readonly removeItem = output(); readonly favoriteToggled = output(); @@ -1015,4 +1018,261 @@ describe('UnifiedCollectionPageComponent', () => { } ); }); + + describe('reload feedback while items stay on screen', () => { + const liveItem = { + uid: 'm3u::playlist-1::https://example.com/one.m3u8', + name: 'Channel One', + contentType: 'live', + sourceType: 'm3u', + playlistId: 'playlist-1', + playlistName: 'Playlist One', + streamUrl: 'https://example.com/one.m3u8', + } satisfies UnifiedCollectionItem; + const otherLiveItem = { + ...liveItem, + uid: 'm3u::playlist-2::https://example.com/two.m3u8', + name: 'Channel Two', + playlistId: 'playlist-2', + playlistName: 'Playlist Two', + streamUrl: 'https://example.com/two.m3u8', + } satisfies UnifiedCollectionItem; + + function deferred(): { + promise: Promise; + resolve: (value: T) => void; + } { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + } + + async function flushMicrotasks(): Promise { + for (let i = 0; i < 8; i++) { + await Promise.resolve(); + } + } + + function liveTab(): StubUnifiedLiveTabComponent | null { + return ( + fixture.debugElement.query( + By.directive(StubUnifiedLiveTabComponent) + )?.componentInstance ?? null + ); + } + + function reloadBar(): HTMLElement | null { + return fixture.nativeElement.querySelector( + '.collection-reload-bar' + ); + } + + function contentRegion(): HTMLElement { + return fixture.nativeElement.querySelector('.collection-content'); + } + + function scopeToggleValue(): CollectionScope { + return fixture.debugElement + .query(By.css('.scope-toggle')) + .injector.get(MatButtonToggleGroup).value; + } + + async function mountWithLiveItems( + scope: CollectionScope = 'playlist', + items: UnifiedCollectionItem[] = [liveItem] + ): Promise { + setRouteParams({ id: 'playlist-1' }); + setRouteQueryParams({ scope }); + playlistsLoaded.set(true); + fixture.componentRef.setInput('portalType', 'm3u'); + fixture.componentRef.setInput('defaultScope', undefined); + favoritesData.getFavorites.mockResolvedValueOnce(items); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(fixture.componentInstance.isLoading()).toBe(false); + expect(fixture.componentInstance.isReloading()).toBe(false); + expect(liveTab()?.items()).toEqual(items); + expect(scopeToggleValue()).toBe(scope); + } + + afterEach(() => { + jest.useRealTimers(); + }); + + it('keeps the previous items and live tab mounted and shows the bar only after the grace period', async () => { + await mountWithLiveItems(); + jest.useFakeTimers(); + const pending = deferred(); + favoritesData.getFavorites.mockReturnValueOnce(pending.promise); + + fixture.componentInstance.onScopeChange('all'); + fixture.detectChanges(); + + expect(favoritesData.getFavorites).toHaveBeenLastCalledWith( + 'all', + 'playlist-1', + 'm3u' + ); + expect(fixture.componentInstance.isReloading()).toBe(true); + expect(fixture.componentInstance.isLoading()).toBe(false); + expect(fixture.componentInstance.showReloadIndicator()).toBe(false); + expect(reloadBar()).toBeNull(); + expect( + fixture.nativeElement.querySelector('.skeleton-list') + ).toBeNull(); + expect(liveTab()?.items()).toEqual([liveItem]); + expect(liveTab()?.reloading()).toBe(false); + expect(contentRegion().getAttribute('aria-busy')).toBe('true'); + expect(scopeToggleValue()).toBe('all'); + + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS); + fixture.detectChanges(); + + expect(reloadBar()).not.toBeNull(); + expect(reloadBar()?.getAttribute('mode')).toBe('indeterminate'); + expect(contentRegion().classList).toContain( + 'collection-content--reloading' + ); + expect(liveTab()?.items()).toEqual([liveItem]); + expect(liveTab()?.reloading()).toBe(true); + + pending.resolve([liveItem, otherLiveItem]); + await flushMicrotasks(); + fixture.detectChanges(); + + expect(fixture.componentInstance.isReloading()).toBe(false); + expect(reloadBar()).toBeNull(); + expect(contentRegion().getAttribute('aria-busy')).toBeNull(); + expect(contentRegion().classList).not.toContain( + 'collection-content--reloading' + ); + expect(liveTab()?.items()).toEqual([liveItem, otherLiveItem]); + expect(liveTab()?.reloading()).toBe(false); + }); + + it('does not clear the indicator when a superseded reload settles', async () => { + await mountWithLiveItems(); + jest.useFakeTimers(); + const first = deferred(); + const second = deferred(); + favoritesData.getFavorites + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise); + + fixture.componentInstance.onScopeChange('all'); + fixture.detectChanges(); + fixture.componentInstance.onScopeChange('playlist'); + fixture.detectChanges(); + + expect(favoritesData.getFavorites).toHaveBeenCalledTimes(3); + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS); + fixture.detectChanges(); + expect(reloadBar()).not.toBeNull(); + + first.resolve([]); + await flushMicrotasks(); + fixture.detectChanges(); + + expect(fixture.componentInstance.isReloading()).toBe(true); + expect(reloadBar()).not.toBeNull(); + expect(liveTab()?.items()).toEqual([liveItem]); + + second.resolve([otherLiveItem]); + await flushMicrotasks(); + fixture.detectChanges(); + + expect(fixture.componentInstance.isReloading()).toBe(false); + expect(reloadBar()).toBeNull(); + expect(liveTab()?.items()).toEqual([otherLiveItem]); + }); + + it('never shows the bar for a reload that settles inside the grace period', async () => { + await mountWithLiveItems(); + jest.useFakeTimers(); + const pending = deferred(); + favoritesData.getFavorites.mockReturnValueOnce(pending.promise); + + fixture.componentInstance.onScopeChange('all'); + fixture.detectChanges(); + pending.resolve([otherLiveItem]); + await flushMicrotasks(); + fixture.detectChanges(); + jest.advanceTimersByTime(COLLECTION_RELOAD_INDICATOR_DELAY_MS * 2); + fixture.detectChanges(); + + expect(fixture.componentInstance.isReloading()).toBe(false); + expect(fixture.componentInstance.showReloadIndicator()).toBe(false); + expect(reloadBar()).toBeNull(); + expect(liveTab()?.items()).toEqual([otherLiveItem]); + }); + + it('binds Clear and reorder to the scope that loaded the rows still on screen', async () => { + await mountWithLiveItems('all', [liveItem, otherLiveItem]); + const pending = deferred(); + favoritesData.getFavorites.mockReturnValueOnce(pending.promise); + + fixture.componentInstance.onScopeChange('playlist'); + fixture.detectChanges(); + + expect(scopeToggleValue()).toBe('playlist'); + expect(liveTab()?.items()).toEqual([liveItem, otherLiveItem]); + + fixture.componentInstance.clearAllCurrent(); + const [dialogConfig] = + dialogService.openConfirmDialog.mock.calls.at(-1) ?? [null]; + expect(dialogConfig.message).toContain( + 'CLEAR_FAVORITES_DIALOG_MESSAGE_ALL' + ); + + await fixture.componentInstance.onReorder([ + otherLiveItem, + liveItem, + ]); + expect(favoritesData.reorder).toHaveBeenLastCalledWith( + [otherLiveItem, liveItem], + { scope: 'all', playlistId: 'playlist-1', portalType: 'm3u' } + ); + + pending.resolve([liveItem]); + await flushMicrotasks(); + fixture.detectChanges(); + + await fixture.componentInstance.onReorder([liveItem]); + expect(favoritesData.reorder).toHaveBeenLastCalledWith([liveItem], { + scope: 'playlist', + playlistId: 'playlist-1', + portalType: 'm3u', + }); + fixture.componentInstance.clearAllCurrent(); + const [playlistDialogConfig] = + dialogService.openConfirmDialog.mock.calls.at(-1) ?? [null]; + expect(playlistDialogConfig.message).toContain( + 'CLEAR_FAVORITES_DIALOG_MESSAGE_PLAYLIST' + ); + }); + + it('keeps the empty first load on the skeleton instead of the reload indicator', async () => { + const pending = deferred(); + favoritesData.getFavorites.mockReturnValueOnce(pending.promise); + + fixture.detectChanges(); + + expect(fixture.componentInstance.isLoading()).toBe(true); + expect(fixture.componentInstance.isReloading()).toBe(false); + expect( + fixture.nativeElement.querySelector('.skeleton-list') + ).not.toBeNull(); + + pending.resolve([]); + await flushMicrotasks(); + fixture.detectChanges(); + + expect(fixture.componentInstance.isLoading()).toBe(false); + }); + }); }); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts index 139d81699..3900a1b20 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.ts @@ -19,6 +19,7 @@ import { MatButtonToggleModule } from '@angular/material/button-toggle'; import { MatIconButton } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatMenuModule } from '@angular/material/menu'; +import { MatProgressBar } from '@angular/material/progress-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; @@ -63,6 +64,7 @@ import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui'; import { UnifiedLiveTabComponent } from './unified-live-tab.component'; import { UnifiedGridTabComponent } from './unified-grid-tab.component'; +import { createCollectionReloadIndicator } from './collection-reload-indicator'; import { UnifiedCollectionDetailContext, UnifiedCollectionDetailDirective, @@ -81,6 +83,7 @@ import { MatIconButton, MatIconModule, MatMenuModule, + MatProgressBar, MatTooltip, TranslatePipe, UnifiedGridTabComponent, @@ -144,8 +147,32 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { getCollectionViewState(window.history.state) ); + /** First load with nothing on screen: the skeleton replaces the content. */ readonly isLoading = signal(true); + private readonly reloadIndicator = createCollectionReloadIndicator( + this.destroyRef + ); + /** + * A reload (scope switch, favorites reload) is in flight while the + * previous items stay mounted. Never swaps to the skeleton, so a playing + * channel and the focused toggle survive. + */ + readonly isReloading = this.reloadIndicator.active; + /** `isReloading` past its grace period: progress bar + dimming render. */ + readonly showReloadIndicator = this.reloadIndicator.visible; readonly allItems = signal([]); + /** + * The request that produced `allItems`. Actions on displayed rows (Clear, + * drag reorder) must use it, not `effectiveScope()`: during a reload the + * toggle already names the requested scope while the previous rows are + * still on screen, and "This playlist" against global rows would delete + * other playlists' favorites or write foreign URLs into this playlist. + */ + private readonly loadedRequest = signal<{ + scope: CollectionScope; + playlistId?: string; + portalType?: string; + } | null>(null); readonly favoriteUidSet = signal>(new Set()); readonly selectedContentType = signal( this.historyCollectionViewState()?.selectedContentType ?? 'live' @@ -581,7 +608,9 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { const isFavorites = this.mode() === 'favorites'; const type = this.translate.instant(this.currentTypeLabelKey()); - const isPlaylistScope = this.effectiveScope() === 'playlist'; + const isPlaylistScope = + (this.loadedRequest()?.scope ?? this.effectiveScope()) === + 'playlist'; const titleKey = isFavorites ? 'WORKSPACE.SHELL.CLEAR_FAVORITES_DIALOG_TITLE' : 'WORKSPACE.SHELL.CLEAR_RECENTLY_VIEWED_DIALOG_TITLE'; @@ -618,11 +647,14 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { async onReorder(items: UnifiedCollectionItem[]): Promise { const nonLive = this.allItems().filter((i) => i.contentType !== 'live'); this.allItems.set([...items, ...nonLive]); - await this.favoritesData.reorder(items, { - scope: this.effectiveScope(), - playlistId: this.playlistId(), - portalType: this.portalType(), - }); + await this.favoritesData.reorder( + items, + this.loadedRequest() ?? { + scope: this.effectiveScope(), + playlistId: this.playlistId(), + portalType: this.portalType(), + } + ); } onItemPlayed(item: UnifiedCollectionItem): void { @@ -671,6 +703,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { const requestId = ++this.loadRequestId; if (this.allItems().length === 0) { this.isLoading.set(true); + } else { + this.reloadIndicator.begin(); } try { @@ -694,6 +728,11 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { return; } this.allItems.set(items); + this.loadedRequest.set({ + scope: params.scope, + playlistId: params.playlistId, + portalType: params.portalType, + }); this.favoriteUidSet.set(favoriteUids); this.autoSelectContentType(); if ( @@ -710,6 +749,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit { } finally { if (requestId === this.loadRequestId) { this.isLoading.set(false); + this.reloadIndicator.settle(); } } } diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index b584f2e26..24ec1f36a 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -1,6 +1,7 @@