feat(portals): fold live TV panels in nested levels with a category dropdown (#1556)

## Summary

Live TV panels now fold from the outside in, in three nested levels, instead of one toggle that hid the categories rail and the channel list together:

1. **Categories + channels + player** (browse, unchanged).
2. **Channels + player** — a new `chevron_left` in the categories rail header hides only that rail. The channels header then turns its title into a **category dropdown** that opens the same shell panel as a popover (search, sort, counts, selection are one implementation), plus a `chevron_right` that brings the rail back.
3. **Player only** — the channels header chevron, as before. The floating restore handle and `Cmd/Ctrl+B` return to the level the user collapsed from, not always to level 1.

Every level is restored as stored, per surface (`live-sidebar-state:<surface>`, from #1555): a hidden rail is discoverable through the workspace header toggle and the hidden-list empty state that #1555 added, so this PR no longer needs its original "player-only never restores" rule. The level `Cmd/Ctrl+B` comes back to is seeded from the restored level and kept for the session.

## Design notes

- Nested levels rather than two independent booleans: "channels hidden, categories visible" makes no sense since a category click has to bring the channels back anyway. The model follows the outside-in collapse of three-pane apps (Mail, Slack, Plex).
- The categories rail folds at level 2 **only while a category is selected**: the live root ("All Items" grid) has no channels header to host the way back, so folding there would strand the user. Level 3 folds it regardless, because the floating restore handle lives in the content area.
- `LIVE_CATEGORIES_POPOVER` (`@iptvnator/portal/shared/util`) is the DI bridge: the workspace shell provides `WorkspaceLiveCategoriesPopoverService` (CDK overlay hosting `WorkspaceContextPanelComponent` in `presentation="popover"`), the Xtream and Stalker live layouts inject it optionally and keep their plain heading without a provider.
- M3U and the unified live tab have no categories rail and treat level 2 like level 1; their code is untouched.

## Merged with #1555 (per-surface rail state)

#1555 landed while this PR was open and reworked the same service: state per surface (`m3u` / `portal` / `collection`), a workspace header toggle, the hidden-list empty state, and the legacy shared key forgotten on startup. This PR keeps that model and layers the three levels onto the `portal` surface (`areCategoriesHiddenFor`, `hideCategories` / `showCategories` / `collapse` / `expand` per surface; `toggle(surface)` returns to the level the surface collapsed from). "Show playing channel" uses `expand('portal')` so it keeps a deliberately hidden categories rail folded, and the category sort preference moved to `PortalCategorySortStateService` so the popover copy of the context panel and the retained rail agree.

## Also fixed along the way

- The channels header showed "Channels" instead of the category name: provider category ids are strings, the selection is numeric. Compared via `String()` now.
- A collapsed context panel left a 22px padding strip beside the channels rail.
- The panel toggle labels said "Hide channels list" while also hiding categories; labels and tooltips are honest now (8 new i18n keys, all 18 locales).



Docs: `docs/architecture/iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar" rewritten), `docs/architecture/workspace-shell.md`. Release note: `.changes/portals-live-panel-collapse-levels.md`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-06 14:42:59 +02:00
1 parent 0dcfba7045
commit 0a2373f192
68 files changed
+2710 -197

No files matched your search

@@ -0,0 +1,10 @@
---
type: feature
area: portals
---
Live TV panels now fold in steps: hide only the categories rail and keep the
channel list, or hide both for a player-only view. With categories hidden, the
list header becomes a category dropdown, so switching categories stays one
click away, and hiding both panels then bringing them back returns to the
level you had before.
+30
View File
@@ -260,6 +260,36 @@ never narrows shared provider pages or resets their index. Category changes
reset list windows and retain playback/active EPG. Contract:
`docs/architecture/stalker-portal.md` (Full ITV Channel List Cache).
## Live TV Panel Levels
Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
from the outside in, in three nested levels owned by `LiveSidebarState`
(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
+ player), `categories-hidden` (channels rail + player) and `collapsed`
(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
shell context sidebar folds the categories rail on
`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
a selected category — the live root has no channels header to host the way
back — and always at level 3), the channels rail folds on
`isCollapsedFor('portal')`. While the rail is folded the
channels header turns its title into a category dropdown that opens the same
`WorkspaceContextPanelComponent` as a CDK popover through the
`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
the live layouts reach it through `createLivePanelsController()` (level
flags, dropdown bridge and focus handoff in one shared object; the token is
optional). `Cmd/Ctrl+B`, the header toggle and the
floating restore handle return to the level the user collapsed from (the
target is session-only; every level is restored as stored per surface).
Folded rails carry `inert`, and
`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
replacement affordance only when the activated button was removed or inerted.
M3U and the unified live tab have no categories rail and treat level 2 like
level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
("Collapsible Live Sidebar").
## Channel and Detail Keyboard Scrolling
Channel scroll owners use `ChannelScrollFocusDirective`; pointer selection
+30
View File
@@ -1739,6 +1739,36 @@ preventing destination failures from penalizing the initial endpoint. Contracts:
copy and Retry now; Stalker preserves cached account data on a failed refresh.
Contract: `docs/architecture/host-connectivity-guard.md`.
## Live TV Panel Levels
Portal live layouts (Xtream `live`, Stalker `itv`/`radio`) fold their panels
from the outside in, in three nested levels owned by `LiveSidebarState`
(`@iptvnator/portal/shared/util`): `expanded` (categories rail + channels rail
+ player), `categories-hidden` (channels rail + player) and `collapsed`
(player only). `LiveLayoutSidebarStateService` is the single source of truth, per
surface (`m3u` / `portal` / `collection`; the levels apply to `portal`); the
shell context sidebar folds the categories rail on
`areCategoriesHiddenFor('portal')` (at level 2 only while the portal store has
a selected category — the live root has no channels header to host the way
back — and always at level 3), the channels rail folds on
`isCollapsedFor('portal')`. While the rail is folded the
channels header turns its title into a category dropdown that opens the same
`WorkspaceContextPanelComponent` as a CDK popover through the
`LIVE_CATEGORIES_POPOVER` token: the workspace shell provides
`WorkspaceLiveCategoriesPopoverService` (focus-trapped `role="dialog"`,
closed by backdrop, Escape, selection, its footer and any `NavigationStart`),
the live layouts reach it through `createLivePanelsController()` (level
flags, dropdown bridge and focus handoff in one shared object; the token is
optional). `Cmd/Ctrl+B`, the header toggle and the
floating restore handle return to the level the user collapsed from (the
target is session-only; every level is restored as stored per surface).
Folded rails carry `inert`, and
`handoffFocusOnLiveSidebarChange()` / `focusIfFocusLost()` move focus to the
replacement affordance only when the activated button was removed or inerted.
M3U and the unified live tab have no categories rail and treat level 2 like
level 1. Contract: `docs/architecture/iptvnator-ui-guidelines.md`
("Collapsible Live Sidebar").
## Live Channel Return
Xtream and Stalker (including radio) capture displayed playback order on explicit
@@ -82,6 +82,13 @@ test.describe('Electron Remote Control', () => {
.getByTestId('channel-item');
await expect(rows.nth(2)).toBeVisible();
if (provider === 'xtream') {
// Read the order before sorting: the menu closes before the
// virtual list re-renders, and reading the titles too early
// captured the server order while the click below landed
// on the re-sorted first row (flaky on slow runners).
const serverOrder = await rows
.locator('.channel-name')
.allTextContents();
await page
.getByRole('button', {
name: 'Sort channels',
@@ -91,6 +98,11 @@ test.describe('Electron Remote Control', () => {
await page
.getByRole('menuitem', { name: 'Name Z-A' })
.click();
await expect
.poll(() =>
rows.locator('.channel-name').allTextContents()
)
.not.toEqual(serverOrder);
}
const titles = await rows
.locator('.channel-name')
@@ -448,15 +460,24 @@ async function waitForRemoteStatus(
): Promise<RemoteControlStatus> {
let latestStatus: RemoteControlStatus | null = null;
await expect
.poll(
async () => {
latestStatus = await getRemoteStatus(request, port);
return latestStatus ? predicate(latestStatus) : false;
},
{ timeout: 20000 }
)
.toBe(true);
try {
await expect
.poll(
async () => {
latestStatus = await getRemoteStatus(request, port);
return latestStatus ? predicate(latestStatus) : false;
},
{ timeout: 20000 }
)
.toBe(true);
} catch (error) {
// The predicate alone says nothing about how far the status got;
// surface the last payload so a CI failure is diagnosable.
throw new Error(
`Remote status never matched. Last status: ${JSON.stringify(latestStatus)}`,
{ cause: error }
);
}
return latestStatus as RemoteControlStatus;
}
+39 -11
View File
@@ -513,22 +513,50 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
timeout: 20_000,
});
// The rail's own chevron hides the list; the workspace header carries a
// second toggle with the same accessible name, so scope to the rail.
// Level 2: only the categories rail folds; the channels header turns
// its title into a category dropdown that opens the same rail as a
// popover, whose footer brings the rail back.
await page
.locator('.sidebar')
.getByRole('button', { name: 'Hide channels list' })
.getByRole('button', { name: 'Hide categories', exact: true })
.click();
const restoreButton = page.locator('.sidebar-restore');
const categoryDropdown = page.locator(
'[data-test-id="live-category-dropdown"]'
);
// The folded rail is 0px wide and inert; its rows keep their own boxes,
// so the rail's attribute is the reliable signal.
const categoriesRail = page.locator('aside.context-panel--route');
await expect(categoryDropdown).toBeVisible();
await expect(categoriesRail).toHaveAttribute('inert', '');
await categoryDropdown.click();
await page
.getByRole('dialog')
.getByRole('button', { name: 'Show categories panel' })
.click();
await expect(categoryDropdown).toBeHidden();
await expect(categoriesRail).not.toHaveAttribute('inert');
await expect(categories.nth(1)).toBeVisible();
// Level 3: both rails fold; the floating handle restores them.
await page
.getByRole('button', { name: 'Hide categories and channels' })
.click();
const restoreButton = page.getByRole('button', {
name: 'Show categories and channels',
});
await expect(restoreButton).toBeVisible();
await expect(
page.locator('app-workspace-shell-header .header-sidebar-toggle')
).toHaveAttribute('aria-pressed', 'false');
await expect(categoriesRail).toHaveAttribute('inert', '');
await expect(page.locator('.sidebar')).toHaveAttribute('inert', '');
// The workspace header carries a second toggle that mirrors the state.
const headerToggle = page.locator(
'app-workspace-shell-header .header-sidebar-toggle'
);
await expect(headerToggle).toHaveAttribute('aria-pressed', 'false');
await restoreButton.click();
await expect(restoreButton).toBeHidden();
await expect(headerToggle).toHaveAttribute('aria-pressed', 'true');
await expect(categoriesRail).not.toHaveAttribute('inert');
await expect(page.locator('.sidebar')).not.toHaveAttribute('inert');
await expect(stations.first()).toBeVisible();
await expect(
page.locator('app-workspace-shell-header .header-sidebar-toggle')
).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
expect(epgRequests).toHaveLength(0);
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "قائمة القنوات مخفية",
"CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "إخفاء الفئات",
"SHOW_CATEGORIES": "إظهار الفئات",
"SHOW_CATEGORIES_PANEL": "إظهار لوحة الفئات",
"CHOOSE_CATEGORY": "اختيار الفئة",
"HIDE_PANELS": "إخفاء الفئات والقنوات",
"HIDE_PANELS_TOOLTIP": "إخفاء اللوحات (⌘/Ctrl+B)",
"SHOW_PANELS": "إظهار الفئات والقنوات",
"SHOW_PANELS_TOOLTIP": "إظهار اللوحات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "قائمة القنوات مخبية",
"CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "إخفاء الفئات",
"SHOW_CATEGORIES": "إظهار الفئات",
"SHOW_CATEGORIES_PANEL": "إظهار لوحة الفئات",
"CHOOSE_CATEGORY": "اختار فئة",
"HIDE_PANELS": "إخفاء الفئات والقنوات",
"HIDE_PANELS_TOOLTIP": "إخفاء اللوحات (⌘/Ctrl+B)",
"SHOW_PANELS": "إظهار الفئات والقنوات",
"SHOW_PANELS_TOOLTIP": "إظهار اللوحات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Паказаць спіс каналаў",
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "Спіс каналаў схаваны",
"CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Схаваць катэгорыі",
"SHOW_CATEGORIES": "Паказаць катэгорыі",
"SHOW_CATEGORIES_PANEL": "Паказаць панэль катэгорый",
"CHOOSE_CATEGORY": "Выбраць катэгорыю",
"HIDE_PANELS": "Схаваць катэгорыі і каналы",
"HIDE_PANELS_TOOLTIP": "Схаваць панэлі (⌘/Ctrl+B)",
"SHOW_PANELS": "Паказаць катэгорыі і каналы",
"SHOW_PANELS_TOOLTIP": "Паказаць панэлі (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Senderliste einblenden",
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)",
"CHANNELS_LIST_HIDDEN": "Die Senderliste ist ausgeblendet",
"CHANNELS_LIST_HIDDEN_HINT": "Blenden Sie sie über die Schaltfläche unten oder mit ⌘/Strg+B wieder ein."
"CHANNELS_LIST_HIDDEN_HINT": "Blenden Sie sie über die Schaltfläche unten oder mit ⌘/Strg+B wieder ein.",
"HIDE_CATEGORIES": "Kategorien ausblenden",
"SHOW_CATEGORIES": "Kategorien einblenden",
"SHOW_CATEGORIES_PANEL": "Kategorien-Panel einblenden",
"CHOOSE_CATEGORY": "Kategorie auswählen",
"HIDE_PANELS": "Kategorien und Sender ausblenden",
"HIDE_PANELS_TOOLTIP": "Panels ausblenden (⌘/Ctrl+B)",
"SHOW_PANELS": "Kategorien und Sender einblenden",
"SHOW_PANELS_TOOLTIP": "Panels einblenden (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Laufenden Sender anzeigen",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών",
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "Η λίστα καναλιών είναι κρυφή",
"CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Απόκρυψη κατηγοριών",
"SHOW_CATEGORIES": "Εμφάνιση κατηγοριών",
"SHOW_CATEGORIES_PANEL": "Εμφάνιση πίνακα κατηγοριών",
"CHOOSE_CATEGORY": "Επιλογή κατηγορίας",
"HIDE_PANELS": "Απόκρυψη κατηγοριών και καναλιών",
"HIDE_PANELS_TOOLTIP": "Απόκρυψη πινάκων (⌘/Ctrl+B)",
"SHOW_PANELS": "Εμφάνιση κατηγοριών και καναλιών",
"SHOW_PANELS_TOOLTIP": "Εμφάνιση πινάκων (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Show channels list",
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "The channels list is hidden",
"CHANNELS_LIST_HIDDEN_HINT": "Bring it back with the button below or press ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Bring it back with the button below or press ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Hide categories",
"SHOW_CATEGORIES": "Show categories",
"SHOW_CATEGORIES_PANEL": "Show categories panel",
"CHOOSE_CATEGORY": "Choose category",
"HIDE_PANELS": "Hide categories and channels",
"HIDE_PANELS_TOOLTIP": "Hide panels (⌘/Ctrl+B)",
"SHOW_PANELS": "Show categories and channels",
"SHOW_PANELS_TOOLTIP": "Show panels (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Show playing channel",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Mostrar lista de canales",
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "La lista de canales está oculta",
"CHANNELS_LIST_HIDDEN_HINT": "Vuelve a mostrarla con el botón de abajo o pulsa ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Vuelve a mostrarla con el botón de abajo o pulsa ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Ocultar categorías",
"SHOW_CATEGORIES": "Mostrar categorías",
"SHOW_CATEGORIES_PANEL": "Mostrar panel de categorías",
"CHOOSE_CATEGORY": "Elegir categoría",
"HIDE_PANELS": "Ocultar categorías y canales",
"HIDE_PANELS_TOOLTIP": "Ocultar paneles (⌘/Ctrl+B)",
"SHOW_PANELS": "Mostrar categorías y canales",
"SHOW_PANELS_TOOLTIP": "Mostrar paneles (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Mostrar el canal en reproducción",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Afficher la liste des chaînes",
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "La liste des chaînes est masquée",
"CHANNELS_LIST_HIDDEN_HINT": "Réaffichez-la avec le bouton ci-dessous ou appuyez sur ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Réaffichez-la avec le bouton ci-dessous ou appuyez sur ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Masquer les catégories",
"SHOW_CATEGORIES": "Afficher les catégories",
"SHOW_CATEGORIES_PANEL": "Afficher le panneau des catégories",
"CHOOSE_CATEGORY": "Choisir une catégorie",
"HIDE_PANELS": "Masquer les catégories et les chaînes",
"HIDE_PANELS_TOOLTIP": "Masquer les panneaux (⌘/Ctrl+B)",
"SHOW_PANELS": "Afficher les catégories et les chaînes",
"SHOW_PANELS_TOOLTIP": "Afficher les panneaux (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Afficher la chaîne en cours",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Csatornalista megjelenítése",
"TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "A csatornalista el van rejtve",
"CHANNELS_LIST_HIDDEN_HINT": "Az alábbi gombbal vagy a ⌘/Ctrl+B billentyűkkel jelenítheti meg újra."
"CHANNELS_LIST_HIDDEN_HINT": "Az alábbi gombbal vagy a ⌘/Ctrl+B billentyűkkel jelenítheti meg újra.",
"HIDE_CATEGORIES": "Kategóriák elrejtése",
"SHOW_CATEGORIES": "Kategóriák megjelenítése",
"SHOW_CATEGORIES_PANEL": "Kategóriapanel megjelenítése",
"CHOOSE_CATEGORY": "Kategória kiválasztása",
"HIDE_PANELS": "Kategóriák és csatornák elrejtése",
"HIDE_PANELS_TOOLTIP": "Panelek elrejtése (⌘/Ctrl+B)",
"SHOW_PANELS": "Kategóriák és csatornák megjelenítése",
"SHOW_PANELS_TOOLTIP": "Panelek megjelenítése (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Lejátszott csatorna megjelenítése",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Mostra elenco canali",
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "L'elenco canali è nascosto",
"CHANNELS_LIST_HIDDEN_HINT": "Mostralo di nuovo con il pulsante qui sotto o premi ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Mostralo di nuovo con il pulsante qui sotto o premi ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Nascondi categorie",
"SHOW_CATEGORIES": "Mostra categorie",
"SHOW_CATEGORIES_PANEL": "Mostra il pannello delle categorie",
"CHOOSE_CATEGORY": "Scegli categoria",
"HIDE_PANELS": "Nascondi categorie e canali",
"HIDE_PANELS_TOOLTIP": "Nascondi i pannelli (⌘/Ctrl+B)",
"SHOW_PANELS": "Mostra categorie e canali",
"SHOW_PANELS_TOOLTIP": "Mostra i pannelli (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Mostra il canale in riproduzione",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "チャンネル一覧を表示",
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "チャンネル一覧は非表示です",
"CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。"
"CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。",
"HIDE_CATEGORIES": "カテゴリーを非表示",
"SHOW_CATEGORIES": "カテゴリーを表示",
"SHOW_CATEGORIES_PANEL": "カテゴリーパネルを表示",
"CHOOSE_CATEGORY": "カテゴリーを選択",
"HIDE_PANELS": "カテゴリーとチャンネルを非表示",
"HIDE_PANELS_TOOLTIP": "パネルを非表示 (⌘/Ctrl+B)",
"SHOW_PANELS": "カテゴリーとチャンネルを表示",
"SHOW_PANELS_TOOLTIP": "パネルを表示 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "채널 목록 표시",
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "채널 목록이 숨겨져 있습니다",
"CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요."
"CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요.",
"HIDE_CATEGORIES": "카테고리 숨기기",
"SHOW_CATEGORIES": "카테고리 표시",
"SHOW_CATEGORIES_PANEL": "카테고리 패널 표시",
"CHOOSE_CATEGORY": "카테고리 선택",
"HIDE_PANELS": "카테고리 및 채널 숨기기",
"HIDE_PANELS_TOOLTIP": "패널 숨기기 (⌘/Ctrl+B)",
"SHOW_PANELS": "카테고리 및 채널 표시",
"SHOW_PANELS_TOOLTIP": "패널 표시 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Zenderlijst tonen",
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "De zenderlijst is verborgen",
"CHANNELS_LIST_HIDDEN_HINT": "Toon de lijst opnieuw met de knop hieronder of druk op ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Toon de lijst opnieuw met de knop hieronder of druk op ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Categorieën verbergen",
"SHOW_CATEGORIES": "Categorieën tonen",
"SHOW_CATEGORIES_PANEL": "Categorieënpaneel tonen",
"CHOOSE_CATEGORY": "Categorie kiezen",
"HIDE_PANELS": "Categorieën en kanalen verbergen",
"HIDE_PANELS_TOOLTIP": "Panelen verbergen (⌘/Ctrl+B)",
"SHOW_PANELS": "Categorieën en kanalen tonen",
"SHOW_PANELS_TOOLTIP": "Panelen tonen (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Huidige zender tonen",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Pokaż listę kanałów",
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "Lista kanałów jest ukryta",
"CHANNELS_LIST_HIDDEN_HINT": "Przywróć ją przyciskiem poniżej lub naciśnij ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Przywróć ją przyciskiem poniżej lub naciśnij ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Ukryj kategorie",
"SHOW_CATEGORIES": "Pokaż kategorie",
"SHOW_CATEGORIES_PANEL": "Pokaż panel kategorii",
"CHOOSE_CATEGORY": "Wybierz kategorię",
"HIDE_PANELS": "Ukryj kategorie i kanały",
"HIDE_PANELS_TOOLTIP": "Ukryj panele (⌘/Ctrl+B)",
"SHOW_PANELS": "Pokaż kategorie i kanały",
"SHOW_PANELS_TOOLTIP": "Pokaż panele (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Pokaż odtwarzany kanał",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Mostrar lista de canais",
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "A lista de canais está oculta",
"CHANNELS_LIST_HIDDEN_HINT": "Volte a mostrá-la com o botão abaixo ou pressione ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Volte a mostrá-la com o botão abaixo ou pressione ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Ocultar categorias",
"SHOW_CATEGORIES": "Mostrar categorias",
"SHOW_CATEGORIES_PANEL": "Mostrar painel de categorias",
"CHOOSE_CATEGORY": "Escolher categoria",
"HIDE_PANELS": "Ocultar categorias e canais",
"HIDE_PANELS_TOOLTIP": "Ocultar painéis (⌘/Ctrl+B)",
"SHOW_PANELS": "Mostrar categorias e canais",
"SHOW_PANELS_TOOLTIP": "Mostrar painéis (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Mostrar canal em reprodução",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Показать список каналов",
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "Список каналов скрыт",
"CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B."
"CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B.",
"HIDE_CATEGORIES": "Скрыть категории",
"SHOW_CATEGORIES": "Показать категории",
"SHOW_CATEGORIES_PANEL": "Показать панель категорий",
"CHOOSE_CATEGORY": "Выбрать категорию",
"HIDE_PANELS": "Скрыть категории и каналы",
"HIDE_PANELS_TOOLTIP": "Скрыть панели (⌘/Ctrl+B)",
"SHOW_PANELS": "Показать категории и каналы",
"SHOW_PANELS_TOOLTIP": "Показать панели (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Показать текущий канал",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "Kanal listesini göster",
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "Kanal listesi gizli",
"CHANNELS_LIST_HIDDEN_HINT": "Aşağıdaki düğmeyle geri getirin veya ⌘/Ctrl+B tuşlarına basın."
"CHANNELS_LIST_HIDDEN_HINT": "Aşağıdaki düğmeyle geri getirin veya ⌘/Ctrl+B tuşlarına basın.",
"HIDE_CATEGORIES": "Kategorileri gizle",
"SHOW_CATEGORIES": "Kategorileri göster",
"SHOW_CATEGORIES_PANEL": "Kategoriler panelini göster",
"CHOOSE_CATEGORY": "Kategori seç",
"HIDE_PANELS": "Kategorileri ve kanalları gizle",
"HIDE_PANELS_TOOLTIP": "Panelleri gizle (⌘/Ctrl+B)",
"SHOW_PANELS": "Kategorileri ve kanalları göster",
"SHOW_PANELS_TOOLTIP": "Panelleri göster (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Oynatılan kanalı göster",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "显示频道列表",
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "频道列表已隐藏",
"CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。"
"CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。",
"HIDE_CATEGORIES": "隐藏分类",
"SHOW_CATEGORIES": "显示分类",
"SHOW_CATEGORIES_PANEL": "显示分类面板",
"CHOOSE_CATEGORY": "选择分类",
"HIDE_PANELS": "隐藏分类和频道",
"HIDE_PANELS_TOOLTIP": "隐藏面板 (⌘/Ctrl+B)",
"SHOW_PANELS": "显示分类和频道",
"SHOW_PANELS_TOOLTIP": "显示面板 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "显示正在播放的频道",
+9 -1
View File
@@ -676,7 +676,15 @@
"SHOW_CHANNELS_LIST": "顯示頻道列表",
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)",
"CHANNELS_LIST_HIDDEN": "頻道列表已隱藏",
"CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。"
"CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。",
"HIDE_CATEGORIES": "隱藏類別",
"SHOW_CATEGORIES": "顯示類別",
"SHOW_CATEGORIES_PANEL": "顯示類別面板",
"CHOOSE_CATEGORY": "選擇類別",
"HIDE_PANELS": "隱藏類別和頻道",
"HIDE_PANELS_TOOLTIP": "隱藏面板 (⌘/Ctrl+B)",
"SHOW_PANELS": "顯示類別和頻道",
"SHOW_PANELS_TOOLTIP": "顯示面板 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道",
+102 -22
View File
@@ -267,8 +267,24 @@ remain local when the meaning is explicit.
### Collapsible Live Sidebar
- M3U, Xtream, and Stalker live layouts share a sidebar collapse toggle that
hides the channels rail to give the player and EPG full width.
- Live-TV panels fold from the outside in, in three nested levels owned by
`LiveSidebarState` (`@iptvnator/portal/shared/util`):
1. `expanded` — categories rail + channels rail + player.
2. `categories-hidden` — channels rail + player. The shell's categories
rail (`WorkspaceShellContextSidebarComponent`, live sections only:
Xtream `live`, Stalker `itv`/`radio`) folds; the channels header turns
its category title into a dropdown that opens the same rail as a
popover, so switching categories stays one click away.
3. `collapsed` — player + EPG only ("theater").
- There is deliberately no "channels hidden, categories visible" state: a
category click has to bring the channels back anyway. Surfaces without a
categories rail (M3U, the unified-collection live tab) treat level 2 like
level 1 — and so does the live ROOT (no selected category, Xtream `/live`
"All Items", Stalker's all-items grid): there is no channels rail to host
the way back, so the shell folds the categories rail at level 2 only while
the portal store has a selected category (`hasLiveCategorySelection`), and
the rail's hide chevron is withheld there too. Level 3 folds it regardless,
since the floating restore handle lives in the content area.
- Xtream Live TV's root view (`/live` with no selected category) follows the
same paginated `All Items` shell as VOD and Series: a widget header with the
total channel count, page-size controls, and page navigation above the shared
@@ -278,26 +294,69 @@ remain local when the meaning is explicit.
selects the channel's category, highlights the active category and channel,
and scrolls the category rail plus virtual channels list to the selected rows
when those rails are visible.
- In Xtream and Stalker live TV, the same toggle also collapses the workspace
shell context sidebar (the "Live Categories" rail rendered by
`WorkspaceShellContextSidebarComponent`), matching M3U's "everything quiets"
behaviour. The shell categories rail only collapses when the active section
is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites,
and recent routes leave it untouched.
- Affordances, each in the panel it acts on:
- A `chevron_left` in the categories rail header
(`WorkspaceContextPanelComponent`, `presentation="sidebar"`, live
sections only) → level 2 (`hideCategories('portal')`).
- A `chevron_right` at the start of the channels header
(`data-test-id="live-show-categories"`) and the popover footer's
"Show categories panel" → level 1, through the shell's
`LiveCategoriesPopover.showCategoriesPanel()`: it sets
`showCategories('portal')` and, at phone widths where the rail is the
off-canvas context drawer whose open state the level does not drive,
also opens that drawer (`WorkspaceShellContextDrawerService.open()`).
- The category dropdown (`data-test-id="live-category-dropdown"`) opens
`LIVE_CATEGORIES_POPOVER` anchored below itself. The token lives in
`@iptvnator/portal/shared/util`; the workspace shell provides it
(`WorkspaceLiveCategoriesPopoverService`, CDK overlay hosting
`WorkspaceLiveCategoriesPopoverComponent`, which stamps the context
panel with `presentation="popover"`) and the live layouts reach it
through their `LivePanelsController` (`createLivePanelsController()`
in a field initializer: level flags, the popover bridge and the focus
handoff in one shared object, so the layout components carry none of
it; without a provider the header keeps its plain title). The stamped
panel opts out of the live-TV column keyboard contract
(`columnHandoff=false`: no `#portal-categories` id, no ArrowRight
handoff to `#live-channels`), since the dialog's focus trap would
bounce that handoff back inside and a second id would shadow the
folded rail's; the category sort preference is shared through
`PortalCategorySortStateService`, so a sort picked in the popover
survives into the restored rail. Backdrop, Escape, the footer, any
category selection (`categorySelected` output), any router
`NavigationStart` and any live-panel level change (`Cmd/Ctrl+B`
reaches the layout through the dialog) close it; focus returns to the
trigger. The popover host is a `role="dialog"` with `aria-modal` and a
`CdkTrapFocus` host directive that captures focus on open, matching
the trigger's `aria-haspopup="dialog"`.
- The `chevron_left` in the channels header → level 3
(`collapse('portal')`).
- While collapsed, a floating `chevron_right` mini-fab at the left edge of
`.content-container`, the workspace header toggle and `Cmd/Ctrl+B`
(`toggle(surface)`) return to the level the user collapsed from, not
always to level 1. The shortcut handler ignores events that originate
inside `<input>`, `<textarea>`, `<select>`, or content-editable
elements via the shared `isTypingInInput` helper. "Show playing
channel" (`XtreamLiveChannelNavigationService`, `StalkerLiveNavigation`)
uses `expand('portal')` for the same reason: revealing the row must not
unfold a deliberately hidden categories rail.
- Collapsed state is owned by `LiveLayoutSidebarStateService`
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util` and kept **per
surface** (`LiveSidebarSurface`): `m3u` (the M3U player), `portal` (Xtream
and Stalker live layouts plus the shell categories rail) and `collection`
(the unified favorites/recent live tab). Every participant injects the
service, holds `isCollapsedFor(surface)` (a stable signal) and calls
`toggle(surface)`; nothing reads localStorage directly. Persistence lives
under `live-sidebar-state:<surface>`. Hiding the list is a per-context
choice: it must not follow the user from a portal to an M3U playlist, nor
from the desktop rail to the phone bottom drawer of another surface. The
pre-split shared key `live-sidebar-state` is forgotten on service
construction and never read — a stored `collapsed` there hid every channel
list in the app behind a 32px chevron and survived restart, "Remove all
playlists" and re-import (issue #1458).
service and reads a derived, stable per-surface signal, never the raw
state: the categories rail folds on `areCategoriesHiddenFor(surface)`, the
channels rail on `isCollapsedFor(surface)`; actions are `toggle`,
`collapse`, `expand`, `hideCategories`, `showCategories` and `setState`,
all per surface. Nothing reads localStorage directly. Persistence lives
under `live-sidebar-state:<surface>` and every level is restored as
stored; the level `toggle()` comes back to is session-only. Hiding the
list is a per-context choice: it must not follow the user from a portal to
an M3U playlist, nor from the desktop rail to the phone bottom drawer of
another surface. The pre-split shared key `live-sidebar-state` is forgotten
on service construction and never read — a stored `collapsed` there hid
every channel list in the app behind a 32px chevron and survived restart,
"Remove all playlists" and re-import (issue #1458).
- The control never moves. Inside the rail a `mat-icon-button` with
`chevron_left` hides it; while collapsed a floating `chevron_right` mini-fab
sits at the left edge of `.content-container`. Because both of those live
@@ -320,21 +379,42 @@ remain local when the meaning is explicit.
`app-portal-empty-state` grew optional `hint`, `actionLabel`, `actionIcon`
inputs and an `action` output for this; the action keeps full opacity while
icon and copy stay muted, because it is the way out of the state.
- Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate
inside `<input>`, `<textarea>`, `<select>`, or content-editable elements via
the shared `isTypingInInput` helper.
- A folded rail is 0px wide but still rendered, so it also carries `inert`
(`isContextPanelInert` on the shell rail, `isSidebarCollapsed` on the
Xtream/Stalker channels rail) to leave the Tab order and the accessibility
tree; the shell rail skips `inert` while it renders as the open phone
drawer, whose stylesheet ignores the folded state — and the rail's hide
chevron is withheld there for the same reason (`canHideCategories`).
Every level change removes or inerts the very button the user activated,
so focus drops to `<body>`; the side that gains the replacement affordance
picks it up after its next render via `focusIfFocusLost()`
(`@iptvnator/portal/shared/util`): the layouts' `LivePanelsController`
installs `handoffFocusOnLiveSidebarChange()` over the EFFECTIVE level (on
the live root the first category selection folds the rail with no state
change) and focuses the floating restore handle at player-only or the
show-categories button while the rail is folded, and the shell sidebar,
watching the rail's ACTUAL fold state (on the live root the rail stays
visible at level 2, so only player-only ↔ visible is a transition there),
focuses the control the context panel names (`focusTarget()`: its hide
chevron, or its first header action when the chevron is withheld) and,
while none is rendered (categories loading, a failed load), the
`tabindex="-1"` aside itself — only on transitions, and never when another
control still owns focus.
- The CSS class `.sidebar-collapsed` (channels rail) and
`.context-panel--collapsed` (workspace shell categories rail) both override
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. Both rails share the same 180 ms width transition so motion stays in
lockstep.
lockstep. The dropdown reuses the static heading's type so folding the rail
does not move the title; the caret is the only added ink
(`_portal-sidebar.scss`).
- At the phone breakpoint the M3U layout's bottom-drawer rule overrides the
desktop collapse to `height: 0` instead of `width: 0`. The floating restore
handle stays visible there: the collapse toggle is reachable by touch, so
hiding the handle left a phone with no way to bring the list back short of
`Cmd/Ctrl+B`.
`Cmd/Ctrl+B`. The phone context drawer ignores the folded state entirely
(the user explicitly opened it).
### EPG Card
+6
View File
@@ -146,6 +146,12 @@ default. The panel header exposes a sort menu next to category search with
synthetic "all categories" entries stay pinned before sorted provider
categories.
On live sections the category panel can be folded away independently of the
channels list; the folded panel is reachable as a popover from the channels
header through the `LIVE_CATEGORIES_POPOVER` token the shell provides. The
three nested levels, their affordances and persistence are specified in
`iptvnator-ui-guidelines.md` ("Collapsible Live Sidebar").
A category click in a LIVE section (Xtream `live`, Stalker `itv` and `radio`)
changes only the selected category: the live layouts gate their player on the
store's selected item, so the handler must not clear it — the channel the user
+4
View File
@@ -16,6 +16,10 @@ export * from './lib/portal-collection-items';
export * from './lib/live-epg-panel-state';
export * from './lib/live-sidebar-state';
export * from './lib/live-layout-sidebar-state.service';
export * from './lib/live-categories-popover.token';
export * from './lib/focus-handoff';
export * from './lib/live-panels-controller';
export * from './lib/category-sort-state.service';
export * from './lib/keyboard';
export * from './lib/keyboard-shortcuts';
export * from './lib/remote-channel-navigation';
@@ -0,0 +1,26 @@
import { Injectable, signal } from '@angular/core';
import {
PortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
} from './category-sort';
/**
* The portal categories sort preference, shared by every rendered category
* list. The workspace context panel is stamped twice while the categories
* rail is folded (the in-flow rail stays mounted, the popover shows a second
* copy), and a per-instance signal seeded from storage would let a sort
* picked in the popover revert the moment the rail comes back.
*/
@Injectable({ providedIn: 'root' })
export class PortalCategorySortStateService {
private readonly _mode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
readonly mode = this._mode.asReadonly();
setMode(mode: PortalCategorySortMode): void {
this._mode.set(mode);
persistPortalCategorySortMode(mode);
}
}
@@ -0,0 +1,50 @@
import { effect, Signal, untracked } from '@angular/core';
import { LiveSidebarState } from './live-sidebar-state';
/**
* Moves focus to `element` only when the document has none to speak of:
* the previously focused control was removed or made `inert`, so focus
* already fell back to `<body>`. A control that still owns focus (a mouse
* user's category row, a keyboard user's search box) is left alone.
*
* Used by the live-TV panel toggles: hiding a rail removes the button that
* was just activated, and the replacement affordance lives in another
* component, so the receiving side picks focus up after its next render.
*/
export function focusIfFocusLost(
element: HTMLElement | null | undefined
): void {
if (!element || !element.isConnected) {
return;
}
const active = document.activeElement;
const focusLost =
!active || active === document.body || active.closest('[inert]') !== null;
if (focusLost) {
element.focus();
}
}
/**
* Installs the focus handoff for a live layout's panel toggles. Must run in
* an injection context (a component constructor). After every level change
* and the render it causes, `targetFor(next)` names the affordance that now
* stands in for the button the user activated — the floating restore handle
* at player-only, the show-categories button while the rail is folded, and
* nothing when the categories rail itself is back (its own component picks
* focus up then) — and focus moves there only if it was lost.
*/
export function handoffFocusOnLiveSidebarChange(
state: Signal<LiveSidebarState>,
targetFor: (next: LiveSidebarState) => HTMLElement | null | undefined
): void {
let previous = untracked(state);
effect(() => {
const next = state();
if (next === previous) {
return;
}
previous = next;
queueMicrotask(() => focusIfFocusLost(untracked(() => targetFor(next))));
});
}
@@ -0,0 +1,33 @@
import { InjectionToken } from '@angular/core';
/**
* Opens the live categories list as a popover anchored to an element.
*
* While the categories rail is folded (`LiveLayoutSidebarStateService.
* areCategoriesHidden`), the channels header turns its category title into a
* dropdown so switching categories stays one click away. The rail itself is
* shell-owned (`WorkspaceContextPanelComponent`), which the portal feature
* libs cannot import, so the workspace shell provides this token and the live
* layouts inject it optionally: without a provider (unit tests, a host that
* renders no categories rail) the dropdown is simply not offered.
*/
export interface LiveCategoriesPopover {
/**
* Opens the popover below `origin`, or closes it when it is already open
* for that origin.
*/
open(origin: HTMLElement): void;
/** Closes the popover if it is open. */
close(): void;
/**
* Brings the categories rail back (level 1) the way the current viewport
* shows it: on desktop the in-flow rail unfolds; at phone widths the rail
* is the shell's off-canvas drawer, whose open state is separate from
* the fold level, so the drawer is opened as well. Closes the popover.
*/
showCategoriesPanel(): void;
}
export const LIVE_CATEGORIES_POPOVER = new InjectionToken<LiveCategoriesPopover>(
'LIVE_CATEGORIES_POPOVER'
);
@@ -73,6 +73,68 @@ describe('LiveLayoutSidebarStateService', () => {
);
});
it('folds only the categories rail on hideCategories', () => {
const service = createService();
service.hideCategories('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
expect(service.areCategoriesHiddenFor('portal')()).toBe(true);
expect(service.isCollapsedFor('portal')()).toBe(false);
expect(service.areCategoriesHiddenFor('m3u')()).toBe(false);
expect(
localStorage.getItem(liveSidebarStateStorageKey('portal'))
).toBe('categories-hidden');
service.showCategories('portal');
expect(service.stateOf('portal')()).toBe('expanded');
});
it('collapse folds both rails and expand returns to the level it was entered from', () => {
const service = createService();
service.hideCategories('portal');
service.collapse('portal');
expect(service.isCollapsedFor('portal')()).toBe(true);
expect(service.areCategoriesHiddenFor('portal')()).toBe(true);
service.collapse('portal');
service.expand('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('toggle leaves collapsed for the previous level and otherwise collapses', () => {
const service = createService();
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('collapsed');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('expanded');
service.hideCategories('portal');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('collapsed');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('restores a hidden categories rail from its surface key and comes back to it after a toggle', () => {
localStorage.setItem(
liveSidebarStateStorageKey('portal'),
'categories-hidden'
);
const service = createService();
expect(service.stateOf('portal')()).toBe('categories-hidden');
service.toggle('portal');
service.toggle('portal');
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('ignores and removes the legacy shared key instead of inheriting it', () => {
localStorage.setItem(
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
@@ -34,6 +34,11 @@ function bySurface<T>(
*
* State is kept per surface (`live-sidebar-state:<surface>` in
* localStorage). The pre-split shared key is forgotten on construction.
*
* The three levels nest (see `LiveSidebarState`). Consumers read one of two
* derived flags rather than the raw state: the categories rail folds on
* `areCategoriesHiddenFor(surface)`, the channels rail only on
* `isCollapsedFor(surface)`.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {
@@ -42,6 +47,18 @@ export class LiveLayoutSidebarStateService {
WritableSignal<LiveSidebarState>
>;
private readonly collapsed: Record<LiveSidebarSurface, Signal<boolean>>;
private readonly categoriesHidden: Record<
LiveSidebarSurface,
Signal<boolean>
>;
/**
* The level to come back to when leaving `collapsed`, per surface.
* Session-only on purpose: a restart restores the stored level itself.
*/
private readonly restoreTargets: Record<
LiveSidebarSurface,
Exclude<LiveSidebarState, 'collapsed'>
>;
constructor() {
forgetLegacyLiveSidebarState();
@@ -51,6 +68,14 @@ export class LiveLayoutSidebarStateService {
this.collapsed = bySurface((surface) =>
computed(() => this.states[surface]() === 'collapsed')
);
this.categoriesHidden = bySurface((surface) =>
computed(() => this.states[surface]() !== 'expanded')
);
// A restored `categories-hidden` is what Cmd/Ctrl+B comes back to.
this.restoreTargets = bySurface((surface) => {
const restored = this.states[surface]();
return restored === 'collapsed' ? 'expanded' : restored;
});
}
stateOf(surface: LiveSidebarSurface): Signal<LiveSidebarState> {
@@ -62,14 +87,49 @@ export class LiveLayoutSidebarStateService {
return this.collapsed[surface];
}
/**
* The categories rail is folded (`categories-hidden` or `collapsed`).
* Stable per-surface signal.
*/
areCategoriesHiddenFor(surface: LiveSidebarSurface): Signal<boolean> {
return this.categoriesHidden[surface];
}
/**
* `Cmd/Ctrl+B`, the header toggle and the floating restore handle: leave
* `collapsed` for the level the user collapsed from, otherwise collapse
* everything.
*/
toggle(surface: LiveSidebarSurface): void {
this.setState(
surface,
this.collapsed[surface]() ? 'expanded' : 'collapsed'
);
if (this.collapsed[surface]()) {
this.expand(surface);
} else {
this.collapse(surface);
}
}
/** Player only. Remembers the current level for `expand()`. */
collapse(surface: LiveSidebarSurface): void {
this.setState(surface, 'collapsed');
}
/** Leaves `collapsed` for the level it was entered from. */
expand(surface: LiveSidebarSurface): void {
this.setState(surface, this.restoreTargets[surface]);
}
hideCategories(surface: LiveSidebarSurface): void {
this.setState(surface, 'categories-hidden');
}
showCategories(surface: LiveSidebarSurface): void {
this.setState(surface, 'expanded');
}
setState(surface: LiveSidebarSurface, state: LiveSidebarState): void {
if (state !== 'collapsed') {
this.restoreTargets[surface] = state;
}
this.states[surface].set(state);
persistLiveSidebarState(state, liveSidebarStateStorageKey(surface));
}
@@ -0,0 +1,129 @@
import { ElementRef, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { createLivePanelsController } from './live-panels-controller';
import { LIVE_CATEGORIES_POPOVER } from './live-categories-popover.token';
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
import { liveSidebarStateStorageKey } from './live-sidebar-state';
describe('LivePanelsController', () => {
const popover = {
open: jest.fn(),
close: jest.fn(),
showCategoriesPanel: jest.fn(),
};
const hasSelectedCategory = signal(true);
let showButton: HTMLButtonElement;
let restoreButton: HTMLButtonElement;
function create(withPopover = true) {
TestBed.configureTestingModule({
providers: withPopover
? [{ provide: LIVE_CATEGORIES_POPOVER, useValue: popover }]
: [],
});
return TestBed.runInInjectionContext(() =>
createLivePanelsController({
surface: 'portal',
hasSelectedCategory,
showCategoriesButton: signal(new ElementRef(showButton)),
restoreButton: signal(new ElementRef(restoreButton)),
})
);
}
async function settle(): Promise<void> {
TestBed.flushEffects();
await new Promise((resolve) => queueMicrotask(resolve));
}
beforeEach(() => {
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
popover.open.mockClear();
popover.close.mockClear();
popover.showCategoriesPanel.mockClear();
hasSelectedCategory.set(true);
showButton = document.createElement('button');
restoreButton = document.createElement('button');
document.body.append(showButton, restoreButton);
});
afterEach(() => {
showButton.remove();
restoreButton.remove();
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
});
it('offers the dropdown only with a provider, a folded rail and a selected category', () => {
const controller = create();
const state = TestBed.inject(LiveLayoutSidebarStateService);
expect(controller.canOpenCategoriesPopover()).toBe(false);
state.hideCategories('portal');
expect(controller.canOpenCategoriesPopover()).toBe(true);
expect(controller.effectiveLevel()).toBe('categories-hidden');
hasSelectedCategory.set(false);
expect(controller.canOpenCategoriesPopover()).toBe(false);
expect(controller.effectiveLevel()).toBe('expanded');
state.collapse('portal');
expect(controller.effectiveLevel()).toBe('collapsed');
});
it('keeps the plain heading without a popover provider', () => {
const controller = create(false);
TestBed.inject(LiveLayoutSidebarStateService).hideCategories('portal');
expect(controller.canOpenCategoriesPopover()).toBe(false);
controller.openCategoriesPopover(showButton);
controller.showCategories();
expect(TestBed.inject(LiveLayoutSidebarStateService).stateOf('portal')()).toBe(
'expanded'
);
});
it('routes the actions through the shared state and the popover', () => {
const controller = create();
const state = TestBed.inject(LiveLayoutSidebarStateService);
controller.openCategoriesPopover(showButton);
expect(popover.open).toHaveBeenCalledWith(showButton);
controller.collapsePanels();
expect(state.stateOf('portal')()).toBe('collapsed');
controller.toggleSidebar();
expect(state.stateOf('portal')()).toBe('expanded');
state.hideCategories('portal');
controller.showCategories();
// The shell restores the rail (and opens the phone drawer if the
// rail is one); the controller only delegates.
expect(popover.showCategoriesPanel).toHaveBeenCalledTimes(1);
expect(state.stateOf('portal')()).toBe('categories-hidden');
});
it('hands focus across level changes, including a fold by category selection', async () => {
const controller = create();
const state = TestBed.inject(LiveLayoutSidebarStateService);
hasSelectedCategory.set(false);
state.hideCategories('portal');
await settle();
(document.activeElement as HTMLElement | null)?.blur();
// Live root: selecting the first category folds the rail.
hasSelectedCategory.set(true);
await settle();
expect(document.activeElement).toBe(showButton);
(document.activeElement as HTMLElement | null)?.blur();
controller.collapsePanels();
await settle();
expect(document.activeElement).toBe(restoreButton);
(document.activeElement as HTMLElement | null)?.blur();
controller.toggleSidebar();
await settle();
expect(document.activeElement).toBe(showButton);
});
});
@@ -0,0 +1,116 @@
import {
computed,
ElementRef,
inject,
Signal,
untracked,
} from '@angular/core';
import { handoffFocusOnLiveSidebarChange } from './focus-handoff';
import { LIVE_CATEGORIES_POPOVER } from './live-categories-popover.token';
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
import { LiveSidebarState, LiveSidebarSurface } from './live-sidebar-state';
export interface LivePanelsControllerOptions {
/** The surface whose per-surface state this layout renders. */
readonly surface: LiveSidebarSurface;
/**
* A live category is selected, so the layout renders a channels rail
* whose header can host the category dropdown and the show-categories
* button. Mirrors the shell's fold rule: on the live root the categories
* rail stays visible at `categories-hidden`.
*/
readonly hasSelectedCategory: Signal<boolean>;
/** The header's show-categories button, once rendered. */
readonly showCategoriesButton: Signal<ElementRef<HTMLElement> | undefined>;
/** The floating restore handle, once rendered at player-only. */
readonly restoreButton: Signal<ElementRef<HTMLElement> | undefined>;
}
/**
* The live-TV panel behaviour shared by the Xtream and Stalker live layouts:
* the nested fold levels read from `LiveLayoutSidebarStateService`, the
* category dropdown bridge to the shell's `LIVE_CATEGORIES_POPOVER`, and the
* focus handoff across level changes. Create it in a field initializer (an
* injection context) with `createLivePanelsController()`.
*/
export class LivePanelsController {
private readonly sidebarState = inject(LiveLayoutSidebarStateService);
// Shell-provided; absent when no categories rail exists to fold (unit
// tests, hosts outside the workspace shell), in which case the header
// keeps its plain title.
private readonly categoriesPopover = inject(LIVE_CATEGORIES_POPOVER, {
optional: true,
});
/** Player only: the channels rail is folded (and `inert`). */
readonly isSidebarCollapsed: Signal<boolean>;
/** The header shows the category dropdown and show-categories button. */
readonly canOpenCategoriesPopover: Signal<boolean>;
/**
* The level the layout actually shows. On the live root the categories
* rail stays visible at `categories-hidden`, and the first category
* selection folds it without a state change — the focus handoff keys on
* this, not on the raw state.
*/
readonly effectiveLevel: Signal<LiveSidebarState>;
constructor(private readonly options: LivePanelsControllerOptions) {
const surface = options.surface;
this.isSidebarCollapsed = this.sidebarState.isCollapsedFor(surface);
const categoriesHidden =
this.sidebarState.areCategoriesHiddenFor(surface);
this.canOpenCategoriesPopover = computed(
() =>
this.categoriesPopover !== null &&
categoriesHidden() &&
this.options.hasSelectedCategory()
);
this.effectiveLevel = computed(() =>
this.isSidebarCollapsed()
? 'collapsed'
: this.canOpenCategoriesPopover()
? 'categories-hidden'
: 'expanded'
);
// Every level change inerts or removes the button the user activated
// and drops focus to <body>; hand it to the affordance that replaces
// it once the layout has rendered (see the helper's contract).
handoffFocusOnLiveSidebarChange(this.effectiveLevel, (next) =>
next === 'collapsed'
? untracked(this.options.restoreButton)?.nativeElement
: next === 'categories-hidden'
? untracked(this.options.showCategoriesButton)?.nativeElement
: null
);
}
/** `Cmd/Ctrl+B` and the floating restore handle. */
toggleSidebar(): void {
this.sidebarState.toggle(this.options.surface);
}
/** The channels header chevron: player only. */
collapsePanels(): void {
this.sidebarState.collapse(this.options.surface);
}
/** Level 1. The shell knows whether the rail is a phone drawer. */
showCategories(): void {
if (this.categoriesPopover) {
this.categoriesPopover.showCategoriesPanel();
return;
}
this.sidebarState.showCategories(this.options.surface);
}
openCategoriesPopover(origin: HTMLElement): void {
this.categoriesPopover?.open(origin);
}
}
/** Must run in an injection context (a component field initializer). */
export function createLivePanelsController(
options: LivePanelsControllerOptions
): LivePanelsController {
return new LivePanelsController(options);
}
@@ -20,6 +20,7 @@ describe('live sidebar state', () => {
it('accepts only known sidebar states', () => {
expect(isLiveSidebarState('expanded')).toBe(true);
expect(isLiveSidebarState('categories-hidden')).toBe(true);
expect(isLiveSidebarState('collapsed')).toBe(true);
expect(isLiveSidebarState('hidden')).toBe(false);
expect(isLiveSidebarState(null)).toBe(false);
@@ -3,7 +3,20 @@ import type {
PortalRailSection,
} from './navigation/portal-rail-links';
export type LiveSidebarState = 'expanded' | 'collapsed';
/**
* Live-TV panel visibility, nested from the outside in:
*
* - `expanded`: categories rail + channels rail + player.
* - `categories-hidden`: channels rail + player. The categories rail is
* folded away but stays one click off through the channels header's
* category dropdown. Surfaces without a categories rail (`m3u`,
* `collection`) treat it exactly like `expanded`.
* - `collapsed`: player only ("theater").
*
* There is deliberately no "channels hidden, categories visible" state: a
* category click has to bring the channels back anyway.
*/
export type LiveSidebarState = 'expanded' | 'categories-hidden' | 'collapsed';
/**
* Collapsible live-channel rails. Each surface remembers its own state:
@@ -38,7 +51,11 @@ export function liveSidebarStateStorageKey(
}
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
return value === 'expanded' || value === 'collapsed';
return (
value === 'expanded' ||
value === 'categories-hidden' ||
value === 'collapsed'
);
}
export function restoreLiveSidebarState(
@@ -47,7 +47,7 @@ describe('StalkerLiveNavigation', () => {
parseUrl: jest.fn(() => ({ queryParams: { q: 'sport', keep: '1' } })),
navigateByUrl: jest.fn().mockResolvedValue(true),
};
const sidebar = { setState: jest.fn() };
const sidebar = { setState: jest.fn(), expand: jest.fn() };
const play = jest.fn();
const revealRow = jest.fn(() => true);
@@ -209,7 +209,7 @@ describe('StalkerLiveNavigation', () => {
);
expect(store.selectedCategoryId()).toBe('news');
expect(store.searchPhrase()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(sidebar.expand).toHaveBeenCalledWith('portal');
expect(revealRow).toHaveBeenCalledWith('1');
expect(play).not.toHaveBeenCalled();
expect(navigation.canReveal()).toBe(false);
@@ -266,7 +266,7 @@ export class StalkerLiveNavigation {
this.options.store.setSearchPhrase('');
this.options.store.setSelectedCategory(category);
this.options.store.setPage(0);
this.options.sidebar.setState('portal', 'expanded');
this.options.sidebar.expand('portal');
this.revealed.set({ owner, category, item: active });
this.pendingReveal.set({
owner,
@@ -4,6 +4,7 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[attr.inert]="isSidebarCollapsed() ? '' : null"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -11,22 +12,62 @@
storageKey="live-channels-sidebar-width"
>
<div class="sidebar-header category-content-header">
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (totalChannelCount() > 0) {
<span class="category-subtitle"
>{{ totalChannelCount() }}
{{
(totalChannelCount() === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
@if (livePanels.canOpenCategoriesPopover()) {
<!-- The categories rail is folded: the title becomes the way
back to it (a dropdown of the same list) so switching
categories stays one click away. -->
<button
#showCategoriesButton
mat-icon-button
type="button"
class="sidebar-show-categories"
data-test-id="live-show-categories"
[attr.aria-label]="'LAYOUT.SHOW_CATEGORIES' | translate"
[matTooltip]="'LAYOUT.SHOW_CATEGORIES' | translate"
(click)="livePanels.showCategories()"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button
#categoryDropdown
type="button"
class="category-dropdown"
data-test-id="live-category-dropdown"
aria-haspopup="dialog"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.CHOOSE_CATEGORY' | translate"
(click)="livePanels.openCategoriesPopover(categoryDropdown)"
>
<span class="category-title">
{{ selectedCategoryTitle() }}
</span>
@if (totalChannelCount() > 0) {
<span class="category-count">{{
totalChannelCount()
}}</span>
}
<mat-icon class="category-dropdown__caret"
>expand_more</mat-icon
>
}
</div>
</button>
} @else {
<div class="category-meta">
<h2 class="category-title">
{{ selectedCategoryTitle() }}
</h2>
@if (totalChannelCount() > 0) {
<span class="category-subtitle"
>{{ totalChannelCount() }}
{{
(totalChannelCount() === 1
? 'PORTALS.ITEM'
: 'PORTALS.ITEMS'
) | translate
}}</span
>
}
</div>
}
@if (navigation.canReveal()) {
<button
mat-icon-button
@@ -57,10 +98,9 @@
<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()"
[attr.aria-label]="'LAYOUT.HIDE_PANELS' | translate"
[matTooltip]="'LAYOUT.HIDE_PANELS_TOOLTIP' | translate"
(click)="livePanels.collapsePanels()"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -73,13 +113,13 @@
<div class="content-container">
@if (isSidebarCollapsed()) {
<button
#restoreButton
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()"
[attr.aria-label]="'LAYOUT.SHOW_PANELS' | translate"
[matTooltip]="'LAYOUT.SHOW_PANELS_TOOLTIP' | translate"
(click)="livePanels.toggleSidebar()"
>
<mat-icon>chevron_right</mat-icon>
</button>
@@ -143,7 +183,7 @@
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
<app-channel-list-hidden-state (restore)="livePanels.toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -75,6 +75,7 @@ import {
import { LiveEpgPanelSummary } from '@iptvnator/ui/shared-portals';
import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access';
import {
createLivePanelsController,
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
createLogger,
@@ -488,8 +489,26 @@ export class StalkerLiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
// `viewChild()` must be a direct field initializer for the compiler to
// register the query, so the refs live here and feed the controller.
private readonly showCategoriesButton = viewChild('showCategoriesButton', {
read: ElementRef<HTMLElement>,
});
private readonly restoreButton = viewChild('restoreButton', {
read: ElementRef<HTMLElement>,
});
// Nested panel levels, category dropdown bridge and focus handoff; the
// template binds to it directly. The sidebar only renders with a
// selected category, and the fold rule keys on the same selection.
readonly livePanels = createLivePanelsController({
surface: 'portal',
hasSelectedCategory: computed(
() => !!this.stalkerStore.selectedCategoryId()
),
showCategoriesButton: this.showCategoriesButton,
restoreButton: this.restoreButton,
});
readonly isSidebarCollapsed = this.livePanels.isSidebarCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -1159,10 +1178,6 @@ export class StalkerLiveStreamLayoutComponent
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle('portal');
}
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
this.handleAdjacentChannelChange(direction === 'next' ? 'down' : 'up');
}
@@ -1179,7 +1194,7 @@ export class StalkerLiveStreamLayoutComponent
!this.hostElement.nativeElement.closest('[inert]')
) {
event.preventDefault();
this.toggleSidebar();
this.livePanels.toggleSidebar();
}
}
@@ -167,6 +167,8 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
provide: LiveLayoutSidebarStateService,
useValue: {
isCollapsedFor: () => signal(false),
areCategoriesHiddenFor: () => signal(false),
stateOf: () => signal('expanded'),
toggle: jest.fn(),
},
},
@@ -143,6 +143,8 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
provide: LiveLayoutSidebarStateService,
useValue: {
isCollapsedFor: () => signal(false),
areCategoriesHiddenFor: () => signal(false),
stateOf: () => signal('expanded'),
toggle: jest.fn(),
},
},
@@ -140,6 +140,8 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
provide: LiveLayoutSidebarStateService,
useValue: {
isCollapsedFor: () => signal(false),
areCategoriesHiddenFor: () => signal(false),
stateOf: () => signal('expanded'),
toggle: jest.fn(),
},
},
@@ -109,7 +109,10 @@ describe('Xtream live auto-open playback queue', () => {
provide: FavoritesService,
useValue: { getFavorites: () => of([]) },
},
{ provide: XtreamUrlService, useValue: {} },
{
provide: XtreamUrlService,
useValue: { constructAutoLiveTsUrl: () => undefined },
},
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: () => true },
@@ -5,6 +5,7 @@
<div
class="sidebar"
[class.sidebar-collapsed]="isSidebarCollapsed()"
[attr.inert]="isSidebarCollapsed() ? '' : null"
appResizable
[minWidth]="250"
[maxWidth]="600"
@@ -12,16 +13,56 @@
storageKey="live-channels-sidebar-width"
>
<div class="sidebar-header">
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
@if (livePanels.canOpenCategoriesPopover()) {
<!-- The categories rail is folded: the title becomes the way
back to it (a dropdown of the same list) so switching
categories stays one click away. -->
<button
#showCategoriesButton
mat-icon-button
type="button"
class="sidebar-show-categories"
data-test-id="live-show-categories"
[attr.aria-label]="'LAYOUT.SHOW_CATEGORIES' | translate"
[matTooltip]="'LAYOUT.SHOW_CATEGORIES' | translate"
(click)="livePanels.showCategories()"
>
<mat-icon>chevron_right</mat-icon>
</button>
<button
#categoryDropdown
type="button"
class="category-dropdown"
data-test-id="live-category-dropdown"
aria-haspopup="dialog"
aria-expanded="false"
[attr.aria-label]="'LAYOUT.CHOOSE_CATEGORY' | translate"
(click)="livePanels.openCategoriesPopover(categoryDropdown)"
>
<span class="category-title">
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</span>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
<mat-icon class="category-dropdown__caret"
>expand_more</mat-icon
>
</button>
} @else {
<div class="category-heading">
<h2 class="category-title">
{{ selectedCategoryInfo()?.name ?? 'All Items' }}
</h2>
@if (selectedCategoryInfo()?.count) {
<span class="category-count">{{
selectedCategoryInfo()?.count
}}</span>
}
</div>
}
@if (channelNavigation.canReveal()) {
<button
mat-icon-button
@@ -46,10 +87,9 @@
<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()"
[attr.aria-label]="'LAYOUT.HIDE_PANELS' | translate"
[matTooltip]="'LAYOUT.HIDE_PANELS_TOOLTIP' | translate"
(click)="livePanels.collapsePanels()"
>
<mat-icon>chevron_left</mat-icon>
</button>
@@ -95,13 +135,13 @@
<div class="content-container">
@if (isSidebarCollapsed()) {
<button
#restoreButton
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()"
[attr.aria-label]="'LAYOUT.SHOW_PANELS' | translate"
[matTooltip]="'LAYOUT.SHOW_PANELS_TOOLTIP' | translate"
(click)="livePanels.toggleSidebar()"
>
<mat-icon>chevron_right</mat-icon>
</button>
@@ -171,7 +211,7 @@
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
<app-channel-list-hidden-state (restore)="livePanels.toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -1153,41 +1153,6 @@ describe('LiveStreamLayoutComponent', () => {
// `app-channel-list-hidden-state` (its TranslatePipe needs a
// TranslateService); this spec is at the max-lines budget, so that branch
// is covered by video-player-sidebar.spec.ts and the Electron E2E instead.
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
selectedCategoryId.set(null);
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'collapsed');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).not.toBeNull();
});
it('hides the floating restore button when the sidebar is expanded', () => {
selectedCategoryId.set(1);
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.sidebar-restore')
).toBeNull();
});
it('persists the channels sidebar width under a dedicated storage key', () => {
// The shell context panel (category sidebar) is visible at the same
// time as this sidebar and persists its width under the shared
// "sidebar-width" key. Reusing that key here makes the two panels
// overwrite each other's stored width across reloads.
selectedCategoryId.set(1);
fixture.detectChanges();
const sidebar: HTMLElement =
fixture.nativeElement.querySelector('.sidebar');
expect(sidebar.getAttribute('storageKey')).toBe(
'live-channels-sidebar-width'
);
});
describe('auto-open from Ctrl+F search navigation state', () => {
const searchChannel = {
xtream_id: 202,
@@ -30,7 +30,7 @@ import {
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
LiveLayoutSidebarStateService,
createLivePanelsController,
PORTAL_PLAYER,
PortalChannelSortMode,
getPortalChannelSortModeLabel,
@@ -149,9 +149,6 @@ export class LiveStreamLayoutComponent
private readonly runtime = inject(RuntimeCapabilitiesService);
private readonly settingsStore = inject(SettingsStore);
private readonly portalPlayer = inject(PORTAL_PLAYER);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly liveAutoOpenState = inject(LiveStreamAutoOpenStateService);
readonly categories = this.xtreamStore.getCategoriesBySelectedType;
@@ -322,8 +319,24 @@ export class LiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
// `viewChild()` must be a direct field initializer for the compiler to
// register the query, so the refs live here and feed the controller.
private readonly showCategoriesButton = viewChild('showCategoriesButton', {
read: ElementRef<HTMLElement>,
});
private readonly restoreButton = viewChild('restoreButton', {
read: ElementRef<HTMLElement>,
});
// Nested panel levels, category dropdown bridge and focus handoff; the
// template binds to it directly. Search-only rails (no selected
// category) keep their plain heading.
readonly livePanels = createLivePanelsController({
surface: 'portal',
hasSelectedCategory: computed(() => !!this.selectedCategoryId()),
showCategoriesButton: this.showCategoriesButton,
restoreButton: this.restoreButton,
});
readonly isSidebarCollapsed = this.livePanels.isSidebarCollapsed;
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
this.activeCatchupProgram() ?? this.currentEpgItem()
@@ -361,8 +374,11 @@ export class LiveStreamLayoutComponent
if (!categoryId) return null;
const categories = this.categories();
// Provider categories carry string ids ("101") while the selection
// is numeric; a strict comparison missed every one and the header
// fell back to "Channels", which the category dropdown cannot afford.
const category = categories?.find(
(c) => (c.category_id ?? c.id) === categoryId
(c) => String(c.category_id ?? c.id) === String(categoryId)
);
const count = this.categoryItemCounts()?.get(categoryId) ?? 0;
@@ -625,10 +641,6 @@ export class LiveStreamLayoutComponent
persistLiveEpgPanelState(state);
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle('portal');
}
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
@@ -641,7 +653,7 @@ export class LiveStreamLayoutComponent
!this.hostElement.nativeElement.closest('[inert]')
) {
event.preventDefault();
this.toggleSidebar();
this.livePanels.toggleSidebar();
}
}
@@ -0,0 +1,405 @@
import { Component, Directive, input, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { Subject, of } from 'rxjs';
import {
LIVE_CATEGORIES_POPOVER,
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
PORTAL_PLAYER,
ResizableDirective,
} from '@iptvnator/portal/shared/util';
import {
ChannelListHiddenStateComponent,
GridListComponent,
} from '@iptvnator/portal/shared/ui';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import { RuntimeCapabilitiesService, SettingsStore } from '@iptvnator/services';
import { EpgListViewComponent, EpgTimelineComponent } from '@iptvnator/ui/epg';
import { WebPlayerViewComponent } from '@iptvnator/ui/playback';
import { PortalChannelsListComponent } from '../portal-channels-list/portal-channels-list.component';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
/**
* The channels header while the shell's categories rail is folded: the
* category dropdown, the show-categories chevron and the collapse-all
* chevron. Split from `live-stream-layout.component.spec.ts`, which sits at
* the spec line cap; the harness here renders no player, no EPG and no
* channel list.
*/
@Component({
selector: 'app-portal-channels-list',
standalone: true,
template: '',
})
class StubPortalChannelsListComponent {
readonly sortMode = input('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly fullscreenPanelCopy = input(false);
readonly revealRequest = input<unknown>(null);
readonly filteredChannels = signal<unknown[]>([]);
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
}
@Component({
selector: 'app-channel-list-hidden-state',
standalone: true,
template: '<div data-test-id="hidden-state-stub"></div>',
})
class StubChannelListHiddenStateComponent {
readonly restore = output<void>();
}
@Component({ selector: 'app-grid-list', standalone: true, template: '' })
class StubGridListComponent {
readonly items = input<unknown[]>([]);
readonly isLoading = input(false);
readonly searchTerm = input('');
readonly variant = input('poster');
readonly type = input<string>();
readonly itemClicked = output<unknown>();
}
@Component({
selector: 'app-web-player-view, app-epg-timeline, app-epg-list-view',
standalone: true,
template: '',
})
class StubPassiveComponent {}
@Directive({ selector: '[appResizable]', standalone: true })
class StubResizableDirective {}
describe('LiveStreamLayoutComponent sidebar levels', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let service: LiveLayoutSidebarStateService;
const categoriesPopover = {
open: jest.fn(),
close: jest.fn(),
showCategoriesPanel: jest.fn(),
};
const selectedCategoryId = signal<number | null>(1);
const emptyList = signal<unknown[]>([]);
const xtreamStore = {
getCategoriesBySelectedType: signal([
{ category_id: 1, category_name: 'News' },
]),
getCategoryItemCounts: signal(new Map<number, number>([[1, 7]])),
getPaginatedContent: emptyList,
hasMoreContent: signal(false),
epgItems: emptyList,
currentEpgItem: signal(null),
isLoadingEpg: signal(false),
selectedTypeContentLoading: signal(false),
selectedCategoryId,
selectedContentType: signal('live'),
// No selected item: the content area shows the empty state, so the
// header is the only thing under test.
selectedItem: signal(null),
currentPlaylist: signal({ _id: 'pl-1', title: 'Playlist' }),
liveStreams: emptyList,
selectItemsFromSelectedCategory: jest.fn(() => []),
constructStreamUrl: jest.fn(() => ''),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
setSelectedCategory: jest.fn(),
loadMoreContent: jest.fn(),
};
function query<T extends HTMLElement>(selector: string): T | null {
return fixture.nativeElement.querySelector(selector);
}
beforeEach(async () => {
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
categoriesPopover.open.mockClear();
categoriesPopover.close.mockClear();
categoriesPopover.showCategoriesPanel.mockClear();
selectedCategoryId.set(1);
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent, NoopAnimationsModule],
providers: [
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: of(convertToParamMap({})),
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{
provide: Router,
useValue: {
events: new Subject().asObservable(),
navigate: jest.fn(),
},
},
{ provide: XtreamStore, useValue: xtreamStore },
{
provide: FavoritesService,
useValue: { getFavorites: jest.fn(() => of([])) },
},
{
provide: XtreamUrlService,
useValue: { resolveCatchupUrl: jest.fn() },
},
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: false,
isElectron: false,
supportsRemoteControl: false,
},
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
{
provide: PORTAL_PLAYER,
useValue: {
isEmbeddedPlayer: () => true,
openExternalPlayback: jest.fn(),
},
},
{ provide: LIVE_CATEGORIES_POPOVER, useValue: categoriesPopover },
],
})
.overrideComponent(LiveStreamLayoutComponent, {
remove: {
imports: [
ChannelListHiddenStateComponent,
EpgListViewComponent,
EpgTimelineComponent,
GridListComponent,
PortalChannelsListComponent,
ResizableDirective,
TranslatePipe,
WebPlayerViewComponent,
],
},
add: {
imports: [
StubChannelListHiddenStateComponent,
StubGridListComponent,
StubPassiveComponent,
StubPortalChannelsListComponent,
StubResizableDirective,
MockPipe(TranslatePipe, (value: string) => value),
],
},
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
service = TestBed.inject(LiveLayoutSidebarStateService);
service.setState('portal', 'expanded');
});
afterEach(() => {
service.setState('portal', 'expanded');
fixture.destroy();
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
});
it('keeps the plain category heading while the rail is expanded', () => {
fixture.detectChanges();
expect(query('[data-test-id="live-category-dropdown"]')).toBeNull();
expect(query('[data-test-id="live-show-categories"]')).toBeNull();
expect(query('.category-heading')?.textContent).toContain('News');
});
it('keeps the plain heading for a search-only rail with no selected category', () => {
selectedCategoryId.set(null);
service.hideCategories('portal');
fixture.detectChanges();
// No category and no search term: the layout renders no rail at all.
expect(query('.sidebar')).toBeNull();
expect(query('[data-test-id="live-category-dropdown"]')).toBeNull();
});
it('turns the heading into a category dropdown that opens the shell popover', () => {
service.hideCategories('portal');
fixture.detectChanges();
const dropdown = query<HTMLButtonElement>(
'[data-test-id="live-category-dropdown"]'
);
expect(dropdown).not.toBeNull();
expect(dropdown?.textContent).toContain('News');
expect(dropdown?.textContent).toContain('7');
expect(dropdown?.getAttribute('aria-label')).toBe(
'LAYOUT.CHOOSE_CATEGORY'
);
expect(query('.category-heading')).toBeNull();
dropdown?.click();
expect(categoriesPopover.open).toHaveBeenCalledWith(dropdown);
});
it('asks the shell to restore the rail from the show-categories button', () => {
service.hideCategories('portal');
fixture.detectChanges();
query<HTMLButtonElement>(
'[data-test-id="live-show-categories"]'
)?.click();
expect(categoriesPopover.showCategoriesPanel).toHaveBeenCalledTimes(1);
});
it('collapses both rails from the header chevron and comes back to the same level', () => {
service.hideCategories('portal');
fixture.detectChanges();
query<HTMLButtonElement>(
'.sidebar-header [aria-label="LAYOUT.HIDE_PANELS"]'
)?.click();
fixture.detectChanges();
expect(service.stateOf('portal')()).toBe('collapsed');
expect(query('.sidebar')?.classList).toContain('sidebar-collapsed');
const restore = query<HTMLButtonElement>('.sidebar-restore');
expect(restore?.getAttribute('aria-label')).toBe('LAYOUT.SHOW_PANELS');
restore?.click();
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
it('picks focus up on the show-categories button when the rail folds and focus was lost', async () => {
fixture.detectChanges();
(document.activeElement as HTMLElement | null)?.blur();
service.hideCategories('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(
query('[data-test-id="live-show-categories"]')
);
});
it('leaves focus alone when a control still owns it', async () => {
fixture.detectChanges();
const sort = query<HTMLButtonElement>(
'.sidebar-header [aria-label="Sort channels"]'
);
sort?.focus();
service.hideCategories('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(sort);
});
it('hands focus to the floating restore handle at player-only and back to show-categories on expand', async () => {
service.hideCategories('portal');
fixture.detectChanges();
(document.activeElement as HTMLElement | null)?.blur();
service.collapse('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(query('.sidebar-restore'));
// The handle is removed with the expand, so focus is lost again.
service.expand('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(
query('[data-test-id="live-show-categories"]')
);
});
it('hands focus to show-categories when the first category selection folds the rail on the live root', async () => {
selectedCategoryId.set(null);
service.hideCategories('portal');
fixture.detectChanges();
(document.activeElement as HTMLElement | null)?.blur();
// The category button the user activated is inert now: focus lost.
selectedCategoryId.set(1);
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(
query('[data-test-id="live-show-categories"]')
);
});
it('shows the floating restore button at player-only even without a selected category, and hides it when expanded', () => {
selectedCategoryId.set(null);
service.collapse('portal');
fixture.detectChanges();
expect(query('.sidebar-restore')).not.toBeNull();
selectedCategoryId.set(1);
service.expand('portal');
fixture.detectChanges();
expect(query('.sidebar-restore')).toBeNull();
});
it('persists the channels sidebar width under a dedicated storage key', () => {
// The shell context panel (category sidebar) is visible at the same
// time as this sidebar and persists its width under the shared
// "sidebar-width" key. Reusing that key here makes the two panels
// overwrite each other's stored width across reloads.
fixture.detectChanges();
expect(query('.sidebar')?.getAttribute('storageKey')).toBe(
'live-channels-sidebar-width'
);
});
it('marks the folded channels rail inert at player-only', () => {
service.collapse('portal');
fixture.detectChanges();
expect(query('.sidebar')?.hasAttribute('inert')).toBe(true);
service.expand('portal');
fixture.detectChanges();
expect(query('.sidebar')?.hasAttribute('inert')).toBe(false);
});
it('answers Cmd/Ctrl+B with the same nested toggle', () => {
service.hideCategories('portal');
fixture.detectChanges();
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'b', ctrlKey: true })
);
expect(service.stateOf('portal')()).toBe('collapsed');
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'b', metaKey: true })
);
expect(service.stateOf('portal')()).toBe('categories-hidden');
});
});
@@ -74,7 +74,7 @@ describe('XtreamLiveChannelNavigationService', () => {
queryParamMap: unknown;
};
const navigate = jest.fn();
const sidebar = { setState: jest.fn() };
const sidebar = { setState: jest.fn(), expand: jest.fn() };
beforeEach(() => {
currentPlaylist.set({ id: 'source-1' });
@@ -187,7 +187,7 @@ describe('XtreamLiveChannelNavigationService', () => {
);
expect(selectedCategoryId()).toBe(10);
expect(categorySearchTerm()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(sidebar.expand).toHaveBeenCalledWith('portal');
expect(service.revealRequest()).toEqual(
expect.objectContaining({ channelId: 1 })
);
@@ -277,6 +277,7 @@ describe('XtreamLiveChannelNavigationService', () => {
await reveal;
expect(store.setSelectedCategory).not.toHaveBeenCalled();
expect(sidebar.setState).not.toHaveBeenCalled();
expect(sidebar.expand).not.toHaveBeenCalled();
expect(service.revealRequest()).toBeNull();
});
});
@@ -232,7 +232,7 @@ export class XtreamLiveChannelNavigationService {
return;
this.store.setCategorySearchTerm('');
this.store.setSelectedCategory(categoryId);
this.sidebar.setState('portal', 'expanded');
this.sidebar.expand('portal');
this.pendingReveal.set({
channelId: target.xtream_id,
categoryId,
+53
View File
@@ -77,5 +77,58 @@
padding: 2px 8px;
border-radius: 999px;
}
// Shown while the shell's categories rail is folded: the way back to
// the rail, in the same spot the rail's own chevron folded it from.
.sidebar-show-categories {
flex-shrink: 0;
margin-left: -6px;
}
// The category title as a dropdown trigger (categories rail folded).
// Same type as the static heading so folding the rail does not move
// the title; the caret is the only added ink.
.category-dropdown {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
height: 32px;
padding: 0 4px 0 8px;
margin-left: -8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
text-align: left;
app-region: no-drag;
.category-title {
flex: 0 1 auto;
min-width: 0;
}
&__caret {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--mat-sys-on-surface-variant);
}
&:hover,
&[aria-expanded='true'] {
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
border-color: var(--app-separator);
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
}
}
}
@@ -1,5 +1,8 @@
@if (items().length > 0) {
<div class="nav-list" id="portal-categories">
<div
class="nav-list"
[attr.id]="columnHandoff() ? 'portal-categories' : null"
>
@if (statusText()) {
<div class="context-inline-status">
{{ statusText() }}
@@ -9,7 +12,7 @@
<button
type="button"
class="nav-item category-item"
(keydown)="focusLiveChannels($event)"
(keydown)="onCategoryKeydown($event)"
[attr.data-category-id]="
$any(item).category_id ?? $any(item).xtream_id ?? item.id
"
@@ -28,7 +28,6 @@ interface WorkspaceCategoryViewItem {
styleUrl: './workspace-context-category-view.component.scss',
})
export class WorkspaceContextCategoryViewComponent {
readonly focusLiveChannels = focusLiveChannels;
readonly items = input<ReadonlyArray<WorkspaceCategoryViewItem>>([]);
readonly selectedCategoryId = input<string | number | null | undefined>();
@@ -43,6 +42,24 @@ export class WorkspaceContextCategoryViewComponent {
readonly omitMissingCounts = input(false);
readonly interactionEnabled = input(true);
readonly statusText = input('');
/**
* The in-flow shell rail takes part in the live-TV column keyboard
* contract: it is `#portal-categories` (ArrowLeft from the channels pane
* lands on the selected category) and ArrowRight on that category hands
* focus to `#live-channels`. The same view stamped into the categories
* popover must opt out: the dialog's focus trap would bounce that
* handoff back into the dialog, and a second `#portal-categories` would
* shadow the folded rail's.
*/
readonly columnHandoff = input(true);
onCategoryKeydown(event: KeyboardEvent): void {
// A method, not an inline `&&`: an event binding whose expression
// evaluates to `false` makes Angular call preventDefault().
if (this.columnHandoff()) {
focusLiveChannels(event);
}
}
private readonly hostEl = inject(ElementRef<HTMLElement>);
@@ -5,6 +5,7 @@
<div class="context-header__actions">
@if (canSearchCategories()) {
<button
#firstHeaderAction
type="button"
mat-icon-button
class="context-header__action"
@@ -64,6 +65,7 @@
type="button"
mat-icon-button
class="context-header__action"
data-test-id="context-manage-categories"
(click)="openManageCategories()"
[disabled]="!canManageXtreamCategories()"
[matTooltip]="
@@ -76,6 +78,22 @@
<mat-icon>tune</mat-icon>
</button>
}
@if (canHideCategories()) {
<button
#hideCategoriesButton
type="button"
mat-icon-button
class="context-header__action"
data-test-id="context-hide-categories"
(click)="hideCategories()"
[matTooltip]="'LAYOUT.HIDE_CATEGORIES' | translate"
[attr.aria-label]="
'LAYOUT.HIDE_CATEGORIES' | translate
"
>
<mat-icon>chevron_left</mat-icon>
</button>
}
</div>
</div>
<!-- @if (subtitle(); as text) {
@@ -130,6 +148,7 @@
</div>
} @else {
<app-workspace-context-category-view
[columnHandoff]="presentation() === 'sidebar'"
[items]="filteredXtreamCategories()"
[selectedCategoryId]="xtreamSelectedCategoryId()"
[itemCounts]="xtreamCategoryItemCounts()"
@@ -162,6 +181,7 @@
/>
} @else {
<app-workspace-context-category-view
[columnHandoff]="presentation() === 'sidebar'"
[items]="filteredStalkerCategories()"
[selectedCategoryId]="$any(stalkerSelectedCategoryId())"
[itemCounts]="stalkerCategoryItemCounts()"
@@ -15,6 +15,7 @@ import {
XtreamContentLoadState,
XtreamStore,
} from '@iptvnator/portal/xtream/data-access';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { WorkspaceContextPanelComponent } from './workspace-context-panel.component';
const translations: Record<string, string> = {
@@ -116,6 +117,7 @@ describe('WorkspaceContextPanelComponent', () => {
const dialog = {
open: jest.fn(),
};
const drawerOpen = signal(false);
beforeEach(async () => {
localStorage.removeItem(WORKSPACE_CATEGORY_SORT_STORAGE_KEY);
@@ -142,6 +144,7 @@ describe('WorkspaceContextPanelComponent', () => {
]);
router.navigate.mockClear();
dialog.open.mockClear();
drawerOpen.set(false);
await TestBed.configureTestingModule({
imports: [WorkspaceContextPanelComponent, NoopAnimationsModule],
@@ -179,7 +182,7 @@ describe('WorkspaceContextPanelComponent', () => {
// Root-provided in production; stubbed because the spec's
// Router mock has no `events` stream for the real service.
provide: WorkspaceShellContextDrawerService,
useValue: { close: jest.fn(), isOpen: () => false },
useValue: { close: jest.fn(), isOpen: drawerOpen },
},
],
}).compileComponents();
@@ -204,9 +207,9 @@ describe('WorkspaceContextPanelComponent', () => {
const status = fixture.nativeElement.querySelector(
'.context-inline-status'
) as HTMLElement | null;
const manageButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(-1) as HTMLButtonElement | undefined;
const manageButton = fixture.nativeElement.querySelector(
'[data-test-id="context-manage-categories"]'
) as HTMLButtonElement | null;
expect(countPlaceholders).toHaveLength(2);
expect(categoryButtons.every((button) => button.disabled)).toBe(true);
@@ -261,9 +264,9 @@ describe('WorkspaceContextPanelComponent', () => {
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
const manageButton = Array.from(
fixture.nativeElement.querySelectorAll('.context-header__action')
).at(-1) as HTMLButtonElement | undefined;
const manageButton = fixture.nativeElement.querySelector(
'[data-test-id="context-manage-categories"]'
) as HTMLButtonElement | null;
expect(countTexts).toEqual(['3', '0']);
expect(categoryButtons.every((button) => !button.disabled)).toBe(true);
@@ -493,6 +496,189 @@ describe('WorkspaceContextPanelComponent', () => {
).toBeNull();
});
describe('folded categories rail', () => {
let liveSidebarService: LiveLayoutSidebarStateService;
beforeEach(() => {
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('portal', 'expanded');
});
afterEach(() => {
liveSidebarService.setState('portal', 'expanded');
});
function hideButton(): HTMLButtonElement | null {
return fixture.nativeElement.querySelector(
'[data-test-id="context-hide-categories"]'
);
}
it('offers the hide-categories chevron on live sections of the sidebar and folds the rail', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
const button = hideButton();
expect(button).not.toBeNull();
expect(button?.getAttribute('aria-label')).toBe(
'LAYOUT.HIDE_CATEGORIES'
);
button?.click();
expect(liveSidebarService.stateOf('portal')()).toBe('categories-hidden');
});
it('offers it for Stalker itv and radio too', () => {
fixture.componentRef.setInput('context', {
provider: 'stalker',
playlistId: 'stalker-1',
});
fixture.componentRef.setInput('section', 'radio');
stalkerStore.selectedCategoryId.set('12');
fixture.detectChanges();
expect(hideButton()).not.toBeNull();
});
it('withholds the chevron inside the open phone drawer, whose stylesheet ignores the folded state', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
drawerOpen.set(true);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('names the chevron as focus target, the first header action without one, and nothing while categories are absent', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
expect(fixture.componentInstance.focusTarget()).toBe(hideButton());
// Live root: no chevron, the search action stands in.
xtreamSelectedCategoryId.set(null);
fixture.detectChanges();
expect(fixture.componentInstance.focusTarget()).toBe(
fixture.nativeElement.querySelector('.context-header__action')
);
// Nothing loaded yet: no control at all, the caller falls back.
xtreamCategories.set([]);
fixture.detectChanges();
expect(fixture.componentInstance.focusTarget()).toBeNull();
});
it('withholds the chevron while no category is selected (live root has no channels rail)', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(null);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('keeps VOD and series rails without the chevron', () => {
fixture.componentRef.setInput('section', 'vod');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('renders no chevron in the popover presentation, whose footer restores the rail instead', () => {
fixture.componentRef.setInput('section', 'live');
fixture.componentRef.setInput('presentation', 'popover');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
expect(hideButton()).toBeNull();
});
it('shares the category sort with a second (popover) instance of the panel', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
fixture.detectChanges();
fixture.componentInstance.setCategorySortMode('name-desc');
const popover = TestBed.createComponent(
WorkspaceContextPanelComponent
);
popover.componentRef.setInput('context', {
provider: 'xtreams',
playlistId: 'playlist-1',
});
popover.componentRef.setInput('section', 'live');
popover.componentRef.setInput('presentation', 'popover');
popover.detectChanges();
expect(popover.componentInstance.categorySortMode()).toBe(
'name-desc'
);
popover.componentInstance.setCategorySortMode('name-asc');
expect(fixture.componentInstance.categorySortMode()).toBe(
'name-asc'
);
popover.destroy();
});
it('keeps the live-TV column keyboard contract out of the popover', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedCategoryId.set(1);
xtreamSelectedTypeContentState.set('ready');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('#portal-categories')
).not.toBeNull();
fixture.componentRef.setInput('presentation', 'popover');
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('#portal-categories')
).toBeNull();
// ArrowRight on the selected category must not reach the
// background channels pane from inside the dialog.
const pane = document.createElement('div');
pane.id = 'live-channels';
pane.tabIndex = -1;
document.body.appendChild(pane);
pane.checkVisibility = () => true;
try {
const selected = fixture.nativeElement.querySelector(
'.category-item[aria-current="true"]'
) as HTMLButtonElement;
selected.focus();
const event = new KeyboardEvent('keydown', {
key: 'ArrowRight',
bubbles: true,
cancelable: true,
});
selected.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(document.activeElement).toBe(selected);
} finally {
pane.remove();
}
});
it('reports a category selection so a popover host can close', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
fixture.detectChanges();
const selected = jest.fn();
fixture.componentInstance.categorySelected.subscribe(selected);
const categoryButtons = Array.from(
fixture.nativeElement.querySelectorAll('.category-item')
) as HTMLButtonElement[];
categoryButtons[1]?.click();
expect(selected).toHaveBeenCalledTimes(1);
});
});
it('preserves the active xtream live item when switching live categories', () => {
fixture.componentRef.setInput('section', 'live');
xtreamSelectedTypeContentState.set('ready');
@@ -5,6 +5,7 @@ import {
effect,
inject,
input,
output,
signal,
viewChild,
ElementRef,
@@ -22,9 +23,9 @@ import {
asStalkerPortalError,
} from '@iptvnator/portal/stalker/data-access';
import {
LiveLayoutSidebarStateService,
PortalCategorySortMode,
persistPortalCategorySortMode,
restorePortalCategorySortMode,
PortalCategorySortStateService,
sortPortalCategoryItems,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
@@ -80,9 +81,49 @@ export class WorkspaceContextPanelComponent {
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService, {
optional: true,
});
private readonly liveSidebarState = inject(LiveLayoutSidebarStateService);
readonly context = input.required<WorkspaceContextRoute>();
readonly section = input.required<string>();
/**
* `sidebar` is the in-flow shell rail and offers the "hide categories"
* chevron on live sections; `popover` is the same panel stamped into the
* live categories dropdown while that rail is folded, where the chevron
* would be meaningless (the popover's own footer restores the rail).
*/
readonly presentation = input<'sidebar' | 'popover'>('sidebar');
/** A category was picked; the popover host closes on it. */
readonly categorySelected = output<void>();
readonly isLiveSection = computed(() => {
const section = this.section();
return (
(this.context().provider === 'xtreams' && section === 'live') ||
(this.context().provider === 'stalker' &&
(section === 'itv' || section === 'radio'))
);
});
// Only while a category is selected: that is when the live layout
// renders the channels rail whose header offers the way back. Not in the
// open phone drawer either: its stylesheet ignores the folded state, so
// the tap would change nothing visible while persisting a preference
// that only bites once the window is wide again.
readonly canHideCategories = computed(
() =>
this.presentation() === 'sidebar' &&
this.isLiveSection() &&
!this.contextDrawer?.isOpen() &&
(this.context().provider === 'xtreams'
? this.xtreamSelectedCategoryId() !== null
: !!this.stalkerSelectedCategoryId())
);
private readonly hideCategoriesButton = viewChild(
'hideCategoriesButton',
{ read: ElementRef<HTMLElement> }
);
private readonly firstHeaderAction = viewChild('firstHeaderAction', {
read: ElementRef<HTMLElement>,
});
readonly isXtreamCategories = computed(
() =>
@@ -212,9 +253,9 @@ export class WorkspaceContextPanelComponent {
viewChild<ElementRef<HTMLInputElement>>('searchInput');
readonly isSearchOpen = signal(false);
readonly categorySearchTerm = signal('');
readonly categorySortMode = signal<PortalCategorySortMode>(
restorePortalCategorySortMode()
);
// Shared with the popover copy of this panel; see the service's note.
private readonly categorySort = inject(PortalCategorySortStateService);
readonly categorySortMode = this.categorySort.mode;
readonly categorySortLabelKey = computed(() =>
this.getCategorySortLabelKey(this.categorySortMode())
);
@@ -333,6 +374,22 @@ export class WorkspaceContextPanelComponent {
});
}
/**
* The control the shell sidebar hands focus to once this rail has
* unfolded (the button the user activated to bring it back is gone by
* then): the hide chevron when it is offered, else the first header
* action. `null` while neither is rendered — categories still loading,
* or a failed Stalker load — and the sidebar falls back to the rail
* itself.
*/
focusTarget(): HTMLElement | null {
return (
this.hideCategoriesButton()?.nativeElement ??
this.firstHeaderAction()?.nativeElement ??
null
);
}
toggleCategorySearch(): void {
const opening = !this.isSearchOpen();
this.isSearchOpen.set(opening);
@@ -352,8 +409,7 @@ export class WorkspaceContextPanelComponent {
}
setCategorySortMode(mode: PortalCategorySortMode): void {
this.categorySortMode.set(mode);
persistPortalCategorySortMode(mode);
this.categorySort.setMode(mode);
}
openManageCategories(): void {
@@ -398,6 +454,10 @@ export class WorkspaceContextPanelComponent {
);
}
hideCategories(): void {
this.liveSidebarState.hideCategories('portal');
}
onXtreamCategoryClicked(category: XtreamCategoryLike): void {
if (!this.isXtreamCategoryInteractionEnabled()) {
return;
@@ -415,6 +475,7 @@ export class WorkspaceContextPanelComponent {
}
const categoryId = numericCategoryId;
this.contextDrawer?.close();
this.categorySelected.emit();
if (section === 'live') {
this.xtreamStore.setSelectedCategory(categoryId);
@@ -456,6 +517,7 @@ export class WorkspaceContextPanelComponent {
const categoryId = String(item.category_id ?? '*');
this.contextDrawer?.close();
this.categorySelected.emit();
this.stalkerStore.setSelectedCategory(categoryId);
this.stalkerStore.setPage(0);
@@ -0,0 +1,22 @@
@if (context(); as context) {
@if (section(); as section) {
<app-workspace-context-panel
class="popover-panel"
presentation="popover"
[context]="context"
[section]="section"
(categorySelected)="onCategorySelected()"
/>
}
}
<div class="popover-footer">
<button
type="button"
class="popover-footer__action"
data-test-id="live-categories-popover-show-panel"
(click)="showCategoriesPanel()"
>
<mat-icon>chevron_right</mat-icon>
<span>{{ 'LAYOUT.SHOW_CATEGORIES_PANEL' | translate }}</span>
</button>
</div>
@@ -0,0 +1,61 @@
// Height-capped by the overlay to the space below the anchor: the embedded
// panel's own list scrolls, header and footer stay put.
:host {
display: flex;
flex-direction: column;
min-height: 0;
max-height: 100%;
width: 300px;
padding: 8px 6px 6px;
border-radius: 14px;
// Widget surface, one step above the channels rail it floats over, plus a
// hairline so the two do not merge where their edges meet.
background: var(--app-widget-bg, var(--mat-sys-surface-container));
outline: 1px solid var(--mat-sys-outline-variant);
box-shadow: 0 16px 40px -16px rgba(0, 0, 0, 0.5);
color: var(--mat-sys-on-surface);
}
.popover-panel {
flex: 1 1 auto;
min-height: 0;
}
.popover-footer {
flex: none;
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid var(--mat-sys-outline-variant);
}
.popover-footer__action {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 8px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--mat-sys-primary);
font: inherit;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
text-align: left;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
}
&:hover {
background: var(--app-hover-overlay, rgba(255, 255, 255, 0.08));
}
&:focus-visible {
outline: 2px solid var(--mat-sys-primary);
outline-offset: -2px;
}
}
@@ -0,0 +1,72 @@
import { CdkTrapFocus } from '@angular/cdk/a11y';
import {
ChangeDetectionStrategy,
Component,
inject,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
import { WorkspaceShellRouteStateService } from '../workspace-shell/services/workspace-shell-route-state.service';
/**
* The live categories rail stamped into a popover while that rail is folded
* (`LiveSidebarState` `categories-hidden`). It renders the very same
* `WorkspaceContextPanelComponent` the shell sidebar uses, so search, sort,
* counts, selection and the category click handlers stay one implementation;
* only the presentation differs (no hide chevron, a footer that restores the
* rail). Opened by `WorkspaceLiveCategoriesPopoverService`.
*
* The trigger advertises `aria-haspopup="dialog"`, so the host is that
* dialog: it carries the role and label, and `CdkTrapFocus` (a host
* directive) moves focus into the panel on open and keeps Tab inside it
* while the backdrop hides the page underneath. The service returns focus
* to the trigger on close.
*/
@Component({
selector: 'app-workspace-live-categories-popover',
imports: [MatIcon, TranslatePipe, WorkspaceContextPanelComponent],
templateUrl: './workspace-live-categories-popover.component.html',
styleUrl: './workspace-live-categories-popover.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
hostDirectives: [CdkTrapFocus],
host: {
role: 'dialog',
'aria-modal': 'true',
'[attr.aria-label]': 'dialogLabel',
},
})
export class WorkspaceLiveCategoriesPopoverComponent {
private readonly routeState = inject(WorkspaceShellRouteStateService);
// The shell-provided popover bridge that hosts this component.
private readonly popover = inject(LIVE_CATEGORIES_POPOVER);
readonly dialogLabel = inject(TranslateService).instant(
'LAYOUT.CHOOSE_CATEGORY'
);
constructor() {
// Host directive inputs cannot be bound from a portal-created
// component, so the trap is configured here; the directive's own
// AfterContentInit runs after this constructor and captures focus.
const focusTrap = inject(CdkTrapFocus, { self: true });
focusTrap.enabled = true;
focusTrap.autoCapture = true;
}
readonly context = this.routeState.currentContext;
readonly section = this.routeState.currentSection;
/** Fired when the popover wants to be closed by its host. */
readonly closed = output<void>();
onCategorySelected(): void {
this.closed.emit();
}
showCategoriesPanel(): void {
// Restores the rail (and opens the phone drawer where the rail is
// one); the service closes this popover as part of it.
this.popover.showCategoriesPanel();
}
}
@@ -0,0 +1,286 @@
import { OverlayModule } from '@angular/cdk/overlay';
import {
ApplicationRef,
Component,
input,
output,
signal,
} from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { NavigationEnd, NavigationStart, Router } from '@angular/router';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { Subject, of } from 'rxjs';
import {
LIVE_CATEGORIES_POPOVER,
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { WorkspaceContextPanelComponent } from '../workspace-context-panel/workspace-context-panel.component';
import { WorkspaceShellRouteStateService } from '../workspace-shell/services/workspace-shell-route-state.service';
import { WorkspaceLiveCategoriesPopoverComponent } from './workspace-live-categories-popover.component';
import { WorkspaceLiveCategoriesPopoverService } from './workspace-live-categories-popover.service';
@Component({
selector: 'app-workspace-context-panel',
template: `<div data-test-id="stub-context-panel">{{ section() }}</div>
<button data-test-id="stub-pick" (click)="categorySelected.emit()">
pick
</button>`,
standalone: true,
})
class StubWorkspaceContextPanelComponent {
readonly context = input.required<unknown>();
readonly section = input.required<string>();
readonly presentation = input<'sidebar' | 'popover'>('sidebar');
readonly categorySelected = output<void>();
}
describe('WorkspaceLiveCategoriesPopoverService', () => {
let service: WorkspaceLiveCategoriesPopoverService;
let sidebarState: LiveLayoutSidebarStateService;
let origin: HTMLButtonElement;
let routerEvents: Subject<unknown>;
const drawer = { open: jest.fn(), close: jest.fn(), isOpen: () => false };
let phoneViewport = false;
// Portal-attached views render on the next application tick; no fixture
// drives change detection here.
function open(): void {
service.open(origin);
TestBed.inject(ApplicationRef).tick();
}
function pane(): HTMLElement | null {
return document.querySelector(
'.workspace-live-categories-popover-pane'
);
}
beforeEach(async () => {
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
routerEvents = new Subject();
drawer.open.mockClear();
phoneViewport = false;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
writable: true,
value: (query: string) => ({
matches: query.includes('640') && phoneViewport,
media: query,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}),
});
await TestBed.configureTestingModule({
imports: [OverlayModule, NoopAnimationsModule],
providers: [
WorkspaceLiveCategoriesPopoverService,
{
provide: LIVE_CATEGORIES_POPOVER,
useExisting: WorkspaceLiveCategoriesPopoverService,
},
{ provide: WorkspaceShellContextDrawerService, useValue: drawer },
{
provide: Router,
useValue: { events: routerEvents.asObservable() },
},
{
provide: WorkspaceShellRouteStateService,
useValue: {
currentContext: signal({
provider: 'xtreams',
playlistId: 'pl-1',
}),
currentSection: signal('live'),
},
},
{
provide: TranslateService,
useValue: {
instant: (key: string) => key,
get: (key: string) => of(key),
stream: (key: string) => of(key),
onLangChange: of(null),
onTranslationChange: of(null),
onDefaultLangChange: of(null),
currentLang: 'en',
defaultLang: 'en',
},
},
],
})
.overrideComponent(WorkspaceLiveCategoriesPopoverComponent, {
remove: {
imports: [WorkspaceContextPanelComponent, TranslatePipe],
},
add: {
imports: [
StubWorkspaceContextPanelComponent,
MockPipe(TranslatePipe, (value: string) => value),
],
},
})
.compileComponents();
service = TestBed.inject(WorkspaceLiveCategoriesPopoverService);
sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
sidebarState.hideCategories('portal');
origin = document.createElement('button');
document.body.appendChild(origin);
});
afterEach(() => {
service.close();
origin.remove();
sidebarState.setState('portal', 'expanded');
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
});
it('opens the categories panel in popover presentation under the origin', () => {
open();
const panel = pane()?.querySelector(
'[data-test-id="stub-context-panel"]'
);
expect(panel).not.toBeNull();
expect(panel?.textContent).toContain('live');
expect(origin.getAttribute('aria-expanded')).toBe('true');
});
it('toggles closed when opened again from the same origin', () => {
open();
open();
expect(pane()).toBeNull();
expect(origin.getAttribute('aria-expanded')).toBe('false');
});
it('restores the rail from the footer and closes', () => {
open();
(
pane()?.querySelector(
'[data-test-id="live-categories-popover-show-panel"]'
) as HTMLButtonElement
).click();
expect(sidebarState.stateOf('portal')()).toBe('expanded');
expect(pane()).toBeNull();
expect(drawer.open).not.toHaveBeenCalled();
});
it('opens the phone context drawer as well, since the rail is that drawer there', () => {
phoneViewport = true;
open();
service.showCategoriesPanel();
expect(sidebarState.stateOf('portal')()).toBe('expanded');
expect(pane()).toBeNull();
expect(drawer.open).toHaveBeenCalledTimes(1);
});
it('closes after a category selection', () => {
open();
(
pane()?.querySelector(
'[data-test-id="stub-pick"]'
) as HTMLButtonElement
).click();
expect(pane()).toBeNull();
expect(sidebarState.stateOf('portal')()).toBe('categories-hidden');
});
it('closes on backdrop click and leaves the rail folded', () => {
open();
const backdrop = document.querySelector(
'.cdk-overlay-backdrop'
) as HTMLElement;
expect(backdrop).not.toBeNull();
backdrop.click();
expect(pane()).toBeNull();
expect(sidebarState.stateOf('portal')()).toBe('categories-hidden');
});
it('is a labelled modal dialog that traps and takes focus on open', async () => {
// JSDOM lays nothing out, so the CDK interactivity checker sees every
// element as invisible and never focuses one; give them geometry.
const geometry = jest
.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
.mockReturnValue(10);
try {
open();
// CdkTrapFocus captures focus once the zone settles.
await new Promise((resolve) => setTimeout(resolve));
const host = pane()?.querySelector(
'app-workspace-live-categories-popover'
);
expect(host?.getAttribute('role')).toBe('dialog');
expect(host?.getAttribute('aria-modal')).toBe('true');
expect(host?.getAttribute('aria-label')).toBe(
'LAYOUT.CHOOSE_CATEGORY'
);
expect(
pane()?.querySelectorAll('.cdk-focus-trap-anchor').length
).toBe(2);
expect(host?.contains(document.activeElement)).toBe(true);
service.close();
expect(document.activeElement).toBe(origin);
} finally {
geometry.mockRestore();
}
});
it('closes when a navigation starts, so it cannot float over the next route', () => {
open();
routerEvents.next(new NavigationEnd(1, '/a', '/a'));
expect(pane()).not.toBeNull();
routerEvents.next(new NavigationStart(2, '/b'));
expect(pane()).toBeNull();
});
it('closes on any live-panel level change, e.g. Cmd/Ctrl+B reaching the layout through the dialog', () => {
open();
sidebarState.collapse('portal');
TestBed.inject(ApplicationRef).tick();
expect(pane()).toBeNull();
});
it('does not hand focus back to a trigger inside a folded, inert rail', () => {
const rail = document.createElement('div');
rail.appendChild(origin);
document.body.appendChild(rail);
open();
rail.setAttribute('inert', '');
service.close();
expect(document.activeElement).not.toBe(origin);
rail.remove();
});
it('closes on Escape', () => {
open();
document.body.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
);
expect(pane()).toBeNull();
});
});
@@ -0,0 +1,168 @@
import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import {
DestroyRef,
effect,
inject,
Injectable,
Injector,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { NavigationStart, Router } from '@angular/router';
import { filter } from 'rxjs';
import {
LiveCategoriesPopover,
LiveLayoutSidebarStateService,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { WorkspaceLiveCategoriesPopoverComponent } from './workspace-live-categories-popover.component';
/**
* Shell-side implementation of `LIVE_CATEGORIES_POPOVER`. Provided on the
* workspace shell so the live layouts rendered in its router outlet can open
* the folded categories rail as a popover under their category dropdown.
*
* One popover at a time: opening from the origin that already owns it closes
* it (toggle), opening from another origin moves it. The transparent backdrop
* and Escape close it, and so does any category selection or the footer's
* "show categories panel" action, which the component reports through
* `closed`. Any router navigation closes it too: the shell (and so this
* service) outlives the child route that owns the trigger, and browser
* Back/Forward or a global shortcut route would otherwise leave the panel
* floating over the destination page, re-rendered for the new route's
* context. So does any live-panel level change: `Cmd/Ctrl+B` reaches the
* layout's document-level handler through the dialog, and the trigger that
* owns this popover is then inside a folded, `inert` rail.
*/
@Injectable()
export class WorkspaceLiveCategoriesPopoverService
implements LiveCategoriesPopover
{
private readonly overlay = inject(Overlay);
private readonly injector = inject(Injector);
private readonly sidebarState = inject(LiveLayoutSidebarStateService);
private readonly contextDrawer = inject(WorkspaceShellContextDrawerService);
private overlayRef: OverlayRef | null = null;
private origin: HTMLElement | null = null;
constructor() {
inject(DestroyRef).onDestroy(() => this.close());
inject(Router)
.events.pipe(
filter((event) => event instanceof NavigationStart),
takeUntilDestroyed()
)
.subscribe(() => this.close());
// Seeded by the effect's first run, not at construction: the state
// may move between the two before anything is open.
const sidebarState = this.sidebarState.stateOf('portal');
let previous: string | null = null;
effect(() => {
const next = sidebarState();
const changed = previous !== null && next !== previous;
previous = next;
if (changed) {
untracked(() => this.close());
}
});
}
open(origin: HTMLElement): void {
if (this.overlayRef && this.origin === origin) {
this.close();
return;
}
this.close();
const overlayRef = this.overlay.create({
hasBackdrop: true,
backdropClass: 'cdk-overlay-transparent-backdrop',
panelClass: 'workspace-live-categories-popover-pane',
scrollStrategy: this.overlay.scrollStrategies.reposition(),
positionStrategy: this.overlay
.position()
.flexibleConnectedTo(origin)
.withPositions([
{
originX: 'start',
originY: 'bottom',
overlayX: 'start',
overlayY: 'top',
offsetY: 6,
},
{
originX: 'start',
originY: 'top',
overlayX: 'start',
overlayY: 'bottom',
offsetY: -6,
},
])
.withFlexibleDimensions(true)
.withGrowAfterOpen(true)
.withPush(true)
.withViewportMargin(8),
maxHeight: 'min(480px, calc(100vh - 16px))',
});
this.overlayRef = overlayRef;
this.origin = origin;
const componentRef = overlayRef.attach(
new ComponentPortal(
WorkspaceLiveCategoriesPopoverComponent,
null,
this.injector
)
);
componentRef.instance.closed.subscribe(() => this.close());
overlayRef.backdropClick().subscribe(() => this.close());
overlayRef.keydownEvents().subscribe((event) => {
if (event.key === 'Escape' && !event.defaultPrevented) {
event.preventDefault();
this.close();
}
});
origin.setAttribute('aria-expanded', 'true');
}
showCategoriesPanel(): void {
this.close();
this.sidebarState.showCategories('portal');
// At phone widths the rail renders as the off-canvas context drawer,
// whose open state the fold level does not drive.
if (isPhoneViewport()) {
this.contextDrawer.open();
}
}
close(): void {
const overlayRef = this.overlayRef;
const origin = this.origin;
this.overlayRef = null;
this.origin = null;
origin?.setAttribute('aria-expanded', 'false');
overlayRef?.dispose();
// Focus returns to the trigger that opened the popover so keyboard
// users continue where they acted instead of dropping to <body> —
// unless that trigger sits in a rail that folded meanwhile; then the
// layout's own handoff picks focus up.
if (
origin &&
origin.isConnected &&
overlayRef &&
!origin.closest('[inert]')
) {
origin.focus();
}
}
}
/** Same breakpoint as the shell's phone drawer styles (≤640px). */
function isPhoneViewport(): boolean {
return (
typeof window !== 'undefined' &&
typeof window.matchMedia === 'function' &&
window.matchMedia('(max-width: 640px)').matches
);
}
@@ -31,10 +31,12 @@
@if (context(); as context) {
@if (section(); as section) {
<aside
#routePanel
tabindex="-1"
cdkFocusInitial
class="context-panel context-panel--route"
[class.context-panel--collapsed]="isContextPanelCollapsed()"
[attr.inert]="isContextPanelInert() ? '' : null"
appResizable
[minWidth]="240"
[maxWidth]="560"
@@ -42,6 +44,7 @@
storageKey="sidebar-width"
>
<app-workspace-context-panel
#contextPanel
[context]="context"
[section]="section"
/>
@@ -31,8 +31,11 @@
.context-panel--collapsed {
width: 0 !important;
min-width: 0 !important;
padding-left: 0;
padding-right: 0;
// !important: the `--route` / `--sources` variants set their padding
// later in the cascade at equal specificity, and 22px of padding left a
// visible empty strip beside the channels rail while "collapsed".
padding-left: 0 !important;
padding-right: 0 !important;
border-right-color: transparent;
.resize-handle {
@@ -1,13 +1,12 @@
import {
Component,
Directive,
input,
} from '@angular/core';
import { Component, Directive, input, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe, TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { WorkspaceShellContextSidebarComponent } from './workspace-shell-context-sidebar.component';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
@@ -30,6 +29,7 @@ class MockResizableDirective {
class MockWorkspaceContextPanelComponent {
readonly context = input.required<unknown>();
readonly section = input.required<string>();
readonly focusTarget = jest.fn<HTMLElement | null, []>(() => null);
}
@Component({
@@ -56,16 +56,30 @@ class MockWorkspaceSourcesFiltersPanelComponent {}
describe('WorkspaceShellContextSidebarComponent', () => {
let fixture: ComponentFixture<WorkspaceShellContextSidebarComponent>;
let liveSidebarService: LiveLayoutSidebarStateService;
const xtreamSelectedCategoryId = signal<number | null>(1);
const stalkerSelectedCategoryId = signal<string | null>('7');
const drawerOpen = signal(false);
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state:portal');
xtreamSelectedCategoryId.set(1);
stalkerSelectedCategoryId.set('7');
drawerOpen.set(false);
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
providers: [
{
provide: WorkspaceShellContextDrawerService,
useValue: { close: jest.fn() },
useValue: { close: jest.fn(), isOpen: drawerOpen },
},
{
provide: XtreamStore,
useValue: { selectedCategoryId: xtreamSelectedCategoryId },
},
{
provide: StalkerStore,
useValue: { selectedCategoryId: stalkerSelectedCategoryId },
},
{
provide: TranslateService,
@@ -181,6 +195,100 @@ describe('WorkspaceShellContextSidebarComponent', () => {
);
});
it('folds the categories rail on the first level already (categories-hidden)', () => {
setupLiveCategory('live');
liveSidebarService.setState('portal', 'categories-hidden');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
});
it('keeps the rail on the live root (no selected category) at categories-hidden, since no channels rail exists to host the way back', () => {
xtreamSelectedCategoryId.set(null);
setupLiveCategory('live');
liveSidebarService.setState('portal', 'categories-hidden');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
expect(aside.classList.contains('context-panel--collapsed')).toBe(
false
);
// Player-only still folds it: the floating restore handle lives
// in the content area and stays reachable there.
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
expect(aside.classList.contains('context-panel--collapsed')).toBe(
true
);
});
it('takes the folded rail out of the focus order with inert, except inside the open phone drawer', () => {
setupLiveCategory('live');
const aside = fixture.nativeElement.querySelector(
'aside.context-panel--route'
);
liveSidebarService.setState('portal', 'categories-hidden');
fixture.detectChanges();
expect(aside.hasAttribute('inert')).toBe(true);
drawerOpen.set(true);
fixture.detectChanges();
expect(aside.hasAttribute('inert')).toBe(false);
drawerOpen.set(false);
liveSidebarService.setState('portal', 'expanded');
fixture.detectChanges();
expect(aside.hasAttribute('inert')).toBe(false);
});
it('hands focus to the panel whenever the rail actually unfolds, including from player-only on the live root', async () => {
xtreamSelectedCategoryId.set(null);
liveSidebarService.setState('portal', 'categories-hidden');
setupLiveCategory('live');
const panel = fixture.debugElement.query(
By.directive(MockWorkspaceContextPanelComponent)
).componentInstance as MockWorkspaceContextPanelComponent;
(document.activeElement as HTMLElement | null)?.blur();
// Root at categories-hidden: the rail is visible, no transition.
liveSidebarService.collapse('portal');
fixture.detectChanges();
liveSidebarService.expand('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
// The panel offered no control (categories not loaded), so the
// rail itself takes focus.
expect(panel.focusTarget).toHaveBeenCalledTimes(1);
expect(document.activeElement).toBe(
fixture.nativeElement.querySelector('aside.context-panel--route')
);
const control = document.createElement('button');
fixture.nativeElement.appendChild(control);
panel.focusTarget.mockReturnValue(control);
(document.activeElement as HTMLElement | null)?.blur();
liveSidebarService.collapse('portal');
fixture.detectChanges();
liveSidebarService.expand('portal');
fixture.detectChanges();
await new Promise((resolve) => queueMicrotask(resolve));
expect(document.activeElement).toBe(control);
});
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
@@ -2,16 +2,22 @@ import {
ChangeDetectionStrategy,
Component,
computed,
effect,
ElementRef,
inject,
input,
viewChild,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { TranslatePipe } from '@ngx-translate/core';
import { ResizableDirective } from '@iptvnator/ui/components';
import {
focusIfFocusLost,
LiveLayoutSidebarStateService,
PortalRailSection,
} from '@iptvnator/portal/shared/util';
import { StalkerStore } from '@iptvnator/portal/stalker/data-access';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import {
WorkspaceShellContextDrawerService,
WorkspacePortalContext,
@@ -47,6 +53,8 @@ export class WorkspaceShellContextSidebarComponent {
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly xtreamStore = inject(XtreamStore);
private readonly stalkerStore = inject(StalkerStore);
// Root-provided; optional keeps standalone unit tests light. Only relevant
// when the sidebar renders as the phone drawer — the close button that
// calls this is CSS-hidden above the phone breakpoint.
@@ -67,11 +75,82 @@ export class WorkspaceShellContextSidebarComponent {
LIVE_SECTIONS.has(section)
);
});
/**
* A live category is selected, so the layout renders a channels rail
* whose header carries the way back to the folded categories rail (the
* category dropdown + show chevron). On the live root there is no such
* rail, and folding the categories there would leave no way back.
*/
readonly hasLiveCategorySelection = computed(() => {
const provider = this.context()?.provider;
if (provider === 'xtreams') {
return this.xtreamStore.selectedCategoryId() !== null;
}
if (provider === 'stalker') {
return !!this.stalkerStore.selectedCategoryId();
}
return false;
});
// The categories rail is the outermost panel, so it folds on the first
// collapse level already (`categories-hidden`, gated on a channels rail
// existing to host the way back), and always on `collapsed`.
readonly isContextPanelCollapsed = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsedFor('portal')()
(this.liveSidebarStateService.isCollapsedFor('portal')() ||
(this.liveSidebarStateService.areCategoriesHiddenFor(
'portal'
)() &&
this.hasLiveCategorySelection()))
);
/**
* A folded rail is only 0px wide; its search, sort and category buttons
* would still take Tab stops and screen-reader focus. `inert` removes
* them from both. Not while the panel renders as the open phone drawer,
* whose stylesheet ignores the folded state.
*/
readonly isContextPanelInert = computed(
() =>
this.isContextPanelCollapsed() && !this.contextDrawer?.isOpen()
);
// By template ref, not class token, so a spec's stand-in panel is found
// the same way the real one is.
private readonly contextPanel =
viewChild<Pick<WorkspaceContextPanelComponent, 'focusTarget'>>(
'contextPanel'
);
private readonly routePanel = viewChild('routePanel', {
read: ElementRef<HTMLElement>,
});
constructor() {
// Unfolding the rail removes the control the user activated (the
// layout's show-categories button or the floating restore handle),
// so focus drops to <body>; the rail picks it up once rendered. This
// watches the rail's ACTUAL fold state rather than the raw level:
// on the live root the rail stays visible at `categories-hidden`,
// and only `collapsed` ↔ visible is a real transition there. The
// panel names its control; with none rendered (categories loading,
// a failed load) the `tabindex="-1"` aside itself takes focus, so a
// keyboard user always lands inside the rail.
// Seeded on the first run: the required inputs are not readable in
// the constructor yet.
let wasCollapsed: boolean | null = null;
effect(() => {
const collapsed = this.isContextPanelCollapsed();
const unfolded = wasCollapsed === true && !collapsed;
wasCollapsed = collapsed;
if (unfolded) {
queueMicrotask(() =>
focusIfFocusLost(
this.contextPanel()?.focusTarget() ??
this.routePanel()?.nativeElement
)
);
}
});
}
closeDrawer(): void {
this.contextDrawer?.close();
@@ -28,7 +28,9 @@ import { WorkspaceShellRouteStateService } from './services/workspace-shell-rout
import { WorkspaceShellSearchSyncService } from './services/workspace-shell-search-sync.service';
import { WorkspaceShellSearchService } from './services/workspace-shell-search.service';
import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service';
import { WorkspaceLiveCategoriesPopoverService } from '../workspace-live-categories-popover/workspace-live-categories-popover.service';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
@Component({
selector: 'app-workspace-shell',
@@ -55,6 +57,12 @@ import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/u
WorkspaceShellXtreamImportService,
WorkspaceShellCommandPaletteService,
WorkspaceKeyboardShortcutsService,
// The live layouts inside the router outlet open the folded categories
// rail through this token; see `LiveCategoriesPopover`.
{
provide: LIVE_CATEGORIES_POPOVER,
useClass: WorkspaceLiveCategoriesPopoverService,
},
],
})
export class WorkspaceShellComponent {
@@ -21,6 +21,18 @@ describe('WorkspaceShellContextDrawerService', () => {
service = TestBed.inject(WorkspaceShellContextDrawerService);
});
it('opens on demand and stays open until closed', () => {
const service = TestBed.inject(WorkspaceShellContextDrawerService);
service.open();
expect(service.isOpen()).toBe(true);
service.open();
expect(service.isOpen()).toBe(true);
service.close();
expect(service.isOpen()).toBe(false);
});
it('starts closed', () => {
expect(service.isOpen()).toBe(false);
});
@@ -67,6 +67,14 @@ export class WorkspaceShellContextDrawerService {
this.openState.update((open) => !open);
}
/**
* Opens the drawer. Only meaningful at phone widths; on wider viewports
* the panel is in flow and the styles ignore this state.
*/
open(): void {
this.openState.set(true);
}
close(): void {
this.openState.set(false);
}