mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 17:36:15 -08:00
feat(ui): align collection live panel control
This commit is contained in:
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
|
||||
|
||||
+82
-45
@@ -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>
|
||||
}
|
||||
}
|
||||
+30
@@ -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;
|
||||
|
||||
+119
-9
@@ -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;
|
||||
}
|
||||
|
||||
+83
-12
@@ -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 {
|
||||
|
||||
+14
-3
@@ -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)="
|
||||
|
||||
+19
-4
@@ -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();
|
||||
|
||||
+3
-9
@@ -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 {
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user