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 ``, `