mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 12:36:15 -08:00
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:
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.
|
||||
|
||||
+88
@@ -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);
|
||||
});
|
||||
});
|
||||
+64
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
+84
-72
@@ -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>
|
||||
}
|
||||
+33
@@ -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 {
|
||||
|
||||
+260
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+46
-6
@@ -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
@@ -1,6 +1,7 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
[class.sidebar-collapsed]="isSidebarCollapsed()"
|
||||
[class.sidebar--reloading]="reloading()"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
@@ -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>();
|
||||
|
||||
|
||||
Reference in new issue
Block a user