fix(collections): non-destructive reload feedback for the scope switch (#1636)

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 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-19 18:08:41 +02:00
1 parent f13d0c55da
commit 15d964ef88
11 files changed
+637 -78

No files matched your search

@@ -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.
@@ -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.
@@ -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);
});
});
@@ -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<boolean>;
/** The reload has outlived the grace period: render the indicator. */
readonly visible: Signal<boolean>;
/** 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<typeof setTimeout> | 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);
},
};
}
@@ -64,10 +64,7 @@
</button>
<mat-menu #favoritesSortMenu="matMenu">
@for (option of favSortOptions; track option.mode) {
<button
mat-menu-item
(click)="setFavSortMode(option.mode)"
>
<button mat-menu-item (click)="setFavSortMode(option.mode)">
<mat-icon>{{ option.icon }}</mat-icon>
<span>{{ option.translationKey | translate }}</span>
@if (favSortMode() === option.mode) {
@@ -156,16 +153,14 @@
clearButtonTooltipKey()
| translate
: {
type:
currentTypeLabelKey() | translate,
type: currentTypeLabelKey() | translate,
}
"
[attr.aria-label]="
clearButtonTooltipKey()
| translate
: {
type:
currentTypeLabelKey() | translate,
type: currentTypeLabelKey() | translate,
}
"
>
@@ -173,71 +168,88 @@
</button>
}
</div>
</div>
@switch (selectedContentType()) {
@case ('live') {
@if (hasLive()) {
<app-unified-live-tab
[items]="liveItems()"
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
[autoOpenItem]="pendingAutoOpenLiveItem()"
[favoriteUids]="favoriteUidSet()"
[sortMode]="favSortMode()"
[isSidebarCollapsed]="isSidebarCollapsed()"
(removeItem)="onRemoveItem($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
(restoreSidebarRequested)="toggleSidebar()"
/>
}
}
@case ('movie') {
@if (hasMovies()) {
<app-unified-grid-tab
[items]="movieItems()"
[mode]="mode()"
[contentType]="'movie'"
[searchTerm]="workspaceSearchTerm()"
(itemSelected)="onGridItemSelected($event)"
(removeItem)="onRemoveItem($event)"
/>
}
}
@case ('series') {
@if (hasSeries()) {
<app-unified-grid-tab
[items]="seriesItems()"
[mode]="mode()"
[contentType]="'series'"
[searchTerm]="workspaceSearchTerm()"
(itemSelected)="onGridItemSelected($event)"
(removeItem)="onRemoveItem($event)"
/>
}
}
}
@if (!hasLive() && !hasMovies() && !hasSeries()) {
@if (workspaceSearchTerm()) {
<div class="empty-state">
<mat-icon class="empty-icon">search_off</mat-icon>
<p class="empty-title">
{{ 'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate }}
</p>
</div>
} @else {
<app-empty-state
type="no-data"
[icon]="emptyStateIcon()"
[titleKey]="emptyStateTitleKey()"
[descriptionKey]="emptyStateBodyKey()"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(secondaryActionClicked)="goToDashboard()"
@if (showReloadIndicator()) {
<mat-progress-bar
class="collection-reload-bar"
mode="indeterminate"
[attr.aria-label]="'LOADING' | translate"
/>
}
}
</div>
<div
class="collection-content"
[class.collection-content--reloading]="showReloadIndicator()"
[attr.aria-busy]="isReloading() ? 'true' : null"
>
@switch (selectedContentType()) {
@case ('live') {
@if (hasLive()) {
<app-unified-live-tab
[items]="liveItems()"
[mode]="mode()"
[searchTerm]="workspaceSearchTerm()"
[autoOpenItem]="pendingAutoOpenLiveItem()"
[favoriteUids]="favoriteUidSet()"
[sortMode]="favSortMode()"
[isSidebarCollapsed]="isSidebarCollapsed()"
[reloading]="showReloadIndicator()"
(removeItem)="onRemoveItem($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
(restoreSidebarRequested)="toggleSidebar()"
/>
}
}
@case ('movie') {
@if (hasMovies()) {
<app-unified-grid-tab
[items]="movieItems()"
[mode]="mode()"
[contentType]="'movie'"
[searchTerm]="workspaceSearchTerm()"
(itemSelected)="onGridItemSelected($event)"
(removeItem)="onRemoveItem($event)"
/>
}
}
@case ('series') {
@if (hasSeries()) {
<app-unified-grid-tab
[items]="seriesItems()"
[mode]="mode()"
[contentType]="'series'"
[searchTerm]="workspaceSearchTerm()"
(itemSelected)="onGridItemSelected($event)"
(removeItem)="onRemoveItem($event)"
/>
}
}
}
@if (!hasLive() && !hasMovies() && !hasSeries()) {
@if (workspaceSearchTerm()) {
<div class="empty-state">
<mat-icon class="empty-icon">search_off</mat-icon>
<p class="empty-title">
{{
'WORKSPACE.GLOBAL_FAVORITES.NO_RESULTS' | translate
}}
</p>
</div>
} @else {
<app-empty-state
type="no-data"
[icon]="emptyStateIcon()"
[titleKey]="emptyStateTitleKey()"
[descriptionKey]="emptyStateBodyKey()"
secondaryActionLabelKey="WORKSPACE.SHELL.GO_TO_DASHBOARD"
(secondaryActionClicked)="goToDashboard()"
/>
}
}
</div>
}
@@ -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 {
@@ -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<ReadonlySet<string>>(new Set<string>());
readonly sortMode = input<FavoritesChannelSortMode>('custom');
readonly isSidebarCollapsed = input(false);
readonly reloading = input(false);
readonly removeItem = output<UnifiedCollectionItem>();
readonly favoriteToggled = output<UnifiedCollectionItem>();
@@ -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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
} {
let resolve!: (value: T) => void;
const promise = new Promise<T>((r) => {
resolve = r;
});
return { promise, resolve };
}
async function flushMicrotasks(): Promise<void> {
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<void> {
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<UnifiedCollectionItem[]>();
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<UnifiedCollectionItem[]>();
const second = deferred<UnifiedCollectionItem[]>();
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<UnifiedCollectionItem[]>();
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<UnifiedCollectionItem[]>();
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<UnifiedCollectionItem[]>();
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);
});
});
});
@@ -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<UnifiedCollectionItem[]>([]);
/**
* 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<ReadonlySet<string>>(new Set<string>());
readonly selectedContentType = signal<CollectionContentType>(
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<void> {
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();
}
}
}
@@ -1,6 +1,7 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[class.sidebar--reloading]="reloading()"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -7,6 +7,13 @@
flex: 1;
min-height: 0;
overflow: hidden;
transition: opacity var(--collection-reload-dim-duration, 160ms) ease;
}
// Reload feedback dims the channel list only; the player in
// .content-container keeps playing at full opacity.
&--reloading > app-global-favorites-list {
opacity: var(--collection-reload-dim-opacity, 0.6);
}
}
@@ -140,6 +140,8 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly isSidebarCollapsed = input(false);
/** The page is reloading the list; dim the rail but never the player. */
readonly reloading = input(false);
/** The rail is owned by the page header toggle; ask it to expand. */
readonly restoreSidebarRequested = output<void>();