feat: enhance sidebar functionality with dynamic width management and shared empty state component

This commit is contained in:
4gray committed 2026-04-06 12:56:42 +02:00
1 parent f229b3b1f2
commit cce0cb172f
4 files changed
+151 -40

No files matched your search

@@ -1,16 +1,22 @@
<div
class="sidebar"
appResizable
[minWidth]="200"
[maxWidth]="600"
[defaultWidth]="460"
storageKey="m3u-sidebar-width"
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
[defaultWidth]="sidebarWidth()"
[style.width.px]="sidebarWidth()"
[storageKey]="sidebarStorageKey()"
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@defer {
<app-sidebar
[channels]="(channels$ | async)!"
[showPlaylistHeader]="!isWorkspaceLayout"
[activeView]="activeView()"
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onGroupedSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onGroupedSidebarWidthRequestEnded($event)"
/>
} @loading {
<p>Loading...</p>
@@ -77,11 +83,9 @@
</div>
}
} @else {
<div class="empty-state-container">
<mat-icon class="empty-state-icon">live_tv</mat-icon>
<h2 class="empty-state-title">
{{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }}
</h2>
</div>
<app-portal-empty-state
icon="live_tv"
[message]="'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate"
/>
}
</div>
@@ -82,32 +82,6 @@
}
}
.empty-state-container {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: rgba(15, 23, 42, 0.45);
.empty-state-icon {
width: 72px;
height: 72px;
margin-bottom: 24px;
font-size: 72px;
opacity: 0.25;
}
.empty-state-title {
margin: 0;
font-family: 'DM Sans', sans-serif;
font-size: 1.25rem;
font-weight: 500;
opacity: 0.7;
}
}
@media only screen and (max-width: 599px) {
:host {
flex-direction: column-reverse;
@@ -140,6 +140,7 @@ describe('VideoPlayerComponent', () => {
beforeEach(async () => {
syncStoreState(null);
localStorage.removeItem('m3u-sidebar-width');
player.set(VideoPlayer.VideoJs);
showCaptions.set(false);
currentEpgProgram.set(null);
@@ -285,11 +286,74 @@ describe('VideoPlayerComponent', () => {
).not.toBeNull();
});
it('renders the empty state when no channel is active', () => {
it('updates the outer sidebar width while grouped view requests a larger total width', () => {
fixture.detectChanges();
component.onGroupedSidebarWidthRequested(540);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('540px');
});
it('uses the single-pane sidebar key for all-channel view instead of the groups total key', () => {
fixture.destroy();
localStorage.setItem('m3u-sidebar-width', '320');
localStorage.setItem('m3u-groups-sidebar-width', '560');
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
headerContext = TestBed.inject(WorkspaceHeaderContextService);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('320px');
});
it('tracks manual sidebar resize and persists the committed width', () => {
fixture.detectChanges();
component.onSidebarWidthChange(420);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('420px');
component.onSidebarResizeEnd(420);
expect(localStorage.getItem('m3u-sidebar-width')).toBe('420');
});
it('clamps and persists grouped-view sidebar width requests on resize end', () => {
fixture.detectChanges();
component.onGroupedSidebarWidthRequestEnded(640);
fixture.detectChanges();
const sidebar = fixture.nativeElement.querySelector(
'.sidebar'
) as HTMLElement | null;
expect(sidebar?.style.width).toBe('600px');
expect(localStorage.getItem('m3u-sidebar-width')).toBe('600');
});
it('renders the shared empty state when no channel is active', () => {
fixture.detectChanges();
const emptyState = fixture.nativeElement.querySelector(
'.empty-state-container'
'app-portal-empty-state'
) as HTMLElement | null;
expect(emptyState).not.toBeNull();
@@ -7,11 +7,12 @@ import {
Injector,
OnDestroy,
OnInit,
computed,
effect,
inject,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatIcon } from '@angular/material/icon';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -50,6 +51,7 @@ import {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
ArtPlayerComponent,
AudioPlayerComponent,
@@ -71,6 +73,11 @@ import {
} from 'shared-interfaces';
const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
const M3U_SIDEBAR_STORAGE_KEY = 'm3u-sidebar-width';
const M3U_GROUPS_SIDEBAR_STORAGE_KEY = 'm3u-groups-sidebar-width';
const M3U_SIDEBAR_MIN_WIDTH = 200;
const M3U_SIDEBAR_MAX_WIDTH = 600;
const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
@Component({
selector: 'app-video-player',
@@ -81,7 +88,7 @@ const M3U_MULTI_EPG_HEADER_ACTION_ID = 'm3u-multi-epg';
CommonModule,
EpgListComponent,
HtmlVideoPlayerComponent,
MatIcon,
PortalEmptyStateComponent,
ResizableDirective,
SidebarComponent,
TranslatePipe,
@@ -109,6 +116,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
readonly activeChannel = this.store.selectSignal(selectActive);
readonly activePlaylistId = this.playlistContext.resolvedPlaylistId;
readonly channels = this.store.selectSignal(selectChannels);
readonly sidebarStorageKey = computed(() =>
this.activeView() === 'groups'
? M3U_GROUPS_SIDEBAR_STORAGE_KEY
: M3U_SIDEBAR_STORAGE_KEY
);
readonly sidebarWidth = signal(M3U_SIDEBAR_DEFAULT_WIDTH);
readonly sidebarMinWidth = M3U_SIDEBAR_MIN_WIDTH;
readonly sidebarMaxWidth = M3U_SIDEBAR_MAX_WIDTH;
/** Channels list */
readonly channels$: Observable<Channel[]> = this.store.select(
@@ -168,6 +183,12 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
};
});
effect(() => {
this.sidebarWidth.set(
this.loadSidebarWidth(this.sidebarStorageKey())
);
});
effect(() => {
const playlistId = this.activePlaylistId();
const activeChannel = this.activeChannel();
@@ -361,6 +382,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
this.statusSubscription?.unsubscribe();
}
onSidebarWidthChange(width: number): void {
this.sidebarWidth.set(this.clampSidebarWidth(width));
}
onSidebarResizeEnd(width: number): void {
this.persistSidebarWidth(this.sidebarStorageKey(), width);
}
onGroupedSidebarWidthRequested(width: number): void {
this.sidebarWidth.set(this.clampSidebarWidth(width));
}
onGroupedSidebarWidthRequestEnded(width: number): void {
this.persistSidebarWidth(this.sidebarStorageKey(), width);
}
/**
* Opens a playlist provided as a url param
* e.g. iptvnat.or?url=http://...
@@ -393,6 +430,38 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
});
}
private loadSidebarWidth(storageKey: string): number {
const fallbackKey =
storageKey === M3U_GROUPS_SIDEBAR_STORAGE_KEY
? M3U_SIDEBAR_STORAGE_KEY
: '';
const storedWidth = Number.parseInt(
localStorage.getItem(storageKey) ??
(fallbackKey ? localStorage.getItem(fallbackKey) : '') ??
'',
10
);
return this.clampSidebarWidth(
Number.isNaN(storedWidth)
? M3U_SIDEBAR_DEFAULT_WIDTH
: storedWidth
);
}
private persistSidebarWidth(storageKey: string, width: number): void {
const clampedWidth = this.clampSidebarWidth(width);
this.sidebarWidth.set(clampedWidth);
localStorage.setItem(storageKey, clampedWidth.toString());
}
private clampSidebarWidth(width: number): number {
return Math.max(
M3U_SIDEBAR_MIN_WIDTH,
Math.min(M3U_SIDEBAR_MAX_WIDTH, width)
);
}
private async persistRecentlyViewedChannel(
playlistId: string,
channel: Channel