feat(live-tv): collapsible sidebar in live tv views

Entire-Checkpoint: f957cd9849e0
This commit is contained in:
4gray committed 2026-05-02 23:53:05 +02:00
1 parent 177148ad5d
commit 15b6ba985a
42 files changed
+541 -22

No files matched your search

+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "السمة الفاتحة",
"SYSTEM_THEME": "سمة النظام"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "البحث عن قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "المظهر الفاتح",
"SYSTEM_THEME": "مظهر النظام"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "قلب على قناة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Светлая тэма",
"SYSTEM_THEME": "Сістэмная тэма"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Схаваць спіс каналаў",
"SHOW_CHANNELS_LIST": "Паказаць спіс каналаў",
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Пошук каналаў",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Hell",
"SYSTEM_THEME": "System"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Senderliste ausblenden",
"SHOW_CHANNELS_LIST": "Senderliste einblenden",
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Nach Sender suchen",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Ανοιχτόχρωμο θέμα",
"SYSTEM_THEME": "Θέμα συστήματος"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών",
"SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών",
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Αναζήτηση καναλιού",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Light theme",
"SYSTEM_THEME": "System theme"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Hide channels list",
"SHOW_CHANNELS_LIST": "Show channels list",
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Search channel",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Tema claro",
"SYSTEM_THEME": "Tema del sistema"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canales",
"SHOW_CHANNELS_LIST": "Mostrar lista de canales",
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Buscar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Thème clair",
"SYSTEM_THEME": "Thème du système"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Masquer la liste des chaînes",
"SHOW_CHANNELS_LIST": "Afficher la liste des chaînes",
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Rechercher une chaîne",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Tema chiaro",
"SYSTEM_THEME": "Tema di sistema"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Nascondi elenco canali",
"SHOW_CHANNELS_LIST": "Mostra elenco canali",
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Cerca canale",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "ライトテーマ",
"SYSTEM_THEME": "システムテーマ"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "チャンネル一覧を非表示",
"SHOW_CHANNELS_LIST": "チャンネル一覧を表示",
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "チャンネルを検索",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "밝은 테마",
"SYSTEM_THEME": "시스템 테마"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "채널 목록 숨기기",
"SHOW_CHANNELS_LIST": "채널 목록 표시",
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "검색 채널",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Licht thema",
"SYSTEM_THEME": "Systeemthema"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Zenderlijst verbergen",
"SHOW_CHANNELS_LIST": "Zenderlijst tonen",
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Zoek een kanaal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Jasny motyw",
"SYSTEM_THEME": "Motyw systemowy"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ukryj listę kanałów",
"SHOW_CHANNELS_LIST": "Pokaż listę kanałów",
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Szukaj kanału",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Tema claro",
"SYSTEM_THEME": "Tema do sistema"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Ocultar lista de canais",
"SHOW_CHANNELS_LIST": "Mostrar lista de canais",
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Pesquisar canal",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Светлая тема",
"SYSTEM_THEME": "Системная тема"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Скрыть список каналов",
"SHOW_CHANNELS_LIST": "Показать список каналов",
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Поиск каналов",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "Açık tema",
"SYSTEM_THEME": "Sistem teması"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "Kanal listesini gizle",
"SHOW_CHANNELS_LIST": "Kanal listesini göster",
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "Kanal ara",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "明亮模式",
"SYSTEM_THEME": "跟随系统"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "隐藏频道列表",
"SHOW_CHANNELS_LIST": "显示频道列表",
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "搜索频道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
+5
View File
@@ -348,6 +348,11 @@
"LIGHT_THEME": "亮色主題",
"SYSTEM_THEME": "跟隨系統"
},
"LAYOUT": {
"HIDE_CHANNELS_LIST": "隱藏頻道列表",
"SHOW_CHANNELS_LIST": "顯示頻道列表",
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SEARCH_CHANNEL": "搜尋頻道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
@@ -143,6 +143,28 @@ The shared row should be reused instead of rebuilding channel markup per view.
- Keep the EPG content mounted while collapsed so current-program state can
continue updating.
### Collapsible Live Sidebar
- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle
that hides the channels rail to give the player and EPG full width.
- Collapsed state is persisted in `live-sidebar-state` (helpers in
`@iptvnator/portal/shared/util`); missing or invalid values restore to
expanded. The state is global across all three modules so the user's choice
carries across portals and reloads.
- A `mat-icon-button` with `chevron_left` lives in the sidebar header and
toggles state. While collapsed, a floating `chevron_right` mini-fab appears
at the left edge of `.content-container` to restore the rail.
- Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate
inside `<input>`, `<textarea>`, `<select>`, or content-editable elements via
the shared `isTypingInInput` helper.
- The CSS class `.sidebar-collapsed` overrides the inline width set by the
`appResizable` directive with `width: 0 !important; min-width: 0 !important`.
The directive's persisted width is preserved so uncollapsing restores the
user's previous resized width.
- Below 600 px viewport, the M3U layout's mobile bottom-drawer rule overrides
the desktop collapse to `height: 0` instead of `width: 0`, and the floating
restore handle is hidden.
### EPG Card
- Radius:
@@ -1,5 +1,6 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="sidebarMinWidth"
[maxWidth]="sidebarMaxWidth"
@@ -9,6 +10,19 @@
(widthChange)="onSidebarWidthChange($event)"
(resizeEnd)="onSidebarResizeEnd($event)"
>
@if (activeView() !== 'groups') {
<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)!"
@@ -20,6 +34,7 @@
(sidebarWidthRequestEnded)="
onGroupedSidebarWidthRequestEnded($event)
"
(sidebarToggleRequested)="toggleSidebar()"
/>
} @loading {
<app-channel-list-loading-state [view]="activeView()" />
@@ -29,6 +44,19 @@
</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 (activeChannel(); as activeChannel) {
@let playbackTarget = playbackChannel();
@if (
@@ -19,6 +19,23 @@
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 {
@@ -104,9 +121,21 @@
height: 50vh;
border-right: none;
border-top: 1px solid var(--mat-sys-outline-variant);
// Sidebar collapse uses width=0 on desktop; on mobile the rail flips
// to a bottom drawer so collapse it via height instead.
&.sidebar-collapsed {
width: 100% !important;
min-width: 100% !important;
height: 0 !important;
}
}
.content-container {
height: 50vh;
}
.sidebar-restore {
display: none;
}
}
@@ -13,6 +13,9 @@ import {
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatTooltipModule } from '@angular/material/tooltip';
import { ActivatedRoute, Router } from '@angular/router';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
@@ -50,11 +53,15 @@ import {
import {
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
LiveSidebarState,
persistLiveEpgPanelState,
persistLiveSidebarState,
PORTAL_EXTERNAL_PLAYBACK,
restoreLiveEpgPanelState,
LiveEpgPanelState,
restoreLiveSidebarState,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
@@ -100,6 +107,9 @@ const M3U_SIDEBAR_DEFAULT_WIDTH = 460;
EpgListComponent,
HtmlVideoPlayerComponent,
LiveEpgPanelComponent,
MatButtonModule,
MatIconModule,
MatTooltipModule,
PortalEmptyStateComponent,
ResizableDirective,
SidebarComponent,
@@ -200,6 +210,12 @@ 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(
@@ -483,6 +499,14 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
@@ -659,12 +683,22 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
@HostListener('document:keydown', ['$event'])
handleKeyPress(event: KeyboardEvent): void {
if (isTypingInInput(event)) {
return;
}
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'b'
) {
event.preventDefault();
this.toggleSidebar();
return;
}
if (event.metaKey || event.ctrlKey || event.altKey) {
return;
}
// Only handle digit keys (0-9)
if (event.key >= '0' && event.key <= '9') {
// Don't trigger hotkeys when user is typing in input fields
if (this.isTypingInInput(event)) {
return;
}
event.preventDefault();
this.handleChannelNumberInput(event.key);
}
@@ -723,19 +757,6 @@ export class VideoPlayerComponent implements OnInit, OnDestroy {
}
}
/**
* Check if the user is currently typing in an input or textarea field
* @param event Keyboard event
* @returns true if the event target is an input or textarea element
*/
private isTypingInInput(event: Event): boolean {
const target = event.target;
return (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement
);
}
private handleRemoteControlCommand(command: {
type:
| 'channel-select-number'
@@ -78,6 +78,27 @@
</mat-menu>
}
@if (showSidebarToggle()) {
<button
type="button"
mat-icon-button
class="collection-sidebar-toggle"
[attr.aria-label]="
(isSidebarCollapsed()
? 'LAYOUT.SHOW_CHANNELS_LIST'
: 'LAYOUT.HIDE_CHANNELS_LIST'
) | translate
"
[matTooltip]="'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate"
[attr.aria-pressed]="isSidebarCollapsed()"
(click)="toggleSidebar()"
>
<mat-icon>{{
isSidebarCollapsed() ? 'chevron_right' : 'chevron_left'
}}</mat-icon>
</button>
}
<div class="collection-header-toggles">
@if (showContentToggle()) {
<mat-button-toggle-group
@@ -164,6 +185,7 @@
[autoOpenItem]="pendingAutoOpenLiveItem()"
[favoriteUids]="favoriteUidSet()"
[sortMode]="favSortMode()"
[isSidebarCollapsed]="isSidebarCollapsed()"
(removeItem)="onRemoveItem($event)"
(favoriteToggled)="onFavoriteToggled($event)"
(reorderItems)="onReorder($event)"
@@ -38,11 +38,14 @@ import {
getOpenLiveCollectionItemState,
getUnifiedCollectionNavigation,
isWorkspaceLayoutRoute,
LiveSidebarState,
OPEN_COLLECTION_DETAIL_STATE_KEY,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
persistFavoritesChannelSortMode,
persistLiveSidebarState,
queryParamSignal,
restoreFavoritesChannelSortMode,
restoreLiveSidebarState,
routeParamSignal,
ScopeToggleService,
STALKER_RETURN_TO_STATE_KEY,
@@ -262,6 +265,15 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType() === 'live' &&
this.hasLive()
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
readonly showSidebarToggle = computed(
() => this.selectedContentType() === 'live' && this.hasLive()
);
readonly favSortOptions: ReadonlyArray<{
mode: FavoritesChannelSortMode;
translationKey: string;
@@ -447,6 +459,14 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
void this.router.navigate(['/workspace', 'dashboard']);
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
this.favSortMode.set(mode);
persistFavoritesChannelSortMode(mode);
@@ -1,5 +1,6 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -91,6 +91,7 @@ export class UnifiedLiveTabComponent {
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly isSidebarCollapsed = input(false);
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
+2
View File
@@ -12,6 +12,8 @@ export * from './lib/portal-collection-context';
export * from './lib/portal-collection-context.service';
export * from './lib/portal-collection-items';
export * from './lib/live-epg-panel-state';
export * from './lib/live-sidebar-state';
export * from './lib/keyboard';
export * from './lib/remote-channel-navigation';
export * from './lib/workspace-header-context.service';
export * from './lib/workspace-view-command.types';
@@ -0,0 +1,15 @@
/**
* Returns true when the keyboard event originated inside an editable text
* field, so global shortcuts should not fire.
*/
export function isTypingInInput(event: Event): boolean {
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
) {
return true;
}
return target instanceof HTMLElement && target.isContentEditable;
}
@@ -0,0 +1,50 @@
import {
DEFAULT_LIVE_SIDEBAR_STATE,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
isLiveSidebarState,
persistLiveSidebarState,
restoreLiveSidebarState,
} from './live-sidebar-state';
describe('live sidebar state', () => {
afterEach(() => {
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem('custom-live-sidebar-state');
});
it('accepts only known sidebar states', () => {
expect(isLiveSidebarState('expanded')).toBe(true);
expect(isLiveSidebarState('collapsed')).toBe(true);
expect(isLiveSidebarState('hidden')).toBe(false);
expect(isLiveSidebarState(null)).toBe(false);
});
it('restores expanded as the default for missing or invalid storage', () => {
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'hidden');
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
});
it('restores and persists collapsed state', () => {
persistLiveSidebarState('collapsed');
expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe(
'collapsed'
);
expect(restoreLiveSidebarState()).toBe('collapsed');
});
it('supports custom storage keys and fallback values', () => {
expect(
restoreLiveSidebarState('custom-live-sidebar-state', 'collapsed')
).toBe('collapsed');
persistLiveSidebarState('expanded', 'custom-live-sidebar-state');
expect(restoreLiveSidebarState('custom-live-sidebar-state')).toBe(
'expanded'
);
});
});
@@ -0,0 +1,23 @@
export type LiveSidebarState = 'expanded' | 'collapsed';
export const LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
export const DEFAULT_LIVE_SIDEBAR_STATE: LiveSidebarState = 'expanded';
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
return value === 'expanded' || value === 'collapsed';
}
export function restoreLiveSidebarState(
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY,
fallback: LiveSidebarState = DEFAULT_LIVE_SIDEBAR_STATE
): LiveSidebarState {
const storedValue = localStorage.getItem(storageKey);
return isLiveSidebarState(storedValue) ? storedValue : fallback;
}
export function persistLiveSidebarState(
state: LiveSidebarState,
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY
): void {
localStorage.setItem(storageKey, state);
}
@@ -3,6 +3,7 @@
@if (stalkerStore.selectedCategoryId()) {
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -23,6 +24,16 @@
>
}
</div>
<button
mat-icon-button
type="button"
[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>
</div>
<div #scrollContainer class="channels-list">
@@ -77,6 +88,19 @@
}
<div class="content-container">
@if (stalkerStore.selectedCategoryId() && 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 (!stalkerStore.selectedCategoryId()) {
<app-portal-empty-state
icon="live_tv"
@@ -3,6 +3,7 @@ import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
HostListener,
computed,
effect,
ElementRef,
@@ -13,8 +14,11 @@ import {
viewChild,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatTooltipModule } from '@angular/material/tooltip';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import {
ChannelListItemComponent,
@@ -44,9 +48,13 @@ import {
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
LiveEpgPanelState,
LiveSidebarState,
persistLiveEpgPanelState,
persistLiveSidebarState,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
@@ -64,8 +72,11 @@ import {
ChannelListItemComponent,
ChannelListSkeletonComponent,
EpgListComponent,
MatProgressSpinnerModule,
LiveEpgPanelComponent,
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
PortalEmptyStateComponent,
ResizableDirective,
@@ -140,6 +151,12 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -380,6 +397,26 @@ export class StalkerLiveStreamLayoutComponent implements OnDestroy {
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
}
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'b' &&
!isTypingInInput(event)
) {
event.preventDefault();
this.toggleSidebar();
}
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
@@ -4,6 +4,7 @@
@if (selectedCategoryId()) {
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -29,6 +30,16 @@
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<button
mat-icon-button
type="button"
[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>
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
@@ -67,6 +78,19 @@
</div>
}
<div class="content-container">
@if (selectedCategoryId() && 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 (!selectedCategoryId()) {
@if (isSelectedTypeContentLoading()) {
<div class="live-loading-content">
@@ -2,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
HostListener,
computed,
effect,
inject,
@@ -9,7 +10,7 @@ import {
OnInit,
signal,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatButtonModule } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatMenuModule } from '@angular/material/menu';
import { MatTooltipModule } from '@angular/material/tooltip';
@@ -22,12 +23,16 @@ import {
getPortalChannelSortModeLabel,
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
LiveSidebarState,
persistLiveEpgPanelState,
persistLiveSidebarState,
persistPortalChannelSortMode,
queryParamSignal,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
restorePortalChannelSortMode,
} from '@iptvnator/portal/shared/util';
import {
@@ -78,8 +83,8 @@ interface XtreamLiveChannelItem {
EpgListComponent,
EpgViewComponent,
LiveEpgPanelComponent,
MatButtonModule,
MatIcon,
MatIconButton,
MatMenuModule,
MatProgressSpinnerModule,
MatTooltipModule,
@@ -172,6 +177,12 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentEpgItem())
);
@@ -367,6 +378,26 @@ export class LiveStreamLayoutComponent implements OnInit, OnDestroy {
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
}
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'b' &&
!isTypingInInput(event)
) {
event.preventDefault();
this.toggleSidebar();
}
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
this.selectedLiveEpgDate.set(
shiftEpgDateKey(this.selectedLiveEpgDate(), direction)
@@ -53,6 +53,7 @@
"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
/>
}
@case ('favorites') {
@@ -136,6 +136,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy {
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route);
private readonly routeSearchTerm = queryParamSignal(
this.route,
@@ -166,7 +166,7 @@
<section class="sidebar groups-content-panel">
@if (selectedGroup(); as group) {
<header class="sidebar-header groups-content-header">
<div class="category-meta">
<div class="category-meta category-meta--stacked">
<h2 class="category-title">
@if (group.key) {
{{ group.key | titlecase }}
@@ -187,6 +187,20 @@
>
<mat-icon>sort_by_alpha</mat-icon>
</button>
<button
mat-icon-button
type="button"
class="groups-sidebar-toggle"
[attr.aria-label]="
'LAYOUT.HIDE_CHANNELS_LIST' | translate
"
[matTooltip]="
'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP' | translate
"
(click)="sidebarToggleRequested.emit()"
>
<mat-icon>chevron_left</mat-icon>
</button>
<mat-menu #channelSortMenu="matMenu">
<button
mat-menu-item
@@ -165,6 +165,12 @@
.category-meta {
@include panel.standard-panel-meta();
&--stacked {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
}
.category-title {
@@ -119,6 +119,9 @@ export class GroupsViewComponent {
readonly sidebarWidthRequestEnded = output<number>();
readonly hiddenGroupTitlesChanged = output<string[]>();
/** Emits when the user clicks the inline collapse toggle in the groups header */
readonly sidebarToggleRequested = output<void>();
readonly isGroupSearchOpen = signal(false);
readonly localGroupSearchTerm = signal('');
readonly selectedGroupKey = signal<string | null>(null);
@@ -25,4 +25,5 @@
[sidebarWidth]="sidebarWidth()"
(sidebarWidthRequested)="onSidebarWidthRequested($event)"
(sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)"
(sidebarToggleRequested)="sidebarToggleRequested.emit()"
/>
@@ -31,6 +31,7 @@ export class SidebarComponent {
readonly sidebarWidth = input<number | null>(null);
readonly sidebarWidthRequested = output<number>();
readonly sidebarWidthRequestEnded = output<number>();
readonly sidebarToggleRequested = output<void>();
private readonly playlistContext = inject(PlaylistContextFacade);
private readonly translate = inject(TranslateService);
+52
View File
@@ -31,6 +31,22 @@
overflow: hidden;
position: relative;
container-type: inline-size;
transition:
width 0.18s ease,
min-width 0.18s ease,
border-color 0.18s ease;
&.sidebar-collapsed {
// Beats the inline width set by the resizable directive while
// preserving it so uncollapsing snaps back to the previous size.
width: 0 !important;
min-width: 0 !important;
border-right-color: transparent;
}
&.sidebar-collapsed .resize-handle {
display: none;
}
app-category-view {
flex: 1;
@@ -47,6 +63,42 @@
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
}
// ─── Floating restore handle (visible when sidebar is collapsed) ─────────
// Anchored top-left of .content-container so it sits in roughly the same
// spot as the hide toggle that lived at the sidebar header's top-right.
.sidebar-restore {
position: absolute;
top: 6px;
left: 6px;
z-index: 2;
background: rgba(128, 128, 128, 0.18);
color: var(--mat-sys-on-surface, rgba(255, 255, 255, 0.75));
box-shadow: none;
opacity: 0.7;
transition:
opacity 0.15s ease,
background 0.15s ease;
.mat-mdc-button-persistent-ripple,
.mat-mdc-button-touch-target {
display: none;
}
mat-icon {
font-size: 20px;
width: 20px;
height: 20px;
line-height: 20px;
}
&:hover,
&:focus-visible {
opacity: 1;
background: rgba(128, 128, 128, 0.32);
}
}
.video-player {