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
This commit is contained in:
4gray authored and GitHub committed 2026-09-06 10:22:07 +02:00
1 parent 5a8c5ca4a4
commit 61b06b9f31
45 files changed
+2267 -141

No files matched your search

+7
View File
@@ -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.
+10
View File
@@ -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
+10
View File
@@ -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
@@ -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<string, unknown> = {}
): Promise<void> {
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<number> {
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 {
@@ -56,7 +56,9 @@ export async function verifyStalkerCategorySearch(page: Page): Promise<void> {
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<void> {
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);
}
+12 -49
View File
@@ -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 = (
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
"ALL_CHANNELS": "جميع القنوات",
"GROUPS": "المجموعات",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
"ALL_CHANNELS": "Усе каналы",
"GROUPS": "Групы",
+1
View File
@@ -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",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
"ALL_CHANNELS": "Όλα τα κανάλια",
"GROUPS": "Ομάδες",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
"ALL_CHANNELS": "すべてのチャンネル",
"GROUPS": "グループ",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
"ALL_CHANNELS": "모든 채널",
"GROUPS": "그룹",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "Показать текущий канал",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
"ALL_CHANNELS": "Все каналы",
"GROUPS": "Группы",
+1
View File
@@ -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",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "显示正在播放的频道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
"ALL_CHANNELS": "所有频道",
"GROUPS": "分组",
+1
View File
@@ -677,6 +677,7 @@
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)"
},
"CHANNELS": {
"SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道",
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
"ALL_CHANNELS": "所有頻道",
"GROUPS": "群組",
+34 -4
View File
@@ -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'`
+4 -3
View File
@@ -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
+5
View File
@@ -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:
@@ -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<T>` 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.
@@ -1,3 +1,4 @@
export * from './lib/collection';
export * from './lib/downloads';
export * from './lib/multi-source';
export * from './lib/live-channel-playback-queue';
@@ -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<Channel>;
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([]);
});
});
@@ -0,0 +1,79 @@
import { signal } from '@angular/core';
interface PlaybackQueueSnapshot<T> {
readonly owner: string;
readonly scope: string;
readonly items: readonly T[];
}
/** Component-owned playback order, independent of the list being browsed. */
export class LiveChannelPlaybackQueue<T> {
private readonly snapshot = signal<PlaybackQueueSnapshot<T> | 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<string, T> {
const result = new Map<string, T>();
for (const item of items) {
const id = this.key(item);
if (id && !result.has(id)) result.set(id, item);
}
return result;
}
}
@@ -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<string | null>('news'),
searchPhrase: signal(''),
selectedItem: signal<StalkerItvChannel | null>(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<boolean>((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');
});
});
@@ -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<string> | 'preserve' | undefined;
interface LiveNavigationOptions {
store: InstanceType<typeof StalkerStore>;
router: Router | null;
sidebar: LiveLayoutSidebarStateService;
rows: (panelTerm?: Signal<string>) => StalkerItvChannel[];
play: (item: StalkerItvChannel) => void;
revealRow: (id: string) => boolean;
loading: () => boolean;
}
interface PlayingOrigin {
owner: string;
item: StalkerItvChannel;
categories: ReadonlyMap<string, string>;
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<StalkerItvChannel>(
(item) => item.id
);
private readonly origin = signal<PlayingOrigin | null>(null);
private readonly pendingReveal = signal<RevealRequest | null>(null);
private readonly revealed = signal<{
owner: string;
category: string;
item: StalkerItvChannel;
} | null>(null);
private readonly panelTerm = signal<Signal<string> | undefined>(undefined);
private ownerSeen = '';
private generation = 0;
private revealGeneration = 0;
private revealTimer: ReturnType<typeof setTimeout> | 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<void> {
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>): 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);
}
}
@@ -27,6 +27,19 @@
>
}
</div>
@if (navigation.canReveal()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="
'CHANNELS.SHOW_PLAYING_CHANNEL' | translate
"
[matTooltip]="'CHANNELS.SHOW_PLAYING_CHANNEL' | translate"
(click)="navigation.reveal()"
>
<mat-icon>playlist_play</mat-icon>
</button>
}
@if (isFullListMode()) {
<button
mat-icon-button
@@ -262,6 +275,7 @@
track item.id ?? $index
) {
<app-channel-list-item
[attr.data-channel-id]="normalizeStalkerEntityId(item.id)"
[name]="item.o_name || item.name || ''"
[logo]="item.logo"
[selected]="isSelectedChannel(item)"
@@ -290,9 +304,11 @@
favorites.get(normalizeStalkerEntityId(item.id)) ??
false
"
(clicked)="playChannel(item)"
(activated)="playChannel(item, true)"
(keyboardActivated)="playChannel(item, true)"
(clicked)="playChannel(item, undefined, panelSearchTerm)"
(activated)="playChannel(item, true, panelSearchTerm)"
(keyboardActivated)="
playChannel(item, true, panelSearchTerm)
"
(favoriteToggled)="toggleFavorite(item)"
(contextMenuRequested)="onChannelContextMenu(item, $event)"
/>
@@ -19,6 +19,7 @@ import {
viewChild,
viewChildren,
} from '@angular/core';
import { Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonModule } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
@@ -77,8 +78,6 @@ import {
LiveLayoutSidebarStateService,
PORTAL_PLAYER,
createLogger,
getAdjacentChannelItem,
getChannelItemByNumber,
isTypingInInput,
LiveEpgPanelState,
persistLiveEpgPanelState,
@@ -134,6 +133,8 @@ function matchesStalkerChannelTerm(
.includes(term);
}
import { StalkerLiveNavigation } from './stalker-live-navigation';
@Component({
selector: 'app-stalker-live-stream-layout',
templateUrl: './stalker-live-stream-layout.component.html',
@@ -267,9 +268,11 @@ export class StalkerLiveStreamLayoutComponent
computation: () => FULL_LIST_RENDER_CHUNK,
});
readonly visibleChannels = computed(() =>
this.isCategoryFromCache() || Boolean(this.searchTerm())
? this.filteredChannels().slice(0, this.renderLimit())
: this.filteredChannels()
this.navigation.withRevealedItem(
this.isCategoryFromCache() || Boolean(this.searchTerm())
? this.filteredChannels().slice(0, this.renderLimit())
: this.filteredChannels()
)
);
/**
* The fullscreen panel's rows while its own search field is blank: the
@@ -313,7 +316,7 @@ export class StalkerLiveStreamLayoutComponent
readonly isInitialChannelsLoading = computed(
() =>
!!this.stalkerStore.selectedCategoryId() &&
this.channels().length === 0 &&
this.visibleChannels().length === 0 &&
!this.searchTerm() &&
(this.isFullListLoading() ||
this.stalkerStore.isPaginatedContentLoading())
@@ -323,7 +326,7 @@ export class StalkerLiveStreamLayoutComponent
() =>
!!this.stalkerStore.selectedCategoryId() &&
!this.searchTerm() &&
this.channels().length === 0 &&
this.visibleChannels().length === 0 &&
!this.isInitialChannelsLoading()
);
@@ -566,6 +569,19 @@ export class StalkerLiveStreamLayoutComponent
: (this.fullscreenChannelPanelTemplate() ?? null)
);
readonly panelTitle = computed(() => this.selectedCategoryTitle() ?? '');
readonly navigation = new StalkerLiveNavigation({
store: this.stalkerStore,
router: inject(Router, { optional: true }),
sidebar: this.liveSidebarStateService,
rows: (term) => this.navigationRows(term),
play: (item) => {
void this.playChannel(item, true, 'preserve');
},
revealRow: (id) => this.revealPlayingRow(id),
loading: () =>
this.isLoadingMore() ||
this.stalkerStore.isPaginatedContentLoading(),
});
private epgPreviewRefreshTimer: ReturnType<typeof setTimeout> | null = null;
private unsubscribeRemoteChannelChange?: () => void;
private unsubscribeRemoteCommand?: () => void;
@@ -739,9 +755,9 @@ export class StalkerLiveStreamLayoutComponent
return;
}
const selectedItem = this.stalkerStore.selectedItem();
const selectedItem = this.navigation.activeItem();
const selectedType = this.stalkerStore.selectedContentType();
const channels = this.filteredChannels();
const channels = this.navigation.channels();
// Radio shares this layout and its remote channel handlers, so
// it must publish live status too — otherwise the remote shows
@@ -808,6 +824,7 @@ export class StalkerLiveStreamLayoutComponent
}
ngOnDestroy() {
this.navigation.reset();
this.unsubscribeRemoteChannelChange?.();
this.unsubscribeRemoteCommand?.();
// Leaving the live view would otherwise keep the last channel
@@ -842,8 +859,13 @@ export class StalkerLiveStreamLayoutComponent
async playChannel(
item: StalkerItvChannel,
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
startPlayback = !this.settingsStore.openStreamOnDoubleClick(),
navigationContext?: Signal<string> | 'preserve'
) {
const commitNavigation = this.navigation.prepare(
item,
navigationContext
);
const requestId = ++this.playbackRequestId;
const channelId = normalizeStalkerEntityId(item.id);
const sourceId = normalizeStalkerEntityId(
@@ -904,12 +926,14 @@ export class StalkerLiveStreamLayoutComponent
) {
return;
}
commitNavigation();
this.setActivePlayback(playback, null);
return;
}
if (deferSelection && (!sourceId || !channelId)) return;
commitNavigation();
if (deferSelection) {
if (!sourceId || !channelId) return;
this.stalkerStore.setSelectedItem(item);
}
@@ -1710,24 +1734,7 @@ export class StalkerLiveStreamLayoutComponent
}
private handleAdjacentChannelChange(direction: 'up' | 'down'): void {
const activeItem = this.stalkerStore.selectedItem();
if (!activeItem?.id) {
return;
}
const channels = this.filteredChannels();
const nextItem = getAdjacentChannelItem(
channels,
activeItem.id,
direction,
(item) => item.id
);
if (!nextItem) {
return;
}
void this.playChannel(nextItem, true);
this.navigation.adjacent(direction);
}
private handleRemoteControlCommand(command: {
@@ -1738,19 +1745,40 @@ export class StalkerLiveStreamLayoutComponent
| 'volume-toggle-mute';
number?: number;
}): void {
if (command.type !== 'channel-select-number' || !command.number) {
return;
}
if (command.type === 'channel-select-number')
this.navigation.selectNumber(command.number);
}
const channel = getChannelItemByNumber(
this.filteredChannels(),
command.number
);
if (!channel) {
return;
}
private navigationRows(term?: Signal<string>): StalkerItvChannel[] {
if (!term)
return this.showItvAllItems()
? this.itvFullChannelList().filter((item) =>
matchesStalkerChannelTerm(item, this.searchTerm())
)
: this.filteredChannels();
const query = term().trim().toLowerCase();
return query
? this.searchableChannels().filter((item) =>
matchesStalkerChannelTerm(item, query)
)
: this.panelIdleSource();
}
void this.playChannel(channel, true);
private revealPlayingRow(id: string): boolean {
this.ensureChannelWithinRenderWindow(id);
this.cdr.detectChanges();
const pane = this.scrollContainers().find(
(container) => container.nativeElement.id === 'live-channels'
)?.nativeElement as HTMLElement | undefined;
const row =
pane &&
Array.from(
pane.querySelectorAll<HTMLElement>('[data-channel-id]')
).find((element) => element.dataset['channelId'] === id);
if (!row || !pane) return false;
row.scrollIntoView({ block: 'nearest' });
pane.focus({ preventScroll: true });
return true;
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -27,12 +27,37 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
const originalElectron = window.electron;
const itvChannels = signal([
{ id: '10001', cmd: 'ffrt4://itv/1', name: 'Alpha TV', o_name: 'Alpha TV', logo: 'a.png' },
{ id: '10002', cmd: 'ffrt4://itv/2', name: 'Beta TV', o_name: 'Beta TV', logo: 'b.png' },
{
id: '10001',
cmd: 'ffrt4://itv/1',
name: 'Alpha TV',
o_name: 'Alpha TV',
logo: 'a.png',
},
{
id: '10002',
cmd: 'ffrt4://itv/2',
name: 'Beta TV',
o_name: 'Beta TV',
logo: 'b.png',
},
]);
const originalItvChannels = itvChannels();
const radioChannels = signal([
{ id: 'radio-1', cmd: 'ifm https://s/jazz.mp3', name: 'Jazz FM', o_name: 'Jazz FM', logo: 'j.png' },
{ id: 'radio-2', cmd: 'ifm https://s/news.mp3', name: 'News Radio', o_name: 'News Radio', logo: 'n.png' },
{
id: 'radio-1',
cmd: 'ifm https://s/jazz.mp3',
name: 'Jazz FM',
o_name: 'Jazz FM',
logo: 'j.png',
},
{
id: 'radio-2',
cmd: 'ifm https://s/news.mp3',
name: 'News Radio',
o_name: 'News Radio',
logo: 'n.png',
},
]);
const selectedItem = signal<{
id: string;
@@ -93,8 +118,35 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
const updateRemoteControlStatus = jest.fn();
it('keeps remote order and number when browsing excludes the playing channel', async () => {
const original = itvChannels();
stalkerStore.setSelectedItem.mockImplementation((item) =>
selectedItem.set(item)
);
fixture.detectChanges();
await fixture.componentInstance.playChannel(original[0]);
fixture.detectChanges();
stalkerStore.selectedCategoryId.set('sports');
itvChannels.set([{ ...original[0], id: '90001', name: 'Sports TV' }]);
fixture.detectChanges();
expect(updateRemoteControlStatus).toHaveBeenLastCalledWith(
expect.objectContaining({ channelNumber: 1 })
);
const host = fixture.componentInstance as unknown as {
handleRemoteChannelChange(direction: 'down'): void;
};
host.handleRemoteChannelChange('down');
await fixture.whenStable();
expect(selectedItem()?.id).toBe(original[1].id);
itvChannels.set(original);
stalkerStore.selectedCategoryId.set('1001');
});
beforeEach(async () => {
updateRemoteControlStatus.mockClear();
itvChannels.set(originalItvChannels);
stalkerStore.selectedCategoryId.set('1001');
stalkerStore.setSelectedItem.mockReset();
window.electron = {
platform: 'darwin',
setUserAgent: jest.fn().mockResolvedValue(true),
@@ -128,8 +180,7 @@ describe('StalkerLiveStreamLayoutComponent remote status', () => {
// remote-control bridge method must be present.
get supportsRemoteControl() {
const bridge = window.electron as
| Record<string, unknown>
| undefined;
Record<string, unknown> | undefined;
return [
'updateRemoteControlStatus',
'onChannelChange',
@@ -0,0 +1,184 @@
import { computed, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ActivatedRoute,
convertToParamMap,
NavigationEnd,
Router,
} from '@angular/router';
import { PORTAL_PLAYER } from '@iptvnator/portal/shared/util';
import {
FavoritesService,
XtreamStore,
XtreamUrlService,
} from '@iptvnator/portal/xtream/data-access';
import {
RecordingsService,
RuntimeCapabilitiesService,
SettingsStore,
} from '@iptvnator/services';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { LiveStreamLayoutComponent } from './live-stream-layout.component';
import { XtreamLiveChannelItem } from './xtream-live-channel-navigation.service';
const target = { xtream_id: 202, category_id: '7', name: 'Search Channel' };
const alpha = { xtream_id: 201, category_id: '7', name: 'Alpha' };
const zulu = { xtream_id: 203, category_id: '7', name: 'Zulu' };
const other = { xtream_id: 101, category_id: '1', name: 'Other' };
const catalog = [other, target, zulu, alpha];
describe('Xtream live auto-open playback queue', () => {
let fixture: ComponentFixture<LiveStreamLayoutComponent>;
let component: LiveStreamLayoutComponent;
const selectedCategoryId = signal<number | null>(1);
const selectedItem = signal<XtreamLiveChannelItem | null>(null);
const categorySearchTerm = signal('');
const visibleItems = computed(() =>
catalog.filter(
(item) =>
(!selectedCategoryId() ||
Number(item.category_id) === selectedCategoryId()) &&
item.name
.toLowerCase()
.includes(categorySearchTerm().toLowerCase())
)
);
let query: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let events: Subject<NavigationEnd>;
const store = {
getCategoriesBySelectedType: signal([
{ category_id: 1 },
{ category_id: 7 },
]),
getCategoryItemCounts: signal(new Map()),
epgItems: signal([]),
currentEpgItem: signal(null),
selectedTypeContentLoading: signal(false),
isLoadingEpg: signal(false),
selectedCategoryId,
selectedItem,
categorySearchTerm,
selectedContentType: signal('live'),
currentPlaylist: signal({ id: 'playlist-1' }),
liveStreams: signal(catalog),
getPaginatedContent: signal(catalog),
hasMoreContent: signal(false),
selectItemsFromSelectedCategory: visibleItems,
setSelectedCategory: jest.fn((id: number) =>
selectedCategoryId.set(id)
),
setSelectedItem: jest.fn((item: XtreamLiveChannelItem) =>
selectedItem.set(item)
),
constructStreamUrl: jest.fn((item: XtreamLiveChannelItem) => {
selectedItem.set(item);
return `https://example.test/live/${item.xtream_id}.ts`;
}),
};
beforeEach(async () => {
selectedCategoryId.set(1);
selectedItem.set(null);
categorySearchTerm.set('');
store.constructStreamUrl.mockClear();
store.setSelectedCategory.mockClear();
window.history.replaceState({}, '');
localStorage.removeItem('xtream-live-channel-sort-mode');
query = new BehaviorSubject(convertToParamMap({}));
events = new Subject();
await TestBed.configureTestingModule({
imports: [LiveStreamLayoutComponent],
providers: [
{ provide: XtreamStore, useValue: store },
{
provide: ActivatedRoute,
useValue: {
snapshot: {
data: {},
params: {},
queryParamMap: convertToParamMap({}),
},
queryParamMap: query,
pathFromRoot: [
{ snapshot: { data: { layout: 'workspace' } } },
],
},
},
{ provide: Router, useValue: { events, navigate: jest.fn() } },
{
provide: FavoritesService,
useValue: { getFavorites: () => of([]) },
},
{ provide: XtreamUrlService, useValue: {} },
{
provide: PORTAL_PLAYER,
useValue: { isEmbeddedPlayer: () => true },
},
{ provide: RecordingsService, useValue: {} },
{
provide: RuntimeCapabilitiesService,
useValue: {
isElectron: false,
supportsEpg: false,
supportsRemoteControl: false,
},
},
{
provide: SettingsStore,
useValue: {
openStreamOnDoubleClick: signal(false),
resolvedEpgViewMode: signal('timeline'),
resolvedEpgOffsetMinutes: signal(0),
},
},
],
})
.overrideComponent(LiveStreamLayoutComponent, {
set: { template: '', imports: [] },
})
.compileComponents();
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
component = fixture.componentInstance;
fixture.detectChanges();
component.setLiveChannelSortMode('name-asc');
});
afterEach(() => {
fixture.destroy();
window.history.replaceState({}, '');
localStorage.removeItem('xtream-live-channel-sort-mode');
});
it.each([
{ category: 1, query: 'Other' },
{ category: null, query: '' },
])(
'auto-opens with the destination category queue from $category',
({ category, query: previousQuery }) => {
selectedCategoryId.set(category);
categorySearchTerm.set(previousQuery);
query.next(convertToParamMap({ q: previousQuery }));
window.history.replaceState(
{ openXtreamLiveItemId: target.xtream_id },
''
);
events.next(new NavigationEnd(1, '/live', '/live'));
fixture.detectChanges();
expect(store.setSelectedCategory).toHaveBeenCalledWith(7);
expect(component.channelNavigation.remoteChannels()).toEqual([
alpha,
target,
zulu,
]);
component['handleRemoteChannelChange']('down');
expect(store.constructStreamUrl).toHaveBeenLastCalledWith(zulu);
}
);
it('keeps the actual displayed cross-category list for an explicit All Items click', () => {
selectedCategoryId.set(null);
component.onLiveRootItemClick(target);
expect(component.channelNavigation.remoteChannels()).toEqual(catalog);
});
});
@@ -22,6 +22,19 @@
}}</span>
}
</div>
@if (channelNavigation.canReveal()) {
<button
mat-icon-button
type="button"
[attr.aria-label]="
'CHANNELS.SHOW_PLAYING_CHANNEL' | translate
"
[matTooltip]="'CHANNELS.SHOW_PLAYING_CHANNEL' | translate"
(click)="channelNavigation.reveal()"
>
<mat-icon>list_alt</mat-icon>
</button>
}
<button
mat-icon-button
[matMenuTriggerFor]="channelSortMenu"
@@ -71,6 +84,7 @@
</mat-menu>
</div>
<app-portal-channels-list
[revealRequest]="channelNavigation.revealRequest()"
[sortMode]="liveChannelSortMode()"
[searchTermInput]="workspaceSearchTerm()"
(playClicked)="playLive($event)"
@@ -215,11 +229,16 @@
<!-- Stamped into the player's fullscreen channel panel (FULLSCREEN_CHANNEL_PANEL). -->
<ng-template #fullscreenChannelPanel let-searchTerm="searchTerm">
<app-portal-channels-list
#fullscreenList
[sortMode]="liveChannelSortMode()"
[channelsOverride]="fullscreenPanelChannels()"
[searchTermInput]="searchTerm()"
[fullscreenPanelCopy]="true"
(playClicked)="playLive($event)"
(playbackRequested)="playLive($event, true)"
(playClicked)="
playLive($event, undefined, fullscreenList.filteredChannels())
"
(playbackRequested)="
playLive($event, true, fullscreenList.filteredChannels())
"
/>
</ng-template>
@@ -57,6 +57,8 @@ class StubPortalChannelsListComponent {
readonly sortMode = input<'server' | 'name-asc' | 'name-desc'>('server');
readonly channelsOverride = input<unknown[] | null>(null);
readonly searchTermInput = input('');
readonly revealRequest = input<unknown>(null);
readonly filteredChannels = signal<unknown[]>([]);
readonly playClicked = output<unknown>();
readonly playbackRequested = output<unknown>();
}
@@ -545,9 +547,7 @@ describe('LiveStreamLayoutComponent', () => {
fixture.nativeElement.querySelector('.category-subtitle')
.textContent
).toContain('3 channels');
expect(
fixture.nativeElement.querySelector('mat-paginator')
).toBeNull();
expect(fixture.nativeElement.querySelector('mat-paginator')).toBeNull();
expect(
fixture.debugElement.query(
By.directive(StubPortalChannelsListComponent)
@@ -1027,9 +1027,29 @@ describe('LiveStreamLayoutComponent', () => {
expect(epgTimeline.componentInstance.activeProgram()).toBeNull();
});
it('keeps remote order after browsing another category and changing sort', () => {
const first = { ...sampleChannel, category_id: '1', name: 'Zulu' };
const next = { ...first, xtream_id: 102, name: 'Alpha' };
liveStreams.set([first, next]);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([
first,
next,
]);
selectedItem.set(first);
component.playLive(first);
xtreamStore.setSelectedCategory.mockClear();
selectedCategoryId.set(2);
xtreamStore.selectItemsFromSelectedCategory.mockReturnValue([]);
component.setLiveChannelSortMode('name-desc');
component['handleRemoteChannelChange']('down');
expect(xtreamStore.constructStreamUrl).toHaveBeenLastCalledWith(next);
expect(xtreamStore.setSelectedCategory).not.toHaveBeenCalled();
});
it('starts external playback from remote channel navigation when double-click opening is enabled', () => {
const nextChannel = {
...sampleChannel,
category_id: '1',
xtream_id: 102,
name: 'Channel 102',
};
@@ -1041,6 +1061,9 @@ describe('LiveStreamLayoutComponent', () => {
nextChannel,
]);
liveStreams.set([{ ...sampleChannel, category_id: '1' }, nextChannel]);
component.playLive(sampleChannel);
xtreamStore.openPlayer.mockClear();
(
component as unknown as {
handleRemoteChannelChange(direction: 'up' | 'down'): void;
@@ -94,20 +94,12 @@ import {
import { LiveStreamAutoOpenStateService } from './live-stream-auto-open-state.service';
import { createPlaybackSessionKey } from '@iptvnator/playback/util';
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
import {
XtreamLiveChannelNavigationService,
type XtreamLiveChannelItem,
} from './xtream-live-channel-navigation.service';
interface XtreamLiveChannelItem {
readonly added?: string;
readonly category_id?: string | number;
readonly last_modified?: string;
readonly name?: string;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
readonly xtream_id: number;
}
const LIVE_CHANNEL_SORT_STORAGE_KEY = 'xtream-live-channel-sort-mode';
@Component({
selector: 'app-live-stream-layout',
@@ -115,6 +107,7 @@ interface XtreamLiveChannelItem {
styleUrls: ['./live-stream-layout.component.scss'],
providers: [
LiveStreamAutoOpenStateService,
XtreamLiveChannelNavigationService,
// The fullscreen channel panel inside the player renders this
// category's channel list (see the `fullscreenChannelPanel` template).
{
@@ -167,12 +160,12 @@ export class LiveStreamLayoutComponent
this.xtreamStore.selectedTypeContentLoading;
readonly isLoadingEpg = this.xtreamStore.isLoadingEpg;
readonly selectedCategoryId = this.xtreamStore.selectedCategoryId;
readonly liveChannelSortMode = signal<PortalChannelSortMode>('server');
readonly channelNavigation = inject(XtreamLiveChannelNavigationService);
readonly liveChannelSortMode = this.channelNavigation.sortMode;
private readonly fullscreenChannelPanelTemplate =
viewChild<TemplateRef<FullscreenChannelPanelContext>>(
'fullscreenChannelPanel'
);
private readonly fullscreenChannelPanelTemplate = viewChild<
TemplateRef<FullscreenChannelPanelContext>
>('fullscreenChannelPanel');
/** FULLSCREEN_CHANNEL_PANEL: the current category's list, unless opted out. */
readonly panelTemplate = computed(() =>
this.settingsStore.fullscreenChannelPanel?.() === false
@@ -444,7 +437,11 @@ export class LiveStreamLayoutComponent
return;
}
this.playLive(item);
this.playLive(
item,
undefined,
this.channelNavigation.channelsForAutoOpen(item)
);
// Ensure selectedItem is set so EPG loading and remote-control
// status reflect the channel (constructStreamUrl also does this
// internally, but an explicit call makes the intent clear and
@@ -537,15 +534,18 @@ export class LiveStreamLayoutComponent
playLive(
item: XtreamLiveChannelItem,
startPlayback = !this.settingsStore.openStreamOnDoubleClick()
startPlayback = !this.settingsStore.openStreamOnDoubleClick(),
displayedChannels?: readonly XtreamLiveChannelItem[],
remote = false
) {
this.playbackRequestId += 1;
const streamUrl = this.xtreamStore.constructStreamUrl(item);
this.channelNavigation.capture(item, displayedChannels, remote);
this.activeCatchupProgram.set(null);
// Keep both root/recently-added playback and same-category replays in
// sync with the category rail. For already-selected channels this is a
// store no-op.
this.selectLiveItemCategory(item);
if (!remote) this.selectLiveItemCategory(item);
this.activeLiveItemId.set(item.xtream_id);
this.activePlayback.set({
streamUrl,
@@ -587,7 +587,11 @@ export class LiveStreamLayoutComponent
}
onLiveRootItemClick(item: unknown): void {
this.playLive(item as XtreamLiveChannelItem);
this.playLive(
item as XtreamLiveChannelItem,
undefined,
this.liveRootItems() as unknown as XtreamLiveChannelItem[]
);
}
onLiveEpgPanelCollapsedChange(collapsed: boolean): void {
@@ -664,7 +668,7 @@ export class LiveStreamLayoutComponent
return;
}
this.playLive(nextItem, true);
this.playLive(nextItem, true, undefined, true);
}
private handleRemoteControlCommand(command: {
@@ -685,7 +689,7 @@ export class LiveStreamLayoutComponent
return;
}
this.playLive(channel, true);
this.playLive(channel, true, undefined, true);
}
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
@@ -702,7 +706,7 @@ export class LiveStreamLayoutComponent
}
private getVisibleChannels(): XtreamLiveChannelItem[] {
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
return [...this.channelNavigation.remoteChannels()];
}
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
@@ -0,0 +1,282 @@
import { Component, computed, inject, signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
ActivatedRoute,
convertToParamMap,
NavigationStart,
Router,
provideRouter,
} from '@angular/router';
import { RouterTestingHarness } from '@angular/router/testing';
import { LiveLayoutSidebarStateService } from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
import { BehaviorSubject, Subject } from 'rxjs';
import { XtreamLiveChannelNavigationService } from './xtream-live-channel-navigation.service';
const first = { xtream_id: 1, category_id: '10', title: 'Zulu' };
const second = { xtream_id: 2, category_id: '10', title: 'Alpha' };
const other = { xtream_id: 3, category_id: '20', title: 'Other' };
@Component({
template: '',
providers: [XtreamLiveChannelNavigationService],
})
class NavigationHostComponent {
readonly navigation = inject(XtreamLiveChannelNavigationService);
}
describe('XtreamLiveChannelNavigationService', () => {
const currentPlaylist = signal({ id: 'source-1' });
const selectedContentType = signal('live');
const selectedCategoryId = signal<number | null>(10);
const categorySearchTerm = signal('');
const liveStreams = signal([first, second, other]);
const categories = signal([
{ category_id: 10, hidden: false },
{ category_id: 20, hidden: false },
]);
const selectedChannels = computed(() =>
liveStreams().filter(
(item) =>
(!selectedCategoryId() ||
Number(item.category_id) === selectedCategoryId()) &&
item.title
.toLowerCase()
.includes(categorySearchTerm().toLowerCase())
)
);
const store = {
currentPlaylist,
selectedContentType,
selectedCategoryId,
categorySearchTerm,
liveStreams,
getCategoriesBySelectedType: categories,
selectItemsFromSelectedCategory: selectedChannels,
setSelectedCategory: jest.fn((id: number) =>
selectedCategoryId.set(id)
),
setCategorySearchTerm: jest.fn((term: string) =>
categorySearchTerm.set(term)
),
constructStreamUrl: jest.fn(),
openPlayer: jest.fn(),
setSelectedItem: jest.fn(),
};
let service: XtreamLiveChannelNavigationService;
let query: BehaviorSubject<ReturnType<typeof convertToParamMap>>;
let events: Subject<NavigationStart>;
let route: {
snapshot: {
params: Record<string, string>;
queryParamMap: ReturnType<typeof convertToParamMap>;
};
queryParamMap: unknown;
};
const navigate = jest.fn();
const sidebar = { setState: jest.fn() };
beforeEach(() => {
currentPlaylist.set({ id: 'source-1' });
selectedContentType.set('live');
selectedCategoryId.set(10);
categorySearchTerm.set('');
liveStreams.set([first, second, other]);
categories.set([
{ category_id: 10, hidden: false },
{ category_id: 20, hidden: false },
]);
query = new BehaviorSubject(convertToParamMap({}));
events = new Subject();
route = {
snapshot: { params: {}, queryParamMap: convertToParamMap({}) },
queryParamMap: query.asObservable(),
};
jest.clearAllMocks();
navigate.mockImplementation(() => {
events.next(new NavigationStart(1, '/live'));
query.next(convertToParamMap({}));
return Promise.resolve(true);
});
TestBed.configureTestingModule({
providers: [
XtreamLiveChannelNavigationService,
{ provide: XtreamStore, useValue: store },
{ provide: ActivatedRoute, useValue: route },
{ provide: Router, useValue: { events, navigate } },
{ provide: LiveLayoutSidebarStateService, useValue: sidebar },
],
});
service = TestBed.inject(XtreamLiveChannelNavigationService);
TestBed.flushEffects();
});
it('captures the displayed sort and keeps it after category, search, and sort changes', () => {
service.sortMode.set('name-asc');
service.capture(first);
selectedCategoryId.set(20);
service.sortMode.set('name-desc');
query.next(convertToParamMap({ q: 'Other' }));
expect(service.remoteChannels()).toEqual([second, first]);
service.capture(second, service.displayedChannels(), true);
expect(service.remoteChannels()).toEqual([second, first]);
service.capture(second);
expect(service.remoteChannels()).toEqual([second, first]);
});
it('captures fullscreen filtered rows and replaces the queue on a new explicit selection', () => {
service.capture(first, [first]);
expect(service.remoteChannels()).toEqual([first]);
service.capture(other, [other, second]);
expect(service.remoteChannels()).toEqual([other, second]);
});
it('drops hidden and removed channels without adopting the browsed list', () => {
selectedCategoryId.set(null);
service.capture(first);
categories.set([
{ category_id: 10, hidden: false },
{ category_id: 20, hidden: true },
]);
expect(service.remoteChannels()).toEqual([first, second]);
liveStreams.set([second, other]);
expect(service.remoteChannels()).toEqual([second]);
expect(service.canReveal()).toBe(false);
});
it('invalidates a queue when its source or content type changes', () => {
service.capture(first);
currentPlaylist.set({ id: 'source-2' });
expect(service.remoteChannels()).toEqual([]);
TestBed.flushEffects();
currentPlaylist.set({ id: 'source-1' });
expect(service.remoteChannels()).toEqual([]);
service.capture(first);
selectedContentType.set('vod');
TestBed.flushEffects();
selectedContentType.set('live');
expect(service.remoteChannels()).toEqual([]);
expect(service.activeItem()).toBeNull();
});
it('shows reveal only when the playing channel is absent from the actual filtered list', () => {
service.capture(first);
expect(service.canReveal()).toBe(false);
query.next(convertToParamMap({ q: 'Alpha' }));
expect(service.canReveal()).toBe(true);
categories.set([{ category_id: 10, hidden: true }]);
expect(service.canReveal()).toBe(false);
});
it.each([false, true])(
'reveals from a category route=%s without touching playback or queue',
async (categoryRoute) => {
service.capture(first);
selectedCategoryId.set(20);
categorySearchTerm.set('Other');
query.next(convertToParamMap({ q: 'Other' }));
if (categoryRoute) route.snapshot.params = { categoryId: '20' };
await service.reveal();
expect(navigate).toHaveBeenCalledWith(
categoryRoute ? ['../', 10] : [],
{
relativeTo: route,
queryParams: { q: null },
queryParamsHandling: 'merge',
}
);
expect(selectedCategoryId()).toBe(10);
expect(categorySearchTerm()).toBe('');
expect(sidebar.setState).toHaveBeenCalledWith('expanded');
expect(service.revealRequest()).toEqual(
expect.objectContaining({ channelId: 1 })
);
expect(service.remoteChannels()).toEqual([first, second]);
expect(store.constructStreamUrl).not.toHaveBeenCalled();
expect(store.openPlayer).not.toHaveBeenCalled();
expect(store.setSelectedItem).not.toHaveBeenCalled();
}
);
it.each(['live', 'live/20', 'live-without-query'])(
'keeps the live host mounted and synchronizes real %s navigation',
async (path) => {
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [
provideRouter([
{
path: 'workspace/xtreams/:id',
children: [
{
path: 'live',
component: NavigationHostComponent,
},
{
path: 'live/:categoryId',
component: NavigationHostComponent,
},
],
},
]),
{ provide: XtreamStore, useValue: store },
{
provide: LiveLayoutSidebarStateService,
useValue: sidebar,
},
],
});
selectedCategoryId.set(20);
const harness = await RouterTestingHarness.create();
const host = await harness.navigateByUrl(
`/workspace/xtreams/source-1/${path === 'live-without-query' ? 'live' : `${path}?q=Other`}`,
NavigationHostComponent
);
host.navigation.capture(first, [first, second]);
await host.navigation.reveal();
harness.detectChanges();
expect(TestBed.inject(Router).url).toBe(
`/workspace/xtreams/source-1/${path !== 'live/20' ? 'live' : 'live/10'}`
);
expect(harness.routeDebugElement?.componentInstance).toBe(host);
expect(selectedCategoryId()).toBe(10);
expect(host.navigation.revealRequest()?.channelId).toBe(
first.xtream_id
);
expect(host.navigation.remoteChannels()).toEqual([first, second]);
}
);
it.each([
'playback',
'source',
'category',
'navigation',
'hidden',
'cancelled',
])('ignores a stale reveal after %s changes', async (change) => {
route.snapshot.params = { categoryId: '20' };
service.capture(first);
selectedCategoryId.set(20);
let finish!: (value: boolean) => void;
navigate.mockImplementation(() => {
events.next(new NavigationStart(1, '/live'));
return new Promise<boolean>((resolve) => {
finish = resolve;
});
});
const reveal = service.reveal();
if (change === 'playback') service.capture(other);
if (change === 'source') currentPlaylist.set({ id: 'source-2' });
if (change === 'category') selectedCategoryId.set(30);
if (change === 'navigation')
events.next(new NavigationStart(2, '/elsewhere'));
if (change === 'hidden')
categories.set([{ category_id: 10, hidden: true }]);
finish(change !== 'cancelled');
await reveal;
expect(store.setSelectedCategory).not.toHaveBeenCalled();
expect(sidebar.setState).not.toHaveBeenCalled();
expect(service.revealRequest()).toBeNull();
});
});
@@ -0,0 +1,245 @@
import {
computed,
DestroyRef,
effect,
inject,
Injectable,
signal,
untracked,
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, NavigationStart, Router } from '@angular/router';
import { LiveChannelPlaybackQueue } from '@iptvnator/portal/shared/data-access';
import {
LiveLayoutSidebarStateService,
PortalChannelSortMode,
queryParamSignal,
sortPortalChannelItems,
} from '@iptvnator/portal/shared/util';
import { XtreamStore } from '@iptvnator/portal/xtream/data-access';
export interface XtreamLiveChannelItem {
readonly added?: string;
readonly category_id?: string | number;
readonly last_modified?: string;
readonly name?: string;
readonly poster_url?: string;
readonly stream_icon?: string;
readonly title?: string;
readonly tv_archive?: number | null;
readonly tv_archive_duration?: number | string | null;
readonly xtream_id: number;
}
/** Browse state is independent from the ordered list that started playback. */
@Injectable()
export class XtreamLiveChannelNavigationService {
private readonly store = inject(XtreamStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly sidebar = inject(LiveLayoutSidebarStateService);
private readonly destroyRef = inject(DestroyRef);
private readonly queue =
new LiveChannelPlaybackQueue<XtreamLiveChannelItem>(
(item) => item.xtream_id
);
private readonly playing = signal<{
owner: string;
item: XtreamLiveChannelItem;
} | null>(null);
readonly sortMode = signal<PortalChannelSortMode>('server');
private readonly query = queryParamSignal(this.route, 'q', (q) => q ?? '');
private readonly owner = computed(() =>
this.store.selectedContentType() === 'live'
? `${this.store.currentPlaylist()?.id ?? ''}:live`
: ''
);
readonly activeItem = computed(() => {
const playing = this.playing();
return playing?.owner === this.owner() ? playing.item : null;
});
readonly displayedChannels = computed(() => {
const term = this.query().trim().toLowerCase();
const channels = sortPortalChannelItems(
this.store.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[],
this.sortMode(),
(item) => item.title ?? item.name
);
return term
? channels.filter((item) =>
`${item.title ?? ''} ${item.name ?? ''}`
.toLowerCase()
.includes(term)
)
: channels;
});
private readonly accessibleChannels = computed(() => {
const categories = new Set(
this.store
.getCategoriesBySelectedType()
.filter(
(category) => !('hidden' in category && category.hidden)
)
.map((category) => Number(category.category_id ?? category.id))
);
return (this.store.liveStreams() as XtreamLiveChannelItem[]).filter(
(item) => categories.has(Number(item.category_id))
);
});
readonly remoteChannels = computed(() => {
const available = new Map(
this.accessibleChannels().map((item) => [
Number(item.xtream_id),
item,
])
);
return this.queue
.items(this.owner())
.map((item) => available.get(Number(item.xtream_id)))
.filter(
(item): item is XtreamLiveChannelItem => item !== undefined
);
});
readonly canReveal = computed(() => {
const active = this.activeItem();
return (
!!active &&
this.accessibleChannels().some(
(item) => Number(item.xtream_id) === Number(active.xtream_id)
) &&
!this.displayedChannels().some(
(item) => Number(item.xtream_id) === Number(active.xtream_id)
)
);
});
private readonly pendingReveal = signal<{
channelId: number;
categoryId: number;
sequence: number;
} | null>(null);
readonly revealRequest = computed(() => {
const request = this.pendingReveal();
return request &&
request.channelId === this.activeItem()?.xtream_id &&
request.categoryId === this.store.selectedCategoryId() &&
!this.query().trim()
? request
: null;
});
private requestSequence = 0;
private navigationGeneration = 0;
constructor() {
let previousOwner = this.owner();
effect(() => {
const owner = this.owner();
if (owner !== previousOwner) {
previousOwner = owner;
untracked(() => {
this.queue.clear();
this.playing.set(null);
this.pendingReveal.set(null);
this.requestSequence++;
});
}
});
this.router.events.pipe(takeUntilDestroyed()).subscribe((event) => {
if (event instanceof NavigationStart) {
this.navigationGeneration++;
this.pendingReveal.set(null);
}
});
}
/** History handoffs carry an item, not a browsed list or its old filter. */
channelsForAutoOpen(
item: XtreamLiveChannelItem
): readonly XtreamLiveChannelItem[] {
return sortPortalChannelItems(
this.accessibleChannels().filter(
(channel) =>
Number(channel.category_id) === Number(item.category_id)
),
this.sortMode(),
(channel) => channel.title ?? channel.name
);
}
capture(
item: XtreamLiveChannelItem,
items: readonly XtreamLiveChannelItem[] = this.displayedChannels(),
preserveQueue = false
): void {
this.requestSequence++;
this.pendingReveal.set(null);
const owner = this.owner();
if (!preserveQueue && this.activeItem()?.xtream_id !== item.xtream_id) {
this.queue.capture(
owner,
JSON.stringify([
this.store.selectedCategoryId(),
this.query(),
this.sortMode(),
]),
items,
item
);
}
this.playing.set({ owner, item });
}
async reveal(): Promise<void> {
if (!this.canReveal()) return;
const active = this.activeItem();
const target = this.accessibleChannels().find(
(item) => item.xtream_id === active?.xtream_id
);
if (!target) return;
const categoryId = Number(target.category_id);
const owner = this.owner();
const sequence = ++this.requestSequence;
const previousCategory = this.store.selectedCategoryId();
const generation = this.navigationGeneration;
const routeCategoryId = this.route.snapshot.params?.['categoryId'];
const needsNavigation =
this.query() ||
this.route.snapshot.queryParamMap.has('q') ||
(routeCategoryId && Number(routeCategoryId) !== categoryId);
// Root category browsing is store-only: Angular reports a same-URL
// navigation as false, even though there is nothing to cancel.
const navigation = needsNavigation
? this.router.navigate(routeCategoryId ? ['../', categoryId] : [], {
relativeTo: this.route,
queryParams: { q: null },
queryParamsHandling: 'merge',
})
: Promise.resolve(true);
try {
const navigated = await navigation;
if (
navigated === false ||
this.destroyRef.destroyed ||
sequence !== this.requestSequence ||
owner !== this.owner() ||
this.navigationGeneration > generation + 1 ||
this.activeItem()?.xtream_id !== target.xtream_id ||
!this.accessibleChannels().some(
(item) => item.xtream_id === target.xtream_id
) ||
(this.store.selectedCategoryId() !== previousCategory &&
this.store.selectedCategoryId() !== categoryId)
)
return;
this.store.setCategorySearchTerm('');
this.store.setSelectedCategory(categoryId);
this.sidebar.setState('expanded');
this.pendingReveal.set({
channelId: target.xtream_id,
categoryId,
sequence,
});
} catch {
// Cancelled/failed navigation leaves playback and browse state intact.
}
}
}
@@ -421,6 +421,41 @@ describe('PortalChannelsListComponent', () => {
expect(scrollToIndex).toHaveBeenCalledWith(15, 'smooth');
});
it('explicitly reveals and focuses an unchanged selected channel after browsing away', () => {
const requestFrame = jest
.spyOn(window, 'requestAnimationFrame')
.mockReturnValue(100000);
selectedTypeContentLoading.set(false);
selectedChannels.set(
Array.from({ length: 20 }, (_, index) => ({
xtream_id: index + 1,
title: `Channel ${index + 1}`,
}))
);
selectedItem.set({ xtream_id: 16 });
fixture.detectChanges();
const viewport = fixture.componentInstance.viewport();
if (!viewport) throw new Error('Expected channel viewport');
const scroll = jest.spyOn(viewport, 'scrollToIndex');
const focus = jest.spyOn(viewport.elementRef.nativeElement, 'focus');
fixture.componentRef.setInput('revealRequest', {
channelId: 16,
sequence: 1,
});
fixture.detectChanges();
expect(scroll).not.toHaveBeenCalled();
const frame =
requestFrame.mock.calls[requestFrame.mock.calls.length - 1]?.[0];
frame?.(0);
requestFrame.mockRestore();
expect(scroll).toHaveBeenCalledWith(15, 'auto');
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
scroll.mockClear();
selectedChannels.set([...selectedChannels()]);
fixture.detectChanges();
expect(scroll).not.toHaveBeenCalled();
});
it('does not start smooth alignment when a selected row is already visible', () => {
selectedTypeContentLoading.set(false);
selectedChannels.set(
@@ -4,6 +4,7 @@ import {
ScrollingModule,
} from '@angular/cdk/scrolling';
import {
afterRenderEffect,
AfterViewInit,
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -105,6 +106,10 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
* invalid and could point that hand-off at the hidden copy.
*/
readonly fullscreenPanelCopy = input(false);
readonly revealRequest = input<{
channelId: number;
sequence: number;
} | null>(null);
readonly xtreamStore = inject(XtreamStore);
private readonly favoritesService = inject(FavoritesService);
@@ -190,6 +195,36 @@ export class PortalChannelsListComponent implements AfterViewInit, OnDestroy {
untracked(() => this.repickPreviewsForOffsetChange());
});
let appliedReveal = -1;
// A filtered-out viewport is recreated on reveal. CDK attaches its
// scroll strategy in a microtask after rendering; align on the next
// frame so scrollToIndex cannot silently run before that attachment.
afterRenderEffect((onCleanup) => {
const request = this.revealRequest();
const viewport = this.viewport();
const channels = this.filteredChannels();
if (!request || !viewport || appliedReveal === request.sequence)
return;
const index = channels.findIndex(
(item) => item.xtream_id === request.channelId
);
if (index < 0) return;
const frame = requestAnimationFrame(() => {
if (
this.revealRequest()?.sequence !== request.sequence ||
this.viewport() !== viewport
)
return;
appliedReveal = request.sequence;
viewport.checkViewportSize();
viewport.scrollToIndex(index, 'auto');
viewport.elementRef.nativeElement.focus({
preventScroll: true,
});
});
onCleanup(() => cancelAnimationFrame(frame));
});
const selectedChannelId = computed(() =>
Number(
(