From 61b06b9f31706e05c46a519a08f5f93cd8f15237 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:22:07 +0200 Subject: [PATCH 1/4] fix(portals): preserve live channel navigation while browsing (#1554) * fix(portals): preserve live channel navigation while browsing * test(portals): await media source assertion in remote E2E * fix(xtream): capture destination queue for live auto-open --- .changes/portals-live-channel-return.md | 7 + AGENTS.md | 10 + CLAUDE.md | 10 + .../src/remote-control.e2e.ts | 250 ++++++++++++- .../src/stalker-category-search.fixture.ts | 72 +++- apps/web-e2e/src/stalker.e2e.ts | 61 +--- apps/web/src/assets/i18n/ar.json | 1 + apps/web/src/assets/i18n/ary.json | 1 + apps/web/src/assets/i18n/by.json | 1 + apps/web/src/assets/i18n/de.json | 1 + apps/web/src/assets/i18n/el.json | 1 + apps/web/src/assets/i18n/en.json | 1 + apps/web/src/assets/i18n/es.json | 1 + apps/web/src/assets/i18n/fr.json | 1 + apps/web/src/assets/i18n/hu.json | 1 + apps/web/src/assets/i18n/it.json | 1 + apps/web/src/assets/i18n/ja.json | 1 + apps/web/src/assets/i18n/ko.json | 1 + apps/web/src/assets/i18n/nl.json | 1 + apps/web/src/assets/i18n/pl.json | 1 + apps/web/src/assets/i18n/pt.json | 1 + apps/web/src/assets/i18n/ru.json | 1 + apps/web/src/assets/i18n/tr.json | 1 + apps/web/src/assets/i18n/zh.json | 1 + apps/web/src/assets/i18n/zhtw.json | 1 + docs/architecture/remote-control.md | 38 +- docs/architecture/stalker-portal.md | 7 +- docs/architecture/workspace-shell.md | 5 + .../plans/2026-09-06-live-channel-return.md | 62 ++++ libs/portal/shared/data-access/src/index.ts | 1 + .../lib/live-channel-playback-queue.spec.ts | 90 +++++ .../src/lib/live-channel-playback-queue.ts | 79 ++++ .../stalker-live-navigation.spec.ts | 273 ++++++++++++++ .../stalker-live-navigation.ts | 344 ++++++++++++++++++ .../stalker-live-stream-layout.component.html | 22 +- .../stalker-live-stream-layout.component.ts | 108 ++++-- ...r-live-stream-layout.remote-status.spec.ts | 63 +++- ...e-stream-auto-open-queue.component.spec.ts | 184 ++++++++++ .../live-stream-layout.component.html | 23 +- .../live-stream-layout.component.spec.ts | 29 +- .../live-stream-layout.component.ts | 54 +-- ...am-live-channel-navigation.service.spec.ts | 282 ++++++++++++++ .../xtream-live-channel-navigation.service.ts | 245 +++++++++++++ .../portal-channels-list.component.spec.ts | 35 ++ .../portal-channels-list.component.ts | 35 ++ 45 files changed, 2267 insertions(+), 141 deletions(-) create mode 100644 .changes/portals-live-channel-return.md create mode 100644 docs/superpowers/plans/2026-09-06-live-channel-return.md create mode 100644 libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts create mode 100644 libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/live-stream-auto-open-queue.component.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.spec.ts create mode 100644 libs/portal/xtream/feature/src/lib/live-stream-layout/xtream-live-channel-navigation.service.ts diff --git a/.changes/portals-live-channel-return.md b/.changes/portals-live-channel-return.md new file mode 100644 index 000000000..a5e093a3e --- /dev/null +++ b/.changes/portals-live-channel-return.md @@ -0,0 +1,7 @@ +--- +type: fix +area: portals +issues: [1520] +--- + +Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior. diff --git a/AGENTS.md b/AGENTS.md index e00bab066..4177fc024 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## Live Channel Return + +Xtream and Stalker (including radio) capture displayed playback order on explicit +selection. Remote up/down, numbers and status use that queue while browsing +categories or search. Stalker commits after successful current URL resolution +and extends only loaded pages of the original scope. The conditional channel +header action clears search, returns to the accessible playing category and +focuses its row without changing playback. Contract: +`docs/architecture/remote-control.md` (Live channel return and playback order). + ## Stalker Live Search ITV sidebar and fullscreen searches independently filter the complete selected diff --git a/CLAUDE.md b/CLAUDE.md index c63701691..299ff6ab8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1725,6 +1725,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## Live Channel Return + +Xtream and Stalker (including radio) capture displayed playback order on explicit +selection. Remote up/down, numbers and status use that queue while browsing +categories or search. Stalker commits after successful current URL resolution +and extends only loaded pages of the original scope. The conditional channel +header action clears search, returns to the accessible playing category and +focuses its row without changing playback. Contract: +`docs/architecture/remote-control.md` (Live channel return and playback order). + ## Stalker Live Search ITV sidebar and fullscreen searches independently filter the complete selected diff --git a/apps/electron-backend-e2e/src/remote-control.e2e.ts b/apps/electron-backend-e2e/src/remote-control.e2e.ts index 7fda35064..82d079cfe 100644 --- a/apps/electron-backend-e2e/src/remote-control.e2e.ts +++ b/apps/electron-backend-e2e/src/remote-control.e2e.ts @@ -2,23 +2,30 @@ import { APIRequestContext, Page } from '@playwright/test'; import { AddressInfo, createServer as createNetServer } from 'net'; import { + addStalkerPortal, + addXtreamPortal, channelItemByTitle, closeElectronApp, enableRemoteControl, expect, + fillWorkspaceSearch, goToDashboard, importM3uPlaylistFromNativeDialog, launchElectronApp, openSettings, openSettingsSection, saveSettings, + resetMockServers, test, waitForM3uCatalog, + waitForStalkerCatalog, + waitForXtreamCatalog, writeTemporaryM3uFile, } from './electron-test-fixtures'; type RemoteControlStatus = { channelName?: string; + channelNumber?: number; isLiveView: boolean; muted?: boolean; portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; @@ -27,6 +34,235 @@ type RemoteControlStatus = { }; test.describe('Electron Remote Control', () => { + for (const mode of ['xtream', 'stalker', 'radio'] as const) { + test(`@remote-control @electron ${mode} keeps playback order while browsing and reveals the playing channel`, async ({ + dataDir, + request, + }) => { + test.setTimeout(120000); + const provider = mode === 'xtream' ? 'xtream' : 'stalker'; + await resetMockServers(request, [provider]); + const remotePort = await reserveFreePort(); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await openSettings(page); + await openSettingsSection(page, 'playback'); + await selectSettingsOption( + page, + 'select-video-player', + 'artplayer' + ); + await enableRemoteControl(page, remotePort); + await saveSettings(page); + await waitForRemoteControlServer(request, remotePort); + await goToDashboard(page); + if (provider === 'xtream') { + await addXtreamPortal(page); + await waitForXtreamCatalog(page); + } else { + await addStalkerPortal(page); + await waitForStalkerCatalog(page); + } + await page + .getByRole('link', { + name: mode === 'radio' ? 'Radio' : 'Live TV', + exact: true, + }) + .click(); + const categories = page.locator( + '.context-panel .category-item:not([data-category-id="*"])' + ); + await expect(categories.nth(1)).toBeVisible(); + const originalCategory = categories.first(); + const browsedCategory = categories.nth(1); + await originalCategory.click(); + const rows = page + .locator('#live-channels') + .getByTestId('channel-item'); + await expect(rows.nth(2)).toBeVisible(); + if (provider === 'xtream') { + await page + .getByRole('button', { + name: 'Sort channels', + exact: true, + }) + .click(); + await page + .getByRole('menuitem', { name: 'Name Z-A' }) + .click(); + } + const titles = await rows + .locator('.channel-name') + .allTextContents(); + const [first, second, third] = titles.map((title) => + title.trim() + ); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + await rows.first().click(); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.portal === provider && + status.channelName === first && + status.channelNumber === 1 + ); + await browsedCategory.click(); + await expect(browsedCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + if (provider === 'xtream') { + await page + .getByRole('button', { + name: 'Sort channels', + exact: true, + }) + .click(); + await page + .getByRole('menuitem', { name: 'Name A-Z' }) + .click(); + } + await fillWorkspaceSearch(page, '__no_playing_channel__'); + await page.waitForURL(/q=__no_playing_channel__/); + await expect(rows).toHaveCount(0); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.portal === provider && + status.channelName === first && + status.channelNumber === 1 + ); + await postRemoteCommand( + request, + remotePort, + '/channel/select-number', + { number: 2 } + ); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === second && + status.channelNumber === 2 + ); + await expect(browsedCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + await postRemoteCommand(request, remotePort, '/channel/down'); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === third && + status.channelNumber === 3 + ); + await postRemoteCommand(request, remotePort, '/channel/up'); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === second && + status.channelNumber === 2 + ); + await expect(browsedCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + const media = page + .locator( + mode === 'radio' + ? 'app-audio-player audio' + : 'app-web-player-view video' + ) + .first(); + await expect(media).toBeAttached(); + const originalMedia = await media.elementHandle(); + const originalSource = await media.evaluate( + (element: HTMLMediaElement) => element.src + ); + const reveal = page.getByRole('button', { + name: 'Show playing channel', + exact: true, + }); + await expect(reveal).toBeVisible(); + for (const theme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: theme }); + if (theme === 'dark') { + await expect(page.locator('body')).toHaveClass( + /dark-theme/ + ); + } else { + await expect(page.locator('body')).not.toHaveClass( + /dark-theme/ + ); + } + await page.screenshot({ + path: test + .info() + .outputPath(`${mode}-show-playing-${theme}.png`), + }); + } + await reveal.click(); + await expect(originalCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + await expect( + page.locator( + 'app-workspace-shell-header input[type="search"]' + ) + ).toHaveValue(''); + await expect(page.locator('#live-channels')).toBeFocused(); + const activeRow = channelItemByTitle(page, second).first(); + await expect(activeRow).toBeVisible(); + await expect(activeRow).toHaveClass(/active/); + await expect(reveal).toHaveCount(0); + expect( + await media.evaluate( + (element, previous) => element === previous, + originalMedia + ) + ).toBe(true); + await expect(media).toHaveJSProperty('src', originalSource); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === second && + status.channelNumber === 2 + ); + await page.screenshot({ + path: test.info().outputPath(`${mode}-revealed-dark.png`), + }); + // Category-only root browsing does not change the URL; a + // router no-op must still reveal without restarting playback. + await browsedCategory.click(); + await expect(reveal).toBeVisible(); + await reveal.click(); + await expect(originalCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + await expect(activeRow).toBeVisible(); + expect( + await media.evaluate( + (element, previous) => element === previous, + originalMedia + ) + ).toBe(true); + await originalMedia?.dispose(); + } finally { + await closeElectronApp(app); + } + }); + } + test('@remote-control @m3u @electron applies remote volume commands to the selected built-in video player', async ({ dataDir, request, @@ -247,10 +483,11 @@ async function getRemoteStatus( async function postRemoteCommand( request: APIRequestContext, port: number, - path: string + path: string, + data: Record = {} ): Promise { const response = await request.post(remoteControlUrl(port, path), { - data: {}, + data, }); expect(response.ok()).toBe(true); @@ -261,9 +498,12 @@ function remoteControlUrl(port: number, path: string): string { } async function readMediaVolume(page: Page, selector: string): Promise { - return page.locator(selector).first().evaluate((element) => { - return Number((element as HTMLMediaElement).volume.toFixed(2)); - }); + return page + .locator(selector) + .first() + .evaluate((element) => { + return Number((element as HTMLMediaElement).volume.toFixed(2)); + }); } function roundVolume(volume: number | undefined): number | null { diff --git a/apps/web-e2e/src/stalker-category-search.fixture.ts b/apps/web-e2e/src/stalker-category-search.fixture.ts index 166380797..1b085a068 100644 --- a/apps/web-e2e/src/stalker-category-search.fixture.ts +++ b/apps/web-e2e/src/stalker-category-search.fixture.ts @@ -56,7 +56,9 @@ export async function verifyStalkerCategorySearch(page: Page): Promise { expect(await playerNode?.evaluate((element) => element.isConnected)).toBe( true ); - expect(await videoNode?.evaluate((element) => element.isConnected)).toBe(true); + expect(await videoNode?.evaluate((element) => element.isConnected)).toBe( + true + ); await sidebarSearch.fill(''); await sidebarSearch.press('Enter'); @@ -144,3 +146,71 @@ async function verifyStalkerPanelCategory( await expect(search).toHaveValue(names[0].trim()); await page.evaluate(() => document.exitFullscreen()); } + +/** A category change and return retain the exact live media element (#1520). */ +export async function verifyStalkerPlaybackCategoryReturn( + page: Page +): Promise { + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const sidebar = page.locator('app-stalker-live-stream-layout .sidebar'); + const sidebarTitle = sidebar.locator('.category-title'); + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + const scrollPane = sidebar.locator('#live-channels'); + await categories.nth(1).focus(); + await page.keyboard.press('ArrowRight'); + await expect(scrollPane).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(categories.nth(1)).toBeFocused(); + const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; + expect(firstCategoryTitle).not.toBe(''); + + await channels.first().click(); + await expect(scrollPane).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect + .poll(() => scrollPane.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); + const player = page.locator('app-web-player-view'); + await expect(player).toBeVisible({ timeout: 20_000 }); + + const media = await player.locator('video').first().elementHandle(); + expect(media).not.toBeNull(); + const activeName = await channels + .first() + .locator('.channel-name') + .textContent(); + await categories.nth(2).click(); + + // The sidebar re-filters to the new category (proves the click landed and + // change detection ran)… + await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, { + timeout: 20_000, + }); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + // …while the channel picked from the previous category keeps playing. + await expect(player).toBeVisible(); + const reveal = page.getByRole('button', { + name: 'Show playing channel', + exact: true, + }); + await reveal.click(); + await expect(sidebarTitle).toHaveText(firstCategoryTitle); + await expect(scrollPane).toBeFocused(); + await expect(sidebar.locator('.active')).toContainText(activeName ?? ''); + expect( + await media?.evaluate( + (video) => + video === document.querySelector('app-web-player-view video') + ) + ).toBe(true); + await expect(reveal).toHaveCount(0); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 2cc2c0609..651b43c25 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -2,6 +2,7 @@ import { type APIRequestContext, type Page } from '@playwright/test'; import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers'; import { verifyStalkerCategorySearch, + verifyStalkerPlaybackCategoryReturn, verifyUncachedStalkerSearch, } from './stalker-category-search.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; @@ -467,53 +468,9 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => { }); test('@stalker ITV playback survives a category switch', async ({ page }) => { - // Regression: the shell context panel used to clear the selected Stalker - // item on every category click, tearing down the player for a channel the - // user never switched away from. Xtream live (#936) and M3U groups keep - // playing across a category/group switch; Stalker must too. await addStalkerPortal(page); - - await page.getByRole('link', { name: /live|itv/i }).click(); - await page.waitForURL(/stalker.*itv/); - - const categories = page.locator('.category-item'); - await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - - const sidebar = page.locator('app-stalker-live-stream-layout .sidebar'); - const sidebarTitle = sidebar.locator('.category-title'); - const channels = page.locator('[data-test-id="channel-item"]'); - await expect(channels.first()).toBeVisible({ timeout: 20_000 }); - const scrollPane = sidebar.locator('#live-channels'); - await categories.nth(1).focus(); - await page.keyboard.press('ArrowRight'); - await expect(scrollPane).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(categories.nth(1)).toBeFocused(); - const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; - expect(firstCategoryTitle).not.toBe(''); - - await channels.first().click(); - await expect(scrollPane).toBeFocused(); - await page.keyboard.press('PageDown'); - await expect - .poll(() => scrollPane.evaluate((el) => el.scrollTop)) - .toBeGreaterThan(0); - - await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); - const player = page.locator('app-web-player-view'); - await expect(player).toBeVisible({ timeout: 20_000 }); - - await categories.nth(2).click(); - - // The sidebar re-filters to the new category (proves the click landed and - // change detection ran)… - await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, { - timeout: 20_000, - }); - await expect(channels.first()).toBeVisible({ timeout: 20_000 }); - // …while the channel picked from the previous category keeps playing. - await expect(player).toBeVisible(); + await verifyStalkerPlaybackCategoryReturn(page); + await expect(page.locator('app-web-player-view')).toBeVisible(); }); test('@stalker radio — stations use the inline audio player without EPG', async ({ @@ -707,7 +664,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover await categories.nth(1).click(); - const channels = page.locator('#live-channels [data-test-id="channel-item"]'); + const channels = page.locator( + '#live-channels [data-test-id="channel-item"]' + ); await expect(channels.first()).toBeVisible({ timeout: 20_000 }); // Regression for "search only finds the first 14 loaded items": once the @@ -717,7 +676,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover timeout: 20_000, }); await expect.poll(() => allChannelsRequests.length).toBeGreaterThan(0); - const firstCategoryNames = await channels.locator('.channel-name').allTextContents(); + const firstCategoryNames = await channels + .locator('.channel-name') + .allTextContents(); // Regression: switching to another category once the full list is cached // must serve that category from the cache, not get stuck on an empty @@ -742,7 +703,9 @@ test('@stalker ITV full channel list loads via get_all_channels and search cover // Counts are identical across categories; wait for the actual category // rows before choosing a search term, or it may come from the previous one. - await expect(channels.locator('.channel-name')).toHaveText(firstCategoryNames); + await expect(channels.locator('.channel-name')).toHaveText( + firstCategoryNames + ); // Search a channel from deep in the list (beyond the first 14 items). const deepChannelName = ( diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index d427dd8a7..2c70c7777 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 9b6069c89..07193fe59 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى", "ALL_CHANNELS": "جميع القنوات", "GROUPS": "المجموعات", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index e650c255b..7dbd9e2cd 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст", "ALL_CHANNELS": "Усе каналы", "GROUPS": "Групы", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 2f47cc0fc..206ac9850 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Laufenden Sender anzeigen", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen", "ALL_CHANNELS": "Alle Sender", "GROUPS": "Gruppen", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index dac1fafda..3af3e99ac 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής", "ALL_CHANNELS": "Όλα τα κανάλια", "GROUPS": "Ομάδες", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index e8efb2483..9497932b3 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Show playing channel", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist", "ALL_CHANNELS": "All channels", "GROUPS": "Groups", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 3bda2b825..1801d2fa5 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Mostrar el canal en reproducción", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción", "ALL_CHANNELS": "Todos los canales", "GROUPS": "Grupos", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 46671d2a4..c8f823727 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Afficher la chaîne en cours", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture", "ALL_CHANNELS": "Toutes les chaînes", "GROUPS": "Groupes", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 11b2a3065..178d6ee2b 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Lejátszott csatorna megjelenítése", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Másik lejátszási lista feltöltése vagy kiválasztása", "ALL_CHANNELS": "Összes csatorna", "GROUPS": "Csoportok", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f3fa771c0..6da189902 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Mostra il canale in riproduzione", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist", "ALL_CHANNELS": "Tutti i canali", "GROUPS": "Gruppi", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 225c96549..79be8eb91 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択", "ALL_CHANNELS": "すべてのチャンネル", "GROUPS": "グループ", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0f67f9521..f99f0f9d8 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오", "ALL_CHANNELS": "모든 채널", "GROUPS": "그룹", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index beca4aa26..8179c2432 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Huidige zender tonen", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst", "ALL_CHANNELS": "Alle kanalen", "GROUPS": "Groepen", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index e828573a6..fc4e60eeb 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Pokaż odtwarzany kanał", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania", "ALL_CHANNELS": "Wszystkie kanały", "GROUPS": "Grupy", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index fba1440c3..8d8d06406 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Mostrar canal em reprodução", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist", "ALL_CHANNELS": "Todos os canais", "GROUPS": "Grupos", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c66fb1dda..6bf700511 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Показать текущий канал", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист", "ALL_CHANNELS": "Все каналы", "GROUPS": "Группы", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 8025e3aa3..daeb836e3 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "Oynatılan kanalı göster", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin", "ALL_CHANNELS": "Tüm kanallar", "GROUPS": "Gruplar", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 0a95f2cc4..54d0dc95e 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "显示正在播放的频道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表", "ALL_CHANNELS": "所有频道", "GROUPS": "分组", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 44089d006..1d91c7e24 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -677,6 +677,7 @@ "TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)" }, "CHANNELS": { + "SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道", "UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單", "ALL_CHANNELS": "所有頻道", "GROUPS": "群組", diff --git a/docs/architecture/remote-control.md b/docs/architecture/remote-control.md index 2b9ff3322..ceaa8abda 100644 --- a/docs/architecture/remote-control.md +++ b/docs/architecture/remote-control.md @@ -126,6 +126,36 @@ publishes it when the active channel is cleared IN PLACE (e.g. quitting an external MPV/VLC session dispatches `resetActiveChannel` while the route stays mounted). +## Live channel return and playback order + +Xtream and Stalker live views keep a component-owned playback queue through +`LiveChannelPlaybackQueue` in `portal-shared-data-access`. Explicit selection +captures the actual displayed order, including search/sort and a fullscreen +panel's own filter. Remote up/down, numeric selection and the published channel +number use that queue while category or search browsing remains independent. +Xtream history handoffs from global search or Recently Added capture the +eligible destination category in the selected channel sort order; an unrelated +previous category/query does not define that queue. Explicit All Items clicks +still capture their displayed list. Same-channel replay and remote selection preserve it. Source/type changes and +view destruction discard it; ITV and radio never share an owner. + +Stalker captures before asynchronous URL resolution and commits only the winning +successful request. Paged lists extend the queue only as more rows arrive for +the original category and search scope. They do not fetch a global catalog for +remote navigation. Xtream excludes removed streams and hidden or removed +categories from eligible queue entries. + +A conditional **Show playing channel** icon in the channel header appears when +the playing channel is absent from the browsed results and its category remains +accessible. It clears `q` and the store query, returns to that category, expands +the sidebar, then scrolls and focuses the playing row. It never starts playback +or changes the playback/session/catchup identity. A collapsed sidebar first uses +its existing restore action. Removed categories are not recreated or unhidden. +Stalker reuses the already-resolved playing item as a temporary normal row when +it lies beyond loaded provider pages. This row is deduplicated once it arrives +in provider results and discarded on browsing or playback changes; returning +never crawls the catalog. Raw provider rows alone extend the playback queue. + ## Shared helpers - File: `libs/portal/shared/util/src/lib/remote-channel-navigation.ts` @@ -184,10 +214,10 @@ Implemented behavior: - `onRemoteControlCommand` for number select - Up/down: - Uses selected live item `selectedItem().xtream_id` - - Navigates inside `selectItemsFromSelectedCategory()` + - Navigates inside the captured eligible playback queue - Calls `playLive(nextItem, true)` so remote actions explicitly start playback - Number select: - - Maps number to item in current category list + - Maps number to item in the same captured eligible queue - Calls `playLive(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - `portal: 'xtream'` @@ -207,10 +237,10 @@ Implemented behavior: - `onRemoteControlCommand` for number select - Up/down: - Uses `selectedItem().id` - - Navigates inside `itvChannels()` + - Navigates inside the captured ITV/radio playback queue - Calls `playChannel(nextItem, true)` so remote actions explicitly start playback - Number select: - - Maps number into `itvChannels()` + - Maps number into the same captured playback queue - Calls `playChannel(channel, true)` so remote actions explicitly start playback - Publishes status via effect: - `portal: 'stalker'` diff --git a/docs/architecture/stalker-portal.md b/docs/architecture/stalker-portal.md index 217e29466..045caf399 100644 --- a/docs/architecture/stalker-portal.md +++ b/docs/architecture/stalker-portal.md @@ -1274,9 +1274,10 @@ The Stalker live route and radio route intentionally share in flight belong to the selection and survive the switch. Only a section change (`itv` ↔ `radio`, where the route session clears the selection) invalidates that request and drops the fallback. A playing channel outside - the newly selected category simply has no highlighted row, and remote - channel up/down finds no neighbour until a channel from the visible list is - played. + the newly selected category can be revealed with **Show playing channel** + in the channel header. Remote up/down, numeric selection and status retain + the captured playback order while browsing. See the + [queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order). ## Full ITV Channel List Cache diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 63cc17262..11448b841 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -154,6 +154,11 @@ is watching keeps playing while the sidebar re-filters (Xtream: #936; Stalker: series category clicks do drop the open detail (`setSelectedItem(null)` / `clearSelectedItem()`) because they navigate to a list route. +The channel header offers **Show playing channel** when browsing excludes the +active channel. It returns to that category and focuses the row without +restarting playback; remote commands retain captured playback order. See the +[queue and reveal contract](./remote-control.md#live-channel-return-and-playback-order). + ## Search And Navigation Rules Search is shell-owned and route-aware: diff --git a/docs/superpowers/plans/2026-09-06-live-channel-return.md b/docs/superpowers/plans/2026-09-06-live-channel-return.md new file mode 100644 index 000000000..924388147 --- /dev/null +++ b/docs/superpowers/plans/2026-09-06-live-channel-return.md @@ -0,0 +1,62 @@ +# Live channel return implementation plan + +> **For agentic workers:** Use subagent-driven-development for the independently owned Xtream integration, with spec and code review. Execute the shared queue, Stalker integration and validation in this task. Do not merge the resulting PR. + +**Goal:** Keep live remote navigation stable during category/search browsing and provide one conditional action to reveal the playing channel, without duplicating its title or restarting playback (#1520). + +**Architecture:** Each live host owns a playback queue scoped by source and content type. Explicit channel activation captures the displayed ordered list; remote commands preserve it. Stalker extends the captured queue only when more rows arrive for the same browsing scope, never by falling back to a global ITV cache. Revealing restores the channel's available category and clears search without invoking playback. Provider-neutral queue state belongs in portal/shared/data-access; portal routing, visibility and scrolling remain in their feature libraries. + +**Tech Stack:** Angular signals, Nx/Jest, Playwright, Electron IPC, existing Material icon buttons and translations. + +## Contract + +- Capture the list before asynchronous playback resolution; commit the queue only for the winning successful request. Same active-channel replays/catch-up return keep the queue. +- Source/type changes invalidate old queue ownership. Numeric, adjacent and status all read the same queue; remote commands never recapture the browsed category. +- A captured paged Stalker queue contains loaded rows. Newly loaded rows extend it only while source/type/category/search scope still matches. No background all-portal crawling; unavailable pages are not advertised as loaded channels. +- Xtream uses the actual sidebar sort/search order and filters hidden/removed categories and channels from navigation eligibility. Revealing never unhides categories. +- An out-of-filter channel gets a localized `CHANNELS.SHOW_PLAYING_CHANNEL` action in the existing list header. No now-playing title block or EPG changes. Reuse the existing sidebar restore action while collapsed. +- Reveal clears interfering query state, selects the active channel's accessible category, waits for rows/rendering and scrolls/focuses the scroll owner. Stale navigation/loading must not reselect a previous channel or restart the player. For paged Stalker search, reuse the already-resolved channel as a scoped normal row until provider results include it, rather than crawling the catalog. +- Fullscreen selection captures its own displayed filtered list; existing fullscreen controls and playback/session ownership remain intact. No redesign of the fullscreen panel. +- Stalker #1543 remains independent: queue capture consumes the actual list and makes no new global-search policy. + +## Tasks + +- [x] Add `LiveChannelPlaybackQueue` and focused tests under `libs/portal/shared/data-access/src/lib/`; export via the public barrel. Test capture/fallback, source/type ownership, preserved order, same-scope extension, stale-scope rejection, unchanged-snapshot identity and reset. Run the focused Jest target red, then green. +- [x] Extend Xtream live layout and channel-list integration with regression coverage: category/search/sort drift, numeric/status/adjacent parity, hidden category exclusion, reveal without playback calls, route category/query handling, fullscreen list capture. Keep new logic in focused feature files where needed for max-lines. Run `pnpm nx test portal-xtream-feature --runInBand` and lint. +- [x] Extend Stalker live layout with the same queue policy and reveal action. Cover ITV/radio ID collisions, asynchronous successful/failed/stale resolution, paged queue extension, cache render windows, category/search drift, and reveal without session reset. Run `pnpm nx test portal-stalker-feature --runInBand` and lint. +- [x] Add `CHANNELS.SHOW_PLAYING_CHANNEL` to all shipped locale dictionaries. Keep icon button tooltip and accessible name identical. +- [x] Extend closest web and Electron E2E flows. Verify real remote HTTP commands preserve the original queue after browsing changes, and reveal retains the same video/session. Check both portal UIs in light/dark with synthetic sources only. +- [x] Update `docs/architecture/remote-control.md`, `stalker-portal.md`, `workspace-shell.md`, and mirrored AGENTS.md/CLAUDE.md guidance. Add one `.changes/portals-live-channel-return.md` note and validate it. +- [x] Run affected unit targets, E2E, lint, Electron E2E build, release-note validator and `git diff --check`. Complete independent spec review, then code-quality review and resolve findings. +- [ ] Commit, create a PR and complete CI/review checks to ready-to-merge without merging, as authorized in this task's original scope. + +## Validation commands + +```sh +pnpm nx test portal-shared-data-access --runInBand +pnpm nx test portal-xtream-feature --runInBand +pnpm nx test portal-stalker-feature --runInBand +pnpm nx run-many -t lint -p portal-shared-data-access,portal-xtream-feature,portal-stalker-feature +pnpm nx run electron-backend:build-e2e --parallel=1 +pnpm run release:notes:validate +git diff --check +``` + +Choose atomized existing web/Electron E2E targets after inspecting project discovery and their runner configuration. Record any environment limitations rather than treating a build or unit pass as full runtime validation. + +## Validation record + +- Stalker data-access after the category-search merge: 546 tests — passed. +- Shared data-access: 169 unit tests; Xtream: 445; Stalker: 355 — passed. +- Electron build and all three affected library lint targets — passed (existing warnings only). +- Electron remote-control suite: 5/5, including both portals and Stalker radio; + Chromium category-switch/reveal regression: 1/1 — passed. +- New regressions exposed the original queue drift, same-URL reveal cancellation, + and virtual viewport attachment race before their fixes. +- Independent spec and quality reviews passed after resolving provider-search + ownership, beyond-page reveal and delayed-playback page reconciliation. +- Release notes and diff whitespace validated. Tested UI in light/dark using + synthetic mock portals. Platform-specific Windows/Linux packaged runs and + Firefox/WebKit were not needed for the shared renderer/IPC-navigation change. + +- Synced with category-scoped Stalker search (#1552); added auto-open queue regressions for global search/Recently Added after Codex review. diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index 0887a2743..6f42a7274 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -1,3 +1,4 @@ export * from './lib/collection'; export * from './lib/downloads'; export * from './lib/multi-source'; +export * from './lib/live-channel-playback-queue'; diff --git a/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts new file mode 100644 index 000000000..b3c7dd2e5 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.spec.ts @@ -0,0 +1,90 @@ +import { LiveChannelPlaybackQueue } from './live-channel-playback-queue'; + +interface Channel { + id: string | number | null; + name: string; +} +const alpha: Channel = { id: 1, name: 'Alpha' }; +const beta: Channel = { id: 2, name: 'Beta' }; +const gamma: Channel = { id: 3, name: 'Gamma' }; + +describe('LiveChannelPlaybackQueue', () => { + let queue: LiveChannelPlaybackQueue; + beforeEach(() => { + queue = new LiveChannelPlaybackQueue((item) => item.id); + }); + + it('captures the displayed order independently of subsequent browsing', () => { + const displayed = [beta, alpha]; + queue.capture('source:itv', 'news:q:sort', displayed, alpha); + displayed.reverse(); + queue.extend('source:itv', 'sports:q:sort', [gamma]); + expect(queue.items('source:itv')).toEqual([beta, alpha]); + }); + + it('does not expose a different source or content type queue', () => { + queue.capture('source:itv', 'news', [alpha, beta], alpha); + expect(queue.items('source:radio')).toEqual([]); + expect(queue.items('other:itv')).toEqual([]); + }); + + it('extends only the original loaded scope and retains channel numbers', () => { + queue.capture('source:itv', 'news', [alpha, beta], alpha); + queue.extend('source:itv', 'news', [gamma, beta, alpha]); + expect(queue.items('source:itv')).toEqual([alpha, beta, gamma]); + }); + + it('ignores a late page from a previous owner', () => { + queue.capture('source:radio', 'news', [alpha], alpha); + queue.extend('source:itv', 'news', [alpha, beta]); + expect(queue.items('source:radio')).toEqual([alpha]); + }); + + it('refreshes metadata without changing existing order', () => { + queue.capture('source:itv', 'news', [alpha, beta], alpha); + const updatedBeta = { ...beta, name: 'Beta HD' }; + queue.extend('source:itv', 'news', [alpha, updatedBeta]); + expect(queue.items('source:itv')).toEqual([alpha, updatedBeta]); + }); + + it('does not lose a captured page when the browse resource temporarily clears', () => { + queue.capture('source:itv', 'news', [alpha, beta], alpha); + queue.extend('source:itv', 'news', []); + expect(queue.items('source:itv')).toEqual([alpha, beta]); + }); + + it('deduplicates normalized IDs and excludes invalid IDs', () => { + queue.capture( + 'source', + 'news', + [alpha, { ...alpha, id: '1' }, { id: null, name: '?' }, beta], + alpha + ); + expect(queue.items('source')).toEqual([alpha, beta]); + }); + + it('falls back to the selected channel, never an unrelated list', () => { + queue.capture('source', 'news', [alpha, beta], gamma); + expect(queue.items('source')).toEqual([gamma]); + }); + + it('rejects invalid selected IDs', () => { + queue.capture('source', 'news', [alpha], { id: null, name: '?' }); + expect(queue.items('source')).toEqual([]); + }); + + it('preserves snapshot identity when a page has not changed', () => { + queue.capture('source', 'news', [alpha, beta], alpha); + const previous = queue.items('source'); + queue.extend('source', 'news', [alpha, beta]); + expect(queue.items('source')).toBe(previous); + }); + + it('replaces the queue on a new explicit channel selection and clears on teardown', () => { + queue.capture('source', 'news', [alpha, beta], alpha); + queue.capture('source', 'sports', [gamma], gamma); + expect(queue.items('source')).toEqual([gamma]); + queue.clear(); + expect(queue.items('source')).toEqual([]); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts new file mode 100644 index 000000000..f0f2f334a --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/live-channel-playback-queue.ts @@ -0,0 +1,79 @@ +import { signal } from '@angular/core'; + +interface PlaybackQueueSnapshot { + readonly owner: string; + readonly scope: string; + readonly items: readonly T[]; +} + +/** Component-owned playback order, independent of the list being browsed. */ +export class LiveChannelPlaybackQueue { + private readonly snapshot = signal | null>(null); + + constructor( + private readonly getId: (item: T) => string | number | null | undefined + ) {} + + items(owner: string): readonly T[] { + const snapshot = this.snapshot(); + return snapshot?.owner === owner ? snapshot.items : []; + } + + capture( + owner: string, + scope: string, + items: readonly T[], + active: T + ): void { + const activeId = this.key(active); + if (!activeId) { + this.clear(); + return; + } + const unique = this.unique(items); + this.snapshot.set({ + owner, + scope, + items: unique.has(activeId) ? [...unique.values()] : [active], + }); + } + + /** Append loaded pages only for the scope that supplied this queue. */ + extend(owner: string, scope: string, items: readonly T[]): void { + const previous = this.snapshot(); + if (!previous || previous.owner !== owner || previous.scope !== scope) + return; + const incoming = this.unique(items); + const next = previous.items.map((item) => { + const id = this.key(item); + const current = incoming.get(id) ?? item; + incoming.delete(id); + return current; + }); + next.push(...incoming.values()); + if ( + next.length === previous.items.length && + next.every((item, index) => item === previous.items[index]) + ) + return; + this.snapshot.set({ ...previous, items: next }); + } + + clear(): void { + this.snapshot.set(null); + } + + private key(item: T): string { + const id = this.getId(item); + return id == null ? '' : String(id); + } + + private unique(items: readonly T[]): Map { + const result = new Map(); + for (const item of items) { + const id = this.key(item); + if (id && !result.has(id)) result.set(id, item); + } + return result; + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts new file mode 100644 index 000000000..2c1ab2dcc --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.spec.ts @@ -0,0 +1,273 @@ +import { signal } from '@angular/core'; +import { fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { Router } from '@angular/router'; +import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util'; +import { + StalkerItvChannel, + StalkerStore, +} from '@iptvnator/portal/stalker/data-access'; +import { StalkerLiveNavigation } from './stalker-live-navigation'; + +const channel = (id: string, category = 'news'): StalkerItvChannel => ({ + id, + name: id, + cmd: `stream-${id}`, + tv_genre_id: category, +}); + +describe('StalkerLiveNavigation', () => { + let navigation: StalkerLiveNavigation; + const first = channel('1'); + const second = channel('2'); + const third = channel('3', 'sports'); + const rows = signal([first, second]); + const loading = signal(false); + const store = { + currentPlaylist: signal({ _id: 'source-a' }), + selectedContentType: signal('itv'), + selectedCategoryId: signal('news'), + searchPhrase: signal(''), + selectedItem: signal(null), + page: signal(0), + hasMoreChannels: signal(false), + getCategoryResource: signal([ + { category_id: 'news' }, + { category_id: 'sports' }, + ]), + setSearchPhrase: jest.fn((query: string) => + store.searchPhrase.set(query) + ), + setSelectedCategory: jest.fn((category: string) => + store.selectedCategoryId.set(category) + ), + setPage: jest.fn((page: number) => store.page.set(page)), + }; + const router = { + url: '/workspace/stalker/source-a/itv?q=sport&keep=1', + parseUrl: jest.fn(() => ({ queryParams: { q: 'sport', keep: '1' } })), + navigateByUrl: jest.fn().mockResolvedValue(true), + }; + const sidebar = { setState: jest.fn() }; + const play = jest.fn(); + const revealRow = jest.fn(() => true); + + beforeEach(() => { + TestBed.configureTestingModule({}); + jest.clearAllMocks(); + rows.set([first, second]); + loading.set(false); + store.currentPlaylist.set({ _id: 'source-a' }); + store.selectedContentType.set('itv'); + store.selectedCategoryId.set('news'); + store.searchPhrase.set(''); + store.selectedItem.set(null); + store.page.set(0); + store.hasMoreChannels.set(false); + store.getCategoryResource.set([ + { category_id: 'news' }, + { category_id: 'sports' }, + ]); + router.navigateByUrl.mockResolvedValue(true); + navigation = TestBed.runInInjectionContext( + () => + new StalkerLiveNavigation({ + store: store as unknown as InstanceType< + typeof StalkerStore + >, + router: router as unknown as Router, + sidebar: + sidebar as unknown as LiveLayoutSidebarStateService, + rows: (term) => { + const query = term?.() ?? store.searchPhrase(); + return rows().filter( + (row) => !query || row.name?.includes(query) + ); + }, + loading, + play, + revealRow, + }) + ); + TestBed.tick(); + }); + afterEach(() => navigation.reset()); + + it('captures resolved selection order and keeps it during category/search browsing', () => { + const commit = navigation.prepare(first); + store.selectedCategoryId.set('sports'); + rows.set([third]); + commit(); + store.searchPhrase.set('3'); + TestBed.tick(); + expect(navigation.channels()).toEqual([first, second]); + navigation.adjacent('down'); + expect(play).toHaveBeenLastCalledWith(second); + navigation.selectNumber(1); + expect(play).toHaveBeenLastCalledWith(first); + expect(store.selectedCategoryId()).toBe('sports'); + }); + + it('does not replace playback navigation for a failed resolution', () => { + navigation.prepare(first)(); + rows.set([third]); + navigation.prepare(third); // Rejected resolver never calls the commit. + navigation.adjacent('down'); + expect(play).toHaveBeenCalledWith(second); + }); + + it('preserves the queue on remote selection and same-channel replay', () => { + navigation.prepare(first)(); + rows.set([third]); + navigation.prepare(second, 'preserve')(); + navigation.prepare(second)(); + navigation.selectNumber(1); + expect(play).toHaveBeenCalledWith(first); + }); + + it('captures the fullscreen filtered list independently of sidebar search', () => { + store.searchPhrase.set('1'); + rows.set([second, first]); + navigation.prepare(second, signal(''))(); + expect(navigation.channels()).toEqual([second, first]); + }); + + it('extends only loaded pages of the original category and query', () => { + navigation.prepare(first)(); + rows.set([first, second, channel('4')]); + store.page.set(1); + TestBed.tick(); + expect(navigation.channels().map((item) => item.id)).toEqual([ + '1', + '2', + '4', + ]); + store.searchPhrase.set('5'); + rows.set([channel('5')]); + TestBed.tick(); + store.searchPhrase.set(''); + store.selectedCategoryId.set('sports'); + rows.set([third]); + TestBed.tick(); + expect(navigation.channels().map((item) => item.id)).toEqual([ + '1', + '2', + '4', + ]); + }); + + it('does not append another provider search after a fullscreen capture', () => { + store.selectedContentType.set('radio'); + TestBed.tick(); + store.searchPhrase.set('1'); + navigation.prepare(first, signal(''))(); + store.searchPhrase.set('3'); + rows.set([third]); + TestBed.tick(); + expect(navigation.channels()).toEqual([first, second]); + }); + + it('includes same-scope pages that finish while playback resolution is pending', () => { + const commit = navigation.prepare(first); + const nextPage = channel('4'); + rows.set([first, second, nextPage]); + TestBed.tick(); + commit(); + TestBed.tick(); + expect(navigation.channels()).toEqual([first, second, nextPage]); + }); + + it('rejects late commits from a different source or content type and after reset', () => { + const commit = navigation.prepare(first); + store.selectedContentType.set('radio'); + commit(); + rows.set([third]); + TestBed.tick(); + expect(navigation.channels()).toEqual([third]); + const radioCommit = navigation.prepare(third); + navigation.reset(); + rows.set([]); + radioCommit(); + expect(navigation.channels()).toEqual([]); + }); + + it('reveals in the original category, clears only q and never plays', fakeAsync(() => { + navigation.prepare(first)(); + store.selectedCategoryId.set('sports'); + store.searchPhrase.set('3'); + rows.set([third]); + expect(navigation.canReveal()).toBe(true); + loading.set(true); + void navigation.reveal(); + tick(); + rows.set([first, second]); + loading.set(false); + TestBed.tick(); + tick(); + expect(router.navigateByUrl).toHaveBeenCalledWith( + { queryParams: { keep: '1' } }, + { replaceUrl: true } + ); + expect(store.selectedCategoryId()).toBe('news'); + expect(store.searchPhrase()).toBe(''); + expect(sidebar.setState).toHaveBeenCalledWith('expanded'); + expect(revealRow).toHaveBeenCalledWith('1'); + expect(play).not.toHaveBeenCalled(); + expect(navigation.canReveal()).toBe(false); + })); + + it('withholds reveal for removed categories', () => { + navigation.prepare(first)(); + rows.set([third]); + store.getCategoryResource.set([{ category_id: 'sports' }]); + expect(navigation.canReveal()).toBe(false); + }); + + it('cancels reveal if browsing changes while routing is pending', async () => { + navigation.prepare(first)(); + store.selectedCategoryId.set('sports'); + let resolve!: (value: boolean) => void; + router.navigateByUrl.mockReturnValue( + new Promise((done) => (resolve = done)) + ); + const pending = navigation.reveal(); + store.selectedCategoryId.set('another'); + resolve(true); + await pending; + expect(store.setSelectedCategory).not.toHaveBeenCalled(); + expect(play).not.toHaveBeenCalled(); + }); + + it('reveals the known row beyond loaded pages and deduplicates when it arrives', fakeAsync(() => { + store.searchPhrase.set('2'); + rows.set([second]); + navigation.prepare(second)(); + store.selectedCategoryId.set('sports'); + rows.set([third]); + void navigation.reveal(); + tick(); + rows.set([first]); + TestBed.tick(); + tick(); + expect(navigation.withRevealedItem(rows())).toEqual([second, first]); + expect(revealRow).toHaveBeenCalledWith('2'); + rows.set([first, second]); + expect(navigation.withRevealedItem(rows())).toEqual([first, second]); + expect(navigation.channels()).toEqual([second]); + store.selectedCategoryId.set('sports'); + TestBed.tick(); + store.selectedCategoryId.set('news'); + rows.set([first]); + expect(navigation.withRevealedItem(rows())).toEqual([first]); + })); + + it('returns within the same route without navigating when q is absent', async () => { + navigation.prepare(first)(); + store.selectedCategoryId.set('sports'); + router.parseUrl.mockReturnValueOnce({ queryParams: {} } as ReturnType< + typeof router.parseUrl + >); + await navigation.reveal(); + expect(router.navigateByUrl).not.toHaveBeenCalled(); + expect(store.selectedCategoryId()).toBe('news'); + }); +}); diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts new file mode 100644 index 000000000..ed1fc0fb3 --- /dev/null +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-navigation.ts @@ -0,0 +1,344 @@ +import { + computed, + effect, + signal, + untracked, + type Signal, +} from '@angular/core'; +import { Router } from '@angular/router'; +import { LiveChannelPlaybackQueue } from '@iptvnator/portal/shared/data-access'; +import { + getAdjacentChannelItem, + getChannelItemByNumber, + LiveLayoutSidebarStateService, +} from '@iptvnator/portal/shared/util'; +import { + StalkerItvChannel, + StalkerStore, + normalizeStalkerEntityId, +} from '@iptvnator/portal/stalker/data-access'; + +type SelectionContext = Signal | 'preserve' | undefined; +interface LiveNavigationOptions { + store: InstanceType; + router: Router | null; + sidebar: LiveLayoutSidebarStateService; + rows: (panelTerm?: Signal) => StalkerItvChannel[]; + play: (item: StalkerItvChannel) => void; + revealRow: (id: string) => boolean; + loading: () => boolean; +} +interface PlayingOrigin { + owner: string; + item: StalkerItvChannel; + categories: ReadonlyMap; + scope: string; +} +interface RevealRequest { + owner: string; + id: string; + category: string; +} + +/** Owns live navigation without letting category browsing replace playback order. */ +export class StalkerLiveNavigation { + private readonly queue = new LiveChannelPlaybackQueue( + (item) => item.id + ); + private readonly origin = signal(null); + private readonly pendingReveal = signal(null); + private readonly revealed = signal<{ + owner: string; + category: string; + item: StalkerItvChannel; + } | null>(null); + private readonly panelTerm = signal | undefined>(undefined); + private ownerSeen = ''; + private generation = 0; + private revealGeneration = 0; + private revealTimer: ReturnType | undefined; + readonly owner = computed(() => + JSON.stringify([ + this.options.store.currentPlaylist()?._id, + this.options.store.selectedContentType(), + ]) + ); + readonly channels = computed(() => { + const captured = this.queue.items(this.owner()); + return captured.length ? [...captured] : this.options.rows(); + }); + readonly canReveal = computed(() => { + const active = this.activeItem(); + return ( + !!active && + !!this.categoryFor(active) && + !this.withRevealedItem(this.options.rows()).some( + (row) => this.id(row) === this.id(active) + ) + ); + }); + + constructor(private readonly options: LiveNavigationOptions) { + effect(() => { + const owner = this.owner(); + if (owner !== this.ownerSeen) { + this.ownerSeen = owner; + untracked(() => this.reset()); + } + }); + effect(() => { + const owner = this.owner(); + const scope = this.scope(this.panelTerm()); + const rows = this.options.rows(this.panelTerm()); + const origin = this.origin(); + if (this.options.loading()) return; + untracked(() => { + this.queue.extend(owner, scope, rows); + if (origin?.owner === owner && origin.scope === scope) { + const categories = new Map(origin.categories); + for (const row of rows) + categories.set( + this.id(row), + String( + row.tv_genre_id ?? + row.category_id ?? + this.options.store.selectedCategoryId() ?? + '*' + ) + ); + if (categories.size > origin.categories.size) + this.origin.set({ + ...origin, + categories, + }); + } + }); + }); + effect(() => { + const revealed = this.revealed(); + if ( + revealed && + (revealed.owner !== this.owner() || + revealed.category !== + this.options.store.selectedCategoryId() || + this.options.store.searchPhrase() || + this.id(revealed.item) !== this.id(this.activeItem())) + ) + this.revealed.set(null); + }); + effect(() => { + const request = this.pendingReveal(); + if (!request) return; + const owner = this.owner(); + const category = this.options.store.selectedCategoryId(); + const query = this.options.store.searchPhrase(); + this.options.rows(); + const loading = this.options.loading(); + untracked(() => { + if ( + owner !== request.owner || + category !== request.category || + query || + this.id(this.activeItem()) !== request.id + ) { + this.pendingReveal.set(null); + return; + } + if (loading) return; + if (this.revealTimer) clearTimeout(this.revealTimer); + // Render after the category resource and its reset effect settle. + this.revealTimer = setTimeout(() => { + if ( + this.pendingReveal() !== request || + this.options.loading() + ) + return; + if ( + this.id(this.activeItem()) !== request.id || + this.owner() !== request.owner || + this.options.store.selectedCategoryId() !== + request.category || + this.options.store.searchPhrase() + ) { + this.pendingReveal.set(null); + return; + } + if (this.options.revealRow(request.id)) + this.pendingReveal.set(null); + }, 0); + }); + }); + } + + /** Capture before resolution; only the winning playback request commits it. */ + prepare(item: StalkerItvChannel, context?: SelectionContext): () => void { + const owner = this.owner(); + const generation = this.generation; + const previous = this.origin(); + const keep = + context === 'preserve' || + (previous?.owner === owner && + this.id(previous.item) === this.id(item)); + const term = typeof context === 'function' ? context : undefined; + const rows = term + ? [...this.options.rows(term)] + : this.withRevealedItem(this.options.rows()); + const scope = this.scope(term); + const category = this.options.store.selectedCategoryId() ?? '*'; + const categories = + keep && previous?.owner === owner + ? previous.categories + : new Map( + rows + .concat(item) + .map((row) => [ + this.id(row), + String( + row.tv_genre_id ?? row.category_id ?? category + ), + ]) + ); + return () => { + if (owner !== this.owner() || generation !== this.generation) + return; + if (!keep || !this.queue.items(owner).length) { + this.queue.capture(owner, scope, rows, item); + this.panelTerm.set(term); + } + this.revealed.set(null); + this.pendingReveal.set(null); + this.origin.set({ + owner, + item, + categories, + scope: keep ? (previous?.scope ?? scope) : scope, + }); + }; + } + + adjacent(direction: 'up' | 'down'): void { + const next = getAdjacentChannelItem( + this.channels(), + this.activeItem()?.id, + direction, + (item) => item.id + ); + if (next) this.options.play(next); + } + + selectNumber(number?: number): void { + if (!number) return; + const channel = getChannelItemByNumber(this.channels(), number); + if (channel) this.options.play(channel); + } + + async reveal(): Promise { + const active = this.activeItem(); + const category = active && this.categoryFor(active); + if (!active || !category) return; + const owner = this.owner(); + const generation = ++this.revealGeneration; + const browsedCategory = this.options.store.selectedCategoryId(); + this.pendingReveal.set(null); + if ( + this.options.router && + this.options.router.parseUrl(this.options.router.url).queryParams[ + 'q' + ] != null + ) { + const tree = this.options.router.parseUrl(this.options.router.url); + delete tree.queryParams['q']; + const navigated = await this.options.router + .navigateByUrl(tree, { + replaceUrl: true, + }) + .catch(() => false); + if ( + !navigated || + generation !== this.revealGeneration || + owner !== this.owner() || + this.id(active) !== this.id(this.activeItem()) || + browsedCategory !== this.options.store.selectedCategoryId() || + category !== this.categoryFor(active) + ) + return; + } + this.options.store.setSearchPhrase(''); + this.options.store.setSelectedCategory(category); + this.options.store.setPage(0); + this.options.sidebar.setState('expanded'); + this.revealed.set({ owner, category, item: active }); + this.pendingReveal.set({ + owner, + category, + id: this.id(active), + }); + } + + reset(): void { + this.generation++; + this.revealGeneration++; + this.revealed.set(null); + this.queue.clear(); + this.origin.set(null); + this.pendingReveal.set(null); + this.panelTerm.set(undefined); + if (this.revealTimer) clearTimeout(this.revealTimer); + } + + /** A known playing row keeps return bounded on provider-paginated search. */ + withRevealedItem(rows: StalkerItvChannel[]): StalkerItvChannel[] { + const revealed = this.revealed(); + if ( + !revealed || + revealed.owner !== this.owner() || + revealed.category !== this.options.store.selectedCategoryId() || + this.options.store.searchPhrase() || + this.id(revealed.item) !== this.id(this.activeItem()) || + !this.categoryFor(revealed.item) || + rows.some((row) => this.id(row) === this.id(revealed.item)) + ) + return rows; + return [revealed.item, ...rows]; + } + + private scope(panelTerm?: Signal): string { + return JSON.stringify([ + this.options.store.selectedCategoryId(), + panelTerm ? 'panel' : 'sidebar', + this.options.store.selectedContentType() === 'radio' + ? this.options.store.searchPhrase() + : '', + panelTerm?.() ?? this.options.store.searchPhrase(), + ]); + } + + activeItem(): StalkerItvChannel | undefined { + const origin = this.origin(); + return origin?.owner === this.owner() + ? origin.item + : (this.options.store.selectedItem() as + StalkerItvChannel | undefined); + } + + private categoryFor(item: StalkerItvChannel): string | undefined { + const origin = this.origin(); + const category = String( + item.tv_genre_id ?? + item.category_id ?? + (origin?.owner === this.owner() + ? origin.categories.get(this.id(item)) + : '') ?? + '' + ); + return this.options.store + .getCategoryResource?.() + .some((entry) => String(entry.category_id) === category) + ? category + : undefined; + } + + private id(item: StalkerItvChannel | null | undefined): string { + return normalizeStalkerEntityId(item?.id); + } +} diff --git a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html index 03c5fc220..99e945688 100644 --- a/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html +++ b/libs/portal/stalker/feature/src/lib/stalker-live-stream-layout/stalker-live-stream-layout.component.html @@ -27,6 +27,19 @@ > } + @if (navigation.canReveal()) { + + } @if (isFullListMode()) { + } - + {{ zoomIcon() }} + } @if (showDateStepper()) {
@@ -145,7 +145,8 @@ } {{ - viewDate() | date: 'EEE, d MMM' : '' : currentLocale() + viewDate() + | date: 'EEE, d MMM' : '' : currentLocale() }}