feat(ui): align collection live panel control

This commit is contained in:
4gray committed 2026-07-31 09:29:27 +02:00
1 parent 60539a46d7
commit 018995e01c
12 files changed
+360 -130

No files matched your search

@@ -561,7 +561,7 @@ git commit -m "feat(ui): separate M3U live panel toggles"
- Modify:
`libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.{ts,html,scss,spec.ts}`
- [ ] **Step 1: Write failing Favorites/Recent tests**
- [x] **Step 1: Write failing Favorites/Recent tests**
Assert exactly one panel-based Channels control in the collection header for
both modes, including zero search results; no control inside the list; loading
@@ -569,7 +569,7 @@ Live state retains the header control; collapsed list DOM is inert/aria-hidden;
`Cmd/Ctrl+B` only suppresses Channels; external/radio Guide disclosure is
absent.
- [ ] **Step 2: Run and verify RED**
- [x] **Step 2: Run and verify RED**
```bash
pnpm nx test portal-shared-ui --runInBand
@@ -578,7 +578,7 @@ pnpm nx test portal-shared-ui --runInBand
Expected: FAIL because collection UI still uses the legacy service and
`aria-pressed`.
- [ ] **Step 3: Implement the collection exception**
- [x] **Step 3: Implement the collection exception**
Inject `LiveLayoutPanelStateService`, keep the one header button, bind
`aria-controls="live-channels-panel"` and effective `aria-expanded`, remove
@@ -586,13 +586,13 @@ Inject `LiveLayoutPanelStateService`, keep the one header button, bind
control in the loading header when the selected type is Live. Add the
non-typing `Cmd/Ctrl+B` host listener.
- [ ] **Step 4: Retain and gate the Live list DOM**
- [x] **Step 4: Retain and gate the Live list DOM**
Bind `id="live-channels-panel"`, `inert`, and `aria-hidden` on the live-tab
sidebar. Pass Guide `collapsible=shouldUseInlinePlayer()` and omit it for radio
as today.
- [ ] **Step 5: Run and verify GREEN**
- [x] **Step 5: Run and verify GREEN**
```bash
pnpm nx test portal-shared-ui --runInBand
@@ -601,7 +601,7 @@ pnpm nx lint portal-shared-ui
Expected: both commands exit 0.
- [ ] **Step 6: Commit**
- [x] **Step 6: Commit**
```bash
git add libs/portal/shared/ui
@@ -1,5 +1,33 @@
<ng-content select="ng-template[unifiedCollectionDetail]"></ng-content>
<ng-template #channelsPanelToggle>
<button
type="button"
mat-icon-button
class="collection-sidebar-toggle"
data-testid="live-channels-panel-toggle"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="
(channelsPanelExpanded()
? 'LAYOUT.HIDE_CHANNELS_PANEL'
: 'LAYOUT.SHOW_CHANNELS_PANEL'
) | translate
"
[matTooltip]="
(channelsPanelExpanded()
? 'LAYOUT.HIDE_CHANNELS_PANEL'
: 'LAYOUT.SHOW_CHANNELS_PANEL'
) | translate
"
(click)="toggleChannelsPanel()"
>
<mat-icon>{{
channelsPanelExpanded() ? 'chevron_left' : 'chevron_right'
}}</mat-icon>
</button>
</ng-template>
@if (detailContext(); as detailContext) {
<ng-container
[ngTemplateOutlet]="detailTemplate()?.templateRef ?? null"
@@ -8,6 +36,9 @@
} @else if (isLoading()) {
<div class="collection-header">
<span class="collection-title">{{ title() | translate }}</span>
@if (showSidebarToggle()) {
<ng-container [ngTemplateOutlet]="channelsPanelToggle" />
}
<div class="collection-header-toggles">
<span class="toggle-placeholder"></span>
@if (showScopeToggle()) {
@@ -18,7 +49,13 @@
</div>
</div>
@if (selectedContentType() === 'live') {
<div class="skeleton-list">
<div
id="live-channels-panel"
class="skeleton-list"
[class.skeleton-list--collapsed]="!channelsPanelExpanded()"
[attr.aria-hidden]="!channelsPanelExpanded()"
[attr.inert]="channelsPanelExpanded() ? null : ''"
>
<app-channel-list-skeleton
[count]="12"
[showEpg]="supportsEpg"
@@ -64,10 +101,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) {
@@ -79,24 +113,7 @@
}
@if (showSidebarToggle()) {
<button
type="button"
mat-icon-button
class="collection-sidebar-toggle"
[attr.aria-label]="
(isSidebarCollapsed()
? 'LAYOUT.SHOW_CHANNELS_LIST'
: 'LAYOUT.HIDE_CHANNELS_LIST'
) | translate
"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>{{
isSidebarCollapsed() ? 'chevron_right' : 'chevron_left'
}}</mat-icon>
</button>
<ng-container [ngTemplateOutlet]="channelsPanelToggle" />
}
<div class="collection-header-toggles">
@@ -156,16 +173,14 @@
clearButtonTooltipKey()
| translate
: {
type:
currentTypeLabelKey() | translate,
type: currentTypeLabelKey() | translate,
}
"
[attr.aria-label]="
clearButtonTooltipKey()
| translate
: {
type:
currentTypeLabelKey() | translate,
type: currentTypeLabelKey() | translate,
}
"
>
@@ -185,7 +200,7 @@
[autoOpenItem]="pendingAutoOpenLiveItem()"
[favoriteUids]="favoriteUidSet()"
[sortMode]="favSortMode()"
[isSidebarCollapsed]="isSidebarCollapsed()"
[channelsPanelExpanded]="channelsPanelExpanded()"
(removeItem)="onRemoveItem($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(reorderItems)="onReorder($event)"
@@ -221,22 +236,44 @@
}
@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
[attr.id]="
selectedContentType() === 'live' ? 'live-channels-panel' : null
"
class="collection-empty-panel"
[class.collection-empty-panel--collapsed]="
selectedContentType() === 'live' && !channelsPanelExpanded()
"
[attr.aria-hidden]="
selectedContentType() === 'live'
? !channelsPanelExpanded()
: null
"
[attr.inert]="
selectedContentType() === 'live' && !channelsPanelExpanded()
? ''
: null
"
>
@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>
}
}
@@ -10,6 +10,15 @@
.skeleton-list {
flex: 1;
overflow: hidden;
transition:
width 0.18s ease,
flex-basis 0.18s ease;
&--collapsed {
width: 0;
flex: 0 0 0;
pointer-events: none;
}
}
.skeleton-grid {
@@ -45,6 +54,11 @@
margin-left: -4px;
}
.collection-sidebar-toggle {
min-width: 40px;
min-height: 40px;
}
.collection-header-toggles {
display: flex;
align-items: center;
@@ -92,6 +106,22 @@ app-unified-live-tab {
flex-direction: row;
}
.collection-empty-panel {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
&--collapsed {
flex: 0 0 0;
pointer-events: none;
}
> app-empty-state {
flex: 1;
}
}
.empty-state {
display: flex;
flex-direction: column;
@@ -17,15 +17,23 @@ import {
CollectionScope,
FavoritesChannelSortMode,
OPEN_COLLECTION_DETAIL_STATE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
ScopeToggleService,
UnifiedCollectionItem,
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import {
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
LIVE_LAYOUT_PANEL,
LiveLayoutPanelStateService,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { BehaviorSubject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -46,7 +54,7 @@ class StubUnifiedLiveTabComponent {
readonly autoOpenItem = input<unknown>(null);
readonly favoriteUids = input<ReadonlySet<string>>(new Set<string>());
readonly sortMode = input<FavoritesChannelSortMode>('custom');
readonly isSidebarCollapsed = input(false);
readonly channelsPanelExpanded = input(true);
readonly removeItem = output<UnifiedCollectionItem>();
readonly favoriteToggled = output<UnifiedCollectionItem>();
@@ -131,6 +139,7 @@ describe('UnifiedCollectionPageComponent', () => {
let workspaceParamMap$: BehaviorSubject<
ReturnType<typeof convertToParamMap>
>;
let livePanelState: LiveLayoutPanelStateService;
const playlistsLoaded = signal(false);
const playlists = signal<PlaylistMeta[]>([]);
const favoritesData = {
@@ -186,6 +195,9 @@ describe('UnifiedCollectionPageComponent', () => {
beforeEach(async () => {
playlistsLoaded.set(false);
playlists.set([]);
localStorage.removeItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
jest.clearAllMocks();
workspaceViewCommands.registerCommand.mockReturnValue(jest.fn());
routeParamMap$ = new BehaviorSubject(convertToParamMap({}));
@@ -313,6 +325,7 @@ describe('UnifiedCollectionPageComponent', () => {
fixture = TestBed.createComponent(UnifiedCollectionPageComponent);
fixture.componentRef.setInput('mode', 'favorites');
fixture.componentRef.setInput('defaultScope', 'all');
livePanelState = TestBed.inject(LiveLayoutPanelStateService);
});
it('uses compact loading rows when the runtime has no EPG support', () => {
@@ -330,6 +343,103 @@ describe('UnifiedCollectionPageComponent', () => {
).toBeNull();
});
it('keeps one real Channels control in the loading header', () => {
fixture.componentInstance.isLoading.set(true);
fixture.componentInstance.selectedContentType.set('live');
fixture.detectChanges();
const toggles = fixture.nativeElement.querySelectorAll(
'[data-testid="live-channels-panel-toggle"]'
) as NodeListOf<HTMLButtonElement>;
expect(toggles).toHaveLength(1);
expect(toggles[0].getAttribute('aria-controls')).toBe(
'live-channels-panel'
);
expect(toggles[0].getAttribute('aria-expanded')).toBe('true');
toggles[0].click();
fixture.detectChanges();
const loadingPanel = fixture.nativeElement.querySelector(
'#live-channels-panel'
) as HTMLElement;
expect(livePanelState.channelsIntent()).toBe('collapsed');
expect(loadingPanel.hasAttribute('inert')).toBe(true);
expect(loadingPanel.getAttribute('aria-hidden')).toBe('true');
});
it('keeps the collection header as the sole Channels owner through zero results', () => {
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set([
{
uid: 'm3u::playlist-1::live',
name: 'Live channel',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/live.m3u8',
},
]);
setRouteQueryParams({ q: 'no-match' });
fixture.detectChanges();
const toggles = fixture.nativeElement.querySelectorAll(
'[data-testid="live-channels-panel-toggle"]'
);
const liveTab = fixture.debugElement.query(
By.directive(StubUnifiedLiveTabComponent)
).componentInstance as StubUnifiedLiveTabComponent;
expect(toggles).toHaveLength(1);
expect(liveTab.channelsPanelExpanded()).toBe(true);
fixture.componentInstance.toggleChannelsPanel();
fixture.detectChanges();
expect(livePanelState.channelsIntent()).toBe('collapsed');
expect(liveTab.channelsPanelExpanded()).toBe(false);
expect(
fixture.nativeElement.querySelectorAll(
'[data-testid="live-channels-panel-toggle"]'
)
).toHaveLength(1);
});
it('uses Cmd/Ctrl+B as Channels-only temporary suppression', () => {
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set([
{
uid: 'xtream::playlist-1::live:1',
name: 'Live channel',
contentType: 'live',
sourceType: 'xtream',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
xtreamId: 1,
},
]);
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS);
fixture.detectChanges();
const shortcut = new KeyboardEvent('keydown', {
key: 'b',
metaKey: true,
});
jest.spyOn(shortcut, 'preventDefault');
fixture.componentInstance.handleSidebarShortcut(shortcut);
fixture.detectChanges();
expect(shortcut.preventDefault).toHaveBeenCalled();
expect(livePanelState.masterSuppressed()).toBe(true);
expect(livePanelState.groupsIntent()).toBe('collapsed');
expect(livePanelState.channelsIntent()).toBe('expanded');
fixture.componentInstance.handleSidebarShortcut(shortcut);
expect(livePanelState.masterSuppressed()).toBe(false);
});
it('reloads favorites after playlist hydration completes', async () => {
fixture.detectChanges();
await fixture.whenStable();
@@ -507,16 +617,18 @@ describe('UnifiedCollectionPageComponent', () => {
await fixture.whenStable();
expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(false);
expect(
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
).toBe(false);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
liveTab.favoriteToggled.emit(recentItem);
await fixture.whenStable();
expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(true);
expect(
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
).toBe(true);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
});
@@ -826,9 +938,7 @@ describe('UnifiedCollectionPageComponent', () => {
},
}
);
expect(
fixture.componentInstance.selectedDetailItem()
).toBeNull();
expect(fixture.componentInstance.selectedDetailItem()).toBeNull();
} finally {
router.url = originalUrl;
}
@@ -6,7 +6,9 @@ import {
computed,
contentChild,
DestroyRef,
ElementRef,
effect,
HostListener,
inject,
linkedSignal,
input,
@@ -21,7 +23,10 @@ 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 { ChannelListSkeletonComponent, DialogService } from '@iptvnator/ui/components';
import {
ChannelListSkeletonComponent,
DialogService,
} from '@iptvnator/ui/components';
import {
buildGlobalCollectionDetailNavigationTarget,
buildCollectionViewState,
@@ -37,8 +42,8 @@ import {
getCollectionViewState,
getOpenLiveCollectionItemState,
getUnifiedCollectionNavigation,
isTypingInInput,
isWorkspaceLayoutRoute,
LiveLayoutSidebarStateService,
OPEN_COLLECTION_DETAIL_STATE_KEY,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
persistFavoritesChannelSortMode,
@@ -52,11 +57,16 @@ import {
WorkspaceViewCommandService,
} from '@iptvnator/portal/shared/util';
import {
LIVE_LAYOUT_PANEL,
LiveLayoutPanelStateService,
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { EmptyStateComponent } from '@iptvnator/playlist/shared/ui';
import { UnifiedLiveTabComponent } from './unified-live-tab.component';
import { UnifiedGridTabComponent } from './unified-grid-tab.component';
@@ -102,10 +112,11 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
private readonly dialogService = inject(DialogService);
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly translate = inject(TranslateService);
private readonly workspaceViewCommands = inject(WorkspaceViewCommandService);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
private readonly workspaceViewCommands = inject(
WorkspaceViewCommandService
);
private readonly livePanelState = inject(LiveLayoutPanelStateService);
private readonly hostElement = inject(ElementRef<HTMLElement>);
readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
@@ -276,9 +287,13 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType() === 'live' &&
this.hasLive()
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly showSidebarToggle = computed(
() => this.selectedContentType() === 'live' && this.hasLive()
() => this.selectedContentType() === 'live'
);
readonly channelsPanelExpanded = computed(() =>
this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.CHANNELS, {
applicable: this.showSidebarToggle(),
})
);
readonly favSortOptions: ReadonlyArray<{
mode: FavoritesChannelSortMode;
@@ -339,6 +354,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}));
private loadRequestId = 0;
private previousChannelsPanelExpanded: boolean | undefined;
private readonly loadEffect = effect(() => {
const { mode, portalType, playlistId, scope } = this.loadRequest();
@@ -408,8 +424,18 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}),
keywords: () =>
this.mode() === 'favorites'
? ['clear', 'favorites', 'remove', this.selectedContentType()]
: ['clear', 'recent', 'history', this.selectedContentType()],
? [
'clear',
'favorites',
'remove',
this.selectedContentType(),
]
: [
'clear',
'recent',
'history',
this.selectedContentType(),
],
priority: 10,
run: () => this.clearAllCurrent(),
});
@@ -418,6 +444,30 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
});
constructor() {
effect(() => {
const applicable = this.showSidebarToggle();
const expanded = this.channelsPanelExpanded();
const previous = this.previousChannelsPanelExpanded;
this.previousChannelsPanelExpanded = applicable
? expanded
: undefined;
if (
!applicable ||
previous === undefined ||
previous === expanded
) {
return;
}
queueMicrotask(() => {
this.hostElement.nativeElement
.querySelector<HTMLElement>(
'[data-testid="live-channels-panel-toggle"]'
)
?.focus();
});
});
if (typeof window !== 'undefined') {
const onPopState = () => {
this.syncCollectionViewStateFromHistory();
@@ -468,8 +518,29 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
void this.router.navigate(['/workspace', 'dashboard']);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
toggleChannelsPanel(): void {
if (this.channelsPanelExpanded()) {
this.livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS);
} else {
this.livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS);
}
}
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
!this.showSidebarToggle() ||
isTypingInInput(event) ||
(!event.metaKey && !event.ctrlKey) ||
event.key.toLowerCase() !== 'b'
) {
return;
}
event.preventDefault();
this.livePanelState.toggleMasterSuppression([
LIVE_LAYOUT_PANEL.CHANNELS,
]);
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
@@ -1,6 +1,9 @@
<div
id="live-channels-panel"
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[class.sidebar-collapsed]="!channelsPanelExpanded()"
[attr.aria-hidden]="!channelsPanelExpanded()"
[attr.inert]="channelsPanelExpanded() ? null : ''"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -83,10 +86,13 @@
<div class="epg-content">
@if (epgViewMode() === 'list') {
<app-epg-list-view
panelId="live-guide-panel"
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -95,6 +101,7 @@
shouldUseInlinePlayer() &&
isLiveEpgPanelCollapsed()
"
[collapsible]="shouldUseInlinePlayer()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
@@ -110,10 +117,13 @@
/>
} @else {
<app-epg-timeline
panelId="live-guide-panel"
[programs]="timelinePrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="timelineArchiveAvailable()"
[archivePlaybackAvailable]="
timelineArchiveAvailable()
"
[archiveDays]="timelineArchiveDays()"
[activeProgram]="activeTimeshiftProgram()"
[isLivePlayback]="!activeTimeshift()"
@@ -122,6 +132,7 @@
shouldUseInlinePlayer() &&
isLiveEpgPanelCollapsed()
"
[collapsible]="shouldUseInlinePlayer()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
@@ -25,10 +25,7 @@ import {
} from '@iptvnator/ui/epg';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import {
EpgItem,
EpgProgram,
@@ -99,6 +96,8 @@ class StubEpgTimelineComponent {
readonly isLivePlayback = input(true);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly panelId = input<string | null>(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
readonly summary = input<any>(null);
readonly summaryLabelKey = input('');
@@ -296,6 +295,20 @@ describe('UnifiedLiveTabComponent', () => {
).componentInstance as StubEpgTimelineComponent;
expect(timeline.programs()).toEqual([buildProgram('M3U Show')]);
expect(timeline.archivePlaybackAvailable()).toBe(false);
expect(timeline.collapsible()).toBe(false);
expect(timeline.panelId()).toBe('live-guide-panel');
});
it('retains a collapsed Channels panel as inert DOM', () => {
fixture.componentRef.setInput('items', [buildLiveItem('m3u')]);
fixture.componentRef.setInput('channelsPanelExpanded', false);
fixture.detectChanges();
const panel = fixture.nativeElement.querySelector(
'#live-channels-panel'
) as HTMLElement;
expect(panel.hasAttribute('inert')).toBe(true);
expect(panel.getAttribute('aria-hidden')).toBe('true');
});
it('skips EPG loading and hides the EPG panel in browser/PWA playback', async () => {
@@ -546,6 +559,8 @@ describe('UnifiedLiveTabComponent', () => {
]);
expect(timeline.archivePlaybackAvailable()).toBe(false);
expect(timeline.collapsed()).toBe(false);
expect(timeline.collapsible()).toBe(true);
expect(timeline.panelId()).toBe('live-guide-panel');
timeline.collapsedChange.emit(true);
fixture.detectChanges();
@@ -96,7 +96,7 @@ export class UnifiedLiveTabComponent {
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly isSidebarCollapsed = input(false);
readonly channelsPanelExpanded = input(true);
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -262,9 +262,7 @@ export class UnifiedLiveTabComponent {
return getLiveEpgPanelSummary(this.activeDetail());
});
readonly liveEpgPanelSummaryLabelKey = computed(() =>
this.activeTimeshift()
? 'EPG.ARCHIVE_PLAYBACK'
: 'EPG.CURRENT_PROGRAM'
this.activeTimeshift() ? 'EPG.ARCHIVE_PLAYBACK' : 'EPG.CURRENT_PROGRAM'
);
readonly channelsForList = computed((): UnifiedFavoriteChannel[] =>
@@ -470,10 +468,7 @@ export class UnifiedLiveTabComponent {
program.startTimestamp,
program.start
);
const stopEpoch = toEpochSeconds(
program.stopTimestamp,
program.stop
);
const stopEpoch = toEpochSeconds(program.stopTimestamp, program.stop);
if (startEpoch == null || stopEpoch == null) {
this.snackBar.open(
this.translate.instant('EPG.TIMELINE.CATCHUP_FAILED'),
@@ -674,5 +669,4 @@ export class UnifiedLiveTabComponent {
};
});
}
}
-1
View File
@@ -15,7 +15,6 @@ export * from './lib/portal-collection-context.service';
export * from './lib/portal-collection-items';
export * from './lib/live-epg-panel-state';
export * from './lib/live-sidebar-state';
export * from './lib/live-layout-sidebar-state.service';
export * from './lib/keyboard';
export * from './lib/keyboard-shortcuts';
export * from './lib/remote-channel-navigation';
@@ -1,29 +0,0 @@
import { computed, Injectable, signal } from '@angular/core';
import {
LiveSidebarState,
persistLiveSidebarState,
restoreLiveSidebarState,
} from './live-sidebar-state';
/**
* @deprecated Migrate consumers to `LiveLayoutPanelStateService` from
* `@iptvnator/portal/shared/data-access`. This compatibility service remains
* until every existing live layout has moved in the current change.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {
private readonly _state = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly state = this._state.asReadonly();
readonly isCollapsed = computed(() => this._state() === 'collapsed');
toggle(): void {
this.setState(this.isCollapsed() ? 'expanded' : 'collapsed');
}
setState(state: LiveSidebarState): void {
this._state.set(state);
persistLiveSidebarState(state);
}
}
@@ -2,7 +2,6 @@ import {
DEFAULT_LIVE_SIDEBAR_STATE,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
isLiveSidebarState,
persistLiveSidebarState,
restoreLiveSidebarState,
} from './live-sidebar-state';
@@ -27,13 +26,13 @@ describe('live sidebar state', () => {
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
});
it('restores and persists collapsed state', () => {
persistLiveSidebarState('collapsed');
it('restores collapsed legacy state without writing it', () => {
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed');
expect(restoreLiveSidebarState()).toBe('collapsed');
expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe(
'collapsed'
);
expect(restoreLiveSidebarState()).toBe('collapsed');
});
it('supports custom storage keys and fallback values', () => {
@@ -41,7 +40,7 @@ describe('live sidebar state', () => {
restoreLiveSidebarState('custom-live-sidebar-state', 'collapsed')
).toBe('collapsed');
persistLiveSidebarState('expanded', 'custom-live-sidebar-state');
localStorage.setItem('custom-live-sidebar-state', 'expanded');
expect(restoreLiveSidebarState('custom-live-sidebar-state')).toBe(
'expanded'
@@ -14,10 +14,3 @@ export function restoreLiveSidebarState(
const storedValue = localStorage.getItem(storageKey);
return isLiveSidebarState(storedValue) ? storedValue : fallback;
}
export function persistLiveSidebarState(
state: LiveSidebarState,
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY
): void {
localStorage.setItem(storageKey, state);
}