From cce0cb172f6980f4fc5b7cbd2ae77e6271f7ee75 Mon Sep 17 00:00:00 2001 From: 4gray Date: Mon, 6 Apr 2026 12:56:42 +0200 Subject: [PATCH] feat: enhance sidebar functionality with dynamic width management and shared empty state component --- .../video-player/video-player.component.html | 24 +++--- .../video-player/video-player.component.scss | 26 ------- .../video-player.component.spec.ts | 68 ++++++++++++++++- .../video-player/video-player.component.ts | 73 ++++++++++++++++++- 4 files changed, 151 insertions(+), 40 deletions(-) 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 189343fc2..83b04d635 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,16 +1,22 @@ } } @else { -
- live_tv -

- {{ 'CHANNELS.SELECT_CHANNEL_PLAYBACK' | translate }} -

-
+ } diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss index 307fedf4c..924b1303d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.scss @@ -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; diff --git a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts index 837fed7ee..1f76c6f2d 100644 --- a/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts +++ b/libs/playlist/m3u/feature-player/src/lib/video-player/video-player.component.spec.ts @@ -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(); 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 88581d0a9..9bf871527 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 @@ -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 = 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