EpgQueryService.getChannelMetadata swallowed database errors into {}, so the
guide's coverage read could publish an empty set after a transient failure;
the guide now uses the strict resolveChannelMetadata (getChannelMetadata is
the fail-soft wrapper around it). The active guide row is matched on the
whole channel entry (all fields except the reducer-rewritten epgParams)
before widening to url and id, so copies that differ only in playback
headers or logo are told apart.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The store spreads the selected channel, so the active row is matched by
id, url, group and name before widening; G no longer closes the guide from
a dialog or menu; guide answers use null-prototype records so a key named
__proto__ stays an own property.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Search hits carry scope-local row ids, so a scope change drops them.
Two playlist entries can share an id but not a stream, so the active row
is matched by id + url before falling back to the id. A failed coverage
query now rejects instead of answering an empty set, which the guide
already treats as "coverage unknown" (every row stays visible).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
## 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>
The shell component now owns rows, focus and the viewport only: the day,
zoom, density, filters, clock and day geometry move to EpgGuideViewState,
and every programme-dialog entry point to EpgGuideDialogController.
Keyboard navigation is reachable by assistive technology: exactly one grid
cell carries tabindex="0" (the focused cell, else the playing row's channel
cell, else the first row's), the guide moves DOM focus with it after each
handled key, a click hands the roving index to the clicked cell, and the
viewport, rows and cells expose grid/row/gridcell roles.
Search results were formatting raw provider instants, so they ignored the
EPG display offset; they go through getProgramTimeMs like every other time
the guide renders.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Favorites are persisted by channel URL (FavoritesActions.updateFavorites),
so the Favorites scope compared the wrong key; the id stays as a legacy
fallback. A double-click arrives as click, click, dblclick and each
activate restarts playback, so the guide now leaves the already-playing row
alone and the commit path only closes.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Adopts the compact icon-only timeline toolbar (#1557): the Guide button is
now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and
aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Guide mode wrapped the sidebar in `@if (!guideOpen())`, so opening the
guide destroyed `app-channel-list-container`, whose `ngOnDestroy`
dispatches `resetActiveChannel()`. That cleared the active channel, which
unmounted the block hosting `app-epg-guide` and tripped the
`!canOpenGuide()` effect into closing the guide again: the guide never
appeared and the page dropped to "Please select a channel".
The sidebar now stays mounted and is hidden with
`.sidebar--guide-hidden` plus `inert`, so it is neither focusable nor read
by assistive technology while the guide owns the layout. Hiding also
preserves the channel list's scroll position across guide toggles.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
While the programme guide is open the docked player carries
`data-player-shortcuts-suspended`, which `ControlsShortcuts` now honours
alongside `[inert]` — the arrows moved the player's volume instead of the
guide's row focus. The external-player strip loses its Collapse toggle
(nothing to reveal, no preference to write), the header action and its
palette command report `disabled` when the guide cannot open, the docked
strip derives its programme from the active channel's own schedule instead
of the retained NgRx value, switching playlists closes the guide, and the
collapsed strip can reach 48 px on phones.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(live-tv): keep a hidden channel list discoverable and scoped per surface
The second report in #1458 ("all channels disappear after clearing the
playback history, reset does not bring them back") was not data loss: the
history write never touches playlist items. The reporter's screenshot shows
a collapsed channel rail, a state persisted under one localStorage key
shared by the M3U player, the Xtream/Stalker live layouts and the
favorites/recent live tab. It survived restart, "Remove all playlists" and
re-import, and the only way back was a 32px chevron or Ctrl/Cmd+B.
- LiveLayoutSidebarStateService keeps the state per surface (m3u / portal /
collection) under live-sidebar-state:<surface>; the M3U player now goes
through the service instead of its own signal. The legacy shared key is
forgotten on startup and never read, so the update itself restores the
list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
the control exists in both states instead of disappearing with the rail.
Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
app-channel-list-hidden-state (title, shortcut hint, full-size "Show
channels list" button) instead of asking to pick from a list that is not
on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.
Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.
Refs #1458
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(live-tv): mirror the EPG offset setting in the sidebar spec mock
Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(web-e2e): scope the Stalker radio rail toggles to the rail
The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones
Codex review follow-ups on #1555:
- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
collection page had no handler; only the routed M3U/Xtream/Stalker live
layouts did. The page now toggles the collection surface while its live
tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
switcher, search and Add; the live rail is a bottom drawer with its own
toggle there, so the header rail toggle is hidden below 640px.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API
master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(xtream): try advertised TS after initial web HLS HTTP failure
* refactor(playback): extract fullscreen channel panel state
* test(xtream): keep synthetic media within the mock project
* feat(epg): compact timeline toolbar with icon-only Now and cycling zoom
Replace the 96px zoom range slider with a single icon button that cycles
day overview → by hour → detailed, make the Now button icon-only (label in
tooltip + aria-label), and add Ctrl/⌘ + wheel zoom around the cursor on the
ribbon (preventDefault keeps Chromium from page-zooming the same gesture).
Zoom math moves into TimelineZoomController so the component stays under
the max-lines limit. The channel/programme heading no longer carries a 60%
width cap and takes the space the controls free up.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(epg): coalesce wheel-zoom anchoring and make the e2e zoom reset deterministic
- TimelineZoomController keeps a logical pending scrollLeft across a burst
of wheel/pinch events and applies it in one animation frame, so every
event anchors on the same minute instead of the stale DOM position
- the e2e settles on the detail preset from whichever band the wheel
landed in (contrast checks need the block's time line, hidden at
narrower tiers)
- zoom tooltip advertises ⌘/Ctrl per the repo's shortcut convention
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(portals): preserve live channel navigation while browsing
* test(portals): await media source assertion in remote E2E
* fix(xtream): capture destination queue for live auto-open
* fix(stalker): keep live search within the selected category
* test(stalker): assert retained video ownership without source timing
* test(stalker): distinguish paged All Items from the initial cache grid
* fix(stalker): reveal remote selections in uncached search results
* test(stalker): wait for category rows and retain settled playback
* fix(migration): recover legacy desktop sources without replacing current data
* test(migration): cover legacy recovery IPC contracts
* test(migration): use static legacy Electron bootstrap
* fix(playback): apply themes to player and EPG panels
* test(playback): verify active recording icon theme
* fix(epg): keep loading shimmer visible in both themes
* fix(playback): close legacy picture-in-picture on video replacement
* test(playback): wait for the selected video before PiP setup
* test(playback): await changed settings before PiP navigation
* fix(playback): release legacy WebKit picture-in-picture
`epgTimestampMs` in the dashboard live-EPG helpers returned
`EpgProgram.startTimestamp`/`stopTimestamp` as-is, but those fields are
unix SECONDS everywhere else (`getProgramTimeMs` in `@iptvnator/ui/epg`
multiplies by 1000). The bug was latent: the backend
`GET_CURRENT_PROGRAMS_BATCH` rows never set the fields, so the ISO
fallback always ran.
Scale a positive finite timestamp by 1000 and treat zero/non-finite
values as absent, mirroring `getProgramTimeMs`. Add a dedicated spec
that feeds seconds-based timestamps and asserts the formatted range and
progress, and fix the rails component spec fixture, which stored
millisecond values in the seconds fields and so encoded the old bug.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>