mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-12 03:56:15 -08:00
feat(ui): separate M3U live panel toggles
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
+225
-12
@@ -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
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+2
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
<div class="groups-view-layout">
|
||||
@if (!groupsPanelExpanded()) {
|
||||
@if (!groupsPanelExpanded() && groupsPanelRestoreAvailable()) {
|
||||
<button
|
||||
mat-icon-button
|
||||
type="button"
|
||||
|
||||
+29
@@ -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 */
|
||||
|
||||
+2
-2
@@ -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 }}
|
||||
|
||||
+18
@@ -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();
|
||||
|
||||
+1
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user