mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-09 01:16:15 -08:00
feat: enhance sidebar functionality with dynamic width management and shared empty state component
This commit is contained in:
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;
|
||||
|
||||
+66
-2
@@ -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
|
||||
|
||||
Reference in new issue
Block a user