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:<surface>; the M3U player now goes
  through the service instead of its own signal. The legacy shared key is
  forgotten on startup and never read, so the update itself restores the
  list for everyone who got stuck.
- The workspace header renders a view_sidebar toggle on every route that
  renders its own rail (M3U all/groups, Xtream live, Stalker itv/radio), so
  the control exists in both states instead of disappearing with the rail.
  Collection pages keep their own toggle beside the content switch.
- While the rail is collapsed and nothing plays, every live host shows
  app-channel-list-hidden-state (title, shortcut hint, full-size "Show
  channels list" button) instead of asking to pick from a list that is not
  on screen. app-portal-empty-state gained optional hint/action inputs.
- New LAYOUT.CHANNELS_LIST_HIDDEN(_HINT) strings in en plus 18 locales.

Tests: service, empty-state, hidden-state and header component specs, a
separate video-player-sidebar spec (the main M3U spec sits at the test
line budget), and an Electron E2E covering history clearing, restore via
button/header/shortcut across restart and re-import, per-surface scoping
against an Xtream portal, and legacy-key cleanup.

Refs #1458

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(live-tv): mirror the EPG offset setting in the sidebar spec mock

Master's player reads `resolvedEpgOffsetMinutes` from the settings store; the
new sidebar spec was cloned from the movie-gate harness before that field
landed, so its playing-channel case threw inside the EPG effect.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(web-e2e): scope the Stalker radio rail toggles to the rail

The workspace header now carries a second "Hide/Show channels list" toggle,
so the role+name locators matched more than one button and tripped
Playwright's strict mode. Target the rail's own chevron and the floating
restore button, and assert the header toggle mirrors the state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(live-tv): honour Cmd/Ctrl+B on collection pages and hide the header rail toggle on phones

Codex review follow-ups on #1555:

- The hidden-list state advertises Cmd/Ctrl+B, but the favorites/recent
  collection page had no handler; only the routed M3U/Xtream/Stalker live
  layouts did. The page now toggles the collection surface while its live
  tab is on screen, with the same typing/inert guards as the other hosts.
- At the phone breakpoint the header already holds the drawer toggle,
  switcher, search and Add; the live rail is a bottom drawer with its own
  toggle there, so the header rail toggle is hidden below 640px.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(live-tv): migrate the live navigation helpers to the per-surface sidebar API

master (#1554) added `XtreamLiveChannelNavigationService` and
`stalker-live-navigation.ts`, which expand the rail through
`sidebar.setState('expanded')` on the pre-split signature. Point them at the
`portal` surface and update their specs; drop the now-unused hidden-state
stub from the Xtream layout spec, which master pushed to the max-lines
budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-06 11:48:58 +02:00
1 parent 436825bdec
commit 0dcfba7045
67 files changed
+1687 -130

No files matched your search

+7
View File
@@ -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.
+2
View File
@@ -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:<surface>`); 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'`):
```
@@ -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<number> {
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<void> {
await openSources(page);
await sourceRowByTitle(page, sourceTitle).first().click();
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
}
function readStorage(page: Page, key: string): Promise<string | null> {
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<void> {
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<void> {
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);
}
});
});
+10 -3
View File
@@ -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
+13 -4
View File
@@ -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);
+3 -1
View File
@@ -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": "إظهار القناة قيد التشغيل",
+3 -1
View File
@@ -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": "بيّن القناة اللي خدامة",
+3 -1
View File
@@ -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": "Паказаць бягучы канал",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Εμφάνιση του καναλιού που παίζει",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "再生中のチャンネルを表示",
+3 -1
View File
@@ -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": "재생 중인 채널 표시",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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ł",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "Показать текущий канал",
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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": "显示正在播放的频道",
+3 -1
View File
@@ -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": "顯示正在播放的頻道",
+37 -11
View File
@@ -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:<surface>`. Hiding the list is a per-context
choice: it must not follow the user from a portal to an M3U playlist, nor
from the desktop rail to the phone bottom drawer of another surface. The
pre-split shared key `live-sidebar-state` is forgotten on service
construction and never read — a stored `collapsed` there hid every channel
list in the app behind a 32px chevron and survived restart, "Remove all
playlists" and re-import (issue #1458).
- 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 `<input>`, `<textarea>`, `<select>`, or content-editable elements via
the shared `isTypingInInput` helper.
@@ -0,0 +1,352 @@
import { AsyncPipe } from '@angular/common';
import { Component, NO_ERRORS_SCHEMA, output, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { ActivatedRoute, Router } from '@angular/router';
import { Overlay } from '@angular/cdk/overlay';
import { Store } from '@ngrx/store';
import { StorageMap } from '@ngx-pwa/local-storage';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { BehaviorSubject, of } from 'rxjs';
import {
selectActive,
selectActiveEpgProgram,
selectActivePlaybackUrl,
selectChannels,
selectChannelsLoading,
selectCurrentEpgProgram,
} from '@iptvnator/m3u-state';
import { EpgService } from '@iptvnator/epg/data-access';
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
import {
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
PORTAL_EXTERNAL_PLAYBACK,
} from '@iptvnator/portal/shared/util';
import {
DataService,
PlaylistsService,
RuntimeCapabilitiesService,
SettingsStore,
TmdbEnrichmentService,
} from '@iptvnator/services';
import {
Channel,
EpgProgram,
Settings,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS
// bundle breaks under the ESM jest environment, so it is mocked before the
// dynamic import evaluates the chain.
jest.unstable_mockModule('video.js', () => ({
default: jest.fn(),
}));
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
@Component({
selector: 'app-channel-list-hidden-state',
standalone: true,
template: '',
})
class StubChannelListHiddenStateComponent {
readonly restore = output<void>();
}
/**
* The collapsible channels rail of the M3U player. Separate from
* `video-player.component.spec.ts` only because that file sits at the
* max-lines test budget; the harness here is the minimal subset needed to
* render the rail and the content-area empty states.
*/
describe('VideoPlayerComponent — collapsible channels rail', () => {
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
let component: VideoPlayerComponentInstance;
let sidebarState: LiveLayoutSidebarStateService;
const playlistId = signal('playlist-1');
const activeChannel = signal<Channel | null>(null);
const activePlaybackUrl = signal<string | null>(null);
const channels = signal<Channel[]>([]);
const channelsLoading = signal(false);
const currentEpgProgram = signal<EpgProgram | null>(null);
const activeEpgProgram = signal<EpgProgram | null>(null);
const channels$ = new BehaviorSubject<Channel[]>([]);
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
const showCaptions = signal(false);
const stripCountryPrefix = signal(false);
const storeMock = {
dispatch: jest.fn(),
selectSignal: jest.fn((selector: unknown) => {
switch (selector) {
case selectActive:
return activeChannel;
case selectActivePlaybackUrl:
return activePlaybackUrl;
case selectChannels:
return channels;
case selectChannelsLoading:
return channelsLoading;
case selectCurrentEpgProgram:
return currentEpgProgram;
case selectActiveEpgProgram:
return activeEpgProgram;
default:
return signal(null);
}
}),
select: jest.fn((selector: unknown) => {
switch (selector) {
case selectChannels:
return channels$.asObservable();
case selectActive:
return activeChannel$.asObservable();
case selectCurrentEpgProgram:
return currentEpgProgram$.asObservable();
default:
return of(null);
}
}),
};
function clearSidebarStorage(): void {
localStorage.removeItem(liveSidebarStateStorageKey('m3u'));
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
localStorage.removeItem(liveSidebarStateStorageKey('collection'));
}
function query(selector: string): Element | null {
return fixture.nativeElement.querySelector(selector);
}
function hiddenState() {
return fixture.debugElement.query(
By.directive(StubChannelListHiddenStateComponent)
);
}
beforeAll(async () => {
({ VideoPlayerComponent } = await import('./video-player.component'));
});
beforeEach(async () => {
clearSidebarStorage();
activeChannel.set(null);
activeChannel$.next(null);
channels.set([]);
channels$.next([]);
await TestBed.configureTestingModule({
imports: [VideoPlayerComponent],
providers: [
{
provide: ActivatedRoute,
useValue: {
params: of({ id: playlistId(), view: 'all' }),
queryParams: of({}),
snapshot: {
data: { layout: 'workspace' },
queryParams: {},
},
},
},
{
provide: Router,
useValue: {
url: '/workspace/playlists/playlist-1/all',
navigate: jest.fn(),
currentNavigation: jest.fn().mockReturnValue(null),
},
},
{ provide: Store, useValue: storeMock },
{
provide: Overlay,
useValue: { position: jest.fn(), create: jest.fn() },
},
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
{
provide: RuntimeCapabilitiesService,
useValue: {
supportsEpg: true,
isElectron: false,
supportsRemoteControl: false,
},
},
{
provide: PlaylistsService,
useValue: {
getPlaylist: jest.fn(() =>
of({ playlist: { items: [] }, favorites: [] })
),
getPlaylistWithGlobalFavorites: jest.fn(() =>
of({ playlist: { items: [] }, favorites: [] })
),
addM3uRecentlyViewed: jest.fn(() =>
of({ recentlyViewed: [] })
),
},
},
{
provide: EpgService,
useValue: {
currentEpgPrograms$: epgPrograms$.asObservable(),
getChannelMetadataForChannels: () => of(new Map()),
},
},
{
provide: PlaylistContextFacade,
useValue: { resolvedPlaylistId: playlistId },
},
{
provide: SettingsStore,
useValue: {
player,
showCaptions,
stripCountryPrefix,
m3uVodDetails: signal(true),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
epgUrl: signal<string[]>([]),
},
},
{
provide: StorageMap,
useValue: {
get: jest.fn(() =>
of({
player: player(),
showCaptions: showCaptions(),
} as Partial<Settings>)
),
},
},
{
provide: PORTAL_EXTERNAL_PLAYBACK,
useValue: { activeSession: signal(null) },
},
{
provide: TmdbEnrichmentService,
useValue: { isEnabled: () => true },
},
],
})
// The rail and the content-area empty states are the subject;
// everything else renders as inert unknown elements.
.overrideComponent(VideoPlayerComponent, {
set: {
imports: [
AsyncPipe,
StubChannelListHiddenStateComponent,
MockPipe(
TranslatePipe,
(value: string | null | undefined) => value ?? ''
),
],
schemas: [NO_ERRORS_SCHEMA],
},
})
.compileComponents();
fixture = TestBed.createComponent(VideoPlayerComponent);
component = fixture.componentInstance;
sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
});
afterEach(() => {
fixture?.destroy();
clearSidebarStorage();
});
it('starts with the rail visible and the "select a channel" empty state', () => {
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(false);
expect(query('.sidebar')?.classList.contains('sidebar-collapsed')).toBe(
false
);
expect(query('.sidebar-restore')).toBeNull();
expect(query('app-portal-empty-state')).not.toBeNull();
expect(hiddenState()).toBeNull();
});
it('hides the rail through the shared per-surface state and swaps the empty state for a full-size way back', () => {
fixture.detectChanges();
component.toggleSidebar();
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(true);
expect(query('.sidebar')?.classList.contains('sidebar-collapsed')).toBe(
true
);
expect(query('.sidebar-restore')).not.toBeNull();
expect(query('app-portal-empty-state')).toBeNull();
expect(hiddenState()).not.toBeNull();
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
'collapsed'
);
hiddenState().componentInstance.restore.emit();
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(false);
expect(query('.sidebar-restore')).toBeNull();
expect(hiddenState()).toBeNull();
expect(query('app-portal-empty-state')).not.toBeNull();
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
'expanded'
);
});
it('reflects a toggle made elsewhere for the M3U surface (the header) and ignores other surfaces', () => {
fixture.detectChanges();
sidebarState.setState('portal', 'collapsed');
sidebarState.setState('collection', 'collapsed');
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(false);
expect(query('.sidebar-restore')).toBeNull();
sidebarState.toggle('m3u');
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(true);
expect(query('.sidebar-restore')).not.toBeNull();
});
it('does not show the hidden-list state while a channel is playing', () => {
const liveChannel = {
id: 'channel-1',
url: 'http://localhost/live.m3u8',
name: 'Sample TV',
epgParams: '',
radio: 'false',
tvg: { id: '', logo: '', name: 'Sample TV' },
} as Channel;
activeChannel.set(liveChannel);
activeChannel$.next(liveChannel);
channels.set([liveChannel]);
channels$.next([liveChannel]);
fixture.detectChanges();
component.toggleSidebar();
fixture.detectChanges();
expect(component.isSidebarCollapsed()).toBe(true);
expect(query('.sidebar-restore')).not.toBeNull();
expect(hiddenState()).toBeNull();
expect(query('.video-player')).not.toBeNull();
});
});
@@ -206,6 +206,8 @@
</div>
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -83,17 +83,18 @@ import {
isTypingInInput,
isWorkspaceLayoutRoute,
LiveEpgPanelState,
LiveSidebarState,
LiveLayoutSidebarStateService,
persistLiveEpgPanelState,
persistLiveSidebarState,
PORTAL_EXTERNAL_PLAYBACK,
isLiveExternalPlayerSession,
REMOTE_CONTROL_RESET_STATUS,
restoreLiveEpgPanelState,
restoreLiveSidebarState,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
AudioPlayerComponent,
FULLSCREEN_CHANNEL_PANEL,
@@ -200,6 +201,7 @@ function isInsideScrollableRegion(
MatButtonModule,
MatIconModule,
MatTooltipModule,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
SidebarComponent,
@@ -232,6 +234,9 @@ export class VideoPlayerComponent
private readonly storage = inject(StorageMap);
private readonly store = inject(Store);
private readonly epgService = inject(EpgService);
private readonly liveSidebarStateService = inject(
LiveLayoutSidebarStateService
);
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
private readonly workspaceHeaderContext = inject(
@@ -481,12 +486,9 @@ export class VideoPlayerComponent
readonly isLiveEpgPanelCollapsed = computed(
() => this.liveEpgPanelState() === 'collapsed'
);
readonly liveSidebarState = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly isSidebarCollapsed = computed(
() => this.liveSidebarState() === 'collapsed'
);
/** Shared with the workspace header toggle; persisted per surface. */
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('m3u');
/** Channels list */
readonly channels$: Observable<Channel[]> = this.store.select(
@@ -1007,11 +1009,7 @@ export class VideoPlayerComponent
}
toggleSidebar(): void {
const next: LiveSidebarState = this.isSidebarCollapsed()
? 'expanded'
: 'collapsed';
this.liveSidebarState.set(next);
persistLiveSidebarState(next);
this.liveSidebarStateService.toggle('m3u');
}
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
+1
View File
@@ -8,6 +8,7 @@ export * from './lib/components/grid-list/grid-list.component';
export * from './lib/components/playlist-error-view/playlist-error-view.component';
export { ProgressCapsuleComponent } from '@iptvnator/ui/components';
export * from './lib/components/portal-empty-state/portal-empty-state.component';
export * from './lib/components/channel-list-hidden-state/channel-list-hidden-state.component';
export * from './lib/components/search-form/search-form.component';
export * from './lib/components/search-layout/search-layout.component';
export { WatchedBadgeComponent } from '@iptvnator/ui/components';
@@ -0,0 +1,49 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { ChannelListHiddenStateComponent } from './channel-list-hidden-state.component';
describe('ChannelListHiddenStateComponent', () => {
let fixture: ComponentFixture<ChannelListHiddenStateComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ChannelListHiddenStateComponent],
})
.overrideComponent(ChannelListHiddenStateComponent, {
remove: { imports: [TranslatePipe] },
add: {
imports: [MockPipe(TranslatePipe, (key: string) => key)],
},
})
.compileComponents();
fixture = TestBed.createComponent(ChannelListHiddenStateComponent);
fixture.detectChanges();
});
it('explains that the list is hidden instead of asking to pick a channel', () => {
const host: HTMLElement = fixture.nativeElement;
expect(host.querySelector('.empty-state-title')?.textContent).toContain(
'LAYOUT.CHANNELS_LIST_HIDDEN'
);
expect(host.querySelector('.empty-state-hint')?.textContent).toContain(
'LAYOUT.CHANNELS_LIST_HIDDEN_HINT'
);
expect(host.textContent).not.toContain('SELECT_CHANNEL_PLAYBACK');
});
it('offers a full-size restore action that emits once per click', () => {
const restore = jest.fn();
fixture.componentInstance.restore.subscribe(restore);
const button: HTMLButtonElement | null =
fixture.nativeElement.querySelector('button.empty-state-action');
expect(button?.textContent).toContain('LAYOUT.SHOW_CHANNELS_LIST');
button?.click();
expect(restore).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,37 @@
import { ChangeDetectionStrategy, Component, output } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
/**
* Content-area placeholder for a live layout whose channel rail is collapsed
* and nothing is playing. It replaces the "select a channel" empty state,
* which would otherwise ask the user to pick from a list they cannot see,
* and gives the collapsed state a full-size way back next to the 32px
* restore chevron. Every live host (M3U player, Xtream/Stalker live layouts,
* unified live tab) renders this same component so the copy stays identical.
*/
@Component({
selector: 'app-channel-list-hidden-state',
imports: [PortalEmptyStateComponent, TranslatePipe],
template: `
<app-portal-empty-state
icon="view_sidebar"
[message]="'LAYOUT.CHANNELS_LIST_HIDDEN' | translate"
[hint]="'LAYOUT.CHANNELS_LIST_HIDDEN_HINT' | translate"
[actionLabel]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
actionIcon="chevron_right"
(action)="restore.emit()"
/>
`,
styles: `
:host {
display: flex;
flex: 1;
min-height: 0;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ChannelListHiddenStateComponent {
readonly restore = output<void>();
}
@@ -1,4 +1,20 @@
<div class="empty-state-container">
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
<h2 class="empty-state-title">{{ message() }}</h2>
@if (hint(); as hint) {
<p class="empty-state-hint">{{ hint }}</p>
}
@if (actionLabel(); as label) {
<button
type="button"
mat-stroked-button
class="empty-state-action"
(click)="action.emit()"
>
@if (actionIcon(); as actionIcon) {
<mat-icon>{{ actionIcon }}</mat-icon>
}
{{ label }}
</button>
}
</div>
@@ -33,6 +33,27 @@
opacity: 0.6;
}
.empty-state-hint {
margin: 8px 0 0;
max-width: 36ch;
font-size: 0.88rem;
line-height: 1.4;
text-wrap: balance;
opacity: 0.55;
}
// The action is the one element here that must not fade into the backdrop:
// it is the way out of the state, so it keeps full opacity and a real
// hit target while the icon and copy stay muted.
.empty-state-action {
margin-top: 20px;
min-height: 40px;
mat-icon {
margin-right: 6px;
}
}
@keyframes portal-empty-state-fade-in {
from {
opacity: 0;
@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { PortalEmptyStateComponent } from './portal-empty-state.component';
describe('PortalEmptyStateComponent', () => {
let fixture: ComponentFixture<PortalEmptyStateComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [PortalEmptyStateComponent],
}).compileComponents();
fixture = TestBed.createComponent(PortalEmptyStateComponent);
fixture.componentRef.setInput('message', 'Nothing here');
fixture.detectChanges();
});
it('renders only the icon and message by default', () => {
const host: HTMLElement = fixture.nativeElement;
expect(host.querySelector('.empty-state-icon')?.textContent).toBe(
'live_tv'
);
expect(host.querySelector('.empty-state-title')?.textContent).toBe(
'Nothing here'
);
expect(host.querySelector('.empty-state-hint')).toBeNull();
expect(host.querySelector('.empty-state-action')).toBeNull();
});
it('renders the hint and a labelled action that emits on click', () => {
const action = jest.fn();
fixture.componentInstance.action.subscribe(action);
fixture.componentRef.setInput('hint', 'Try the button');
fixture.componentRef.setInput('actionLabel', 'Do it');
fixture.componentRef.setInput('actionIcon', 'chevron_right');
fixture.detectChanges();
const host: HTMLElement = fixture.nativeElement;
const button = host.querySelector<HTMLButtonElement>(
'button.empty-state-action'
);
expect(host.querySelector('.empty-state-hint')?.textContent).toBe(
'Try the button'
);
expect(button?.textContent).toContain('Do it');
expect(button?.querySelector('mat-icon')?.textContent).toBe(
'chevron_right'
);
button?.click();
expect(action).toHaveBeenCalledTimes(1);
});
});
@@ -1,4 +1,10 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import {
ChangeDetectionStrategy,
Component,
input,
output,
} from '@angular/core';
import { MatButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
@Component({
@@ -6,9 +12,15 @@ import { MatIcon } from '@angular/material/icon';
templateUrl: './portal-empty-state.component.html',
styleUrl: './portal-empty-state.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [MatIcon],
imports: [MatButton, MatIcon],
})
export class PortalEmptyStateComponent {
readonly icon = input<string>('live_tv');
readonly message = input.required<string>();
/** One short secondary sentence under the title. */
readonly hint = input<string | null>(null);
/** Renders the primary action button; the host reacts to `action`. */
readonly actionLabel = input<string | null>(null);
readonly actionIcon = input<string | null>(null);
readonly action = output<void>();
}
@@ -191,6 +191,7 @@
(reorderItems)="onReorder($event)"
(itemPlayed)="onItemPlayed($event)"
(autoOpenHandled)="onLiveAutoOpenHandled()"
(restoreSidebarRequested)="toggleSidebar()"
/>
}
}
@@ -20,12 +20,17 @@ import {
ScopeToggleService,
UnifiedCollectionItem,
WorkspaceViewCommandService,
LiveLayoutSidebarStateService,
liveSidebarStateStorageKey,
} from '@iptvnator/portal/shared/util';
import {
UnifiedFavoritesDataService,
UnifiedRecentDataService,
} from '@iptvnator/portal/shared/data-access';
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
import {
selectAllPlaylistsMeta,
selectPlaylistsLoadingFlag,
} from '@iptvnator/m3u-state';
import { RuntimeCapabilitiesService } from '@iptvnator/services';
import { BehaviorSubject } from 'rxjs';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
@@ -53,6 +58,7 @@ class StubUnifiedLiveTabComponent {
readonly reorderItems = output<UnifiedCollectionItem[]>();
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly restoreSidebarRequested = output<void>();
}
@Component({
@@ -507,19 +513,70 @@ describe('UnifiedCollectionPageComponent', () => {
await fixture.whenStable();
expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(false);
expect(
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
).toBe(false);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
liveTab.favoriteToggled.emit(recentItem);
await fixture.whenStable();
expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem);
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
.toBe(true);
expect(
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
).toBe(true);
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
});
it('toggles the collection live rail with Cmd/Ctrl+B only while the live tab is on screen', async () => {
const recentItem = {
uid: 'm3u::playlist-1::https://example.com/one.m3u8',
name: 'Recent One',
contentType: 'live',
sourceType: 'm3u',
playlistId: 'playlist-1',
playlistName: 'Playlist One',
streamUrl: 'https://example.com/one.m3u8',
} satisfies UnifiedCollectionItem;
recentData.getRecentItems.mockResolvedValueOnce([recentItem]);
const sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
sidebarState.setState('collection', 'expanded');
fixture.componentRef.setInput('mode', 'recent');
fixture.detectChanges();
await fixture.whenStable();
await new Promise<void>((resolve) => setTimeout(resolve, 0));
fixture.componentInstance.isLoading.set(false);
fixture.componentInstance.selectedContentType.set('live');
fixture.componentInstance.allItems.set([recentItem]);
fixture.detectChanges();
const press = () => {
const event = new KeyboardEvent('keydown', {
key: 'b',
ctrlKey: true,
cancelable: true,
bubbles: true,
});
document.dispatchEvent(event);
return event;
};
expect(press().defaultPrevented).toBe(true);
expect(sidebarState.isCollapsedFor('collection')()).toBe(true);
expect(sidebarState.isCollapsedFor('m3u')()).toBe(false);
// Not on the movies grid: there is no rail to hide there.
fixture.componentInstance.selectedContentType.set('movie');
fixture.detectChanges();
expect(press().defaultPrevented).toBe(false);
expect(sidebarState.isCollapsedFor('collection')()).toBe(true);
sidebarState.setState('collection', 'expanded');
localStorage.removeItem(liveSidebarStateStorageKey('collection'));
});
it('removes one recent live row through the live tab remove event', async () => {
const recentItems = [
{
@@ -826,9 +883,7 @@ describe('UnifiedCollectionPageComponent', () => {
},
}
);
expect(
fixture.componentInstance.selectedDetailItem()
).toBeNull();
expect(fixture.componentInstance.selectedDetailItem()).toBeNull();
} finally {
router.url = originalUrl;
}
@@ -7,6 +7,8 @@ import {
contentChild,
DestroyRef,
effect,
ElementRef,
HostListener,
inject,
linkedSignal,
input,
@@ -38,6 +40,7 @@ import {
getOpenLiveCollectionItemState,
getUnifiedCollectionNavigation,
isWorkspaceLayoutRoute,
isTypingInInput,
LiveLayoutSidebarStateService,
OPEN_COLLECTION_DETAIL_STATE_KEY,
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
@@ -96,6 +99,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
private readonly router = inject(Router);
private readonly store = inject(Store);
private readonly destroyRef = inject(DestroyRef);
private readonly hostElement = inject(ElementRef<HTMLElement>);
private readonly scopeService = inject(ScopeToggleService);
private readonly favoritesData = inject(UnifiedFavoritesDataService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -276,7 +280,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
this.selectedContentType() === 'live' &&
this.hasLive()
);
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('collection');
readonly showSidebarToggle = computed(
() => this.selectedContentType() === 'live' && this.hasLive()
);
@@ -469,7 +474,30 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
this.liveSidebarStateService.toggle('collection');
}
/**
* Cmd/Ctrl+B mirrors the routed live layouts (M3U, Xtream, Stalker): the
* hidden-list state advertises the shortcut, so the collection live tab
* must honour it too. Only while that tab, and therefore the rail, is on
* screen; the movies/series grids have nothing to hide.
*/
@HostListener('document:keydown', ['$event'])
handleSidebarShortcut(event: KeyboardEvent): void {
if (
this.showSidebarToggle() &&
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === 'b' &&
!isTypingInInput(event) &&
// Behind the workspace's phone context drawer the route content
// is inert; this document-level listener still fires, so it
// opts out itself instead of toggling an obscured rail.
!this.hostElement.nativeElement.closest('[inert]')
) {
event.preventDefault();
this.toggleSidebar();
}
}
setFavSortMode(mode: FavoritesChannelSortMode): void {
@@ -148,6 +148,10 @@
</div>
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state
(restore)="restoreSidebarRequested.emit()"
/>
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -58,6 +58,7 @@ import {
toLiveEpgPanelSummary,
} from './unified-live-epg-summary.util';
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
import { ChannelListHiddenStateComponent } from '../channel-list-hidden-state/channel-list-hidden-state.component';
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
import {
AudioPlayerComponent,
@@ -100,6 +101,7 @@ import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-ses
MatButtonModule,
MatIconModule,
MatProgressSpinnerModule,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
@@ -130,6 +132,8 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
readonly itemPlayed = output<UnifiedCollectionItem>();
readonly autoOpenHandled = output<void>();
readonly isSidebarCollapsed = input(false);
/** The rail is owned by the page header toggle; ask it to expand. */
readonly restoreSidebarRequested = output<void>();
private readonly streamResolver = inject(StreamResolverService);
private readonly recentData = inject(UnifiedRecentDataService);
@@ -0,0 +1,91 @@
import { TestBed } from '@angular/core/testing';
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
import {
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
LIVE_SIDEBAR_SURFACES,
liveSidebarStateStorageKey,
} from './live-sidebar-state';
describe('LiveLayoutSidebarStateService', () => {
function clearStorage(): void {
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
for (const surface of LIVE_SIDEBAR_SURFACES) {
localStorage.removeItem(liveSidebarStateStorageKey(surface));
}
}
function createService(): LiveLayoutSidebarStateService {
TestBed.resetTestingModule();
return TestBed.inject(LiveLayoutSidebarStateService);
}
beforeEach(clearStorage);
afterEach(clearStorage);
it('starts every surface expanded', () => {
const service = createService();
for (const surface of LIVE_SIDEBAR_SURFACES) {
expect(service.stateOf(surface)()).toBe('expanded');
expect(service.isCollapsedFor(surface)()).toBe(false);
}
});
it('keeps surfaces independent: collapsing the M3U rail leaves portals and collections alone', () => {
const service = createService();
service.toggle('m3u');
expect(service.isCollapsedFor('m3u')()).toBe(true);
expect(service.isCollapsedFor('portal')()).toBe(false);
expect(service.isCollapsedFor('collection')()).toBe(false);
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
'collapsed'
);
expect(
localStorage.getItem(liveSidebarStateStorageKey('portal'))
).toBeNull();
});
it('returns the same signal instance per surface so components can hold it', () => {
const service = createService();
expect(service.isCollapsedFor('portal')).toBe(
service.isCollapsedFor('portal')
);
expect(service.isCollapsedFor('portal')).not.toBe(
service.isCollapsedFor('collection')
);
});
it('restores each surface from its own storage key', () => {
localStorage.setItem(liveSidebarStateStorageKey('portal'), 'collapsed');
const service = createService();
expect(service.isCollapsedFor('portal')()).toBe(true);
expect(service.isCollapsedFor('m3u')()).toBe(false);
service.setState('portal', 'expanded');
expect(localStorage.getItem(liveSidebarStateStorageKey('portal'))).toBe(
'expanded'
);
});
it('ignores and removes the legacy shared key instead of inheriting it', () => {
localStorage.setItem(
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
'collapsed'
);
const service = createService();
for (const surface of LIVE_SIDEBAR_SURFACES) {
expect(service.isCollapsedFor(surface)()).toBe(false);
}
expect(
localStorage.getItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY)
).toBeNull();
});
});
@@ -1,31 +1,76 @@
import { computed, Injectable, signal } from '@angular/core';
import {
computed,
Injectable,
Signal,
signal,
WritableSignal,
} from '@angular/core';
import {
forgetLegacyLiveSidebarState,
LiveSidebarState,
LiveSidebarSurface,
liveSidebarStateStorageKey,
persistLiveSidebarState,
restoreLiveSidebarState,
} from './live-sidebar-state';
function bySurface<T>(
create: (surface: LiveSidebarSurface) => T
): Record<LiveSidebarSurface, T> {
return {
m3u: create('m3u'),
portal: create('portal'),
collection: create('collection'),
};
}
/**
* Shared collapse state for the live-TV sidebar across the workspace shell
* categories rail (Xtream/Stalker), the inline channels rail, and the
* unified-collection live tab. A single signal keeps all surfaces in sync
* within a session; localStorage persistence is delegated to the existing
* `live-sidebar-state` helpers so the storage key stays unchanged.
* Single owner of the live-TV sidebar collapse state. Every surface that
* renders a collapsible channel rail — the M3U player, the Xtream/Stalker
* live layouts together with the workspace shell categories rail, the
* unified favorites/recent live tab, and the workspace header toggle — reads
* and writes through this service, so a toggle in one place is reflected
* everywhere the same surface is rendered within the session.
*
* State is kept per surface (`live-sidebar-state:<surface>` in
* localStorage). The pre-split shared key is forgotten on construction.
*/
@Injectable({ providedIn: 'root' })
export class LiveLayoutSidebarStateService {
private readonly _state = signal<LiveSidebarState>(
restoreLiveSidebarState()
);
readonly state = this._state.asReadonly();
readonly isCollapsed = computed(() => this._state() === 'collapsed');
private readonly states: Record<
LiveSidebarSurface,
WritableSignal<LiveSidebarState>
>;
private readonly collapsed: Record<LiveSidebarSurface, Signal<boolean>>;
toggle(): void {
this.setState(this.isCollapsed() ? 'expanded' : 'collapsed');
constructor() {
forgetLegacyLiveSidebarState();
this.states = bySurface((surface) =>
signal(restoreLiveSidebarState(liveSidebarStateStorageKey(surface)))
);
this.collapsed = bySurface((surface) =>
computed(() => this.states[surface]() === 'collapsed')
);
}
setState(state: LiveSidebarState): void {
this._state.set(state);
persistLiveSidebarState(state);
stateOf(surface: LiveSidebarSurface): Signal<LiveSidebarState> {
return this.states[surface].asReadonly();
}
/** Stable per-surface signal; safe to assign to a component field. */
isCollapsedFor(surface: LiveSidebarSurface): Signal<boolean> {
return this.collapsed[surface];
}
toggle(surface: LiveSidebarSurface): void {
this.setState(
surface,
this.collapsed[surface]() ? 'expanded' : 'collapsed'
);
}
setState(surface: LiveSidebarSurface, state: LiveSidebarState): void {
this.states[surface].set(state);
persistLiveSidebarState(state, liveSidebarStateStorageKey(surface));
}
}
@@ -1,15 +1,21 @@
import {
DEFAULT_LIVE_SIDEBAR_STATE,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
forgetLegacyLiveSidebarState,
isLiveSidebarState,
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
LIVE_SIDEBAR_SURFACES,
liveSidebarStateStorageKey,
persistLiveSidebarState,
resolveRouteLiveSidebarSurface,
restoreLiveSidebarState,
} from './live-sidebar-state';
describe('live sidebar state', () => {
const key = liveSidebarStateStorageKey('m3u');
afterEach(() => {
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem('custom-live-sidebar-state');
localStorage.removeItem(key);
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
});
it('accepts only known sidebar states', () => {
@@ -19,32 +25,73 @@ describe('live sidebar state', () => {
expect(isLiveSidebarState(null)).toBe(false);
});
it('restores expanded as the default for missing or invalid storage', () => {
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
it('derives one storage key per surface under the legacy prefix', () => {
const keys = LIVE_SIDEBAR_SURFACES.map(liveSidebarStateStorageKey);
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'hidden');
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
expect(keys).toEqual([
'live-sidebar-state:m3u',
'live-sidebar-state:portal',
'live-sidebar-state:collection',
]);
expect(new Set(keys).size).toBe(keys.length);
expect(keys).not.toContain(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
});
it('restores and persists collapsed state', () => {
persistLiveSidebarState('collapsed');
it('restores expanded as the default for missing or invalid storage', () => {
expect(restoreLiveSidebarState(key)).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe(
localStorage.setItem(key, 'hidden');
expect(restoreLiveSidebarState(key)).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
});
it('restores and persists collapsed state under the given key', () => {
persistLiveSidebarState('collapsed', key);
expect(localStorage.getItem(key)).toBe('collapsed');
expect(restoreLiveSidebarState(key)).toBe('collapsed');
expect(restoreLiveSidebarState(key, 'expanded')).toBe('collapsed');
});
it('never reads the legacy shared key and can forget it', () => {
localStorage.setItem(
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
'collapsed'
);
expect(restoreLiveSidebarState()).toBe('collapsed');
expect(restoreLiveSidebarState(key)).toBe('expanded');
forgetLegacyLiveSidebarState();
expect(
localStorage.getItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY)
).toBeNull();
});
it('supports custom storage keys and fallback values', () => {
expect(
restoreLiveSidebarState('custom-live-sidebar-state', 'collapsed')
).toBe('collapsed');
persistLiveSidebarState('expanded', 'custom-live-sidebar-state');
expect(restoreLiveSidebarState('custom-live-sidebar-state')).toBe(
'expanded'
it('maps only routes that render their own live rail to a surface', () => {
expect(resolveRouteLiveSidebarSurface('playlists', 'all')).toBe('m3u');
expect(resolveRouteLiveSidebarSurface('playlists', 'groups')).toBe(
'm3u'
);
expect(resolveRouteLiveSidebarSurface('xtreams', 'live')).toBe(
'portal'
);
expect(resolveRouteLiveSidebarSurface('stalker', 'itv')).toBe('portal');
expect(resolveRouteLiveSidebarSurface('stalker', 'radio')).toBe(
'portal'
);
// Collection pages own their toggle: only they know whether the live
// tab is on screen.
expect(
resolveRouteLiveSidebarSurface('playlists', 'recent')
).toBeNull();
expect(
resolveRouteLiveSidebarSurface('xtreams', 'favorites')
).toBeNull();
expect(resolveRouteLiveSidebarSurface('xtreams', 'vod')).toBeNull();
expect(resolveRouteLiveSidebarSurface('stalker', 'series')).toBeNull();
expect(resolveRouteLiveSidebarSurface(null, 'live')).toBeNull();
expect(resolveRouteLiveSidebarSurface('xtreams', null)).toBeNull();
});
});
@@ -1,14 +1,48 @@
import type {
PortalProvider,
PortalRailSection,
} from './navigation/portal-rail-links';
export type LiveSidebarState = 'expanded' | 'collapsed';
export const LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
/**
* Collapsible live-channel rails. Each surface remembers its own state:
* hiding the list in the M3U player must not hide the channel rail of an
* Xtream/Stalker portal or of the favorites/recent collection pages — the
* user made that choice in one context, not in all of them.
*/
export type LiveSidebarSurface = 'm3u' | 'portal' | 'collection';
export const LIVE_SIDEBAR_SURFACES: readonly LiveSidebarSurface[] = [
'm3u',
'portal',
'collection',
];
/**
* Key every live surface shared before the per-surface split. It is no longer
* read: a stored `collapsed` left every playlist and portal without a channel
* list and only a 32px chevron to recover it (issue #1458). The state service
* removes it once, so the update itself restores the list for everyone.
*/
export const LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
/** @deprecated The legacy shared key; use `liveSidebarStateStorageKey()`. */
export const LIVE_SIDEBAR_STATE_STORAGE_KEY =
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY;
export const DEFAULT_LIVE_SIDEBAR_STATE: LiveSidebarState = 'expanded';
export function liveSidebarStateStorageKey(
surface: LiveSidebarSurface
): string {
return `${LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY}:${surface}`;
}
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
return value === 'expanded' || value === 'collapsed';
}
export function restoreLiveSidebarState(
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY,
storageKey: string,
fallback: LiveSidebarState = DEFAULT_LIVE_SIDEBAR_STATE
): LiveSidebarState {
const storedValue = localStorage.getItem(storageKey);
@@ -17,7 +51,41 @@ export function restoreLiveSidebarState(
export function persistLiveSidebarState(
state: LiveSidebarState,
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY
storageKey: string
): void {
localStorage.setItem(storageKey, state);
}
export function forgetLegacyLiveSidebarState(): void {
try {
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
} catch {
// Storage unavailable (private mode, blocked site data): nothing to forget.
}
}
/**
* The rail a portal route renders itself, for a toggle that lives outside the
* rail (the workspace header). Favorites/recent routes return `null` on
* purpose: the collection page owns that toggle, because only it knows
* whether the live tab, and therefore the rail, is on screen.
*/
export function resolveRouteLiveSidebarSurface(
provider: PortalProvider | null | undefined,
section: PortalRailSection | null | undefined
): LiveSidebarSurface | null {
if (!provider || !section) {
return null;
}
switch (provider) {
case 'playlists':
return section === 'all' || section === 'groups' ? 'm3u' : null;
case 'xtreams':
return section === 'live' ? 'portal' : null;
case 'stalker':
return section === 'itv' || section === 'radio' ? 'portal' : null;
default:
return null;
}
}
@@ -209,7 +209,7 @@ describe('StalkerLiveNavigation', () => {
);
expect(store.selectedCategoryId()).toBe('news');
expect(store.searchPhrase()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('expanded');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(revealRow).toHaveBeenCalledWith('1');
expect(play).not.toHaveBeenCalled();
expect(navigation.canReveal()).toBe(false);
@@ -266,7 +266,7 @@ export class StalkerLiveNavigation {
this.options.store.setSearchPhrase('');
this.options.store.setSelectedCategory(category);
this.options.store.setPage(0);
this.options.sidebar.setState('expanded');
this.options.sidebar.setState('portal', 'expanded');
this.revealed.set({ owner, category, item: active });
this.pendingReveal.set({
owner,
@@ -142,6 +142,8 @@
<ng-container *ngTemplateOutlet="epgPanelContent" />
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -84,7 +84,10 @@ import {
REMOTE_CONTROL_RESET_STATUS,
restoreLiveEpgPanelState,
} from '@iptvnator/portal/shared/util';
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
import {
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
ACTIVE_EPG_FALLBACK_SIZE,
StalkerFavoriteItem,
@@ -152,6 +155,7 @@ import { StalkerLiveNavigation } from './stalker-live-navigation';
MatProgressSpinnerModule,
MatTooltipModule,
NgTemplateOutlet,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
StalkerItvAllItemsComponent,
@@ -484,7 +488,8 @@ export class StalkerLiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(this.currentProgram())
);
@@ -1155,7 +1160,7 @@ export class StalkerLiveStreamLayoutComponent
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
this.liveSidebarStateService.toggle('portal');
}
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
@@ -165,7 +165,10 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
useValue: {
isCollapsedFor: () => signal(false),
toggle: jest.fn(),
},
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
@@ -141,7 +141,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
useValue: {
isCollapsedFor: () => signal(false),
toggle: jest.fn(),
},
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
@@ -138,7 +138,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
},
{
provide: LiveLayoutSidebarStateService,
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
useValue: {
isCollapsedFor: () => signal(false),
toggle: jest.fn(),
},
},
{ provide: EpgRuntimeBridgeService, useValue: {} },
{ provide: MatDialog, useValue: { open: jest.fn() } },
@@ -170,6 +170,8 @@
/>
</div>
}
} @else if (isSidebarCollapsed()) {
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
} @else {
<app-portal-empty-state
icon="live_tv"
@@ -13,7 +13,7 @@ import { TranslatePipe } from '@ngx-translate/core';
import { BehaviorSubject, Subject, of } from 'rxjs';
import {
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
LIVE_SIDEBAR_STATE_STORAGE_KEY,
liveSidebarStateStorageKey,
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
ResizableDirective,
@@ -222,7 +222,7 @@ describe('LiveStreamLayoutComponent', () => {
settingsStore.resolvedEpgViewMode.set('timeline');
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
settingsStore.openStreamOnDoubleClick.set(false);
window.electron = {
@@ -345,16 +345,16 @@ describe('LiveStreamLayoutComponent', () => {
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
});
afterEach(() => {
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
fixture.destroy();
jest.useRealTimers();
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
window.electron = originalElectron;
});
@@ -1149,9 +1149,13 @@ describe('LiveStreamLayoutComponent', () => {
);
});
// Collapsing with a selected category and no stream renders the real
// `app-channel-list-hidden-state` (its TranslatePipe needs a
// TranslateService); this spec is at the max-lines budget, so that branch
// is covered by video-player-sidebar.spec.ts and the Electron E2E instead.
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
selectedCategoryId.set(null);
TestBed.inject(LiveLayoutSidebarStateService).setState('collapsed');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'collapsed');
fixture.detectChanges();
expect(
@@ -1161,7 +1165,7 @@ describe('LiveStreamLayoutComponent', () => {
it('hides the floating restore button when the sidebar is expanded', () => {
selectedCategoryId.set(1);
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
fixture.detectChanges();
expect(
@@ -26,6 +26,7 @@ import { ResizableDirective } from '@iptvnator/ui/components';
import {
GridListComponent,
InfiniteScrollDirective,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
} from '@iptvnator/portal/shared/ui';
import {
@@ -127,6 +128,7 @@ const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
GridListComponent,
InfiniteScrollDirective,
PortalChannelsListComponent,
ChannelListHiddenStateComponent,
PortalEmptyStateComponent,
ResizableDirective,
TranslatePipe,
@@ -320,7 +322,8 @@ export class LiveStreamLayoutComponent
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
readonly isSidebarCollapsed =
this.liveSidebarStateService.isCollapsedFor('portal');
readonly liveEpgPanelSummary = computed(() =>
this.toLiveEpgPanelSummary(
this.activeCatchupProgram() ?? this.currentEpgItem()
@@ -623,7 +626,7 @@ export class LiveStreamLayoutComponent
}
toggleSidebar(): void {
this.liveSidebarStateService.toggle();
this.liveSidebarStateService.toggle('portal');
}
@HostListener('document:keydown', ['$event'])
@@ -187,7 +187,7 @@ describe('XtreamLiveChannelNavigationService', () => {
);
expect(selectedCategoryId()).toBe(10);
expect(categorySearchTerm()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('expanded');
expect(sidebar.setState).toHaveBeenCalledWith('portal', 'expanded');
expect(service.revealRequest()).toEqual(
expect.objectContaining({ channelId: 1 })
);
@@ -232,7 +232,7 @@ export class XtreamLiveChannelNavigationService {
return;
this.store.setCategorySearchTerm('');
this.store.setSelectedCategory(categoryId);
this.sidebar.setState('expanded');
this.sidebar.setState('portal', 'expanded');
this.pendingReveal.set({
channelId: target.xtream_id,
categoryId,
@@ -58,7 +58,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
let liveSidebarService: LiveLayoutSidebarStateService;
beforeEach(async () => {
localStorage.removeItem('live-sidebar-state');
localStorage.removeItem('live-sidebar-state:portal');
await TestBed.configureTestingModule({
imports: [WorkspaceShellContextSidebarComponent],
@@ -99,7 +99,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
fixture = TestBed.createComponent(WorkspaceShellContextSidebarComponent);
liveSidebarService = TestBed.inject(LiveLayoutSidebarStateService);
liveSidebarService.setState('expanded');
liveSidebarService.setState('portal', 'expanded');
});
it('closes the phone drawer from its always-available close button', () => {
@@ -170,7 +170,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('collapses the categories rail when the shared service is collapsed on a live route', () => {
setupLiveCategory('live');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -184,7 +184,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('also collapses on the Stalker itv section', () => {
setupLiveCategory('itv');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -198,7 +198,7 @@ describe('WorkspaceShellContextSidebarComponent', () => {
it('does not collapse the categories rail on non-live sections', () => {
setupLiveCategory('vod');
liveSidebarService.setState('collapsed');
liveSidebarService.setState('portal', 'collapsed');
fixture.detectChanges();
const aside = fixture.nativeElement.querySelector(
@@ -70,7 +70,7 @@ export class WorkspaceShellContextSidebarComponent {
readonly isContextPanelCollapsed = computed(
() =>
this.isLiveCategoryRoute() &&
this.liveSidebarStateService.isCollapsed()
this.liveSidebarStateService.isCollapsedFor('portal')()
);
closeDrawer(): void {
@@ -115,6 +115,22 @@
<mat-icon>add</mat-icon>
</button>
@if (!isSettingsRoute()) {
@if (headerSidebarToggle(); as sidebarToggle) {
<button
type="button"
class="header-shortcut header-sidebar-toggle"
[class.header-sidebar-toggle--collapsed]="
!sidebarToggle.expanded
"
mat-icon-button
(click)="onHeaderSidebarToggleRequested()"
[matTooltip]="sidebarToggle.tooltip"
[attr.aria-label]="sidebarToggle.ariaLabel"
[attr.aria-pressed]="sidebarToggle.expanded"
>
<mat-icon>view_sidebar</mat-icon>
</button>
}
@if (headerShortcut(); as action) {
<button
type="button"
@@ -209,6 +209,12 @@
}
}
// The rail is visible most of the time, so the toggle stays neutral then and
// only tints once the rail is hidden: the exception is what deserves the cue.
.header-sidebar-toggle--collapsed {
color: var(--mat-sys-primary);
}
.header-actions--leading {
margin-left: -4px;
}
@@ -392,5 +398,12 @@
.shortcuts-trigger {
display: none;
}
// At the phone breakpoint the live rail is a bottom drawer with its
// own toggle, and the header has no room left for another permanent
// icon: it already holds the drawer toggle, switcher, search and Add.
.header-sidebar-toggle {
display: none;
}
}
}
@@ -158,6 +158,71 @@ describe('WorkspaceShellHeaderComponent', () => {
expect(requested).toHaveBeenCalledTimes(1);
});
it('does not render the live rail toggle when the route has no rail', () => {
expect(
fixture.nativeElement.querySelector('.header-sidebar-toggle')
).toBeNull();
});
it('renders the live rail toggle in both rail states and emits toggle requests', () => {
const requested = jest.fn();
component.headerSidebarToggleRequested.subscribe(requested);
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: true,
tooltip: 'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP',
ariaLabel: 'LAYOUT.HIDE_CHANNELS_LIST',
});
fixture.detectChanges();
const button: HTMLButtonElement = fixture.nativeElement.querySelector(
'.header-sidebar-toggle'
);
expect(button.getAttribute('aria-pressed')).toBe('true');
expect(button.getAttribute('aria-label')).toBe(
'LAYOUT.HIDE_CHANNELS_LIST'
);
expect(button.classList).not.toContain(
'header-sidebar-toggle--collapsed'
);
button.click();
expect(requested).toHaveBeenCalledTimes(1);
// The control stays in place once the rail is hidden; only its
// pressed state, label and tint change.
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: false,
tooltip: 'LAYOUT.TOGGLE_SIDEBAR_TOOLTIP',
ariaLabel: 'LAYOUT.SHOW_CHANNELS_LIST',
});
fixture.detectChanges();
const collapsedButton: HTMLButtonElement =
fixture.nativeElement.querySelector('.header-sidebar-toggle');
expect(collapsedButton).toBe(button);
expect(collapsedButton.getAttribute('aria-pressed')).toBe('false');
expect(collapsedButton.getAttribute('aria-label')).toBe(
'LAYOUT.SHOW_CHANNELS_LIST'
);
expect(collapsedButton.classList).toContain(
'header-sidebar-toggle--collapsed'
);
});
it('hides the live rail toggle on the settings route', () => {
fixture.componentRef.setInput('headerSidebarToggle', {
expanded: true,
tooltip: 'tooltip',
ariaLabel: 'aria',
});
fixture.componentRef.setInput('isSettingsRoute', true);
fixture.detectChanges();
expect(
fixture.nativeElement.querySelector('.header-sidebar-toggle')
).toBeNull();
});
it('renders scope and status chips when search metadata is provided', () => {
fixture.componentRef.setInput('searchScopeLabel', 'Movies / All Items');
fixture.componentRef.setInput(
@@ -14,7 +14,10 @@ import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import { WorkspaceHeaderBulkAction } from '../../services/helpers/workspace-shell-constants';
import {
WorkspaceHeaderBulkAction,
WorkspaceHeaderSidebarToggle,
} from '../../services/helpers/workspace-shell-constants';
@Component({
selector: 'app-workspace-shell-header',
@@ -54,6 +57,9 @@ export class WorkspaceShellHeaderComponent {
readonly searchStatusLabel = input('');
readonly headerShortcut = input<WorkspaceHeaderAction | null>(null);
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<WorkspaceHeaderSidebarToggle | null>(
null
);
readonly canRefreshPlaylist = input(false);
readonly isRefreshingPlaylist = input(false);
readonly isElectron = input(false);
@@ -101,6 +107,7 @@ export class WorkspaceShellHeaderComponent {
readonly addPlaylistRequested = output<void>();
readonly headerShortcutRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly playlistInfoRequested = output<void>();
@@ -167,6 +174,10 @@ export class WorkspaceShellHeaderComponent {
this.headerBulkActionRequested.emit();
}
onHeaderSidebarToggleRequested(): void {
this.headerSidebarToggleRequested.emit();
}
onRefreshPlaylistRequested(): void {
this.refreshPlaylistRequested.emit();
}
@@ -43,3 +43,15 @@ export interface WorkspaceHeaderBulkAction {
ariaLabel: string;
disabled: boolean;
}
/**
* Header toggle for the live-channel rail of the current route. It exists in
* both rail states so the control never moves: inside the rail the chevron
* disappears together with the rail it hides.
*/
export interface WorkspaceHeaderSidebarToggle {
/** True while the rail is visible; drives `aria-pressed`. */
expanded: boolean;
tooltip: string;
ariaLabel: string;
}
@@ -10,6 +10,8 @@ import {
PlaylistRefreshActionService,
} from '@iptvnator/playlist/shared/ui';
import {
LiveLayoutSidebarStateService,
resolveRouteLiveSidebarSurface,
WorkspaceHeaderAction,
WorkspaceHeaderContextService,
} from '@iptvnator/portal/shared/util';
@@ -30,6 +32,7 @@ import {
CLEAR_RECENTLY_VIEWED_ARIA,
CLEAR_RECENTLY_VIEWED_TOOLTIP,
WorkspaceHeaderBulkAction,
WorkspaceHeaderSidebarToggle,
} from './helpers/workspace-shell-constants';
import { bumpRefreshQueryParam } from './helpers/workspace-shell-route-utils';
import { WorkspaceShellRouteStateService } from './workspace-shell-route-state.service';
@@ -48,6 +51,7 @@ export class WorkspaceShellHeaderService {
private readonly playlistRefreshAction = inject(
PlaylistRefreshActionService
);
private readonly liveSidebar = inject(LiveLayoutSidebarStateService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -75,6 +79,38 @@ export class WorkspaceShellHeaderService {
return action;
});
/**
* The live rail of the current route, when the route renders one itself.
* The header toggle needs a rail that exists in both states; collection
* pages keep their own toggle beside the live/movies/series switch.
*/
private readonly liveSidebarSurface = computed(() =>
resolveRouteLiveSidebarSurface(
this.routeState.currentContext()?.provider,
this.routeState.currentSection()
)
);
readonly headerSidebarToggle = computed<WorkspaceHeaderSidebarToggle | null>(
() => {
this.languageTick();
const surface = this.liveSidebarSurface();
if (!surface) {
return null;
}
const collapsed = this.liveSidebar.isCollapsedFor(surface)();
return {
expanded: !collapsed,
tooltip: this.translateText('LAYOUT.TOGGLE_SIDEBAR_TOOLTIP'),
ariaLabel: this.translateText(
collapsed
? 'LAYOUT.SHOW_CHANNELS_LIST'
: 'LAYOUT.HIDE_CHANNELS_LIST'
),
};
}
);
readonly canOpenPlaylistInfo = computed(() =>
Boolean(this.routeState.activePlaylist())
);
@@ -219,6 +255,13 @@ export class WorkspaceShellHeaderService {
}
}
toggleLiveSidebar(): void {
const surface = this.liveSidebarSurface();
if (surface) {
this.liveSidebar.toggle(surface);
}
}
navigateToGlobalFavorites(): void {
void this.router.navigate(['/workspace/global-favorites']);
}
@@ -127,6 +127,7 @@ export class WorkspaceShellFacade {
readonly canRefreshPlaylist = this.header.canRefreshPlaylist;
readonly isRefreshingPlaylist = this.header.isRefreshingPlaylist;
readonly headerBulkAction = this.header.headerBulkAction;
readonly headerSidebarToggle = this.header.headerSidebarToggle;
readonly playlistSubtitle = this.header.playlistSubtitle;
readonly activeDownloadsCount = computed(() =>
this.supportsDownloads ? this.downloadsService.activeCount() : 0
@@ -205,6 +206,10 @@ export class WorkspaceShellFacade {
return this.header.runHeaderBulkAction();
}
toggleLiveSidebar(): void {
this.header.toggleLiveSidebar();
}
navigateToGlobalFavorites(): void {
this.header.navigateToGlobalFavorites();
}
@@ -39,6 +39,7 @@
[searchStatusLabel]="facade.searchStatusLabel()"
[headerShortcut]="facade.headerShortcut()"
[headerBulkAction]="facade.headerBulkAction()"
[headerSidebarToggle]="facade.headerSidebarToggle()"
[canRefreshPlaylist]="facade.canRefreshPlaylist()"
[isRefreshingPlaylist]="facade.isRefreshingPlaylist()"
[isElectron]="facade.isElectron"
@@ -59,6 +60,7 @@
(addPlaylistRequested)="facade.openAddPlaylistDialog()"
(headerShortcutRequested)="facade.runHeaderShortcut()"
(headerBulkActionRequested)="facade.runHeaderBulkAction()"
(headerSidebarToggleRequested)="facade.toggleLiveSidebar()"
(downloadsRequested)="facade.openDownloadsShortcut()"
(playlistInfoRequested)="facade.openPlaylistInfo()"
(accountInfoRequested)="facade.openAccountInfo()"
@@ -64,6 +64,7 @@ class MockWorkspaceShellHeaderComponent {
readonly contextDrawerToggleAriaKey = input('');
readonly contextDrawerTooltipKey = input('');
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<unknown>(null);
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -73,6 +74,7 @@ class MockWorkspaceShellHeaderComponent {
readonly refreshPlaylistRequested = output<void>();
readonly downloadsRequested = output<void>();
readonly headerBulkActionRequested = output<void>();
readonly headerSidebarToggleRequested = output<void>();
readonly playlistInfoRequested = output<void>();
readonly accountInfoRequested = output<void>();
readonly contextDrawerToggleRequested = output<void>();
@@ -162,6 +164,8 @@ class MockWorkspaceShellFacade {
readonly isDownloadsView = signal(false);
readonly activeDownloadsCount = signal(3);
readonly headerBulkAction = signal<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = signal(null);
toggleLiveSidebar = jest.fn();
readonly showContextPanel = signal(true);
readonly hasContextPanelContent = signal(true);
readonly contextDrawerLabelKeys = signal({