fix(ui): ignore hidden panels in master shortcut

This commit is contained in:
4gray committed 2026-07-31 10:22:05 +02:00
1 parent fae19c2041
commit 18c82144a4
10 files changed
+155 -67

No files matched your search

@@ -123,9 +123,17 @@ test('@live-panels @m3u @electron keeps Groups and Channels independent, persist
)
.toBe('collapsed');
await showPanel(page, 'groups');
await expectPanelState(page, 'groups', true);
await expectPanelState(page, 'channels', false);
await page.setViewportSize({ width: 580, height: 900 });
await expect(panelControl(page, 'groups', 'restore')).toHaveCount(0);
await expect(panelControl(page, 'channels', 'restore')).toBeVisible();
await page.keyboard.press(masterShortcut());
await page.setViewportSize({ width: 1280, height: 720 });
await expectPanelState(page, 'groups', true);
await expectPanelState(page, 'channels', false);
await page.setViewportSize({ width: 580, height: 900 });
await showPanel(page, 'channels');
await expectPanelState(page, 'channels', true);
} finally {
@@ -296,7 +304,7 @@ async function showPanel(
const control = panelControl(page, panel, 'restore');
await expect(control).toBeVisible();
await expect(control).toHaveAttribute('aria-expanded', 'false');
await control.click();
await control.press('Enter');
}
async function expectPanelState(
@@ -88,7 +88,7 @@ expect(
responsiveSuppressed: true,
})
).toBe(false);
service.toggleMasterSuppression(['groups', 'channels']);
service.toggleMasterSuppression(['groups']);
expect(service.masterSuppressed()).toBe(true);
service.showPanel('channels');
expect(service.masterSuppressed()).toBe(false);
@@ -144,7 +144,9 @@ export class LiveLayoutPanelStateService {
hidePanel(panel: LiveLayoutPanel): void;
showPanel(panel: LiveLayoutPanel): void;
toggleMasterSuppression(applicablePanels: readonly LiveLayoutPanel[]): void;
toggleMasterSuppression(
effectivelyVisiblePanels: readonly LiveLayoutPanel[]
): void;
}
```
@@ -447,13 +449,16 @@ Inject `LiveLayoutPanelStateService`, compute Channels effective visibility for
```ts
this.livePanelState.toggleMasterSuppression(
this.showLiveChannelSidebar() ? ['groups', 'channels'] : ['groups']
this.effectivelyVisibleLeftPanels()
);
```
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"`.
The shortcut helper must resolve Groups with the workspace `1023px`
responsive suppression and include Channels only when its effective state is
expanded; persisted intent alone must not manufacture a visible panel.
- [x] **Step 4: Apply the same contract to Stalker**
@@ -753,7 +758,7 @@ git commit -m "docs(ui): document live panel ownership"
- Review every file changed relative to `origin/master`.
- [ ] **Step 1: Run affected unit targets**
- [x] **Step 1: Run affected unit targets**
```bash
pnpm nx run-many \
@@ -764,7 +769,7 @@ pnpm nx run-many \
Expected: all affected unit targets pass.
- [ ] **Step 2: Run affected lint**
- [x] **Step 2: Run affected lint**
```bash
pnpm nx run-many \
@@ -775,7 +780,7 @@ pnpm nx run-many \
Expected: all affected lint targets pass.
- [ ] **Step 3: Run build and atomized E2E**
- [x] **Step 3: Run build and atomized E2E**
```bash
pnpm nx build web
@@ -784,7 +789,7 @@ pnpm nx run electron-backend-e2e:e2e-ci--src/live-panel-toggles.e2e.ts
Expected: build and all panel E2E tests pass.
- [ ] **Step 4: Run repository policy checks**
- [x] **Step 4: Run repository policy checks**
```bash
pnpm run i18n:check
@@ -795,7 +800,7 @@ git diff --check origin/master...HEAD
Expected: all checks exit 0.
- [ ] **Step 5: Perform running Electron visual/accessibility verification**
- [x] **Step 5: Perform running Electron visual/accessibility verification**
Start `pnpm run serve:backend`, connect through the required Computer Use or
Electron CDP workflow, and inspect M3U, Xtream, Stalker, Favorites, Recent,
@@ -803,7 +808,7 @@ desktop, narrow, dark, and light states. Record exact target sizes, focus
movement, absence of duplicate/no-op controls, reclaimed widths, and restore
ordering.
- [ ] **Step 6: Review the complete diff against the design**
- [x] **Step 6: Review the complete diff against the design**
```bash
git status --short
@@ -816,7 +821,7 @@ Re-read every requirement in the design and verify a code path plus test
covers it. Confirm no channel-row responsive selector and no external-player
right-region sizing rule changed.
- [ ] **Step 7: Fix review findings with test-first cycles**
- [x] **Step 7: Fix review findings with test-first cycles**
For every finding, add or tighten the closest failing test, run it RED, make the
smallest correction, rerun GREEN, and commit with an appropriate conventional
@@ -1123,6 +1123,29 @@ describe('VideoPlayerComponent', () => {
);
});
it('does not enable master suppression when every panel is effectively hidden on mobile', async () => {
routeParams$.next({ id: playlistId(), view: 'groups' });
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS);
await renderSidebar();
breakpointState$.next({
breakpoints: { '(max-width: 599px)': true },
matches: true,
});
fixture.detectChanges();
component.handleKeyPress(
new KeyboardEvent('keydown', {
key: 'b',
ctrlKey: true,
})
);
expect(livePanelState.masterSuppressed()).toBe(false);
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(livePanelState.channelsIntent()).toBe('collapsed');
});
it('keeps loading controls wired to both effective panel states', async () => {
routeParams$.next({ id: playlistId(), view: 'groups' });
channelsLoading.set(true);
@@ -961,7 +961,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
) {
event.preventDefault();
this.livePanelState.toggleMasterSuppression(
this.applicableLeftPanels()
this.effectivelyVisibleLeftPanels()
);
return;
}
@@ -975,10 +975,15 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
}
private applicableLeftPanels(): readonly LiveLayoutPanel[] {
return this.groupsApplicable()
? [LIVE_LAYOUT_PANEL.GROUPS, LIVE_LAYOUT_PANEL.CHANNELS]
: [LIVE_LAYOUT_PANEL.CHANNELS];
private effectivelyVisibleLeftPanels(): readonly LiveLayoutPanel[] {
const panels: LiveLayoutPanel[] = [];
if (this.groupsPanelExpanded()) {
panels.push(LIVE_LAYOUT_PANEL.GROUPS);
}
if (this.channelsPanelExpanded()) {
panels.push(LIVE_LAYOUT_PANEL.CHANNELS);
}
return panels;
}
/**
@@ -17,9 +17,9 @@ describe('LiveLayoutPanelStateService', () => {
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('expanded');
expect(
localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
expect(localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)).toBe(
'expanded'
);
expect(
localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
@@ -38,14 +38,8 @@ describe('LiveLayoutPanelStateService', () => {
});
it('keeps valid new keys and migrates only a missing or invalid sibling', () => {
localStorage.setItem(
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
'expanded'
);
localStorage.setItem(
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
'hidden'
);
localStorage.setItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY, 'expanded');
localStorage.setItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY, 'hidden');
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'collapsed');
const service = createService();
@@ -76,9 +70,9 @@ describe('LiveLayoutPanelStateService', () => {
expect(service.groupsIntent()).toBe('collapsed');
expect(service.channelsIntent()).toBe('expanded');
expect(
localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)
).toBe('collapsed');
expect(localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)).toBe(
'collapsed'
);
expect(
localStorage.getItem(LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
@@ -110,33 +104,33 @@ describe('LiveLayoutPanelStateService', () => {
})
).toBe(false);
expect(service.groupsIntent()).toBe('expanded');
expect(
localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)
).toBe('expanded');
expect(localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)).toBe(
'expanded'
);
});
it('temporarily suppresses applicable visible panels and restores their persisted intents', () => {
const service = createService();
service.hidePanel('channels');
service.toggleMasterSuppression(['groups', 'channels']);
service.toggleMasterSuppression(['groups']);
expect(service.masterSuppressed()).toBe(true);
expect(
service.isPanelExpanded('groups', { applicable: true })
).toBe(false);
expect(service.isPanelExpanded('groups', { applicable: true })).toBe(
false
);
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('collapsed');
service.toggleMasterSuppression(['groups', 'channels']);
service.toggleMasterSuppression([]);
expect(service.masterSuppressed()).toBe(false);
expect(
service.isPanelExpanded('groups', { applicable: true })
).toBe(true);
expect(
service.isPanelExpanded('channels', { applicable: true })
).toBe(false);
expect(service.isPanelExpanded('groups', { applicable: true })).toBe(
true
);
expect(service.isPanelExpanded('channels', { applicable: true })).toBe(
false
);
});
it('exits master suppression before applying a panel-local action', () => {
@@ -155,14 +149,14 @@ describe('LiveLayoutPanelStateService', () => {
expect(service.groupsIntent()).toBe('collapsed');
});
it('leaves master suppression off when no applicable intent is visible', () => {
it('leaves master suppression off when no panel is effectively visible', () => {
const service = createService();
service.hidePanel('groups');
service.hidePanel('channels');
service.toggleMasterSuppression(['groups', 'channels']);
service.toggleMasterSuppression([]);
expect(service.masterSuppressed()).toBe(false);
expect(service.groupsIntent()).toBe('expanded');
expect(service.channelsIntent()).toBe('expanded');
});
});
@@ -16,8 +16,7 @@ export type LiveLayoutPanel =
export type LivePanelState = LiveSidebarState;
export const LIVE_GROUPS_PANEL_STATE_STORAGE_KEY =
'live-groups-panel-state';
export const LIVE_GROUPS_PANEL_STATE_STORAGE_KEY = 'live-groups-panel-state';
export const LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY =
'live-channels-panel-state';
@@ -63,17 +62,14 @@ export class LiveLayoutPanelStateService {
}
toggleMasterSuppression(
applicablePanels: readonly LiveLayoutPanel[]
effectivelyVisiblePanels: readonly LiveLayoutPanel[]
): void {
if (this._masterSuppressed()) {
this._masterSuppressed.set(false);
return;
}
const hasVisiblePanel = applicablePanels.some(
(panel) => this.intentFor(panel)() === 'expanded'
);
this._masterSuppressed.set(hasVisiblePanel);
this._masterSuppressed.set(effectivelyVisiblePanels.length > 0);
}
private setPanelIntent(
@@ -97,9 +93,7 @@ export class LiveLayoutPanelStateService {
}
function restoreLiveLeftPanelIntents(): LiveLeftPanelIntents {
const legacyValue = localStorage.getItem(
LIVE_SIDEBAR_STATE_STORAGE_KEY
);
const legacyValue = localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
const legacyFallback = isLiveSidebarState(legacyValue)
? legacyValue
: DEFAULT_LIVE_SIDEBAR_STATE;
@@ -538,9 +538,9 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}
event.preventDefault();
this.livePanelState.toggleMasterSuppression([
LIVE_LAYOUT_PANEL.CHANNELS,
]);
this.livePanelState.toggleMasterSuppression(
this.channelsPanelExpanded() ? [LIVE_LAYOUT_PANEL.CHANNELS] : []
);
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
@@ -6,6 +6,7 @@ import {
output,
signal,
} from '@angular/core';
import { BreakpointObserver } from '@angular/cdk/layout';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -682,6 +683,24 @@ describe('StalkerLiveStreamLayoutComponent', () => {
expect(livePanelState.masterSuppressed()).toBe(false);
});
it('ignores the shortcut when compact Groups and collapsed Channels leave no visible panel', () => {
const livePanelState = TestBed.inject(LiveLayoutPanelStateService);
livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS);
jest.spyOn(
TestBed.inject(BreakpointObserver),
'isMatched'
).mockReturnValue(true);
fixture.detectChanges();
component.handleSidebarShortcut(
new KeyboardEvent('keydown', { ctrlKey: true, key: 'b' })
);
expect(livePanelState.masterSuppressed()).toBe(false);
expect(livePanelState.groupsIntent()).toBe('expanded');
expect(livePanelState.channelsIntent()).toBe('collapsed');
});
it('reuses a pending playback resolution during double-click activation', async () => {
settingsStore.openStreamOnDoubleClick.set(true);
portalPlayer.isEmbeddedPlayer.mockReturnValue(false);
@@ -1,4 +1,5 @@
import { NgTemplateOutlet } from '@angular/common';
import { BreakpointObserver } from '@angular/cdk/layout';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -57,6 +58,7 @@ import {
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
type LiveLayoutPanel,
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
@@ -86,6 +88,7 @@ type StalkerPlayableChannel = StalkerPortalItem & {
/** Channels rendered per "page" when the full list is served from the cache. */
const FULL_LIST_RENDER_CHUNK = 100;
const WORKSPACE_GROUPS_COMPACT_QUERY = '(max-width: 1023px)';
@Component({
selector: 'app-stalker-live-stream-layout',
@@ -123,6 +126,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
private readonly translate = inject(TranslateService);
private readonly livePanelState = inject(LiveLayoutPanelStateService);
private readonly hostElement = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly breakpointObserver = inject(BreakpointObserver);
private readonly logger = createLogger('StalkerLiveStream');
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
@@ -778,13 +782,29 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
) {
event.preventDefault();
this.livePanelState.toggleMasterSuppression(
this.channelsPanelApplicable()
? [LIVE_LAYOUT_PANEL.GROUPS, LIVE_LAYOUT_PANEL.CHANNELS]
: [LIVE_LAYOUT_PANEL.GROUPS]
this.effectivelyVisibleLeftPanels()
);
}
}
private effectivelyVisibleLeftPanels(): readonly LiveLayoutPanel[] {
const panels: LiveLayoutPanel[] = [];
if (
this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.GROUPS, {
applicable: true,
responsiveSuppressed: this.breakpointObserver.isMatched(
WORKSPACE_GROUPS_COMPACT_QUERY
),
})
) {
panels.push(LIVE_LAYOUT_PANEL.GROUPS);
}
if (this.channelsPanelExpanded()) {
panels.push(LIVE_LAYOUT_PANEL.CHANNELS);
}
return panels;
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
@@ -1,4 +1,5 @@
import { NgTemplateOutlet } from '@angular/common';
import { BreakpointObserver } from '@angular/cdk/layout';
import {
ChangeDetectionStrategy,
Component,
@@ -22,6 +23,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
type LiveLayoutPanel,
LiveLayoutPanelStateService,
LIVE_LAYOUT_PANEL,
} from '@iptvnator/portal/shared/data-access';
@@ -75,6 +77,7 @@ import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
const WORKSPACE_GROUPS_COMPACT_QUERY = '(max-width: 1023px)';
interface XtreamLiveChannelItem {
readonly added?: string;
@@ -126,6 +129,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly livePanelState = inject(LiveLayoutPanelStateService);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
private readonly breakpointObserver = inject(BreakpointObserver);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
@@ -559,13 +563,29 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
) {
event.preventDefault();
this.livePanelState.toggleMasterSuppression(
this.showLiveChannelSidebar()
? [LIVE_LAYOUT_PANEL.GROUPS, LIVE_LAYOUT_PANEL.CHANNELS]
: [LIVE_LAYOUT_PANEL.GROUPS]
this.effectivelyVisibleLeftPanels()
);
}
}
private effectivelyVisibleLeftPanels(): readonly LiveLayoutPanel[] {
const panels: LiveLayoutPanel[] = [];
if (
this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.GROUPS, {
applicable: true,
responsiveSuppressed: this.breakpointObserver.isMatched(
WORKSPACE_GROUPS_COMPACT_QUERY
),
})
) {
panels.push(LIVE_LAYOUT_PANEL.GROUPS);
}
if (this.channelsPanelExpanded()) {
panels.push(LIVE_LAYOUT_PANEL.CHANNELS);
}
return panels;
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)