diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 1cd19ee62..4b5acf96b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "تحميل أو اختيار قائمة أخرى", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index a73426a62..4622cb321 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "رفع ولا اختار قائمة تشغيل أخرى", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index dad2474fc..e28d7034b 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "Загрузіць або выбраць іншы плэйліст", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index bca22b425..f0ffeab6a 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 10785962d..070208e9b 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 6184bbda3..0e75ff242 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index dd3fa3cab..d1a82b5d1 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 130c9505a..dd2580eed 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c689b4007..be9632c17 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index cb32783f0..9d4305812 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "アップロードまたは他のプレイリストを選択", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 657681b24..4b7c837f1 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "다른 재생 목록을 업로드하거나 선택하십시오", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index a8c6f77b4..0d30e684e 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index d7155953e..a730f627a 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 3f54d12f2..2d1d92be6 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 8fca59409..72af1c2f5 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "Загрузить или выбрать другой плейлист", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 84f454682..d0460a941 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 04ea45051..535e01ad9 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "上传或选择其他播放列表", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 6fe9094cb..75b51143e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "上傳或選擇其他播放清單", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 429d7559b..ac461ba70 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -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 ``, ``, ``, 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: 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 ff8bea16f..947efd1f1 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,5 +1,6 @@ + @if (activeView() !== 'groups') { + + chevron_left + + } @defer { } @loading { @@ -29,6 +44,19 @@ + @if (isSidebarCollapsed()) { + + chevron_right + + } @if (activeChannel(); as activeChannel) { @let playbackTarget = playbackChannel(); @if ( 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 806f3a2e8..246226635 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 @@ -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; + } } 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 39ad23674..3a1a725e9 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 @@ -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( + restoreLiveSidebarState() + ); + readonly isSidebarCollapsed = computed( + () => this.liveSidebarState() === 'collapsed' + ); /** Channels list */ readonly channels$: Observable = 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' diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html index a7807219c..8808dc868 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-collection-page.component.html @@ -78,6 +78,27 @@ } + @if (showSidebarToggle()) { + + {{ + isSidebarCollapsed() ? 'chevron_right' : 'chevron_left' + }} + + } + @if (showContentToggle()) { ( + 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); diff --git a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html index ca9824a98..e43486c2d 100644 --- a/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html +++ b/libs/portal/shared/ui/src/lib/components/unified-collection/unified-live-tab.component.html @@ -1,5 +1,6 @@ (); readonly itemPlayed = output(); readonly autoOpenHandled = output(); + readonly isSidebarCollapsed = input(false); private readonly streamResolver = inject(StreamResolverService); private readonly recentData = inject(UnifiedRecentDataService); diff --git a/libs/portal/shared/util/src/index.ts b/libs/portal/shared/util/src/index.ts index 974b814eb..0f570f9e7 100644 --- a/libs/portal/shared/util/src/index.ts +++ b/libs/portal/shared/util/src/index.ts @@ -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'; diff --git a/libs/portal/shared/util/src/lib/keyboard.ts b/libs/portal/shared/util/src/lib/keyboard.ts new file mode 100644 index 000000000..7e6a7bf07 --- /dev/null +++ b/libs/portal/shared/util/src/lib/keyboard.ts @@ -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; +} diff --git a/libs/portal/shared/util/src/lib/live-sidebar-state.spec.ts b/libs/portal/shared/util/src/lib/live-sidebar-state.spec.ts new file mode 100644 index 000000000..7903bb750 --- /dev/null +++ b/libs/portal/shared/util/src/lib/live-sidebar-state.spec.ts @@ -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' + ); + }); +}); diff --git a/libs/portal/shared/util/src/lib/live-sidebar-state.ts b/libs/portal/shared/util/src/lib/live-sidebar-state.ts new file mode 100644 index 000000000..076d6b103 --- /dev/null +++ b/libs/portal/shared/util/src/lib/live-sidebar-state.ts @@ -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); +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 7b02aa632..19602096f 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -3,6 +3,7 @@ @if (stalkerStore.selectedCategoryId()) { } + + chevron_left + @@ -77,6 +88,19 @@ } + @if (stalkerStore.selectedCategoryId() && isSidebarCollapsed()) { + + chevron_right + + } @if (!stalkerStore.selectedCategoryId()) { this.liveEpgPanelState() === 'collapsed' ); + readonly liveSidebarState = signal( + 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) diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html index 5ccf60360..88376267a 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.html @@ -4,6 +4,7 @@ @if (selectedCategoryId()) { sort_by_alpha + + chevron_left + } + @if (selectedCategoryId() && isSidebarCollapsed()) { + + chevron_right + + } @if (!selectedCategoryId()) { @if (isSelectedTypeContentLoading()) { diff --git a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts index 3438e6dcf..407fac226 100644 --- a/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts +++ b/libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-layout.component.ts @@ -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( + 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) diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html index a034e2e24..344f4055b 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.html @@ -53,6 +53,7 @@ " (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" + (sidebarToggleRequested)="sidebarToggleRequested.emit()" /> } @case ('favorites') { diff --git a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts index f94ee2e75..0447fb841 100644 --- a/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/channel-list-container.component.ts @@ -136,6 +136,7 @@ export class ChannelListContainerComponent implements OnInit, OnDestroy { readonly sidebarWidth = input(null); readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); + readonly sidebarToggleRequested = output(); readonly isWorkspaceLayout = isWorkspaceLayoutRoute(this.route); private readonly routeSearchTerm = queryParamSignal( this.route, diff --git a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html index 0d702ead7..fc2ac3a05 100644 --- a/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html +++ b/libs/ui/components/src/lib/channel-list-container/groups-view/groups-view.component.html @@ -166,7 +166,7 @@ @if (selectedGroup(); as group) { - + @if (group.key) { {{ group.key | titlecase }} @@ -187,6 +187,20 @@ > sort_by_alpha + + chevron_left + (); readonly hiddenGroupTitlesChanged = output(); + /** Emits when the user clicks the inline collapse toggle in the groups header */ + readonly sidebarToggleRequested = output(); + readonly isGroupSearchOpen = signal(false); readonly localGroupSearchTerm = signal(''); readonly selectedGroupKey = signal(null); diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html index 290e030d2..d32411fd9 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.html @@ -25,4 +25,5 @@ [sidebarWidth]="sidebarWidth()" (sidebarWidthRequested)="onSidebarWidthRequested($event)" (sidebarWidthRequestEnded)="onSidebarWidthRequestEnded($event)" + (sidebarToggleRequested)="sidebarToggleRequested.emit()" /> diff --git a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts index 583fac1ca..2d4190bb7 100644 --- a/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts +++ b/libs/ui/playback/src/lib/video-player/sidebar/sidebar.component.ts @@ -31,6 +31,7 @@ export class SidebarComponent { readonly sidebarWidth = input(null); readonly sidebarWidthRequested = output(); readonly sidebarWidthRequestEnded = output(); + readonly sidebarToggleRequested = output(); private readonly playlistContext = inject(PlaylistContextFacade); private readonly translate = inject(TranslateService); diff --git a/libs/ui/styles/_portal-layout.scss b/libs/ui/styles/_portal-layout.scss index 051a34713..f746c0def 100644 --- a/libs/ui/styles/_portal-layout.scss +++ b/libs/ui/styles/_portal-layout.scss @@ -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 {