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

+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