mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 19:46:16 -08:00
feat(ui): separate portal live panel toggles
This commit is contained in:
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
|
||||
|
||||
+39
-21
@@ -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() &&
|
||||
|
||||
+81
-12
@@ -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();
|
||||
|
||||
+64
-13
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
-18
@@ -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()"
|
||||
|
||||
+88
-14
@@ -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', () => {
|
||||
|
||||
+57
-8
@@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user