mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(live-tv): collapsible sidebar in live tv views
Entire-Checkpoint: f957cd9849e0
This commit is contained in:
1 parent
177148ad5d
commit
15b6ba985a
42 files changed
+541
-22
No files matched your search
@@ -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": "تحميل أو اختيار قائمة أخرى",
|
||||
|
||||
@@ -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": "رفع ولا اختار قائمة تشغيل أخرى",
|
||||
|
||||
@@ -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": "Загрузіць або выбраць іншы плэйліст",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "アップロードまたは他のプレイリストを選択",
|
||||
|
||||
@@ -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": "다른 재생 목록을 업로드하거나 선택하십시오",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Загрузить или выбрать другой плейлист",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "上传或选择其他播放列表",
|
||||
|
||||
@@ -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'
|
||||
|
||||
+22
@@ -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)"
|
||||
|
||||
+20
@@ -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
@@ -1,5 +1,6 @@
|
||||
<div
|
||||
class="sidebar"
|
||||
[class.sidebar-collapsed]="isSidebarCollapsed()"
|
||||
appResizable
|
||||
[minWidth]="250"
|
||||
[maxWidth]="600"
|
||||
|
||||
+1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
+24
@@ -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"
|
||||
|
||||
+38
-1
@@ -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)
|
||||
|
||||
+24
@@ -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">
|
||||
|
||||
+33
-2
@@ -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)
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
+6
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user