From 60539a46d703fbd71d843f05a7b5415060e4b040 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 31 Jul 2026 09:23:25 +0200 Subject: [PATCH] feat(ui): separate M3U live panel toggles --- ...026-07-31-live-panel-toggle-consistency.md | 14 +- .../video-player/video-player.component.html | 90 ++++--- .../video-player/video-player.component.scss | 27 +- .../video-player.component.spec.ts | 237 +++++++++++++++++- .../video-player/video-player.component.ts | 128 ++++++++-- .../all-channels-view.component.html | 4 +- .../channel-list-container.component.html | 2 + .../channel-list-container.component.ts | 1 + .../groups-view/groups-view.component.html | 2 +- .../groups-view/groups-view.component.spec.ts | 29 +++ .../groups-view/groups-view.component.ts | 1 + .../channel-list-loading-state.component.html | 4 +- ...annel-list-loading-state.component.spec.ts | 18 ++ .../channel-list-loading-state.component.ts | 1 + .../sidebar/sidebar.component.html | 1 + .../video-player/sidebar/sidebar.component.ts | 1 + 16 files changed, 461 insertions(+), 99 deletions(-) diff --git a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md index ca669508c..27b633a37 100644 --- a/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md +++ b/docs/superpowers/plans/2026-07-31-live-panel-toggle-consistency.md @@ -491,7 +491,7 @@ git commit -m "feat(ui): separate portal live panel toggles" - Modify: `libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.{ts,html,scss,spec.ts}` -- [ ] **Step 1: Write failing M3U feature tests** +- [x] **Step 1: Write failing M3U feature tests** Prove All Channels uses the shared Channels intent, Groups passes two independent effective inputs, mobile responsive suppression does not persist @@ -499,7 +499,7 @@ Groups intent, both collapsed controls order Groups before Channels, loading retains structural controls, `Cmd/Ctrl+B` preserves intents, and mobile keeps a Channels restore button. -- [ ] **Step 2: Run and verify RED** +- [x] **Step 2: Run and verify RED** ```bash pnpm nx test playlist-m3u-feature-player --runInBand @@ -507,7 +507,7 @@ pnpm nx test playlist-m3u-feature-player --runInBand Expected: FAIL because M3U still owns a local legacy-key signal. -- [ ] **Step 3: Replace local state with the shared service** +- [x] **Step 3: Replace local state with the shared service** Remove `restoreLiveSidebarState`/`persistLiveSidebarState` usage. Inject `LiveLayoutPanelStateService`, observe `(max-width: 599px)`, and compute @@ -523,20 +523,20 @@ readonly channelsApplicable = computed( Use responsive suppression only for Groups. Pass explicit panel inputs/outputs through `app-sidebar` and the loading state. -- [ ] **Step 4: Preserve focus and mobile restore geometry** +- [x] **Step 4: Preserve focus and mobile restore geometry** For All Channels, hide the resizable panel with inert/aria-hidden and place the restore rail between sidebar and content. For Groups, let the shared GroupsView own its two internal rails. On mobile, keep the Channels restore rail visible and let content reclaim height when Channels is collapsed. -- [ ] **Step 5: Wire Guide capability** +- [x] **Step 5: Wire Guide capability** Pass `collapsible=shouldShowInlinePlayer(activeChannel)` and `panelId="live-guide-panel"` to both EPG modes. Radio continues to omit the Guide region. -- [ ] **Step 6: Run and verify GREEN** +- [x] **Step 6: Run and verify GREEN** ```bash pnpm nx test playlist-m3u-feature-player --runInBand @@ -545,7 +545,7 @@ pnpm nx lint playlist-m3u-feature-player Expected: both commands exit 0. -- [ ] **Step 7: Commit** +- [x] **Step 7: Commit** ```bash git add libs/playlist/m3u/feature-player diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html index f4e33afdb..ee49e2410 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.html @@ -1,6 +1,9 @@ -
- @if (isSidebarCollapsed()) { - - } +@if (isAllChannelsPanelCollapsed()) { + +} + +
@if (activeChannel(); as activeChannel) { @if ( activeChannel.radio === 'true' || @@ -109,10 +129,13 @@
@if (epgViewMode() === 'list') { + } + `, }) class StubSidebarComponent { readonly channels = input([]); readonly channelsLoading = input(false); readonly showPlaylistHeader = input(false); readonly activeView = input(''); + readonly groupsPanelExpanded = input(true); + readonly groupsPanelRestoreAvailable = input(true); + readonly channelsPanelExpanded = input(true); readonly sidebarWidth = input(0); readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); - readonly sidebarToggleRequested = output(); + readonly groupsPanelExpandedChange = output(); + readonly channelsPanelExpandedChange = output(); } @Component({ @@ -140,6 +167,8 @@ class StubEpgTimelineComponent { readonly emptyReason = input(null); readonly selectedDate = input(null); readonly collapsed = input(false); + readonly collapsible = input(true); + readonly panelId = input(null); readonly summary = input(null); readonly summaryLabelKey = input(''); readonly programActivated = output<{ @@ -166,11 +195,20 @@ class StubResizableDirective { readonly resizeEnd = output(); } +@Directive({ + selector: '[matTooltip]', + standalone: true, +}) +class StubMatTooltipDirective { + readonly matTooltip = input(''); +} + describe('VideoPlayerComponent', () => { let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent; let fixture: ComponentFixture; let component: VideoPlayerComponentInstance; let headerContext: WorkspaceHeaderContextService; + let livePanelState: LiveLayoutPanelStateService; const playlistId = signal('playlist-1'); const activeChannel = signal(null); @@ -184,6 +222,14 @@ describe('VideoPlayerComponent', () => { const activeChannel$ = new BehaviorSubject(null); const currentEpgProgram$ = new BehaviorSubject(null); const epgPrograms$ = new BehaviorSubject([]); + const routeParams$ = new BehaviorSubject>({ + id: 'playlist-1', + view: 'all', + }); + const breakpointState$ = new BehaviorSubject({ + breakpoints: {}, + matches: false, + }); const epgServiceMock = { currentEpgPrograms$: epgPrograms$.asObservable(), getChannelMetadataForChannels: () => of(new Map()), @@ -313,6 +359,11 @@ describe('VideoPlayerComponent', () => { syncStoreState(null); localStorage.removeItem('m3u-sidebar-width'); 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); + routeParams$.next({ id: playlistId(), view: 'all' }); + breakpointState$.next({ breakpoints: {}, matches: false }); player.set(VideoPlayer.VideoJs); showCaptions.set(false); stripCountryPrefix.set(false); @@ -337,7 +388,7 @@ describe('VideoPlayerComponent', () => { { provide: ActivatedRoute, useValue: { - params: of({ id: playlistId(), view: 'all' }), + params: routeParams$.asObservable(), queryParams: of({}), snapshot: { data: { layout: 'workspace' }, @@ -349,6 +400,12 @@ describe('VideoPlayerComponent', () => { provide: Router, useValue: routerMock, }, + { + provide: BreakpointObserver, + useValue: { + observe: jest.fn(() => breakpointState$.asObservable()), + }, + }, { provide: Store, useValue: storeMock, @@ -424,7 +481,10 @@ describe('VideoPlayerComponent', () => { StubPortalEmptyStateComponent, StubResizableDirective, StubSidebarComponent, + StubMatTooltipDirective, StubWebPlayerViewComponent, + MatButtonModule, + MatIconModule, MockPipe( TranslatePipe, (value: string | null | undefined) => value ?? '' @@ -437,11 +497,24 @@ describe('VideoPlayerComponent', () => { fixture = TestBed.createComponent(VideoPlayerComponent); component = fixture.componentInstance; headerContext = TestBed.inject(WorkspaceHeaderContextService); + livePanelState = TestBed.inject(LiveLayoutPanelStateService); }); + async function renderSidebar( + state: DeferBlockState = DeferBlockState.Complete + ): Promise { + fixture.detectChanges(); + const [deferBlock] = await fixture.getDeferBlocks(); + await deferBlock?.render(state); + fixture.detectChanges(); + } + afterEach(() => { fixture?.destroy(); 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; }); @@ -535,7 +608,9 @@ describe('VideoPlayerComponent', () => { 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(); expect(headerContext.action()).toBeNull(); }); @@ -625,6 +700,24 @@ describe('VideoPlayerComponent', () => { .querySelector('.epg') ?.classList.contains('epg-collapsed') ).toBe(false); + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.collapsible()).toBe(false); + expect(timeline.componentInstance.panelId()).toBe('live-guide-panel'); + }); + + it('makes Guide collapsible only while an inline player can honor it', () => { + syncStoreState(sampleChannel); + player.set(VideoPlayer.VideoJs); + + fixture.detectChanges(); + + const timeline = fixture.debugElement.query( + By.directive(StubEpgTimelineComponent) + ); + expect(timeline.componentInstance.collapsible()).toBe(true); + expect(timeline.componentInstance.panelId()).toBe('live-guide-panel'); }); it('keeps DASH channels inline on the HTML5 player even when MPV is configured', () => { @@ -640,8 +733,7 @@ describe('VideoPlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ); expect(playerView).not.toBeNull(); - const stub = - playerView.componentInstance as StubWebPlayerViewComponent; + const stub = playerView.componentInstance as StubWebPlayerViewComponent; expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); expect(dataServiceMock.sendIpcEvent).not.toHaveBeenCalled(); }); @@ -657,8 +749,7 @@ describe('VideoPlayerComponent', () => { By.directive(StubWebPlayerViewComponent) ); expect(playerView).not.toBeNull(); - const stub = - playerView.componentInstance as StubWebPlayerViewComponent; + const stub = playerView.componentInstance as StubWebPlayerViewComponent; expect(stub.playerOverride()).toBe(VideoPlayer.Html5Player); }); @@ -675,9 +766,7 @@ describe('VideoPlayerComponent', () => { // The external-player guard declines DASH channels, so the inline // player must stay — otherwise the session has no player at all. expect( - fixture.debugElement.query( - By.directive(StubWebPlayerViewComponent) - ) + fixture.debugElement.query(By.directive(StubWebPlayerViewComponent)) ).not.toBeNull(); }); @@ -958,6 +1047,130 @@ describe('VideoPlayerComponent', () => { ); }); + it('uses the shared Channels intent for All Channels and keeps a restore control', async () => { + await renderSidebar(); + + const sidebar = fixture.debugElement.query( + By.directive(StubSidebarComponent) + ); + expect(sidebar.componentInstance.channelsPanelExpanded()).toBe(true); + + sidebar.componentInstance.channelsPanelExpandedChange.emit(false); + fixture.detectChanges(); + + const panel = fixture.nativeElement.querySelector( + '#live-channels-panel' + ) as HTMLElement; + const restore = fixture.nativeElement.querySelector( + '[data-testid="live-channels-panel-restore"]' + ) as HTMLButtonElement; + expect(livePanelState.channelsIntent()).toBe('collapsed'); + expect(panel.getAttribute('aria-hidden')).toBe('true'); + expect(panel.hasAttribute('inert')).toBe(true); + expect(restore.getAttribute('aria-controls')).toBe( + 'live-channels-panel' + ); + + restore.click(); + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(livePanelState.channelsIntent()).toBe('expanded'); + expect(document.activeElement).toBe( + fixture.nativeElement.querySelector( + '[data-testid="live-channels-panel-hide"]' + ) + ); + }); + + it('passes independent effective Groups and Channels state in grouped view', async () => { + routeParams$.next({ id: playlistId(), view: 'groups' }); + await renderSidebar(); + + const sidebar = fixture.debugElement.query( + By.directive(StubSidebarComponent) + ); + sidebar.componentInstance.groupsPanelExpandedChange.emit(false); + fixture.detectChanges(); + + expect(livePanelState.groupsIntent()).toBe('collapsed'); + expect(livePanelState.channelsIntent()).toBe('expanded'); + expect(sidebar.componentInstance.groupsPanelExpanded()).toBe(false); + expect(sidebar.componentInstance.channelsPanelExpanded()).toBe(true); + }); + + it('suppresses Groups responsively without changing its persisted intent', async () => { + routeParams$.next({ id: playlistId(), view: 'groups' }); + await renderSidebar(); + + breakpointState$.next({ + breakpoints: { '(max-width: 599px)': true }, + matches: true, + }); + fixture.detectChanges(); + + const sidebar = fixture.debugElement.query( + By.directive(StubSidebarComponent) + ); + expect(sidebar.componentInstance.groupsPanelExpanded()).toBe(false); + expect(sidebar.componentInstance.groupsPanelRestoreAvailable()).toBe( + false + ); + expect(sidebar.componentInstance.channelsPanelExpanded()).toBe(true); + expect(livePanelState.groupsIntent()).toBe('expanded'); + expect(localStorage.getItem(LIVE_GROUPS_PANEL_STATE_STORAGE_KEY)).toBe( + 'expanded' + ); + }); + + it('keeps loading controls wired to both effective panel states', async () => { + routeParams$.next({ id: playlistId(), view: 'groups' }); + channelsLoading.set(true); + livePanelState.hidePanel(LIVE_LAYOUT_PANEL.CHANNELS); + + await renderSidebar(DeferBlockState.Loading); + + const loading = fixture.debugElement.query( + By.directive(StubChannelListLoadingStateComponent) + ); + expect(loading.componentInstance.groupsPanelExpanded()).toBe(true); + expect(loading.componentInstance.channelsPanelExpanded()).toBe(false); + + loading.componentInstance.groupsPanelExpandedChange.emit(false); + expect(livePanelState.groupsIntent()).toBe('collapsed'); + }); + + it('uses Cmd/Ctrl+B as temporary master suppression without changing intents', async () => { + routeParams$.next({ id: playlistId(), view: 'groups' }); + livePanelState.hidePanel(LIVE_LAYOUT_PANEL.GROUPS); + await renderSidebar(); + + const shortcut = new KeyboardEvent('keydown', { + key: 'b', + ctrlKey: true, + }); + jest.spyOn(shortcut, 'preventDefault'); + component.handleKeyPress(shortcut); + fixture.detectChanges(); + + const sidebar = fixture.debugElement.query( + By.directive(StubSidebarComponent) + ); + expect(shortcut.preventDefault).toHaveBeenCalled(); + expect(livePanelState.masterSuppressed()).toBe(true); + expect(livePanelState.groupsIntent()).toBe('collapsed'); + expect(livePanelState.channelsIntent()).toBe('expanded'); + expect(sidebar.componentInstance.groupsPanelExpanded()).toBe(false); + expect(sidebar.componentInstance.channelsPanelExpanded()).toBe(false); + + component.handleKeyPress(shortcut); + fixture.detectChanges(); + + expect(livePanelState.masterSuppressed()).toBe(false); + expect(sidebar.componentInstance.groupsPanelExpanded()).toBe(false); + expect(sidebar.componentInstance.channelsPanelExpanded()).toBe(true); + }); + it('updates the outer sidebar width while grouped view requests a larger total width', () => { fixture.detectChanges(); diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts index dfeb98601..074f39937 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.ts @@ -1,8 +1,10 @@ +import { BreakpointObserver } from '@angular/cdk/layout'; import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { ComponentPortal } from '@angular/cdk/portal'; import { AsyncPipe, CommonModule } from '@angular/common'; import { Component, + ElementRef, HostListener, Injector, OnDestroy, @@ -30,6 +32,11 @@ import { isM3uCatchupPlaybackSupported, } from '@iptvnator/shared/m3u-utils'; import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util'; +import { + LIVE_LAYOUT_PANEL, + LiveLayoutPanelStateService, + type LiveLayoutPanel, +} from '@iptvnator/portal/shared/data-access'; import { COMPONENT_OVERLAY_REF, EpgDateNavigationDirection, @@ -73,12 +80,9 @@ import { isTypingInInput, isWorkspaceLayoutRoute, LiveEpgPanelState, - LiveSidebarState, persistLiveEpgPanelState, - persistLiveSidebarState, PORTAL_EXTERNAL_PLAYBACK, restoreLiveEpgPanelState, - restoreLiveSidebarState, WorkspaceHeaderContextService, } from '@iptvnator/portal/shared/util'; import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui'; @@ -154,9 +158,15 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly store = inject(Store); private readonly epgService = inject(EpgService); private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK); + private readonly hostElement = inject(ElementRef); + private readonly livePanelState = inject(LiveLayoutPanelStateService); private readonly workspaceHeaderContext = inject( WorkspaceHeaderContextService ); + private readonly mobileViewport = toSignal( + inject(BreakpointObserver).observe('(max-width: 599px)'), + { initialValue: { breakpoints: {}, matches: false } } + ); private readonly debugLog = createDevLogger('VideoPlayerComponent'); /** Active selected channel */ @@ -164,9 +174,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly activePlaybackUrl = this.store.selectSignal( selectActivePlaybackUrl ); - readonly activeEpgProgram = this.store.selectSignal( - selectActiveEpgProgram - ); + readonly activeEpgProgram = this.store.selectSignal(selectActiveEpgProgram); readonly activeEpgProgramOrNull = computed( () => this.activeEpgProgram() ?? null ); @@ -233,9 +241,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private readonly epgChannelLogo = toSignal( toObservable(this.activeChannel).pipe( switchMap((channel) => { - const key = channel - ? resolveChannelEpgLookupKey(channel) - : ''; + const key = channel ? resolveChannelEpgLookupKey(channel) : ''; if (!key) { return of(''); } @@ -328,13 +334,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { readonly isLiveEpgPanelCollapsed = computed( () => this.liveEpgPanelState() === 'collapsed' ); - readonly liveSidebarState = signal( - restoreLiveSidebarState() - ); - readonly isSidebarCollapsed = computed( - () => this.liveSidebarState() === 'collapsed' - ); - /** Channels list */ readonly channels$: Observable = this.store.select( selectChannels @@ -363,6 +362,28 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ), { initialValue: 'all' } ); + readonly groupsApplicable = computed(() => this.activeView() === 'groups'); + readonly channelsApplicable = computed( + () => this.activeView() === 'groups' || this.activeView() === 'all' + ); + readonly isMobileViewport = computed(() => this.mobileViewport().matches); + readonly groupsResponsiveSuppressed = computed( + () => this.groupsApplicable() && this.isMobileViewport() + ); + readonly groupsPanelExpanded = computed(() => + this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.GROUPS, { + applicable: this.groupsApplicable(), + responsiveSuppressed: this.groupsResponsiveSuppressed(), + }) + ); + readonly channelsPanelExpanded = computed(() => + this.livePanelState.isPanelExpanded(LIVE_LAYOUT_PANEL.CHANNELS, { + applicable: this.channelsApplicable(), + }) + ); + readonly isAllChannelsPanelCollapsed = computed( + () => this.activeView() === 'all' && !this.channelsPanelExpanded() + ); /** Selected video player options */ playerSettings: Partial = { @@ -383,6 +404,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { private lastExternalSessionStateKey = this.getExternalSessionStateKey( this.externalPlayback.activeSession() ); + private previousAllChannelsPanelExpanded: boolean | undefined; /** Channel number input state */ channelNumberInput = ''; @@ -455,6 +477,29 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { ); }); + effect(() => { + const isAllChannelsView = this.activeView() === 'all'; + const expanded = this.channelsPanelExpanded(); + const previous = this.previousAllChannelsPanelExpanded; + this.previousAllChannelsPanelExpanded = isAllChannelsView + ? expanded + : undefined; + if ( + !isAllChannelsView || + previous === undefined || + previous === expanded + ) { + return; + } + + queueMicrotask(() => { + this.focusPanelAction( + 'channels', + expanded ? 'hide' : 'restore' + ); + }); + }); + effect(() => { const playlistId = this.activePlaylistId(); const activeChannel = this.activeChannel(); @@ -585,9 +630,7 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { } const currentEpgProgram = epgProgram as - | EpgProgram - | null - | undefined; + EpgProgram | null | undefined; const currentIndex = channels.findIndex( (channel) => channel.url === activeChannel.url ); @@ -679,12 +722,37 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { persistLiveEpgPanelState(state); } - toggleSidebar(): void { - const next: LiveSidebarState = this.isSidebarCollapsed() - ? 'expanded' - : 'collapsed'; - this.liveSidebarState.set(next); - persistLiveSidebarState(next); + onGroupsPanelExpandedChange(expanded: boolean): void { + this.setPanelExpanded(LIVE_LAYOUT_PANEL.GROUPS, expanded); + } + + onChannelsPanelExpandedChange(expanded: boolean): void { + this.setPanelExpanded(LIVE_LAYOUT_PANEL.CHANNELS, expanded); + } + + private setPanelExpanded(panel: LiveLayoutPanel, expanded: boolean): void { + if (expanded) { + this.livePanelState.showPanel(panel); + } else { + this.livePanelState.hidePanel(panel); + } + + const panelName = + panel === LIVE_LAYOUT_PANEL.GROUPS ? 'groups' : 'channels'; + this.focusPanelAction(panelName, expanded ? 'hide' : 'restore'); + } + + private focusPanelAction( + panel: 'groups' | 'channels', + action: 'hide' | 'restore' + ): void { + setTimeout(() => { + this.hostElement.nativeElement + .querySelector( + `[data-testid="live-${panel}-panel-${action}"]` + ) + ?.focus(); + }, 0); } onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void { @@ -892,7 +960,9 @@ export class VideoPlayerComponent implements OnInit, OnDestroy { event.key.toLowerCase() === 'b' ) { event.preventDefault(); - this.toggleSidebar(); + this.livePanelState.toggleMasterSuppression( + this.applicableLeftPanels() + ); return; } if (event.metaKey || event.ctrlKey || event.altKey) { @@ -905,6 +975,12 @@ 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]; + } + /** * Handle channel number input from keyboard * Debounces input to allow multi-digit channel numbers diff --git a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html index b60b159be..4b873fb57 100644 --- a/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/all-channels-view/all-channels-view.component.html @@ -18,8 +18,8 @@ data-testid="live-channels-panel-hide" aria-controls="live-channels-panel" [attr.aria-expanded]="channelsPanelExpanded()" - [attr.aria-label]="'LAYOUT.HIDE_CHANNELS_LIST' | translate" - [matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate" + [attr.aria-label]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate" + [matTooltip]="'LAYOUT.HIDE_CHANNELS_PANEL' | translate" (click)="channelsPanelExpandedChange.emit(false)" > chevron_left diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index 5e64da173..09c233ae8 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -3,6 +3,7 @@ [view]="activeView()" [showEpg]="shouldShowEpg()" [groupsPanelExpanded]="groupsPanelExpanded()" + [groupsPanelRestoreAvailable]="groupsPanelRestoreAvailable()" [channelsPanelExpanded]="channelsPanelExpanded()" (groupsPanelExpandedChange)="groupsPanelExpandedChange.emit($event)" (channelsPanelExpandedChange)="channelsPanelExpandedChange.emit($event)" @@ -63,6 +64,7 @@ [hiddenGroupTitles]="hiddenGroupTitles()" [sidebarWidth]="sidebarWidth()" [groupsPanelExpanded]="groupsPanelExpanded()" + [groupsPanelRestoreAvailable]="groupsPanelRestoreAvailable()" [channelsPanelExpanded]="channelsPanelExpanded()" (channelSelected)="onChannelSelected($event)" (channelPlaybackRequested)="onChannelPlaybackRequested($event)" diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index a3009df10..a86be84b1 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -185,6 +185,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { readonly channelsLoading = input(false); readonly recentItems = input([]); readonly groupsPanelExpanded = input(true); + readonly groupsPanelRestoreAvailable = input(true); readonly channelsPanelExpanded = input(true); readonly sidebarWidth = input(null); readonly sidebarWidthRequested = output(); diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 25cbb91fa..20b7b58c1 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -1,5 +1,5 @@
- @if (!groupsPanelExpanded()) { + @if (!groupsPanelExpanded() && groupsPanelRestoreAvailable()) {
} @else { -
+
{{ 'CHANNELS.LOADING' | translate }} diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts index 97aa27526..55e83c257 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.spec.ts @@ -88,6 +88,24 @@ describe('ChannelListLoadingStateComponent', () => { expect(channelsPanelExpandedChange).toHaveBeenCalledWith(false); }); + it('omits the loading Groups restore when responsive restoration is unavailable', () => { + fixture.componentRef.setInput('view', 'groups'); + fixture.componentRef.setInput('groupsPanelExpanded', false); + fixture.componentRef.setInput('groupsPanelRestoreAvailable', false); + fixture.detectChanges(); + + expect( + fixture.nativeElement.querySelector( + '[data-testid="live-groups-panel-restore"]' + ) + ).toBeNull(); + expect( + fixture.nativeElement.querySelector( + '[data-testid="live-channels-panel-hide"]' + ) + ).not.toBeNull(); + }); + it('keeps the Channels control available while All Channels is loading', () => { fixture.componentRef.setInput('view', 'all'); fixture.detectChanges(); diff --git a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts index 0f61742eb..df88e46b3 100644 --- a/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts +++ b/libs/ui/components/src/lib/channel-list-loading-state/channel-list-loading-state.component.ts @@ -28,6 +28,7 @@ export class ChannelListLoadingStateComponent { readonly view = input('all'); readonly showEpg = input(true); readonly groupsPanelExpanded = input(true); + readonly groupsPanelRestoreAvailable = input(true); readonly channelsPanelExpanded = input(true); readonly groupsPanelExpandedChange = output(); readonly channelsPanelExpandedChange = output(); diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index f5d697e03..b67b5625d 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -23,6 +23,7 @@ [activeView]="activeView()" [recentItems]="activePlaylist()?.recentlyViewed ?? []" [groupsPanelExpanded]="groupsPanelExpanded()" + [groupsPanelRestoreAvailable]="groupsPanelRestoreAvailable()" [channelsPanelExpanded]="channelsPanelExpanded()" [sidebarWidth]="sidebarWidth()" (sidebarWidthRequested)="onSidebarWidthRequested($event)" diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index d60098638..f13c1c28e 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -29,6 +29,7 @@ export class SidebarComponent { readonly showPlaylistHeader = input(true); readonly activeView = input('all'); readonly groupsPanelExpanded = input(true); + readonly groupsPanelRestoreAvailable = input(true); readonly channelsPanelExpanded = input(true); readonly sidebarWidth = input(null); readonly sidebarWidthRequested = output();