feat(sidebar): implement shared collapse state for live-TV sidebar across components

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-03 23:26:52 +02:00
1 parent bbbe7b4036
commit e36c953865
13 files changed
+214 -54

No files matched your search

+21 -9
View File
@@ -147,20 +147,32 @@ The shared row should be reused instead of rebuilding channel markup per view.
- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle
that hides the channels rail to give the player and EPG full width.
- Collapsed state is persisted in `live-sidebar-state` (helpers in
`@iptvnator/portal/shared/util`); missing or invalid values restore to
expanded. The state is global across all three modules so the user's choice
carries across portals and reloads.
- In Xtream and Stalker live TV, the same toggle also collapses the workspace
shell context sidebar (the "Live Categories" rail rendered by
`WorkspaceShellContextSidebarComponent`), matching M3U's "everything quiets"
behaviour. The shell categories rail only collapses when the active section
is `live` (Xtream) or `itv` (Stalker); movies, series, favorites, and recent
routes leave it untouched.
- Collapsed state is owned by `LiveLayoutSidebarStateService`
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util`. Every surface that
participates injects the service and reads `isCollapsed`; any toggle calls
`service.toggle()`. Persistence delegates to the existing
`live-sidebar-state` helpers, so the localStorage key stays unchanged and
missing/invalid values restore to expanded.
- A `mat-icon-button` with `chevron_left` lives in the sidebar header and
toggles state. While collapsed, a floating `chevron_right` mini-fab appears
at the left edge of `.content-container` to restore the rail.
at the left edge of `.content-container` to restore the rail (and the
categories rail, in Xtream/Stalker live).
- Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate
inside `<input>`, `<textarea>`, `<select>`, or content-editable elements via
the shared `isTypingInInput` helper.
- The CSS class `.sidebar-collapsed` overrides the inline width set by the
`appResizable` directive with `width: 0 !important; min-width: 0 !important`.
The directive's persisted width is preserved so uncollapsing restores the
user's previous resized width.
- The CSS class `.sidebar-collapsed` (channels rail) and
`.context-panel--collapsed` (workspace shell categories rail) both override
the inline width set by the `appResizable` directive with
`width: 0 !important; min-width: 0 !important`. The directive's persisted
width is preserved so uncollapsing restores the user's previous resized
width. Both rails share the same 180 ms width transition so motion stays in
lockstep.
- Below 600 px viewport, the M3U layout's mobile bottom-drawer rule overrides
the desktop collapse to `height: 0` instead of `width: 0`, and the floating
restore handle is hidden.
@@ -38,14 +38,12 @@ import {
getOpenLiveCollectionItemState,
getUnifiedCollectionNavigation,
isWorkspaceLayoutRoute,
LiveSidebarState,
LiveLayoutSidebarStateService,
OPEN_COLLECTION_DETAIL_STATE_KEY,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
persistFavoritesChannelSortMode,
persistLiveSidebarState,
queryParamSignal,
restoreFavoritesChannelSortMode,
restoreLiveSidebarState,
routeParamSignal,
ScopeToggleService,
STALKER_RETURN_TO_STATE_KEY,
@@ -100,6 +98,9 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
private readonly dialogService = inject(DialogService);
private readonly translate = inject(TranslateService);
private readonly workspaceViewCommands = inject(WorkspaceViewCommandService);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
readonly detailTemplate = contentChild(UnifiedCollectionDetailDirective);
private readonly playlists = this.store.selectSignal(
selectAllPlaylistsMeta
@@ -265,12 +266,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType() === 'live' &&
this.hasLive()
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly showSidebarToggle = computed(
() => this.selectedContentType() === 'live' && this.hasLive()
);
@@ -460,11 +456,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
this.liveSidebarStateService.toggle();
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
+1
View File
@@ -13,6 +13,7 @@ export * from './lib/portal-collection-context.service';
export * from './lib/portal-collection-items';
export * from './lib/live-epg-panel-state';
export * from './lib/live-sidebar-state';
export * from './lib/live-layout-sidebar-state.service';
export * from './lib/keyboard';
export * from './lib/remote-channel-navigation';
export * from './lib/workspace-header-context.service';
@@ -0,0 +1,31 @@
import { computed, Injectable, signal } from '@angular/core';
import {
LiveSidebarState,
persistLiveSidebarState,
restoreLiveSidebarState,
} from './live-sidebar-state';
/**
* Shared collapse state for the live-TV sidebar across the workspace shell
* categories rail (Xtream/Stalker), the inline channels rail, and the
* unified-collection live tab. A single signal keeps all surfaces in sync
* within a session; localStorage persistence is delegated to the existing
* `live-sidebar-state` helpers so the storage key stays unchanged.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {
private readonly _state = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly state = this._state.asReadonly();
readonly isCollapsed = computed(() => this._state() === 'collapsed');
toggle(): void {
this.setState(this.isCollapsed() ? 'expanded' : 'collapsed');
}
setState(state: LiveSidebarState): void {
this._state.set(state);
persistLiveSidebarState(state);
}
}
@@ -88,7 +88,7 @@
}
<div class="content-container">
@if (stalkerStore.selectedCategoryId() && isSidebarCollapsed()) {
@if (isSidebarCollapsed()) {
<button
mat-icon-button
type="button"
@@ -44,17 +44,15 @@ import {
WebPlayerViewComponent,
} from 'shared-portals';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
LiveEpgPanelState,
LiveSidebarState,
persistLiveEpgPanelState,
persistLiveSidebarState,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
@@ -91,6 +89,9 @@ 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 logger = createLogger('StalkerLiveStream');
readonly selectedCategoryTitle = this.stalkerStore.getSelectedCategoryName;
@@ -151,12 +152,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -398,11 +394,7 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
this.liveSidebarStateService.toggle();
}
@HostListener('document:keydown', ['$event'])
@@ -78,7 +78,7 @@
</div>
}
<div class="content-container">
@if (selectedCategoryId() && isSidebarCollapsed()) {
@if (isSidebarCollapsed()) {
<button
mat-icon-button
type="button"
@@ -7,6 +7,8 @@ import { TranslatePipe } from '@ngx-translate/core';
import { of } from 'rxjs';
import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
@@ -162,6 +164,7 @@ describe('LiveStreamLayoutComponent', () => {
jest.setSystemTime(fixedNow);
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
window.electron = {
updateRemoteControlStatus: jest.fn(),
@@ -243,13 +246,17 @@ describe('LiveStreamLayoutComponent', () => {
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
});
afterEach(() => {
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
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);
window.electron = originalElectron;
});
@@ -472,6 +479,26 @@ describe('LiveStreamLayoutComponent', () => {
).toBeNull();
});
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
selectedCategoryId.set(null);
TestBed.inject(LiveLayoutSidebarStateService).setState('collapsed');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).not.toBeNull();
});
it('hides the floating restore button when the sidebar is expanded', () => {
selectedCategoryId.set(1);
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).toBeNull();
});
it('hides the archive-unavailable notice when there are no past programs yet', () => {
const nonArchiveChannel = {
...sampleChannel,
@@ -18,6 +18,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from 'components';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
PortalChannelSortMode,
getPortalChannelSortModeLabel,
@@ -26,13 +27,10 @@ import {
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
LiveSidebarState,
persistLiveEpgPanelState,
persistLiveSidebarState,
persistPortalChannelSortMode,
queryParamSignal,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
restorePortalChannelSortMode,
} from '@iptvnator/portal/shared/util';
import {
@@ -103,6 +101,9 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
private readonly xtreamStore = inject(XtreamStore);
private readonly xtreamUrlService = inject(XtreamUrlService);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
readonly categoryItemCounts = this.xtreamStore.getCategoryItemCounts;
@@ -177,12 +178,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentEpgItem())
);
@@ -379,11 +375,7 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
this.liveSidebarStateService.toggle();
}
@HostListener('document:keydown', ['$event'])
@@ -14,6 +14,7 @@
@if (section(); as section) {
<aside
class="context-panel context-panel--route"
[class.context-panel--collapsed]="isContextPanelCollapsed()"
appResizable
[minWidth]="240"
[maxWidth]="560"
@@ -16,6 +16,22 @@
min-width: 0;
min-height: 0;
overflow: hidden;
transition:
width 180ms ease,
padding 180ms ease,
border-right-color 180ms ease;
}
.context-panel--collapsed {
width: 0 !important;
min-width: 0 !important;
padding-left: 0;
padding-right: 0;
border-right-color: transparent;
.resize-handle {
display: none;
}
}
.context-panel--route {
@@ -4,6 +4,7 @@ import {
input,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
@Directive({
@@ -50,8 +51,11 @@ class MockWorkspaceSourcesFiltersPanelComponent {}
describe('WorkspaceShellContextSidebarComponent', () => {
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
let liveSidebarService: LiveLayoutSidebarStateService;
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state');
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
})
@@ -69,6 +73,8 @@ describe('WorkspaceShellContextSidebarComponent', () => {
.compileComponents();
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('expanded');
});
it('renders the settings panel for the settings variant', () => {
@@ -106,4 +112,69 @@ describe('WorkspaceShellContextSidebarComponent', () => {
)
).not.toBeNull();
});
describe('live sidebar collapse', () => {
function setupLiveCategory(section: 'live' | 'itv' | 'vod'): void {
fixture.componentRef.setInput('variant', 'category');
fixture.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'pl-1',
});
fixture.componentRef.setInput('section', section);
fixture.detectChanges();
}
it('collapses the categories rail when the shared service is collapsed on a live route', () => {
setupLiveCategory('live');
liveSidebarService.setState('collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
});
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
liveSidebarService.setState('collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
});
it('does not collapse the categories rail on non-live sections', () => {
setupLiveCategory('vod');
liveSidebarService.setState('collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
false
);
});
it('keeps the categories rail expanded when the service is expanded', () => {
setupLiveCategory('live');
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
false
);
});
});
});
@@ -1,10 +1,15 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core';
import { ResizableDirective } from 'components';
import { PortalRailSection } from '@iptvnator/portal/shared/util';
import {
LiveLayoutSidebarStateService,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
@@ -14,6 +19,8 @@ import { WorkspaceContextPanelComponent } from '../../../workspace-context-panel
import { WorkspaceSettingsContextPanelComponent } from '../../../workspace-context-panel/workspace-settings-context-panel.component';
import { WorkspaceSourcesFiltersPanelComponent } from '../../../workspace-sources-filters-panel/workspace-sources-filters-panel.component';
const LIVE_SECTIONS: ReadonlySet<PortalRailSection> = new Set(['live', 'itv']);
@Component({
selector: 'app-workspace-shell-context-sidebar',
imports: [
@@ -28,8 +35,26 @@ import { WorkspaceSourcesFiltersPanelComponent } from '../../../workspace-source
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WorkspaceShellContextSidebarComponent {
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
readonly variant = input.required<WorkspaceShellContextPanel>();
readonly context = input<WorkspacePortalContext | null>(null);
readonly section = input<PortalRailSection | null>(null);
readonly hasPlaylists = input(true);
readonly isLiveCategoryRoute = computed(() => {
const section = this.section();
return (
this.variant() === 'category' &&
section !== null &&
LIVE_SECTIONS.has(section)
);
});
readonly isContextPanelCollapsed = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsed()
);
}