feat(ui): separate portal live panel toggles

This commit is contained in:
4gray committed 2026-07-31 09:12:37 +02:00
1 parent 7d11cbf6a2
commit 98337108ab
8 files changed
+384 -117

No files matched your search

@@ -417,7 +417,7 @@ git commit -m "feat(ui): add workspace groups disclosure"
`libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.{ts,html,scss,spec.ts}`
- Modify: `libs/ui/styles/_portal-layout.scss`
- [ ] **Step 1: Write failing provider component tests**
- [x] **Step 1: Write failing provider component tests**
For each provider prove:
@@ -430,7 +430,7 @@ For each provider prove:
- root All Items applies the shortcut only to Groups;
- external MPV/VLC passes `collapsible=false` to Guide.
- [ ] **Step 2: Run both targets and verify RED**
- [x] **Step 2: Run both targets and verify RED**
```bash
pnpm nx test portal-xtream-feature --runInBand
@@ -439,7 +439,7 @@ pnpm nx test portal-stalker-feature --runInBand
Expected: FAIL because the components still consume the shared sidebar state.
- [ ] **Step 3: Replace Xtream state and markup**
- [x] **Step 3: Replace Xtream state and markup**
Inject `LiveLayoutPanelStateService`, compute Channels effective visibility for
`showLiveChannelSidebar()`, use `hidePanel('channels')` /
@@ -455,18 +455,18 @@ Keep the resizable Channels DOM mounted and inert when collapsed. Move restore
UI into a non-overlay boundary rail before `.content-container`. Pass
`[collapsible]="isEmbeddedPlayer"` and `panelId="live-guide-panel"`.
- [ ] **Step 4: Apply the same contract to Stalker**
- [x] **Step 4: Apply the same contract to Stalker**
Use the same panel IDs, test IDs, focus behavior, and master shortcut. Radio
must render no Guide disclosure because it renders no Guide region.
- [ ] **Step 5: Normalize shared boundary rail SCSS**
- [x] **Step 5: Normalize shared boundary rail SCSS**
Replace the floating restore overlay in `_portal-layout.scss` with a 40px
flex-shrink-zero boundary rail. Keep width transitions at 180ms, never modify
the external-player content sizing, and do not touch channel-row styles.
- [ ] **Step 6: Run and verify GREEN**
- [x] **Step 6: Run and verify GREEN**
```bash
pnpm nx test portal-xtream-feature --runInBand
@@ -477,7 +477,7 @@ pnpm nx lint portal-stalker-feature
Expected: all commands exit 0.
- [ ] **Step 7: Commit**
- [x] **Step 7: Commit**
```bash
git add libs/portal/xtream/feature libs/portal/stalker/feature libs/ui/styles
@@ -2,8 +2,11 @@
@if (stalkerStore.selectedCategoryId()) {
<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"
@@ -32,7 +35,9 @@
mat-icon-button
type="button"
[disabled]="isFullListLoading()"
[attr.aria-label]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
[attr.aria-label]="
'PORTALS.REFRESH_CHANNEL_LIST' | translate
"
[matTooltip]="'PORTALS.REFRESH_CHANNEL_LIST' | translate"
(click)="refreshChannels()"
>
@@ -42,10 +47,13 @@
<button
mat-icon-button
type="button"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
class="sidebar-panel-toggle"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
(click)="hideChannelsPanel()"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -83,7 +91,9 @@
<div class="empty-search-state">
<app-portal-empty-state
icon="tv_off"
[message]="'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate"
[message]="
'PORTALS.NO_CHANNELS_IN_CATEGORY' | translate
"
/>
</div>
} @else {
@@ -120,7 +130,9 @@
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
(contextMenuRequested)="
onChannelContextMenu(item, $event)
"
/>
}
}
@@ -134,20 +146,22 @@
</div>
}
@if (channelsPanelApplicable() && !channelsPanelExpanded()) {
<button
mat-icon-button
type="button"
class="sidebar-restore"
data-testid="live-channels-panel-restore"
aria-controls="live-channels-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
(click)="showChannelsPanel()"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
<div class="content-container">
@if (isSidebarCollapsed()) {
<button
mat-icon-button
type="button"
class="sidebar-restore"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
@if (!stalkerStore.selectedItem() && !stalkerStore.selectedCategoryId()) {
@if (showItvAllItems()) {
<!-- Xtream parity: all channels of the portal, paginated, before
@@ -226,6 +240,8 @@
[loading]="isLoadingEpg()"
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
[selectedDate]="selectedLiveEpgDate()"
[collapsible]="!!streamUrl() && usesEmbeddedPlayer()"
panelId="live-guide-panel"
[collapsed]="
!!streamUrl() &&
usesEmbeddedPlayer() &&
@@ -248,6 +264,8 @@
[loading]="isLoadingEpg()"
[emptyReason]="supportsEpg ? 'none' : 'provider-no-epg'"
[selectedDate]="selectedLiveEpgDate()"
[collapsible]="!!streamUrl() && usesEmbeddedPlayer()"
panelId="live-guide-panel"
[collapsed]="
!!streamUrl() &&
usesEmbeddedPlayer() &&
@@ -16,11 +16,14 @@ import {
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import {
EpgListViewComponent,
EpgTimelineComponent,
} from '@iptvnator/ui/epg';
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import { AudioPlayerComponent } from '@iptvnator/ui/playback';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { ChannelListItemComponent } from '@iptvnator/ui/components';
@@ -107,6 +110,8 @@ class StubEpgTimelineComponent {
readonly emptyReason = input<string>('none');
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly panelId = input<string | null>(null);
readonly summary = input<{ title?: string } | null>(null);
readonly summaryLabelKey = input('');
readonly selectedDateChange = output<string>();
@@ -202,9 +207,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
loaded: number;
total: number;
} | null>(null);
const itvFullChannelList = signal<
ReturnType<typeof defaultItvChannels>
>([]);
const itvFullChannelList = signal<ReturnType<typeof defaultItvChannels>>(
[]
);
const itvSelectedCategoryFromCache = signal(false);
const isPaginatedContentLoading = signal(false);
@@ -311,6 +316,8 @@ describe('StalkerLiveStreamLayoutComponent', () => {
isPaginatedContentLoading.set(false);
stalkerStore.preloadItvChannels.mockClear();
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY);
settingsStore.openStreamOnDoubleClick.set(false);
resolveItvPlayback.mockReset();
@@ -406,9 +413,7 @@ describe('StalkerLiveStreamLayoutComponent', () => {
useValue: {
supportsEpgMapping: false,
getEpgMapping: jest.fn().mockResolvedValue(null),
getEpgMappingsBatch: jest
.fn()
.mockResolvedValue(null),
getEpgMappingsBatch: jest.fn().mockResolvedValue(null),
},
},
],
@@ -445,11 +450,16 @@ describe('StalkerLiveStreamLayoutComponent', () => {
fixture = TestBed.createComponent(StalkerLiveStreamLayoutComponent);
component = fixture.componentInstance;
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.GROUPS);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS);
});
afterEach(() => {
fixture?.destroy();
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY);
window.electron = originalElectron;
});
@@ -502,7 +512,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
fixture.nativeElement.querySelector('app-web-player-view')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.epg')).toBeNull();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
const channelRows = fixture.debugElement.queryAll(
By.directive(StubChannelListItemComponent)
);
@@ -608,6 +620,8 @@ describe('StalkerLiveStreamLayoutComponent', () => {
By.directive(StubEpgTimelineComponent)
).componentInstance as StubEpgTimelineComponent;
expect(timeline.collapsed()).toBe(false);
expect(timeline.collapsible()).toBe(false);
expect(timeline.panelId()).toBe('live-guide-panel');
expect(
fixture.nativeElement
.querySelector('.epg')
@@ -615,6 +629,59 @@ describe('StalkerLiveStreamLayoutComponent', () => {
).toBe(false);
});
it('collapses and restores Channels independently with ARIA and focus transfer', async () => {
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
fixture.detectChanges();
const hide = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
) as HTMLButtonElement;
expect(hide.getAttribute('aria-controls')).toBe('live-channels-panel');
hide.click();
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 0));
const sidebar = fixture.nativeElement.querySelector(
'#live-channels-panel'
) as HTMLElement;
const restore = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-restore"]'
) as HTMLButtonElement;
expect(sidebar.hasAttribute('inert')).toBe(true);
expect(sidebar.getAttribute('aria-hidden')).toBe('true');
expect(livePanelState.channelsIntent()).toBe('collapsed');
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(document.activeElement).toBe(restore);
restore.click();
fixture.detectChanges();
await new Promise((resolve) => setTimeout(resolve, 0));
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
)
);
});
it('uses Cmd/Ctrl+B as temporary suppression without rewriting intents', () => {
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
fixture.detectChanges();
component.handleSidebarShortcut(
new KeyboardEvent('keydown', { ctrlKey: true, key: 'b' })
);
fixture.detectChanges();
expect(livePanelState.masterSuppressed()).toBe(true);
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(livePanelState.channelsIntent()).toBe('expanded');
component.handleSidebarShortcut(
new KeyboardEvent('keydown', { metaKey: true, key: 'b' })
);
expect(livePanelState.masterSuppressed()).toBe(false);
});
it('reuses a pending playback resolution during double-click activation', async () => {
settingsStore.openStreamOnDoubleClick.set(true);
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
@@ -1114,7 +1181,9 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(ensureBulkItvEpg).not.toHaveBeenCalled();
expect(fetchChannelEpg).not.toHaveBeenCalled();
expect(portalPlayer.openResolvedPlayback).not.toHaveBeenCalled();
expect(fixture.nativeElement.querySelector('app-epg-timeline')).toBeNull();
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
expect(
fixture.nativeElement.querySelector('app-audio-player')
).not.toBeNull();
@@ -57,7 +57,10 @@ import {
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
LiveLayoutSidebarStateService,
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
import {
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
@@ -118,9 +121,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly snackBar = inject(MatSnackBar);
private readonly translate = inject(TranslateService);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly livePanelState = inject(LiveLayoutPanelStateService);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly logger = createLogger('StalkerLiveStream');
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
@@ -304,7 +306,14 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
);
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly channelsPanelApplicable = computed(
() => !!this.stalkerStore.selectedCategoryId()
);
readonly channelsPanelExpanded = computed(() =>
this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.CHANNELS, {
applicable: this.channelsPanelApplicable(),
})
);
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -360,6 +369,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private unsubscribeRemoteCommand?: () => void;
private epgLoadRequestId = 0;
private playbackRequestId = 0;
private previousChannelsPanelApplicable: boolean | undefined;
private previousChannelsPanelExpanded: boolean | undefined;
private playbackResolution: {
channelId: string;
promise: Promise<ResolvedPortalPlayback>;
@@ -367,6 +378,28 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private lastPlaylistId: string | null | undefined = undefined;
constructor() {
effect(() => {
const applicable = this.channelsPanelApplicable();
const expanded = this.channelsPanelExpanded();
const shouldTransferFocus =
this.previousChannelsPanelApplicable === applicable &&
this.previousChannelsPanelExpanded !== undefined &&
this.previousChannelsPanelExpanded !== expanded;
this.previousChannelsPanelApplicable = applicable;
this.previousChannelsPanelExpanded = expanded;
if (shouldTransferFocus) {
queueMicrotask(() => {
const action = expanded ? 'hide' : 'restore';
this.hostElement.nativeElement
.querySelector<HTMLElement>(
`[data-testid="live-channels-panel-${action}"]`
)
?.focus();
});
}
});
// Load favorites for current playlist
const playlistId = this.stalkerStore.currentPlaylist()?._id;
if (playlistId) {
@@ -409,9 +442,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
untracked(() => {
if (contentType === 'radio') {
this.stalkerStore.setRadioChannels([]);
} else if (
!this.stalkerStore.itvSelectedCategoryFromCache()
) {
} else if (!this.stalkerStore.itvSelectedCategoryFromCache()) {
this.stalkerStore.setItvChannels([]);
}
this.stalkerStore.setPage(0);
@@ -444,8 +475,8 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
// store dedupes per channel id, so this is cheap on rerenders.
if (this.supportsEpgMapping && channels.length > 0) {
const channelIds = channels.map((channel) => channel.id);
untracked(() =>
void this.stalkerStore.applyMappedItvEpg(channelIds)
untracked(
() => void this.stalkerStore.applyMappedItvEpg(channelIds)
);
}
});
@@ -714,8 +745,24 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
hideChannelsPanel(): void {
this.livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS);
this.focusChannelsPanelAction('restore');
}
showChannelsPanel(): void {
this.livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS);
this.focusChannelsPanelAction('hide');
}
private focusChannelsPanelAction(action: 'hide' | 'restore'): void {
setTimeout(() => {
this.hostElement.nativeElement
.querySelector<HTMLElement>(
`[data-testid="live-channels-panel-${action}"]`
)
?.focus();
}, 0);
}
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
@@ -730,7 +777,11 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
!isTypingInInput(event)
) {
event.preventDefault();
this.toggleSidebar();
this.livePanelState.toggleMasterSuppression(
this.channelsPanelApplicable()
? [LIVE_LAYOUT_PANEL.GROUPS, LIVE_LAYOUT_PANEL.CHANNELS]
: [LIVE_LAYOUT_PANEL.GROUPS]
);
}
}
@@ -3,8 +3,11 @@
@if (showLiveChannelSidebar()) {
<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"
@@ -33,10 +36,13 @@
<button
mat-icon-button
type="button"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
class="sidebar-panel-toggle"
data-testid="live-channels-panel-hide"
aria-controls="live-channels-panel"
[attr.aria-expanded]="channelsPanelExpanded()"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate"
(click)="hideChannelsPanel()"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -78,20 +84,22 @@
/>
</div>
}
@if (showLiveChannelSidebar() && !channelsPanelExpanded()) {
<button
mat-icon-button
type="button"
class="sidebar-restore"
data-testid="live-channels-panel-restore"
aria-controls="live-channels-panel"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
[matTooltip]="'LAYOUT.SHOW_CHANNELS_PANEL' | translate"
(click)="showChannelsPanel()"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
<div class="content-container">
@if (isSidebarCollapsed()) {
<button
mat-icon-button
type="button"
class="sidebar-restore"
[attr.aria-label]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>chevron_right</mat-icon>
</button>
}
@if (streamUrl) {
@if (isEmbeddedPlayer) {
<div class="video-player">
@@ -185,6 +193,8 @@
[isLivePlayback]="!showReturnToLive()"
[loading]="isLoadingEpg()"
[selectedDate]="selectedLiveEpgDate()"
[collapsible]="usesEmbeddedPlayer()"
panelId="live-guide-panel"
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
@@ -205,6 +215,8 @@
[isLivePlayback]="!showReturnToLive()"
[loading]="isLoadingEpg()"
[selectedDate]="selectedLiveEpgDate()"
[collapsible]="usesEmbeddedPlayer()"
panelId="live-guide-panel"
[collapsed]="usesEmbeddedPlayer() && isLiveEpgPanelCollapsed()"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
@@ -14,10 +14,15 @@ import { BehaviorSubject, Subject, of } from 'rxjs';
import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import {
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
import {
FavoritesService,
XtreamStore,
@@ -106,6 +111,8 @@ class StubEpgTimelineComponent {
readonly loading = input(false);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly panelId = input<string | null>(null);
readonly summary = input<EpgTimelineSummary | null>(null);
readonly summaryLabelKey = input('');
readonly programActivated = output<EpgProgramActivationEvent>();
@@ -212,6 +219,8 @@ describe('LiveStreamLayoutComponent', () => {
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY);
settingsStore.openStreamOnDoubleClick.set(false);
window.electron = {
@@ -336,16 +345,22 @@ describe('LiveStreamLayoutComponent', () => {
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.GROUPS);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS);
});
afterEach(() => {
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.GROUPS);
livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS);
fixture.destroy();
jest.useRealTimers();
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY);
window.electron = originalElectron;
});
@@ -462,6 +477,8 @@ describe('LiveStreamLayoutComponent', () => {
);
expect(timeline).not.toBeNull();
expect(timeline.componentInstance.collapsed()).toBe(false);
expect(timeline.componentInstance.collapsible()).toBe(false);
expect(timeline.componentInstance.panelId()).toBe('live-guide-panel');
expect(
fixture.nativeElement
.querySelector('.epg')
@@ -893,7 +910,9 @@ describe('LiveStreamLayoutComponent', () => {
isLive: true,
})
);
expect(timeline.componentInstance.summary()?.title).toBe('Current Show');
expect(timeline.componentInstance.summary()?.title).toBe(
'Current Show'
);
expect(timeline.componentInstance.summaryLabelKey()).toBe(
'EPG.CURRENT_PROGRAM'
);
@@ -1066,27 +1085,82 @@ describe('LiveStreamLayoutComponent', () => {
const timeline = fixture.debugElement.query(
By.directive(StubEpgTimelineComponent)
);
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(true);
expect(timeline.componentInstance.archivePlaybackAvailable()).toBe(
true
);
});
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
it('does not show a Channels restore control when Channels is not applicable', () => {
selectedCategoryId.set(null);
TestBed.inject(LiveLayoutSidebarStateService).setState('collapsed');
TestBed.inject(LiveLayoutPanelStateService).hidePanel(
LIVE_LAYOUT_PANEL.CHANNELS
);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).not.toBeNull();
fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-restore"]'
)
).toBeNull();
});
it('hides the floating restore button when the sidebar is expanded', () => {
it('collapses and restores Channels independently with ARIA and focus transfer', async () => {
selectedCategoryId.set(1);
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).toBeNull();
const hide = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
) as HTMLButtonElement;
expect(hide.getAttribute('aria-controls')).toBe('live-channels-panel');
expect(hide.getAttribute('aria-expanded')).toBe('true');
hide.click();
fixture.detectChanges();
jest.advanceTimersByTime(0);
await Promise.resolve();
const sidebar = fixture.nativeElement.querySelector(
'#live-channels-panel'
) as HTMLElement;
const restore = fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-restore"]'
) as HTMLButtonElement;
expect(sidebar.hasAttribute('inert')).toBe(true);
expect(sidebar.getAttribute('aria-hidden')).toBe('true');
expect(livePanelState.channelsIntent()).toBe('collapsed');
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(document.activeElement).toBe(restore);
restore.click();
fixture.detectChanges();
jest.advanceTimersByTime(0);
await Promise.resolve();
expect(livePanelState.channelsIntent()).toBe('expanded');
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
)
);
});
it('uses Cmd/Ctrl+B as temporary suppression without rewriting panel intents', () => {
selectedCategoryId.set(1);
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
fixture.detectChanges();
component.handleSidebarShortcut(
new KeyboardEvent('keydown', { ctrlKey: true, key: 'b' })
);
fixture.detectChanges();
expect(livePanelState.masterSuppressed()).toBe(true);
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(livePanelState.channelsIntent()).toBe('expanded');
component.handleSidebarShortcut(
new KeyboardEvent('keydown', { metaKey: true, key: 'b' })
);
expect(livePanelState.masterSuppressed()).toBe(false);
});
it('persists the channels sidebar width under a dedicated storage key', () => {
@@ -21,12 +21,15 @@ import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
import {
GridListComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
PortalChannelSortMode,
getPortalChannelSortModeLabel,
@@ -121,9 +124,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly livePanelState = inject(LiveLayoutPanelStateService);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
@@ -212,7 +213,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
);
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly channelsPanelExpanded = computed(() =>
this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.CHANNELS, {
applicable: this.showLiveChannelSidebar(),
})
);
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
this.activeCatchupProgram() ?? this.currentEpgItem()
@@ -269,6 +274,8 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
private previousChannelsPanelApplicable: boolean | undefined;
private previousChannelsPanelExpanded: boolean | undefined;
readonly usesEmbeddedPlayer = computed(() =>
this.portalPlayer.isEmbeddedPlayer()
@@ -302,6 +309,28 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
});
effect(() => {
const applicable = this.showLiveChannelSidebar();
const expanded = this.channelsPanelExpanded();
const shouldTransferFocus =
this.previousChannelsPanelApplicable === applicable &&
this.previousChannelsPanelExpanded !== undefined &&
this.previousChannelsPanelExpanded !== expanded;
this.previousChannelsPanelApplicable = applicable;
this.previousChannelsPanelExpanded = expanded;
if (shouldTransferFocus) {
queueMicrotask(() => {
const action = expanded ? 'hide' : 'restore';
this.hostElement.nativeElement
.querySelector<HTMLElement>(
`[data-testid="live-channels-panel-${action}"]`
)
?.focus();
});
}
});
// Read pending auto-open state on every NavigationEnd — covers both the
// initial navigation (Angular fires NavigationEnd after component creation)
// and re-navigation to the same /live route when the component is reused.
@@ -501,8 +530,24 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
hideChannelsPanel(): void {
this.livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS);
this.focusChannelsPanelAction('restore');
}
showChannelsPanel(): void {
this.livePanelState.showPanel(LIVE_LAYOUT_PANEL.CHANNELS);
this.focusChannelsPanelAction('hide');
}
private focusChannelsPanelAction(action: 'hide' | 'restore'): void {
setTimeout(() => {
this.hostElement.nativeElement
.querySelector<HTMLElement>(
`[data-testid="live-channels-panel-${action}"]`
)
?.focus();
}, 0);
}
@HostListener('document:keydown', ['$event'])
@@ -513,7 +558,11 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
!isTypingInInput(event)
) {
event.preventDefault();
this.toggleSidebar();
this.livePanelState.toggleMasterSuppression(
this.showLiveChannelSidebar()
? [LIVE_LAYOUT_PANEL.GROUPS, LIVE_LAYOUT_PANEL.CHANNELS]
: [LIVE_LAYOUT_PANEL.GROUPS]
);
}
}
+18 -24
View File
@@ -43,6 +43,7 @@
width: 0 !important;
min-width: 0 !important;
border-right-color: transparent;
pointer-events: none;
}
&.sidebar-collapsed .resize-handle {
@@ -67,29 +68,23 @@
position: relative;
}
// ─── Floating restore handle (visible when sidebar is collapsed) ─────────
// Anchored top-left of .content-container so it sits in roughly the same
// spot as the hide toggle that lived at the sidebar header's top-right.
.sidebar-restore {
position: absolute;
top: 6px;
left: 6px;
// Player internals, including diagnostic overlays, may use z-index 5
// inside the content area. Keep the restore affordance above them for
// both paint order and hit testing.
z-index: 6;
background: rgba(128, 128, 128, 0.18);
color: var(--mat-sys-on-surface, rgba(255, 255, 255, 0.75));
box-shadow: none;
opacity: 0.7;
transition:
opacity 0.15s ease,
background 0.15s ease;
.sidebar-panel-toggle {
min-width: 40px;
min-height: 40px;
}
.mat-mdc-button-persistent-ripple,
.mat-mdc-button-touch-target {
display: none;
}
// ─── Boundary restore rail (visible when sidebar is collapsed) ───────────
.sidebar-restore {
width: 40px;
min-width: 40px;
height: 100%;
min-height: 40px;
flex: 0 0 40px;
border-radius: 0;
background: var(--app-rail-bg, var(--mat-sys-surface-container-low));
color: var(--mat-sys-on-surface-variant);
box-shadow: none;
transition: background 0.15s ease;
mat-icon {
font-size: 20px;
@@ -100,8 +95,7 @@
&:hover,
&:focus-visible {
opacity: 1;
background: rgba(128, 128, 128, 0.32);
background: var(--mat-sys-surface-container);
}
}