mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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:
1 parent
0dcfba7045
commit
0a2373f192
68 files changed
+2710
-197
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user