From ebf34e5dcb92ef2869ad0d13a79028e920a4e904 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sun, 6 Sep 2026 08:55:35 +0200 Subject: [PATCH] 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 --- .changes/live-tv-hidden-channel-list.md | 7 + CLAUDE.md | 2 + .../src/live-sidebar-collapse.e2e.ts | 386 ++++++++++++++++++ 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 | 46 ++- .../video-player/video-player-sidebar.spec.ts | 351 ++++++++++++++++ .../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 | 1 + .../unified-collection-page.component.ts | 5 +- .../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-stream-layout.component.html | 2 + .../stalker-live-stream-layout.component.ts | 11 +- ...alker-live-stream-layout.epg-races.spec.ts | 5 +- ...stream-layout.playback-session-key.spec.ts | 5 +- .../live-stream-layout.component.html | 2 + .../live-stream-layout.component.spec.ts | 30 +- .../live-stream-layout.component.ts | 7 +- ...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 | 6 + .../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 + 60 files changed, 1568 insertions(+), 111 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 19a014fc8..273f5fbda 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -482,6 +482,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/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index fbd4203d2..1bceb390b 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 4dcce5c5c..4fae00b53 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 4337ae612..05da06b5c 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 7a8b4e445..d2318b009 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index e0b91103a..2a7263f91 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index f871c1abf..9bd9782ec 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 6d8c3673b..e263b57ad 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 1758dbd1d..05cd1eae2 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index f9ccebef7..1bf346653 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Másik lejátszási lista feltöltése vagy kiválasztása", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index c388169d5..bc7b3b6ab 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 4f928da30..0cc23dbcf 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 8225ffa64..89d597940 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index eb0ab4f14..12cf285d3 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 62679ee51..d9721d265 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 31e02cd30..d01fa22cf 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 29ee84b02..de9257a8d 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 52c54a950..d04546c3c 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 5a58877db..7bc28ce1d 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index dc57eea2c..2b6fa0a0e 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -645,7 +645,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": { "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單", diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 8c53863c2..c07fc78cd 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -216,8 +216,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 @@ -234,15 +234,39 @@ 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. +- 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 ``, `