From 0dcfba704567554a383f27d7e8d13634d659f46a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:48:58 +0200 Subject: [PATCH] fix(live-tv): keep a hidden channel list discoverable and scoped per surface (#1555) * 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:; 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 * 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 * 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 * 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 * 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 --------- Co-authored-by: Claude Fable 5.1 --- .changes/live-tv-hidden-channel-list.md | 7 + CLAUDE.md | 2 + .../src/live-sidebar-collapse.e2e.ts | 386 ++++++++++++++++++ apps/web-e2e/src/mobile-layout.e2e.ts | 13 +- apps/web-e2e/src/stalker.e2e.ts | 17 +- apps/web/src/assets/i18n/ar.json | 4 +- apps/web/src/assets/i18n/ary.json | 4 +- apps/web/src/assets/i18n/by.json | 4 +- apps/web/src/assets/i18n/de.json | 4 +- apps/web/src/assets/i18n/el.json | 4 +- apps/web/src/assets/i18n/en.json | 4 +- apps/web/src/assets/i18n/es.json | 4 +- apps/web/src/assets/i18n/fr.json | 4 +- apps/web/src/assets/i18n/hu.json | 4 +- apps/web/src/assets/i18n/it.json | 4 +- apps/web/src/assets/i18n/ja.json | 4 +- apps/web/src/assets/i18n/ko.json | 4 +- apps/web/src/assets/i18n/nl.json | 4 +- apps/web/src/assets/i18n/pl.json | 4 +- apps/web/src/assets/i18n/pt.json | 4 +- apps/web/src/assets/i18n/ru.json | 4 +- apps/web/src/assets/i18n/tr.json | 4 +- apps/web/src/assets/i18n/zh.json | 4 +- apps/web/src/assets/i18n/zhtw.json | 4 +- docs/architecture/iptvnator-ui-guidelines.md | 48 ++- .../video-player/video-player-sidebar.spec.ts | 352 ++++++++++++++++ .../video-player/video-player.component.html | 2 + .../video-player/video-player.component.ts | 28 +- libs/portal/shared/ui/src/index.ts | 1 + ...hannel-list-hidden-state.component.spec.ts | 49 +++ .../channel-list-hidden-state.component.ts | 37 ++ .../portal-empty-state.component.html | 16 + .../portal-empty-state.component.scss | 21 + .../portal-empty-state.component.spec.ts | 55 +++ .../portal-empty-state.component.ts | 16 +- .../unified-collection-page.component.html | 1 + .../unified-collection-page.component.spec.ts | 71 +++- .../unified-collection-page.component.ts | 32 +- .../unified-live-tab.component.html | 4 + .../unified-live-tab.component.ts | 4 + .../live-layout-sidebar-state.service.spec.ts | 91 +++++ .../lib/live-layout-sidebar-state.service.ts | 77 +++- .../util/src/lib/live-sidebar-state.spec.ts | 89 +++- .../shared/util/src/lib/live-sidebar-state.ts | 74 +++- .../stalker-live-navigation.spec.ts | 2 +- .../stalker-live-navigation.ts | 2 +- .../stalker-live-stream-layout.component.html | 2 + .../stalker-live-stream-layout.component.ts | 11 +- ...alker-live-stream-layout.epg-races.spec.ts | 5 +- ...er-live-stream-layout.panel-search.spec.ts | 5 +- ...stream-layout.playback-session-key.spec.ts | 5 +- .../live-stream-layout.component.html | 2 + .../live-stream-layout.component.spec.ts | 18 +- .../live-stream-layout.component.ts | 7 +- ...am-live-channel-navigation.service.spec.ts | 2 +- .../xtream-live-channel-navigation.service.ts | 2 +- ...ce-shell-context-sidebar.component.spec.ts | 10 +- ...rkspace-shell-context-sidebar.component.ts | 2 +- .../workspace-shell-header.component.html | 16 + .../workspace-shell-header.component.scss | 13 + .../workspace-shell-header.component.spec.ts | 65 +++ .../workspace-shell-header.component.ts | 13 +- .../helpers/workspace-shell-constants.ts | 12 + .../workspace-shell-header.service.ts | 43 ++ .../services/workspace-shell.facade.ts | 5 + .../workspace-shell.component.html | 2 + .../workspace-shell.component.spec.ts | 4 + 67 files changed, 1687 insertions(+), 130 deletions(-) create mode 100644 .changes/live-tv-hidden-channel-list.md create mode 100644 apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts create mode 100644 libs/playlist/m3u/feature-player/src/lib/video-player/video-player-sidebar.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/channel-list-hidden-state/channel-list-hidden-state.component.spec.ts create mode 100644 libs/portal/shared/ui/src/lib/components/channel-list-hidden-state/channel-list-hidden-state.component.ts create mode 100644 libs/portal/shared/ui/src/lib/components/portal-empty-state/portal-empty-state.component.spec.ts create mode 100644 libs/portal/shared/util/src/lib/live-layout-sidebar-state.service.spec.ts diff --git a/.changes/live-tv-hidden-channel-list.md b/.changes/live-tv-hidden-channel-list.md new file mode 100644 index 000000000..108e7fee0 --- /dev/null +++ b/.changes/live-tv-hidden-channel-list.md @@ -0,0 +1,7 @@ +--- +type: fix +area: live-tv +issues: [1458] +--- + +A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else. diff --git a/CLAUDE.md b/CLAUDE.md index 52769fda7..01af2cd7a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -504,6 +504,8 @@ The M3U playlist module handles traditional M3U/M3U8 playlists with support for The live EPG panel is a horizontal **timeline ribbon** under the player (`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`), not a right-side drawer (reworked in PR #1102). See `docs/architecture/m3u-playlist-module.md` for the timeline's controllers and scroll behavior. +**Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`. + **Radio Channel Layout** (when `channel.radio === 'true'`): ``` diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts new file mode 100644 index 000000000..9c99fcad8 --- /dev/null +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -0,0 +1,386 @@ +import { Page } from '@playwright/test'; +import { + addXtreamPortal, + channelItemByTitle, + clickCategoryByNameExact, + closeElectronApp, + expect, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + LaunchedElectronApp, + openPlaylistRecent, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + test, + waitForM3uCatalog, + waitForXtreamWorkspaceReady, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; +import { fetchXtreamLiveFixture } from './portal-mock-fixtures'; + +/** + * Issue #1458, second report: "all channels disappear after clearing the + * playback history; a reset does not bring them back". The history write + * never touched the channels — the reporter's screenshot shows a collapsed + * channel rail, a state that used to be persisted under one key shared by + * every live surface and that survived restart, "Remove all playlists" and + * re-import. These scenarios pin both halves: the data path stays intact, + * and the collapsed rail is discoverable, scoped, and restorable. + */ + +const CHANNELS = [ + { + groupTitle: 'News', + name: 'Channel Alpha', + url: 'https://streams.example.test/alpha.m3u8', + }, + { + groupTitle: 'News', + name: 'Channel Beta', + url: 'https://streams.example.test/beta.m3u8', + }, + { + groupTitle: 'Sports', + name: 'Channel Gamma', + url: 'https://streams.example.test/gamma.m3u8', + }, +]; + +const M3U_STATE_KEY = 'live-sidebar-state:m3u'; +const PORTAL_STATE_KEY = 'live-sidebar-state:portal'; +const LEGACY_STATE_KEY = 'live-sidebar-state'; + +function playlistIdFromUrl(page: Page): string { + const match = page.url().match(/\/workspace\/playlists\/([^/?#]+)\//); + if (!match) { + throw new Error(`Not on a playlist route: ${page.url()}`); + } + return match[1]; +} + +async function storedItemCount( + page: Page, + playlistId: string +): Promise { + return page.evaluate(async (id) => { + const bridge = ( + window as unknown as { + electron: { + dbGetAppPlaylist: ( + playlistId: string + ) => Promise<{ playlist?: { items?: unknown[] } } | null>; + }; + } + ).electron; + const playlist = await bridge.dbGetAppPlaylist(id); + return playlist?.playlist?.items?.length ?? -1; + }, playlistId); +} + +async function reopenPlaylist(page: Page, sourceTitle: string): Promise { + await openSources(page); + await sourceRowByTitle(page, sourceTitle).first().click(); + await page.waitForURL(/\/workspace\/playlists\/.+\/all$/); +} + +function readStorage(page: Page, key: string): Promise { + return page.evaluate((storageKey) => localStorage.getItem(storageKey), key); +} + +function headerToggle(page: Page) { + return page.locator('app-workspace-shell-header .header-sidebar-toggle'); +} + +function hiddenStateRestoreButton(page: Page) { + return page.locator( + 'app-channel-list-hidden-state button.empty-state-action' + ); +} + +/** + * The collapsed rail keeps its children in the DOM (overflow-clipped inside + * a zero-width container), so Playwright still reports them "visible"; + * assert on the container and its restore affordances instead. + */ +async function expectRailCollapsed(page: Page): Promise { + const sidebar = page.locator('.sidebar').first(); + await expect(sidebar).toHaveClass(/sidebar-collapsed/); + // width: 0 !important, but the 1px (transparent) border still counts. + await expect + .poll(async () => (await sidebar.boundingBox())?.width ?? -1) + .toBeLessThanOrEqual(1); + await expect(page.locator('.sidebar-restore')).toBeVisible(); + await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'false'); +} + +async function expectRailExpanded(page: Page): Promise { + await expect(page.locator('.sidebar').first()).not.toHaveClass( + /sidebar-collapsed/ + ); + await expect(page.locator('.sidebar-restore')).toHaveCount(0); + await expect(page.getByTestId('channel-item').first()).toBeVisible(); + await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'true'); +} + +test.describe('Live channel rail collapse (#1458)', () => { + test('clearing the playlist history keeps every channel in SQLite and in the All channels view, also after restart', async ({ + dataDir, + }) => { + const sourceTitle = 'rail-history'; + const filePath = writeTemporaryM3uFile( + dataDir, + `${sourceTitle}.m3u`, + CHANNELS + ); + const app: LaunchedElectronApp = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, filePath); + await waitForM3uCatalog(app.mainWindow); + const playlistId = playlistIdFromUrl(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + + await channelItemByTitle(app.mainWindow, 'Channel Alpha') + .first() + .click(); + + await openPlaylistRecent(app.mainWindow); + await expect( + channelItemByTitle(app.mainWindow, 'Channel Alpha').first() + ).toBeVisible({ timeout: 20000 }); + + await app.mainWindow + .getByRole('button', { name: 'Clear recently viewed Live TV' }) + .click(); + await app.mainWindow.getByRole('button', { name: 'Yes' }).click(); + await expect( + channelItemByTitle(app.mainWindow, 'Channel Alpha') + ).toHaveCount(0); + + await reopenPlaylist(app.mainWindow, sourceTitle); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(3); + await expectRailExpanded(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await reopenPlaylist(app.mainWindow, sourceTitle); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(3); + await expectRailExpanded(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + } finally { + await closeElectronApp(app); + } + }); + + test('a collapsed rail stays discoverable and restorable: hidden-list state, header toggle, restart, and a fresh import', async ({ + dataDir, + }) => { + const firstTitle = 'rail-collapse-a'; + const secondTitle = 'rail-collapse-b'; + const firstPath = writeTemporaryM3uFile( + dataDir, + `${firstTitle}.m3u`, + CHANNELS + ); + const secondPath = writeTemporaryM3uFile( + dataDir, + `${secondTitle}.m3u`, + CHANNELS + ); + const app: LaunchedElectronApp = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, firstPath); + await waitForM3uCatalog(app.mainWindow); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).not.toBe( + 'collapsed' + ); + + // The chevron in the "All channels" header hides the rail. With + // nothing playing, the content area must say so instead of + // asking the user to pick from a list they cannot see. + await app.mainWindow + .locator('.all-channels-sidebar-toggle') + .first() + .click(); + await expectRailCollapsed(app.mainWindow); + await expect( + app.mainWindow.locator('app-channel-list-hidden-state') + ).toBeVisible(); + await expect( + hiddenStateRestoreButton(app.mainWindow) + ).toBeVisible(); + await expect( + app.mainWindow.locator('app-portal-empty-state', { + hasText: 'select a channel', + }) + ).toHaveCount(0); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'collapsed' + ); + + // The full-size action restores the rail. + await hiddenStateRestoreButton(app.mainWindow).click(); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'expanded' + ); + + // The header toggle exists in both states and drives the same + // rail. + await headerToggle(app.mainWindow).click(); + await expectRailCollapsed(app.mainWindow); + await headerToggle(app.mainWindow).click(); + await expectRailExpanded(app.mainWindow); + + // A hidden rail is a preference the user made, so it survives a + // restart and applies to a second playlist — but the way back + // survives with it. + await headerToggle(app.mainWindow).click(); + await expectRailCollapsed(app.mainWindow); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await reopenPlaylist(app.mainWindow, firstTitle); + const playlistId = playlistIdFromUrl(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + await expectRailCollapsed(app.mainWindow); + await expect( + hiddenStateRestoreButton(app.mainWindow) + ).toBeVisible(); + + await importM3uPlaylistFromNativeDialog(app, secondPath); + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+\/all$/); + await expectRailCollapsed(app.mainWindow); + + // Ctrl/Cmd+B still works and is reflected by the header toggle. + await app.mainWindow.keyboard.press('Control+b'); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'expanded' + ); + } finally { + await closeElectronApp(app); + } + }); + + test('the collapsed state is scoped per surface and the legacy shared key is forgotten', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const liveFixture = await fetchXtreamLiveFixture(request, { + username: 'user1', + password: 'pass1', + }); + const m3uTitle = 'rail-scope'; + const m3uPath = writeTemporaryM3uFile( + dataDir, + `${m3uTitle}.m3u`, + CHANNELS + ); + const app: LaunchedElectronApp = await launchElectronApp(dataDir); + + try { + // A leftover pre-split value must not hide anything after the + // update, and it must be cleaned up. + await app.mainWindow.evaluate((key) => { + localStorage.setItem(key, 'collapsed'); + }, LEGACY_STATE_KEY); + + await importM3uPlaylistFromNativeDialog(app, m3uPath); + await waitForM3uCatalog(app.mainWindow); + await expectRailExpanded(app.mainWindow); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await reopenPlaylist(app.mainWindow, m3uTitle); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, LEGACY_STATE_KEY)).toBe( + null + ); + + // Hide the M3U rail, then open an Xtream portal's Live TV: its + // rail (and the header toggle for it) must still be expanded. + await headerToggle(app.mainWindow).click(); + await expectRailCollapsed(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: 'Rail Scope Portal', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact( + app.mainWindow, + liveFixture.categoryName + ); + + await expect(headerToggle(app.mainWindow)).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect( + app.mainWindow.locator('.sidebar-restore') + ).toHaveCount(0); + expect( + await readStorage(app.mainWindow, PORTAL_STATE_KEY) + ).not.toBe('collapsed'); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'collapsed' + ); + + // And the other way round: hiding the portal rail leaves the + // M3U preference untouched. With a category open and nothing + // playing, the portal host shows the same hidden-list state and + // its button restores the rail. + await headerToggle(app.mainWindow).click(); + await expect(headerToggle(app.mainWindow)).toHaveAttribute( + 'aria-pressed', + 'false' + ); + await expect(hiddenStateRestoreButton(app.mainWindow)).toBeVisible(); + expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe( + 'collapsed' + ); + + await hiddenStateRestoreButton(app.mainWindow).click(); + await expect(headerToggle(app.mainWindow)).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect( + app.mainWindow.locator('app-channel-list-hidden-state') + ).toHaveCount(0); + + await headerToggle(app.mainWindow).click(); + expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe( + 'collapsed' + ); + + await reopenPlaylist(app.mainWindow, m3uTitle); + await expectRailCollapsed(app.mainWindow); + await headerToggle(app.mainWindow).click(); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe( + 'collapsed' + ); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts index 71c1be549..c0535cdad 100644 --- a/apps/web-e2e/src/mobile-layout.e2e.ts +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -134,9 +134,7 @@ test.describe('portrait phone 375x812', () => { // sections; now the list scrolls and ends above the footer. const listBox = await boxOf(panel.locator('.settings-sections-list')); const footerBox = await boxOf(footer); - expect(listBox.y + listBox.height).toBeLessThanOrEqual( - footerBox.y + 1 - ); + expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1); // Tapping the backdrop (right of the drawer) closes it. await page @@ -216,7 +214,16 @@ test.describe('xtream portal routes on a phone', () => { const sidebar = page.locator('app-live-stream-layout .sidebar'); await expect(sidebar).toBeVisible(); + // The workspace header carries the live rail toggle at desktop + // width only; on the phone the rail is a bottom drawer with its own + // toggle and the header has no room for another permanent icon. + const headerRailToggle = page.locator( + 'app-workspace-shell-header .header-sidebar-toggle' + ); + await expect(headerRailToggle).toBeVisible(); + await page.setViewportSize(LANDSCAPE_PHONE); + await expect(headerRailToggle).toBeHidden(); // The sidebar stacks at full width above the player. await expect diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 651b43c25..8f0f48bd3 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -513,13 +513,22 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn timeout: 20_000, }); - await page.getByRole('button', { name: 'Hide channels list' }).click(); - const restoreButton = page.getByRole('button', { - name: 'Show channels list', - }); + // 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. + await page + .locator('.sidebar') + .getByRole('button', { name: 'Hide channels list' }) + .click(); + const restoreButton = page.locator('.sidebar-restore'); await expect(restoreButton).toBeVisible(); + await expect( + page.locator('app-workspace-shell-header .header-sidebar-toggle') + ).toHaveAttribute('aria-pressed', 'false'); await restoreButton.click(); 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); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 0ceee228e..729b84b1c 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات", "SHOW_CHANNELS_LIST": "إظهار قائمة القنوات", - "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "قائمة القنوات مخفية", + "CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B." }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 3e0964d6e..cc6a21c4d 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات", "SHOW_CHANNELS_LIST": "إظهار قائمة القنوات", - "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "قائمة القنوات مخبية", + "CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B." }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e61367805..e0c736366 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Схаваць спіс каналаў", "SHOW_CHANNELS_LIST": "Паказаць спіс каналаў", - "TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "Спіс каналаў схаваны", + "CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B." }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 3cc695184..141aa1314 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Senderliste ausblenden", "SHOW_CHANNELS_LIST": "Senderliste einblenden", - "TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Laufenden Sender anzeigen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1b32e374f..2492622d6 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών", "SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών", - "TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "Η λίστα καναλιών είναι κρυφή", + "CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B." }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index a2c4f1b68..24c173208 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Hide channels list", "SHOW_CHANNELS_LIST": "Show channels list", - "TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Show playing channel", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 578d9275e..c9c4444ce 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Ocultar lista de canales", "SHOW_CHANNELS_LIST": "Mostrar lista de canales", - "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Mostrar el canal en reproducción", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 3c11651e5..6ac584f32 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Masquer la liste des chaînes", "SHOW_CHANNELS_LIST": "Afficher la liste des chaînes", - "TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Afficher la chaîne en cours", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index d031e6794..da78c5060 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Csatornalista elrejtése", "SHOW_CHANNELS_LIST": "Csatornalista megjelenítése", - "TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Lejátszott csatorna megjelenítése", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index b7a049cc1..0ae8eb489 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Nascondi elenco canali", "SHOW_CHANNELS_LIST": "Mostra elenco canali", - "TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Mostra il canale in riproduzione", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 40514202a..5da77c661 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "チャンネル一覧を非表示", "SHOW_CHANNELS_LIST": "チャンネル一覧を表示", - "TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "チャンネル一覧は非表示です", + "CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。" }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 3566b1a42..64cd75eb1 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "채널 목록 숨기기", "SHOW_CHANNELS_LIST": "채널 목록 표시", - "TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "채널 목록이 숨겨져 있습니다", + "CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요." }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 63a5798e6..e50c3dda2 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Zenderlijst verbergen", "SHOW_CHANNELS_LIST": "Zenderlijst tonen", - "TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Huidige zender tonen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 8388b00f7..ea7f39e10 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Ukryj listę kanałów", "SHOW_CHANNELS_LIST": "Pokaż listę kanałów", - "TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Pokaż odtwarzany kanał", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 072e838ba..59eebef7d 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Ocultar lista de canais", "SHOW_CHANNELS_LIST": "Mostrar lista de canais", - "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Mostrar canal em reprodução", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 2d871c185..152862748 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Скрыть список каналов", "SHOW_CHANNELS_LIST": "Показать список каналов", - "TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "Список каналов скрыт", + "CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B." }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "Показать текущий канал", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 237dc8e4a..a9df159cb 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "Kanal listesini gizle", "SHOW_CHANNELS_LIST": "Kanal listesini göster", - "TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)" + "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": { "SHOW_PLAYING_CHANNEL": "Oynatılan kanalı göster", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 4ab122512..9fc8fb6cd 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "隐藏频道列表", "SHOW_CHANNELS_LIST": "显示频道列表", - "TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "频道列表已隐藏", + "CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。" }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "显示正在播放的频道", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 57035cd01..5d9f5931d 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -674,7 +674,9 @@ "LAYOUT": { "HIDE_CHANNELS_LIST": "隱藏頻道列表", "SHOW_CHANNELS_LIST": "顯示頻道列表", - "TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)" + "TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)", + "CHANNELS_LIST_HIDDEN": "頻道列表已隱藏", + "CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。" }, "CHANNELS": { "SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 8a168d498..546ac4c91 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -267,8 +267,8 @@ remain local when the meaning is explicit. ### Collapsible Live Sidebar -- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle - that hides the channels rail to give the player and EPG full width. +- M3U, Xtream, and Stalker live layouts share a sidebar collapse toggle that + hides the channels rail to give the player and EPG full width. - 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 @@ -285,15 +285,41 @@ remain local when the meaning is explicit. is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites, and recent routes leave it untouched. - Collapsed state is owned by `LiveLayoutSidebarStateService` - (`providedIn: 'root'`) in `@iptvnator/portal/shared/util`. Every surface that - participates injects the service and reads `isCollapsed`; any toggle calls - `service.toggle()`. Persistence delegates to the existing - `live-sidebar-state` helpers, so the localStorage key stays unchanged and - missing/invalid values restore to expanded. -- A `mat-icon-button` with `chevron_left` lives in the sidebar header and - toggles state. While collapsed, a floating `chevron_right` mini-fab appears - at the left edge of `.content-container` to restore the rail (and the - categories rail, in Xtream/Stalker live). + (`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:`. 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 + in the thing they hide, the workspace header additionally renders + `view_sidebar` (`headerSidebarToggle`, `WorkspaceShellHeaderService`) on + every route that renders its own rail — M3U `all`/`groups`, Xtream `live`, + Stalker `itv`/`radio` (`resolveRouteLiveSidebarSurface`). It stays in place + in both states, uses `aria-pressed` (pressed = rail visible) and tints + primary only while the rail is hidden, since the hidden state is the + exception that deserves the cue. Collection pages are deliberately excluded: + only the page knows whether its live tab, and therefore the rail, is on + screen, so its own header toggle beside the content switch stays the owner. + At the phone breakpoint (≤640px) the header toggle is hidden: the rail is a + bottom drawer there with its own toggle and the header has no spare width. +- While the rail is collapsed and nothing is playing, every live host renders + `app-channel-list-hidden-state` (`@iptvnator/portal/shared/ui`) instead of + the "select a channel" empty state: a title that says the list is hidden, a + one-line hint naming the shortcut, and a full-size "Show channels list" + stroked button wired to the same toggle. The generic + `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 ``, `