feat(ui): separate M3U live panel toggles

This commit is contained in:
4gray committed 2026-07-31 09:23:25 +02:00
1 parent 98337108ab
commit 60539a46d7
16 files changed
+461 -99

No files matched your search

@@ -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
@@ -1,6 +1,9 @@
<div
[attr.id]="activeView() === 'all' ? 'live-channels-panel' : null"
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[class.sidebar-collapsed]="isAllChannelsPanelCollapsed()"
[attr.aria-hidden]="isAllChannelsPanelCollapsed()"
[attr.inert]="isAllChannelsPanelCollapsed() ? '' : null"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
@@ -10,59 +13,76 @@
(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()"
[groupsPanelExpanded]="groupsPanelExpanded()"
[groupsPanelRestoreAvailable]="!groupsResponsiveSuppressed()"
[channelsPanelExpanded]="channelsPanelExpanded()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
(sidebarToggleRequested)="toggleSidebar()"
(groupsPanelExpandedChange)="onGroupsPanelExpandedChange($event)"
(channelsPanelExpandedChange)="
onChannelsPanelExpandedChange($event)
"
/>
} @loading {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
[groupsPanelExpanded]="groupsPanelExpanded()"
[groupsPanelRestoreAvailable]="!groupsResponsiveSuppressed()"
[channelsPanelExpanded]="channelsPanelExpanded()"
(groupsPanelExpandedChange)="onGroupsPanelExpandedChange($event)"
(channelsPanelExpandedChange)="
onChannelsPanelExpandedChange($event)
"
/>
} @placeholder {
<app-channel-list-loading-state
[view]="activeView()"
[showEpg]="supportsEpg"
[groupsPanelExpanded]="groupsPanelExpanded()"
[groupsPanelRestoreAvailable]="!groupsResponsiveSuppressed()"
[channelsPanelExpanded]="channelsPanelExpanded()"
(groupsPanelExpandedChange)="onGroupsPanelExpandedChange($event)"
(channelsPanelExpandedChange)="
onChannelsPanelExpandedChange($event)
"
/>
}
</div>
<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 (isAllChannelsPanelCollapsed()) {
<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)="onChannelsPanelExpandedChange(true)"
>
<mat-icon>
{{ isMobileViewport() ? 'expand_less' : 'chevron_right' }}
</mat-icon>
</button>
}
<div
class="content-container"
[class.content-container--channels-collapsed]="
isAllChannelsPanelCollapsed()
"
>
@if (activeChannel(); as activeChannel) {
@if (
activeChannel.radio === 'true' ||
@@ -109,10 +129,13 @@
<div class="epg-content">
@if (epgViewMode() === 'list') {
<app-epg-list-view
panelId="live-guide-panel"
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -120,6 +143,7 @@
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[collapsible]="canCollapseEpgPanel"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
@@ -135,10 +159,13 @@
/>
} @else {
<app-epg-timeline
panelId="live-guide-panel"
[programs]="epgPrograms()"
[channelName]="timelineChannelName()"
[channelLogo]="timelineChannelLogo()"
[archivePlaybackAvailable]="archivePlaybackAvailable()"
[archivePlaybackAvailable]="
archivePlaybackAvailable()
"
[archiveDays]="epgArchiveDays()"
[activeProgram]="activeEpgProgramOrNull()"
[isLivePlayback]="!showReturnToLive()"
@@ -146,6 +173,7 @@
[collapsed]="
canCollapseEpgPanel && isLiveEpgPanelCollapsed()
"
[collapsible]="canCollapseEpgPanel"
[summary]="liveEpgPanelSummary()"
[summaryLabelKey]="liveEpgPanelSummaryLabelKey()"
(programActivated)="
@@ -19,23 +19,6 @@
min-height: 0;
overflow: hidden;
}
.sidebar-collapse-toggle {
position: absolute;
top: 6px;
right: 6px;
z-index: 3;
width: 32px;
height: 32px;
opacity: 0.7;
transition: opacity 0.15s ease;
padding: 0;
&:hover,
&:focus-visible {
opacity: 1;
}
}
}
.content-container {
@@ -133,9 +116,17 @@
.content-container {
height: 50vh;
&.content-container--channels-collapsed {
height: calc(100% - 40px);
}
}
.sidebar-restore {
display: none;
width: 100%;
min-width: 100%;
height: 40px;
min-height: 40px;
flex: 0 0 40px;
}
}
@@ -1,4 +1,5 @@
import { AsyncPipe } from '@angular/common';
import { BreakpointObserver, BreakpointState } from '@angular/cdk/layout';
import {
Component,
Directive,
@@ -7,9 +8,15 @@ import {
output,
signal,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ComponentFixture,
DeferBlockState,
TestBed,
} from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
@@ -30,8 +37,15 @@ import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
PORTAL_EXTERNAL_PLAYBACK,
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import {
LIVE_CHANNELS_PANEL_STATE_STORAGE_KEY,
LIVE_GROUPS_PANEL_STATE_STORAGE_KEY,
LIVE_LAYOUT_PANEL,
LiveLayoutPanelStateService,
} from '@iptvnator/portal/shared/data-access';
import {
DataService,
PlaylistsService,
@@ -65,22 +79,35 @@ jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
class StubChannelListLoadingStateComponent {
readonly view = input<string | null>(null);
readonly showEpg = input(true);
readonly groupsPanelExpanded = input(true);
readonly groupsPanelRestoreAvailable = input(true);
readonly channelsPanelExpanded = input(true);
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
}
@Component({
selector: 'app-sidebar',
standalone: true,
template: '',
template: `
@if (channelsPanelExpanded()) {
<button data-testid="live-channels-panel-hide"></button>
}
`,
})
class StubSidebarComponent {
readonly channels = input<Channel[]>([]);
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<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
}
@Component({
@@ -140,6 +167,8 @@ class StubEpgTimelineComponent {
readonly emptyReason = input<string | null>(null);
readonly selectedDate = input<string | null>(null);
readonly collapsed = input(false);
readonly collapsible = input(true);
readonly panelId = input<string | null>(null);
readonly summary = input<LiveEpgPanelSummary | null>(null);
readonly summaryLabelKey = input('');
readonly programActivated = output<{
@@ -166,11 +195,20 @@ class StubResizableDirective {
readonly resizeEnd = output<number>();
}
@Directive({
selector: '[matTooltip]',
standalone: true,
})
class StubMatTooltipDirective {
readonly matTooltip = input('');
}
describe('VideoPlayerComponent', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
let headerContext: WorkspaceHeaderContextService;
let livePanelState: LiveLayoutPanelStateService;
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
@@ -184,6 +222,14 @@ describe('VideoPlayerComponent', () => {
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
const routeParams$ = new BehaviorSubject<Record<string, string>>({
id: 'playlist-1',
view: 'all',
});
const breakpointState$ = new BehaviorSubject<BreakpointState>({
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<void> {
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();
@@ -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<HTMLElement>);
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<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
/** Channels list */
readonly channels$: Observable<Channel[]> = 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<Settings> = {
@@ -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<HTMLElement>(
`[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
@@ -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)"
>
<mat-icon>chevron_left</mat-icon>
@@ -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)"
@@ -185,6 +185,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
readonly channelsLoading = input(false);
readonly recentItems = input<PlaylistRecentlyViewedItem[]>([]);
readonly groupsPanelExpanded = input(true);
readonly groupsPanelRestoreAvailable = input(true);
readonly channelsPanelExpanded = input(true);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
@@ -1,5 +1,5 @@
<div class="groups-view-layout">
@if (!groupsPanelExpanded()) {
@if (!groupsPanelExpanded() && groupsPanelRestoreAvailable()) {
<button
mat-icon-button
type="button"
@@ -134,6 +134,7 @@ describe('GroupsViewComponent', () => {
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
groupsPanelExpanded: boolean;
groupsPanelRestoreAvailable: boolean;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
@@ -153,6 +154,7 @@ describe('GroupsViewComponent', () => {
favoriteIds: Set<string>;
groupedChannels: Record<string, Channel[]>;
groupsPanelExpanded: boolean;
groupsPanelRestoreAvailable: boolean;
hiddenGroupTitles: string[];
progressTick: number;
searchTerm: string;
@@ -199,6 +201,10 @@ describe('GroupsViewComponent', () => {
'groupsPanelExpanded',
overrides.groupsPanelExpanded ?? true
);
fixture.componentRef.setInput(
'groupsPanelRestoreAvailable',
overrides.groupsPanelRestoreAvailable ?? true
);
fixture.componentRef.setInput(
'channelsPanelExpanded',
overrides.channelsPanelExpanded ?? true
@@ -260,6 +266,29 @@ describe('GroupsViewComponent', () => {
);
});
it('omits the Groups restore control while restoration is responsively unavailable', () => {
setInputs({
groupsPanelExpanded: false,
groupsPanelRestoreAvailable: false,
});
expect(
fixture.nativeElement.querySelector(
'[data-testid="live-groups-panel-restore"]'
)
).toBeNull();
expect(
fixture.nativeElement
.querySelector('#live-groups-panel')
.hasAttribute('inert')
).toBe(true);
expect(
fixture.nativeElement.querySelector(
'[data-testid="live-channels-panel-hide"]'
)
).not.toBeNull();
});
it('sorts groups with numeric buckets before alphabetic buckets', () => {
setInputs({
groupedChannels: {
@@ -108,6 +108,7 @@ export class GroupsViewComponent {
/** Current outer sidebar width */
readonly sidebarWidth = input<number | null>(null);
readonly groupsPanelExpanded = input(true);
readonly groupsPanelRestoreAvailable = input(true);
readonly channelsPanelExpanded = input(true);
/** Emits when a channel is selected */
@@ -5,7 +5,7 @@
@if (isGroupsView()) {
<div class="groups-loading-layout">
@if (!groupsPanelExpanded()) {
@if (!groupsPanelExpanded() && groupsPanelRestoreAvailable()) {
<button
mat-icon-button
type="button"
@@ -121,7 +121,7 @@
</section>
</div>
} @else {
<div id="live-channels-panel" class="channels-loading-state">
<div class="channels-loading-state">
<div class="channels-loading-state__copy">
<div class="channels-loading-state__title">
{{ 'CHANNELS.LOADING' | translate }}
@@ -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();
@@ -28,6 +28,7 @@ export class ChannelListLoadingStateComponent {
readonly view = input<string>('all');
readonly showEpg = input(true);
readonly groupsPanelExpanded = input(true);
readonly groupsPanelRestoreAvailable = input(true);
readonly channelsPanelExpanded = input(true);
readonly groupsPanelExpandedChange = output<boolean>();
readonly channelsPanelExpandedChange = output<boolean>();
@@ -23,6 +23,7 @@
[activeView]="activeView()"
[recentItems]="activePlaylist()?.recentlyViewed ?? []"
[groupsPanelExpanded]="groupsPanelExpanded()"
[groupsPanelRestoreAvailable]="groupsPanelRestoreAvailable()"
[channelsPanelExpanded]="channelsPanelExpanded()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
@@ -29,6 +29,7 @@ export class SidebarComponent {
readonly showPlaylistHeader = input(true);
readonly activeView = input<string>('all');
readonly groupsPanelExpanded = input(true);
readonly groupsPanelRestoreAvailable = input(true);
readonly channelsPanelExpanded = input(true);
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();