fix(m3u): keep the sidebar mounted while the guide is open

Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".

The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 committed 2026-09-06 13:40:47 +02:00
1 parent 8d73286d93
commit 1fc8e1cf73
3 files changed
+90 -58

No files matched your search

@@ -1,55 +1,62 @@
@if (!guideOpen()) {
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@if (activeView() !== 'groups' && activeView() !== 'all') {
<button
mat-icon-button
type="button"
class="sidebar-collapse-toggle"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
} @placeholder {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
}
</div>
}
<!--
Guide mode HIDES this sidebar instead of removing it: destroying it would
destroy `app-channel-list-container`, whose `ngOnDestroy` dispatches
`resetActiveChannel()` — that clears the active channel the guide itself
needs, so the guide would close again the moment it opened. `inert` keeps
the hidden rail out of the tab order and away from assistive technology.
-->
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[class.sidebar--guide-hidden]="guideOpen()"
[attr.inert]="guideOpen() ? '' : null"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@if (activeView() !== 'groups' && activeView() !== 'all') {
<button
mat-icon-button
type="button"
class="sidebar-collapse-toggle"
[attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[channelsLoading]="channelsLoading()"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
} @placeholder {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
/>
}
</div>
<div
class="content-container"
@@ -147,6 +147,17 @@
}
// ─── Guide mode ─────────────────────────────────────────────────────────────
// The sidebar stays MOUNTED while the guide is open and is only hidden: its
// channel-list container dispatches `resetActiveChannel()` from `ngOnDestroy`,
// so removing it would clear the active channel the guide needs and close the
// guide again. `!important` beats the resizable directive's inline width, and
// it is declared after the phone breakpoint so the bottom-drawer rules
// (`height: 50vh`, borders) cannot leave a phantom band on small screens.
// Hiding rather than removing also preserves the list's scroll position.
.sidebar.sidebar--guide-hidden {
display: none !important;
}
// The player keeps its DOM position and is only re-flowed into a docked strip;
// no remount, so playback (and native-view Embedded MPV bounds) survive.
.content-container.is-guide {
@@ -273,7 +273,7 @@ describe('VideoPlayerComponent', () => {
expect(headerContext.action()).toBeNull();
});
it('opens the guide in place of sidebar and timeline without remounting the player', () => {
it('opens the guide, hides the sidebar without unmounting it, and keeps the player', () => {
syncStoreState(sampleChannel);
player.set(VideoPlayer.VideoJs);
fixture.detectChanges();
@@ -284,7 +284,9 @@ describe('VideoPlayerComponent', () => {
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.sidebar')).not.toBeNull();
const sidebarBefore = fixture.nativeElement.querySelector('.sidebar');
expect(sidebarBefore).not.toBeNull();
storeMock.dispatch.mockClear();
component.openGuide();
fixture.detectChanges();
@@ -295,7 +297,18 @@ describe('VideoPlayerComponent', () => {
expect(
fixture.nativeElement.querySelector('app-epg-guide-now-playing')
).not.toBeNull();
expect(fixture.nativeElement.querySelector('.sidebar')).toBeNull();
// Regression: the sidebar used to sit behind `@if (!guideOpen())`, so
// opening the guide destroyed the channel-list container, whose
// `ngOnDestroy` dispatches `resetActiveChannel()`. That cleared the
// active channel and closed the guide again the moment it opened.
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
ChannelActions.resetActiveChannel()
);
const sidebarWhileOpen =
fixture.nativeElement.querySelector('.sidebar');
expect(sidebarWhileOpen).toBe(sidebarBefore);
expect(sidebarWhileOpen.classList).toContain('sidebar--guide-hidden');
expect(sidebarWhileOpen.hasAttribute('inert')).toBe(true);
expect(
fixture.nativeElement.querySelector('app-epg-timeline')
).toBeNull();
@@ -310,9 +323,10 @@ describe('VideoPlayerComponent', () => {
component.closeGuide();
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('app-epg-guide')).toBeNull();
// The sidebar list itself sits behind an `@defer`, so the assertion
// targets the container the guide mode actually removes.
expect(fixture.nativeElement.querySelector('.sidebar')).not.toBeNull();
const sidebarAfter = fixture.nativeElement.querySelector('.sidebar');
expect(sidebarAfter).toBe(sidebarBefore);
expect(sidebarAfter.classList).not.toContain('sidebar--guide-hidden');
expect(sidebarAfter.hasAttribute('inert')).toBe(false);
expect(fixture.nativeElement.querySelector('app-web-player-view')).toBe(
playerBefore
);