From e888841b216d1e4b6358e22d36849274e442de88 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 2 May 2026 00:52:38 +0200 Subject: [PATCH] refactor(ui): unify live-tv channel skeleton across m3u, xtream, stalker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The loading placeholders for live channel rows were inconsistent and visibly heavier than the real channel-list-item: each module rendered its own skeleton with a colored card surface, larger padding/radius, mismatched logo border-radius, and only a single tiny right-side dot instead of the real 34×34 action buttons. Stalker had no initial-load skeleton at all. Extract a shared app-channel-list-item-skeleton that mirrors channel-list-item 1:1 (transparent surface, 8/10/8/12 padding, 12px radius, 44×44 logo with 10px radius, optional EPG progress placeholder, 0–3 button placeholders) plus a thin app-channel-list-skeleton wrapper that renders N rows with deterministic width variance, and route every live-tv loading state through it (m3u channel-list-loading-state, xtream portal-channels-list, stalker live-stream-layout via a derived isInitialChannelsLoading signal, and the unified favorites/recent collection page). Drops the bespoke channel-skeleton-shimmer keyframes in favor of the shared skeleton-shimmer animation. Co-Authored-By: Claude Opus 4.7 (1M context) Entire-Checkpoint: 58a2d50756dd --- .../unified-collection-page.component.html | 20 +-- .../unified-collection-page.component.ts | 3 +- .../stalker-live-stream-layout.component.html | 8 +- .../stalker-live-stream-layout.component.ts | 13 +- .../portal-channels-list.component.html | 14 +- .../portal-channels-list.component.scss | 81 +---------- .../portal-channels-list.component.ts | 7 +- libs/ui/components/src/index.ts | 2 + .../channel-list-item-skeleton.component.html | 33 +++++ .../channel-list-item-skeleton.component.scss | 132 ++++++++++++++++++ .../channel-list-item-skeleton.component.ts | 16 +++ .../channel-list-skeleton.component.html | 10 ++ .../channel-list-skeleton.component.scss | 11 ++ .../channel-list-skeleton.component.ts | 31 ++++ .../channel-list-loading-state.component.html | 52 ++----- .../channel-list-loading-state.component.scss | 66 +-------- ...annel-list-loading-state.component.spec.ts | 7 +- .../channel-list-loading-state.component.ts | 6 +- 18 files changed, 291 insertions(+), 221 deletions(-) create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.ts create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.html create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.scss create mode 100644 libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.ts 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 fb21f7d15..a7807219c 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 @@ -19,21 +19,11 @@ @if (selectedContentType() === 'live') {
- @for (row of skeletonRows; track row) { - - } +
} @else {
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 28c7df6e3..d76077915 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 @@ -21,7 +21,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { ActivatedRoute, Router } from '@angular/router'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; -import { DialogService } from 'components'; +import { ChannelListSkeletonComponent, DialogService } from 'components'; import { buildGlobalCollectionDetailNavigationTarget, buildCollectionViewState, @@ -66,6 +66,7 @@ import { styleUrl: './unified-collection-page.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + ChannelListSkeletonComponent, EmptyStateComponent, NgTemplateOutlet, MatButtonToggleModule, diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index e1732252a..31841ad4f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -26,7 +26,13 @@
- @if (searchTerm() && visibleChannels().length === 0) { + @if (isInitialChannelsLoading()) { + + } @else if (searchTerm() && visibleChannels().length === 0) {
+ !!this.stalkerStore.selectedCategoryId() && + this.itvChannels().length === 0 && + !this.searchTerm() + ); readonly selectedChannelId = this.stalkerStore.selectedItvId; protected readonly normalizeStalkerEntityId = normalizeStalkerEntityId; diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html index 3b1e0dc9a..ef08b5a5b 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.html @@ -7,14 +7,12 @@
- } @else if (filteredChannels().length > 0) { diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss index 3e0183e83..0df50214b 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.scss @@ -150,83 +150,10 @@ } .channels-loading-state__list { - display: grid; - gap: 8px; + min-height: 0; overflow: hidden; } -.channel-skeleton { - display: grid; - grid-template-columns: 44px minmax(0, 1fr); - grid-template-rows: 14px 10px; - align-items: center; - gap: 8px 12px; - min-height: 68px; - padding: 10px 12px; - border-radius: 14px; - background: color-mix( - in srgb, - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%, - var(--app-widget-bg, var(--mat-sys-surface-container)) 12% - ); - border: 1px solid - color-mix( - in srgb, - var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%, - transparent - ); -} - -.channel-skeleton__logo, -.channel-skeleton__title, -.channel-skeleton__meta { - position: relative; - overflow: hidden; - background: color-mix( - in srgb, - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 52%, - var(--app-body-color, var(--mat-sys-on-surface-variant)) 48% - ); - - &::after { - content: ''; - position: absolute; - inset: 0; - background: linear-gradient( - 100deg, - transparent 8%, - color-mix( - in srgb, - var(--app-heading-color, var(--mat-sys-on-surface)) 18%, - transparent - ) - 44%, - transparent 74% - ); - transform: translateX(-120%); - animation: channel-skeleton-shimmer 1.15s ease-in-out infinite; - } -} - -.channel-skeleton__logo { - grid-row: 1 / span 2; - width: 44px; - height: 44px; - border-radius: 12px; -} - -.channel-skeleton__title { - height: 11px; - width: min(68%, 180px); - border-radius: 999px; -} - -.channel-skeleton__meta { - height: 9px; - width: min(42%, 120px); - border-radius: 999px; -} - @container (max-width: 350px) { .scroll-viewport-portals { padding-inline: 6px; @@ -244,9 +171,3 @@ transform: translateY(0); } } - -@keyframes channel-skeleton-shimmer { - 100% { - transform: translateX(120%); - } -} diff --git a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts index 9639af3b8..fabface7c 100644 --- a/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts +++ b/libs/portal/xtream/feature/src/lib/portal-channels-list/portal-channels-list.component.ts @@ -26,7 +26,10 @@ import { XtreamCategory, XtreamItem, } from 'shared-interfaces'; -import { ChannelListItemComponent } from 'components'; +import { + ChannelListItemComponent, + ChannelListSkeletonComponent, +} from 'components'; import { PortalChannelSortMode, sortPortalChannelItems, @@ -59,6 +62,7 @@ interface XtreamCategoryLike { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ ChannelListItemComponent, + ChannelListSkeletonComponent, MatIcon, ScrollingModule, TranslatePipe, @@ -76,7 +80,6 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy { private readonly route = inject(ActivatedRoute); readonly isSelectedTypeContentLoading = this.xtreamStore.selectedTypeContentLoading; - readonly loadingRows = Array.from({ length: 9 }, (_, index) => index); readonly channels = computed(() => { const override = this.channelsOverride(); if (Array.isArray(override)) { diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index e13958e6e..4aacc7da0 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -1,5 +1,7 @@ export * from './lib/channel-list-container/channel-list-container.component'; export * from './lib/channel-list-container/channel-list-item/channel-list-item.component'; +export * from './lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component'; +export * from './lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component'; export * from './lib/channel-list-loading-state/channel-list-loading-state.component'; export * from './lib/confirm-dialog/confirm-dialog.component'; export * from './lib/confirm-dialog/dialog.service'; diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.html new file mode 100644 index 000000000..7b3bafb9d --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.html @@ -0,0 +1,33 @@ + diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss new file mode 100644 index 000000000..8deb39f91 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.scss @@ -0,0 +1,132 @@ +@use '../../../../../../portal/shared/ui/src/lib/styles/skeleton'; + +:host { + display: block; + width: 100%; + container-type: inline-size; +} + +.channel-list-item-skeleton { + width: 100%; + min-height: 68px; + display: flex; + align-items: center; + gap: 12px; + padding: 8px 10px 8px 12px; + border: 1px solid transparent; + border-radius: 12px; + background: transparent; + box-sizing: border-box; + overflow: hidden; + + &.compact { + min-height: 52px; + padding-block: 7px; + } +} + +.channel-content-skeleton { + min-width: 0; + flex: 1; + display: flex; + align-items: center; + gap: 12px; +} + +.channel-logo-skeleton { + width: 44px; + height: 44px; + flex-shrink: 0; + border-radius: 10px; + + .compact & { + width: 38px; + height: 38px; + } +} + +.channel-details-skeleton { + min-width: 0; + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: 6px; +} + +.channel-name-skeleton { + height: 11px; + border-radius: 999px; + max-width: 100%; +} + +.epg-title-skeleton { + height: 9px; + border-radius: 999px; + max-width: 100%; +} + +.epg-progress-skeleton { + height: 6px; + width: 100%; + border-radius: 999px; +} + +.action-buttons-skeleton { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 2px; + margin-inline-start: auto; +} + +.action-button-skeleton { + width: 34px; + height: 34px; + border-radius: 10px; +} + +@container (max-width: 360px) { + .channel-list-item-skeleton { + gap: 10px; + padding-inline: 10px 8px; + } + + .channel-logo-skeleton { + width: 40px; + height: 40px; + } + + .action-buttons-skeleton { + gap: 0; + } +} + +@container (max-width: 270px) { + .channel-list-item-skeleton { + min-height: 52px; + gap: 8px; + padding-inline: 8px 6px; + } + + .channel-logo-skeleton { + width: 34px; + height: 34px; + } + + .epg-title-skeleton, + .epg-progress-skeleton { + display: none; + } +} + +@container (max-width: 220px) { + .channel-logo-skeleton, + .action-buttons-skeleton { + display: none; + } + + .channel-content-skeleton { + gap: 0; + } +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.ts new file mode 100644 index 000000000..e972f46da --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-item-skeleton/channel-list-item-skeleton.component.ts @@ -0,0 +1,16 @@ +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +@Component({ + selector: 'app-channel-list-item-skeleton', + templateUrl: './channel-list-item-skeleton.component.html', + styleUrl: './channel-list-item-skeleton.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChannelListItemSkeletonComponent { + readonly showEpg = input(true); + readonly actionCount = input<0 | 1 | 2 | 3>(1); + readonly titleWidth = input(70); + readonly metaWidth = input(40); + + readonly actionSlots = Array.from({ length: 3 }, (_, i) => i); +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.html new file mode 100644 index 000000000..7efc8da0c --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.html @@ -0,0 +1,10 @@ + diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.scss b/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.scss new file mode 100644 index 000000000..d0690e66d --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.scss @@ -0,0 +1,11 @@ +:host { + display: block; + width: 100%; +} + +.channel-list-skeleton { + display: flex; + flex-direction: column; + gap: 0; + width: 100%; +} diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.ts new file mode 100644 index 000000000..261baf268 --- /dev/null +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-skeleton/channel-list-skeleton.component.ts @@ -0,0 +1,31 @@ +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { ChannelListItemSkeletonComponent } from '../channel-list-item-skeleton/channel-list-item-skeleton.component'; + +const TITLE_WIDTHS = [72, 61, 68, 54, 78, 64, 70, 57, 75, 66, 73, 59]; +const META_WIDTHS = [38, 34, 42, 36, 44, 35, 40, 32, 39, 37, 43, 33]; + +@Component({ + selector: 'app-channel-list-skeleton', + templateUrl: './channel-list-skeleton.component.html', + styleUrl: './channel-list-skeleton.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ChannelListItemSkeletonComponent], +}) +export class ChannelListSkeletonComponent { + readonly count = input(9); + readonly showEpg = input(true); + readonly actionCount = input<0 | 1 | 2 | 3>(1); + + readonly rows = computed(() => + Array.from({ length: this.count() }, (_, index) => ({ + index, + title: TITLE_WIDTHS[index % TITLE_WIDTHS.length], + meta: META_WIDTHS[index % META_WIDTHS.length], + })) + ); +} diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html index 54defc1a4..1ce0fe8c3 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.html @@ -40,26 +40,11 @@
- @for (row of channelRows; track row) { -
- - - - - - -
- } +
@@ -71,27 +56,12 @@ - } diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss index a1732a8cc..3292c762c 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.scss @@ -33,6 +33,7 @@ height: 100%; min-height: 0; padding: 16px 12px 18px; + grid-template-rows: auto 1fr; } .channels-loading-state__copy { @@ -50,61 +51,10 @@ .channels-loading-state__list, .groups-loading-content__list { - display: grid; - gap: 8px; + min-height: 0; overflow: hidden; } -.channel-loading-row { - display: grid; - grid-template-columns: 44px minmax(0, 1fr) 18px; - align-items: center; - gap: 12px; - min-height: 68px; - padding: 10px 12px; - border-radius: 14px; - background: color-mix( - in srgb, - var(--app-widget-header-bg, var(--mat-sys-surface-container-high)) 88%, - var(--app-widget-bg, var(--mat-sys-surface-container)) 12% - ); - border: 1px solid - color-mix( - in srgb, - var(--app-widget-header-border, var(--mat-sys-outline-variant)) 96%, - transparent - ); -} - -.channel-loading-row__logo { - width: 44px; - height: 44px; - border-radius: 12px; -} - -.channel-loading-row__text { - min-width: 0; - display: grid; - gap: 8px; -} - -.channel-loading-row__title { - height: 11px; - border-radius: 999px; -} - -.channel-loading-row__meta { - height: 9px; - border-radius: 999px; -} - -.channel-loading-row__favorite { - justify-self: end; - width: 18px; - height: 18px; - border-radius: 999px; -} - .groups-loading-layout { display: flex; width: 100%; @@ -225,18 +175,6 @@ padding-inline: 8px; } - .channel-loading-row { - grid-template-columns: 40px minmax(0, 1fr) 16px; - gap: 10px; - padding-inline: 10px; - } - - .channel-loading-row__logo { - width: 40px; - height: 40px; - border-radius: 10px; - } - .groups-loading-nav { min-width: 136px; width: 42%; diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts index 1e088688e..b223d283b 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts @@ -18,15 +18,14 @@ describe('ChannelListLoadingStateComponent', () => { component = fixture.componentInstance; }); - it('renders channel rows for non-group views', () => { + it('renders the channel-list skeleton wrapper for non-group views', () => { fixture.componentRef.setInput('view', 'all'); fixture.detectChanges(); expect(component.isGroupsView()).toBe(false); expect( - fixture.nativeElement.querySelectorAll('.channel-loading-row') - .length - ).toBe(component.channelRows.length); + fixture.nativeElement.querySelector('app-channel-list-skeleton') + ).not.toBeNull(); expect( fixture.nativeElement.querySelector('.groups-loading-layout') ).toBeNull(); diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts index 0750d232d..a65429ae4 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts @@ -5,21 +5,19 @@ import { input, } from '@angular/core'; import { TranslatePipe } from '@ngx-translate/core'; +import { ChannelListSkeletonComponent } from '../channel-list-container/channel-list-skeleton/channel-list-skeleton.component'; @Component({ selector: 'app-channel-list-loading-state', templateUrl: './channel-list-loading-state.component.html', styleUrl: './channel-list-loading-state.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [TranslatePipe], + imports: [TranslatePipe, ChannelListSkeletonComponent], }) export class ChannelListLoadingStateComponent { readonly view = input('all'); readonly isGroupsView = computed(() => this.view() === 'groups'); - readonly channelRows = Array.from({ length: 9 }, (_, index) => index); readonly groupRows = Array.from({ length: 10 }, (_, index) => index); - readonly channelMetaWidths = [38, 34, 42, 36, 44, 35, 40, 32, 39]; - readonly channelTitleWidths = [72, 61, 68, 54, 78, 64, 70, 57, 75]; readonly groupLabelWidths = [78, 66, 84, 58, 73, 69, 81, 62, 76, 71]; }