mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
Merge origin/master into the programme guide branch
Adopts the compact icon-only timeline toolbar (#1557): the Guide button is now an icon-only `epg-timeline__iconbtn` with its label in the tooltip and aria-label, so the unused EPG.TIMELINE.GUIDE key is dropped from all locales. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
commit
49fdd40c9d
123 files changed
+5976
-474
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: feature
|
||||
area: epg
|
||||
---
|
||||
|
||||
The EPG timeline toolbar is more compact: "Now" is an icon button, and the zoom slider is replaced by a single button that cycles day overview → by hour → detailed. Ctrl/⌘ + scroll (or a trackpad pinch) over the timeline zooms smoothly around the cursor. The channel and programme title now use all the space the controls free up.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: live-tv
|
||||
issues: [1458]
|
||||
---
|
||||
|
||||
A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: xtream
|
||||
issues: [1513]
|
||||
---
|
||||
|
||||
Xtream Live TV in Auto can try advertised TS once when HLS initially fails with an HTTP error in a web player. The selected player and stream headers are kept. Settings explain the manual TS workaround for external players, Embedded MPV, and HLS retries that never report a terminal failure.
|
||||
@@ -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
|
||||
@@ -262,6 +272,18 @@ scrollbar and guarded initial page focus. Contracts:
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` and
|
||||
`docs/architecture/portal-detail-navigation.md`.
|
||||
|
||||
## Xtream Live Auto Format
|
||||
|
||||
The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit
|
||||
HLS+TS account evidence, using the canonical URL builder and original headers.
|
||||
The same web player may try TS once after an owned initial terminal HTTP failure,
|
||||
before `playing`; the old transport unmounts before the guarded render callback
|
||||
starts TS. No player preference or playlist cache changes. Manual formats,
|
||||
unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External
|
||||
MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without
|
||||
a terminal diagnostic also need manual TS. Contract and full support matrix:
|
||||
`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure).
|
||||
|
||||
## Radio / Audio Player
|
||||
|
||||
M3U playlists can contain radio channels identified by the `radio="true"` attribute on `#EXTINF` lines. When a radio channel is selected:
|
||||
|
||||
@@ -504,6 +504,8 @@ The M3U playlist module handles traditional M3U/M3U8 playlists with support for
|
||||
|
||||
The live EPG panel is a horizontal **timeline ribbon** under the player (`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`), not a right-side drawer (reworked in PR #1102). See `docs/architecture/m3u-playlist-module.md` for the timeline's controllers and scroll behavior.
|
||||
|
||||
**Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:<surface>`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`.
|
||||
|
||||
**Radio Channel Layout** (when `channel.radio === 'true'`):
|
||||
|
||||
```
|
||||
@@ -782,6 +784,18 @@ categories by provider ID and type. See `docs/architecture/category-management.m
|
||||
|
||||
### Key Features
|
||||
|
||||
#### Xtream Live Auto Format
|
||||
|
||||
The routed Xtream live host supplies `liveAutoTsUrl` only for Auto with explicit
|
||||
HLS+TS account evidence, using the canonical URL builder and original headers.
|
||||
The same web player may try TS once after an owned initial terminal HTTP failure,
|
||||
before `playing`; the old transport unmounts before the guarded render callback
|
||||
starts TS. No player preference or playlist cache changes. Manual formats,
|
||||
unknown formats, DRM, VOD/catch-up and stale sessions are excluded. External
|
||||
MPV/VLC and Embedded MPV retain manual TS; Video.js segment retry cycles without
|
||||
a terminal diagnostic also need manual TS. Contract and full support matrix:
|
||||
`docs/architecture/xtream-portal-compatibility.md` (Initial Auto HLS failure).
|
||||
|
||||
#### M3U URL User-Agent
|
||||
|
||||
- The URL import form accepts an optional User-Agent and stores it as
|
||||
@@ -1726,6 +1740,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
|
||||
|
||||
@@ -78,25 +78,60 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an
|
||||
.first();
|
||||
await expect(nowBlock).toBeVisible();
|
||||
|
||||
// Zooming re-renders the ribbon: block widths grow with px/minute.
|
||||
const zoomInput = timeline.locator(
|
||||
'.epg-timeline__zoom input[type="range"]'
|
||||
// The zoom button cycles hours → detail → day; block widths follow
|
||||
// px/minute, so "detail" must render the same block wider than "day".
|
||||
const zoomButton = timeline.locator('.epg-timeline__zoom');
|
||||
await expect(zoomButton).toBeVisible();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'hours');
|
||||
|
||||
const blockWidth = async () =>
|
||||
(await nowBlock.boundingBox())?.width ?? 0;
|
||||
|
||||
await zoomButton.click();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
||||
const detailZoomWidth = await blockWidth();
|
||||
|
||||
await zoomButton.click();
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'day');
|
||||
const dayZoomWidth = await blockWidth();
|
||||
expect(detailZoomWidth).toBeGreaterThan(dayZoomWidth);
|
||||
|
||||
// Ctrl + wheel over the ribbon fine-tunes the zoom (and must not
|
||||
// trigger Chromium's page zoom, which would scale the whole window).
|
||||
const ribbon = timeline.locator('.epg-timeline__ribbon');
|
||||
await ribbon.hover({ position: { x: 200, y: 40 } });
|
||||
const pageZoomBefore = await app.mainWindow.evaluate(
|
||||
() => window.devicePixelRatio
|
||||
);
|
||||
await expect(zoomInput).toBeVisible();
|
||||
await app.mainWindow.keyboard.down('Control');
|
||||
await app.mainWindow.mouse.wheel(0, -300);
|
||||
await app.mainWindow.keyboard.up('Control');
|
||||
await expect
|
||||
.poll(blockWidth, { timeout: 5000 })
|
||||
.toBeGreaterThan(dayZoomWidth);
|
||||
// Chromium page zoom scales devicePixelRatio; the ribbon must have
|
||||
// swallowed the gesture instead.
|
||||
expect(
|
||||
await app.mainWindow.evaluate(() => window.devicePixelRatio)
|
||||
).toBe(pageZoomBefore);
|
||||
|
||||
const blockWidthAt = async (zoom: 'min' | 'max') => {
|
||||
await zoomInput.evaluate((element, target) => {
|
||||
const input = element as HTMLInputElement;
|
||||
input.value = target === 'min' ? input.min : input.max;
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}, zoom);
|
||||
const box = await nowBlock.boundingBox();
|
||||
return box?.width ?? 0;
|
||||
// The contrast checks below read the block's time line, which the
|
||||
// narrower tiers hide, so settle on the "detail" preset. The
|
||||
// wheel-tuned scale may sit in any band (Chromium scales the delivered
|
||||
// delta), so cycle from wherever it landed rather than assuming a
|
||||
// fixed number of clicks.
|
||||
const clicksToDetail: Record<string, number> = {
|
||||
detail: 0,
|
||||
day: 2,
|
||||
hours: 1,
|
||||
};
|
||||
|
||||
const minZoomWidth = await blockWidthAt('min');
|
||||
const maxZoomWidth = await blockWidthAt('max');
|
||||
expect(maxZoomWidth).toBeGreaterThan(minZoomWidth);
|
||||
const landedLevel =
|
||||
(await zoomButton.getAttribute('data-zoom-level')) ?? '';
|
||||
expect(Object.keys(clicksToDetail)).toContain(landedLevel);
|
||||
for (let i = 0; i < clicksToDetail[landedLevel]; i += 1) {
|
||||
await zoomButton.click();
|
||||
}
|
||||
await expect(zoomButton).toHaveAttribute('data-zoom-level', 'detail');
|
||||
|
||||
for (const theme of ['light', 'dark', 'light'] as const) {
|
||||
await applyTheme(app.mainWindow, theme);
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
channelItemByTitle,
|
||||
clickCategoryByNameExact,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
importM3uPlaylistFromNativeDialog,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
openPlaylistRecent,
|
||||
openSources,
|
||||
openWorkspaceSection,
|
||||
resetMockServers,
|
||||
restartElectronApp,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
waitForM3uCatalog,
|
||||
waitForXtreamWorkspaceReady,
|
||||
writeTemporaryM3uFile,
|
||||
} from './electron-test-fixtures';
|
||||
import { fetchXtreamLiveFixture } from './portal-mock-fixtures';
|
||||
|
||||
/**
|
||||
* Issue #1458, second report: "all channels disappear after clearing the
|
||||
* playback history; a reset does not bring them back". The history write
|
||||
* never touched the channels — the reporter's screenshot shows a collapsed
|
||||
* channel rail, a state that used to be persisted under one key shared by
|
||||
* every live surface and that survived restart, "Remove all playlists" and
|
||||
* re-import. These scenarios pin both halves: the data path stays intact,
|
||||
* and the collapsed rail is discoverable, scoped, and restorable.
|
||||
*/
|
||||
|
||||
const CHANNELS = [
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Channel Alpha',
|
||||
url: 'https://streams.example.test/alpha.m3u8',
|
||||
},
|
||||
{
|
||||
groupTitle: 'News',
|
||||
name: 'Channel Beta',
|
||||
url: 'https://streams.example.test/beta.m3u8',
|
||||
},
|
||||
{
|
||||
groupTitle: 'Sports',
|
||||
name: 'Channel Gamma',
|
||||
url: 'https://streams.example.test/gamma.m3u8',
|
||||
},
|
||||
];
|
||||
|
||||
const M3U_STATE_KEY = 'live-sidebar-state:m3u';
|
||||
const PORTAL_STATE_KEY = 'live-sidebar-state:portal';
|
||||
const LEGACY_STATE_KEY = 'live-sidebar-state';
|
||||
|
||||
function playlistIdFromUrl(page: Page): string {
|
||||
const match = page.url().match(/\/workspace\/playlists\/([^/?#]+)\//);
|
||||
if (!match) {
|
||||
throw new Error(`Not on a playlist route: ${page.url()}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
async function storedItemCount(
|
||||
page: Page,
|
||||
playlistId: string
|
||||
): Promise<number> {
|
||||
return page.evaluate(async (id) => {
|
||||
const bridge = (
|
||||
window as unknown as {
|
||||
electron: {
|
||||
dbGetAppPlaylist: (
|
||||
playlistId: string
|
||||
) => Promise<{ playlist?: { items?: unknown[] } } | null>;
|
||||
};
|
||||
}
|
||||
).electron;
|
||||
const playlist = await bridge.dbGetAppPlaylist(id);
|
||||
return playlist?.playlist?.items?.length ?? -1;
|
||||
}, playlistId);
|
||||
}
|
||||
|
||||
async function reopenPlaylist(page: Page, sourceTitle: string): Promise<void> {
|
||||
await openSources(page);
|
||||
await sourceRowByTitle(page, sourceTitle).first().click();
|
||||
await page.waitForURL(/\/workspace\/playlists\/.+\/all$/);
|
||||
}
|
||||
|
||||
function readStorage(page: Page, key: string): Promise<string | null> {
|
||||
return page.evaluate((storageKey) => localStorage.getItem(storageKey), key);
|
||||
}
|
||||
|
||||
function headerToggle(page: Page) {
|
||||
return page.locator('app-workspace-shell-header .header-sidebar-toggle');
|
||||
}
|
||||
|
||||
function hiddenStateRestoreButton(page: Page) {
|
||||
return page.locator(
|
||||
'app-channel-list-hidden-state button.empty-state-action'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsed rail keeps its children in the DOM (overflow-clipped inside
|
||||
* a zero-width container), so Playwright still reports them "visible";
|
||||
* assert on the container and its restore affordances instead.
|
||||
*/
|
||||
async function expectRailCollapsed(page: Page): Promise<void> {
|
||||
const sidebar = page.locator('.sidebar').first();
|
||||
await expect(sidebar).toHaveClass(/sidebar-collapsed/);
|
||||
// width: 0 !important, but the 1px (transparent) border still counts.
|
||||
await expect
|
||||
.poll(async () => (await sidebar.boundingBox())?.width ?? -1)
|
||||
.toBeLessThanOrEqual(1);
|
||||
await expect(page.locator('.sidebar-restore')).toBeVisible();
|
||||
await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'false');
|
||||
}
|
||||
|
||||
async function expectRailExpanded(page: Page): Promise<void> {
|
||||
await expect(page.locator('.sidebar').first()).not.toHaveClass(
|
||||
/sidebar-collapsed/
|
||||
);
|
||||
await expect(page.locator('.sidebar-restore')).toHaveCount(0);
|
||||
await expect(page.getByTestId('channel-item').first()).toBeVisible();
|
||||
await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'true');
|
||||
}
|
||||
|
||||
test.describe('Live channel rail collapse (#1458)', () => {
|
||||
test('clearing the playlist history keeps every channel in SQLite and in the All channels view, also after restart', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const sourceTitle = 'rail-history';
|
||||
const filePath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`${sourceTitle}.m3u`,
|
||||
CHANNELS
|
||||
);
|
||||
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, filePath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
const playlistId = playlistIdFromUrl(app.mainWindow);
|
||||
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
|
||||
|
||||
await channelItemByTitle(app.mainWindow, 'Channel Alpha')
|
||||
.first()
|
||||
.click();
|
||||
|
||||
await openPlaylistRecent(app.mainWindow);
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'Channel Alpha').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
|
||||
await app.mainWindow
|
||||
.getByRole('button', { name: 'Clear recently viewed Live TV' })
|
||||
.click();
|
||||
await app.mainWindow.getByRole('button', { name: 'Yes' }).click();
|
||||
await expect(
|
||||
channelItemByTitle(app.mainWindow, 'Channel Alpha')
|
||||
).toHaveCount(0);
|
||||
|
||||
await reopenPlaylist(app.mainWindow, sourceTitle);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item')
|
||||
).toHaveCount(3);
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await reopenPlaylist(app.mainWindow, sourceTitle);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('channel-item')
|
||||
).toHaveCount(3);
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('a collapsed rail stays discoverable and restorable: hidden-list state, header toggle, restart, and a fresh import', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const firstTitle = 'rail-collapse-a';
|
||||
const secondTitle = 'rail-collapse-b';
|
||||
const firstPath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`${firstTitle}.m3u`,
|
||||
CHANNELS
|
||||
);
|
||||
const secondPath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`${secondTitle}.m3u`,
|
||||
CHANNELS
|
||||
);
|
||||
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await importM3uPlaylistFromNativeDialog(app, firstPath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).not.toBe(
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
// The chevron in the "All channels" header hides the rail. With
|
||||
// nothing playing, the content area must say so instead of
|
||||
// asking the user to pick from a list they cannot see.
|
||||
await app.mainWindow
|
||||
.locator('.all-channels-sidebar-toggle')
|
||||
.first()
|
||||
.click();
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-channel-list-hidden-state')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
hiddenStateRestoreButton(app.mainWindow)
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.locator('app-portal-empty-state', {
|
||||
hasText: 'select a channel',
|
||||
})
|
||||
).toHaveCount(0);
|
||||
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
// The full-size action restores the rail.
|
||||
await hiddenStateRestoreButton(app.mainWindow).click();
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
|
||||
'expanded'
|
||||
);
|
||||
|
||||
// The header toggle exists in both states and drives the same
|
||||
// rail.
|
||||
await headerToggle(app.mainWindow).click();
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
await headerToggle(app.mainWindow).click();
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
|
||||
// A hidden rail is a preference the user made, so it survives a
|
||||
// restart and applies to a second playlist — but the way back
|
||||
// survives with it.
|
||||
await headerToggle(app.mainWindow).click();
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await reopenPlaylist(app.mainWindow, firstTitle);
|
||||
const playlistId = playlistIdFromUrl(app.mainWindow);
|
||||
expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3);
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
await expect(
|
||||
hiddenStateRestoreButton(app.mainWindow)
|
||||
).toBeVisible();
|
||||
|
||||
await importM3uPlaylistFromNativeDialog(app, secondPath);
|
||||
await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+\/all$/);
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
|
||||
// Ctrl/Cmd+B still works and is reflected by the header toggle.
|
||||
await app.mainWindow.keyboard.press('Control+b');
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
|
||||
'expanded'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('the collapsed state is scoped per surface and the legacy shared key is forgotten', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const liveFixture = await fetchXtreamLiveFixture(request, {
|
||||
username: 'user1',
|
||||
password: 'pass1',
|
||||
});
|
||||
const m3uTitle = 'rail-scope';
|
||||
const m3uPath = writeTemporaryM3uFile(
|
||||
dataDir,
|
||||
`${m3uTitle}.m3u`,
|
||||
CHANNELS
|
||||
);
|
||||
const app: LaunchedElectronApp = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
// A leftover pre-split value must not hide anything after the
|
||||
// update, and it must be cleaned up.
|
||||
await app.mainWindow.evaluate((key) => {
|
||||
localStorage.setItem(key, 'collapsed');
|
||||
}, LEGACY_STATE_KEY);
|
||||
|
||||
await importM3uPlaylistFromNativeDialog(app, m3uPath);
|
||||
await waitForM3uCatalog(app.mainWindow);
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
|
||||
const restarted = await restartElectronApp(app, dataDir);
|
||||
app.electronApp = restarted.electronApp;
|
||||
app.mainWindow = restarted.mainWindow;
|
||||
|
||||
await reopenPlaylist(app.mainWindow, m3uTitle);
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await readStorage(app.mainWindow, LEGACY_STATE_KEY)).toBe(
|
||||
null
|
||||
);
|
||||
|
||||
// Hide the M3U rail, then open an Xtream portal's Live TV: its
|
||||
// rail (and the header toggle for it) must still be expanded.
|
||||
await headerToggle(app.mainWindow).click();
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
|
||||
await addXtreamPortal(app.mainWindow, {
|
||||
name: 'Rail Scope Portal',
|
||||
});
|
||||
await waitForXtreamWorkspaceReady(app.mainWindow);
|
||||
await openWorkspaceSection(app.mainWindow, 'Live TV');
|
||||
await clickCategoryByNameExact(
|
||||
app.mainWindow,
|
||||
liveFixture.categoryName
|
||||
);
|
||||
|
||||
await expect(headerToggle(app.mainWindow)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('.sidebar-restore')
|
||||
).toHaveCount(0);
|
||||
expect(
|
||||
await readStorage(app.mainWindow, PORTAL_STATE_KEY)
|
||||
).not.toBe('collapsed');
|
||||
expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
// And the other way round: hiding the portal rail leaves the
|
||||
// M3U preference untouched. With a category open and nothing
|
||||
// playing, the portal host shows the same hidden-list state and
|
||||
// its button restores the rail.
|
||||
await headerToggle(app.mainWindow).click();
|
||||
await expect(headerToggle(app.mainWindow)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'false'
|
||||
);
|
||||
await expect(hiddenStateRestoreButton(app.mainWindow)).toBeVisible();
|
||||
expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
await hiddenStateRestoreButton(app.mainWindow).click();
|
||||
await expect(headerToggle(app.mainWindow)).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
);
|
||||
await expect(
|
||||
app.mainWindow.locator('app-channel-list-hidden-state')
|
||||
).toHaveCount(0);
|
||||
|
||||
await headerToggle(app.mainWindow).click();
|
||||
expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
await reopenPlaylist(app.mainWindow, m3uTitle);
|
||||
await expectRailCollapsed(app.mainWindow);
|
||||
await headerToggle(app.mainWindow).click();
|
||||
await expectRailExpanded(app.mainWindow);
|
||||
expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
openSources,
|
||||
openSourceEditor,
|
||||
updateSourceDialog,
|
||||
saveSourceDialog,
|
||||
sourceRowByTitle,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
import {
|
||||
addLiveFormatPortal,
|
||||
configureLiveFormat,
|
||||
expectLiveFormatPlaying,
|
||||
liveChannels,
|
||||
observeLiveFormatMedia,
|
||||
} from './xtream-live-format.fixture';
|
||||
|
||||
for (const player of ['html5', 'artplayer'] as const) {
|
||||
test(`@electron @xtream Auto live format ${player}: failing segment to playable TS`, async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, player);
|
||||
await addLiveFormatPortal(page);
|
||||
await openSources(page);
|
||||
const dialog = await openSourceEditor(page, 'Synthetic live');
|
||||
await updateSourceDialog(dialog, {
|
||||
userAgent: 'IPTVnator-1513-fixture',
|
||||
});
|
||||
await saveSourceDialog(page, dialog);
|
||||
await sourceRowByTitle(page, 'Synthetic live').first().click();
|
||||
await page
|
||||
.getByRole('link', { name: 'Live TV', exact: true })
|
||||
.click();
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
const [tsResponse] = await Promise.all([
|
||||
page.waitForResponse(
|
||||
(response) => response.url().endsWith('/10000.ts'),
|
||||
{ timeout: 60_000 }
|
||||
),
|
||||
liveChannels(page).first().click(),
|
||||
expectLiveFormatPlaying(page, player),
|
||||
]);
|
||||
expect(await tsResponse.headerValue('x-fixture-user-agent')).toBe(
|
||||
'IPTVnator-1513-fixture'
|
||||
);
|
||||
|
||||
expect(media).toContainEqual({ file: '10000.m3u8', status: 200 });
|
||||
expect(media).toContainEqual({
|
||||
file: 'denied-segment.ts',
|
||||
status: 403,
|
||||
});
|
||||
expect(media.filter((r) => r.file === '10000.ts')).toEqual([
|
||||
{ file: '10000.ts', status: 200 },
|
||||
]);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@electron @xtream Video.js supports the manual TS workaround', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir, {
|
||||
args: ['--autoplay-policy=no-user-gesture-required'],
|
||||
});
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'videojs', 'ts');
|
||||
await addLiveFormatPortal(page);
|
||||
await liveChannels(page).first().click();
|
||||
await expectLiveFormatPlaying(page, 'videojs');
|
||||
expect(media.some((r) => r.file.endsWith('.m3u8'))).toBe(false);
|
||||
expect(media.filter((r) => r.file === '10000.ts')).toHaveLength(1);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export const liveFormatMock = `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}`;
|
||||
export type LiveFormatPlayer = 'html5' | 'videojs' | 'artplayer';
|
||||
|
||||
export async function configureLiveFormat(
|
||||
page: Page,
|
||||
player: LiveFormatPlayer,
|
||||
format = 'auto'
|
||||
) {
|
||||
await page.getByRole('link', { name: 'Open settings' }).click();
|
||||
await page.locator('[data-test-id="settings-section-playback"]').click();
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: {
|
||||
html5: 'HTML5 video player',
|
||||
videojs: 'Video.js player',
|
||||
artplayer: 'ArtPlayer',
|
||||
}[player],
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page.locator('[data-test-id="select-stream-format"]').click();
|
||||
await page.getByRole('option', { name: format, exact: true }).click();
|
||||
if (player !== 'videojs' || format !== 'auto') {
|
||||
const save = page.getByRole('button', { name: 'Save changes' });
|
||||
await expect(save).toBeVisible();
|
||||
await save.click();
|
||||
await expect(save).toBeHidden();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
}
|
||||
|
||||
export async function addLiveFormatPortal(page: Page) {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
await dialog.locator('#title').fill('Synthetic live');
|
||||
await dialog.locator('#serverUrl').fill(liveFormatMock);
|
||||
await dialog.locator('#username').fill('live-fallback');
|
||||
await dialog.locator('#password').fill('live-fallback');
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
await expect(liveChannels(page).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export const liveChannels = (page: Page) =>
|
||||
page.locator('app-live-stream-layout [data-test-id="channel-item"]');
|
||||
export function observeLiveFormatMedia(page: Page) {
|
||||
const media: { file: string; status: number }[] = [];
|
||||
page.on('response', (response) => {
|
||||
if (response.url().startsWith(`${liveFormatMock}/live/`))
|
||||
media.push({
|
||||
file: new URL(response.url()).pathname.split('/').pop() ?? '',
|
||||
status: response.status(),
|
||||
});
|
||||
});
|
||||
return media;
|
||||
}
|
||||
export async function expectLiveFormatPlaying(
|
||||
page: Page,
|
||||
player: LiveFormatPlayer
|
||||
) {
|
||||
await expect(
|
||||
page.locator(
|
||||
`app-${player === 'html5' ? 'html-video' : player === 'videojs' ? 'vjs' : 'art'}-player`
|
||||
)
|
||||
).toBeVisible();
|
||||
const video = page.locator('app-web-player-view video').first();
|
||||
await expect
|
||||
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), {
|
||||
timeout: 50_000,
|
||||
})
|
||||
.toBeGreaterThan(0.5);
|
||||
return video;
|
||||
}
|
||||
@@ -134,9 +134,7 @@ test.describe('portrait phone 375x812', () => {
|
||||
// sections; now the list scrolls and ends above the footer.
|
||||
const listBox = await boxOf(panel.locator('.settings-sections-list'));
|
||||
const footerBox = await boxOf(footer);
|
||||
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
|
||||
footerBox.y + 1
|
||||
);
|
||||
expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1);
|
||||
|
||||
// Tapping the backdrop (right of the drawer) closes it.
|
||||
await page
|
||||
@@ -216,7 +214,16 @@ test.describe('xtream portal routes on a phone', () => {
|
||||
const sidebar = page.locator('app-live-stream-layout .sidebar');
|
||||
await expect(sidebar).toBeVisible();
|
||||
|
||||
// The workspace header carries the live rail toggle at desktop
|
||||
// width only; on the phone the rail is a bottom drawer with its own
|
||||
// toggle and the header has no room for another permanent icon.
|
||||
const headerRailToggle = page.locator(
|
||||
'app-workspace-shell-header .header-sidebar-toggle'
|
||||
);
|
||||
await expect(headerRailToggle).toBeVisible();
|
||||
|
||||
await page.setViewportSize(LANDSCAPE_PHONE);
|
||||
await expect(headerRailToggle).toBeHidden();
|
||||
|
||||
// The sidebar stacks at full width above the player.
|
||||
await expect
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ({
|
||||
@@ -556,13 +513,22 @@ test('@stalker radio — stations use the inline audio player without EPG', asyn
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
await page.getByRole('button', { name: 'Hide channels list' }).click();
|
||||
const restoreButton = page.getByRole('button', {
|
||||
name: 'Show channels list',
|
||||
});
|
||||
// The rail's own chevron hides the list; the workspace header carries a
|
||||
// second toggle with the same accessible name, so scope to the rail.
|
||||
await page
|
||||
.locator('.sidebar')
|
||||
.getByRole('button', { name: 'Hide channels list' })
|
||||
.click();
|
||||
const restoreButton = page.locator('.sidebar-restore');
|
||||
await expect(restoreButton).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .header-sidebar-toggle')
|
||||
).toHaveAttribute('aria-pressed', 'false');
|
||||
await restoreButton.click();
|
||||
await expect(stations.first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator('app-workspace-shell-header .header-sidebar-toggle')
|
||||
).toHaveAttribute('aria-pressed', 'true');
|
||||
|
||||
await expect(page.locator('app-epg-timeline')).toHaveCount(0);
|
||||
expect(epgRequests).toHaveLength(0);
|
||||
@@ -707,7 +673,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 +685,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 +712,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 = (
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { expect, test } from './fixtures';
|
||||
import { interceptProviderTargetRegistration } from './provider-target-route';
|
||||
import {
|
||||
addLiveFormatPortal,
|
||||
configureLiveFormat,
|
||||
expectLiveFormatPlaying,
|
||||
liveChannels,
|
||||
liveFormatMock,
|
||||
observeLiveFormatMedia,
|
||||
} from './xtream-live-format.fixture';
|
||||
|
||||
test.use({
|
||||
launchOptions: { args: ['--autoplay-policy=no-user-gesture-required'] },
|
||||
serviceWorkers: 'block',
|
||||
});
|
||||
test.skip(
|
||||
({ browserName }) => browserName !== 'chromium',
|
||||
'Chromium media engines'
|
||||
);
|
||||
test.beforeEach(async ({ page }) => {
|
||||
test.setTimeout(100_000);
|
||||
await interceptProviderTargetRegistration(page);
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
url.host = new URL(liveFormatMock).host;
|
||||
await route.continue({ url: url.toString() });
|
||||
});
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
for (const player of ['html5', 'artplayer'] as const) {
|
||||
for (const channel of [0, 1]) {
|
||||
test(`@web @xtream Auto live format ${player}: ${channel ? 'manifest' : 'segment'} 403 then playable TS`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, player);
|
||||
await addLiveFormatPortal(page);
|
||||
await liveChannels(page).nth(channel).click();
|
||||
await expectLiveFormatPlaying(page, player);
|
||||
if (!channel)
|
||||
expect(media).toContainEqual({
|
||||
file: '10000.m3u8',
|
||||
status: 200,
|
||||
});
|
||||
expect(media).toContainEqual({
|
||||
file: channel ? '10001.m3u8' : 'denied-segment.ts',
|
||||
status: 403,
|
||||
});
|
||||
expect(
|
||||
media.filter((r) => r.file === `${10000 + channel}.ts`)
|
||||
).toEqual([{ file: `${10000 + channel}.ts`, status: 200 }]);
|
||||
await expect(
|
||||
page.locator('[data-test-id="playback-diagnostic-banner"]')
|
||||
).toBeHidden();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('@web @xtream Auto live format: TS failure is terminal without a cycle', async ({
|
||||
page,
|
||||
}) => {
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'html5');
|
||||
await addLiveFormatPortal(page);
|
||||
await liveChannels(page).nth(2).click();
|
||||
const banner = page.locator('[data-test-id="playback-diagnostic-banner"]');
|
||||
await expect(banner).toBeVisible({ timeout: 50_000 });
|
||||
expect(media.filter((r) => r.file === '10002.ts')).toEqual([
|
||||
{ file: '10002.ts', status: 403 },
|
||||
]);
|
||||
await expect(banner).toContainText(/MPEG-TS|mpegts/i);
|
||||
});
|
||||
|
||||
for (const format of ['ts', 'm3u8']) {
|
||||
test(`@web @xtream Auto live format: manual ${format} wins`, async ({
|
||||
page,
|
||||
}) => {
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'html5', format);
|
||||
await addLiveFormatPortal(page);
|
||||
await liveChannels(page).first().click();
|
||||
if (format === 'ts') {
|
||||
await expectLiveFormatPlaying(page, 'html5');
|
||||
expect(media.some((r) => r.file.endsWith('.m3u8'))).toBe(false);
|
||||
} else {
|
||||
await expect(
|
||||
page.locator('[data-test-id="playback-diagnostic-banner"]')
|
||||
).toBeVisible({ timeout: 50_000 });
|
||||
expect(media.some((r) => r.file === '10000.ts')).toBe(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test('@web @xtream Auto live format: channel switch cancels delayed HLS failure', async ({
|
||||
page,
|
||||
}) => {
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'html5');
|
||||
await addLiveFormatPortal(page);
|
||||
const pending = page.waitForRequest('**/delayed-segment.ts');
|
||||
await liveChannels(page).nth(3).click();
|
||||
await pending;
|
||||
await liveChannels(page).nth(1).click();
|
||||
await expectLiveFormatPlaying(page, 'html5');
|
||||
expect(media.some((r) => r.file === '10003.ts')).toBe(false);
|
||||
expect(media.filter((r) => r.file === '10001.ts')).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('@web @xtream Auto live format: no TS advertisement means no downgrade', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.route('**/localhost:3000/xtream**', async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
url.host = new URL(liveFormatMock).host;
|
||||
const response = await route.fetch({ url: url.toString() });
|
||||
const json = await response.json();
|
||||
if (json.payload?.user_info)
|
||||
json.payload.user_info.allowed_output_formats = ['m3u8'];
|
||||
await route.fulfill({ response, json });
|
||||
});
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'html5');
|
||||
await addLiveFormatPortal(page);
|
||||
await liveChannels(page).first().click();
|
||||
await expect(
|
||||
page.locator('[data-test-id="playback-diagnostic-banner"]')
|
||||
).toBeVisible({ timeout: 50_000 });
|
||||
expect(media.some((r) => r.file === '10000.ts')).toBe(false);
|
||||
});
|
||||
|
||||
test('@web @xtream Auto live format: leaving the host aborts pending media without TS', async ({
|
||||
page,
|
||||
}) => {
|
||||
const media = observeLiveFormatMedia(page);
|
||||
await configureLiveFormat(page, 'html5');
|
||||
await addLiveFormatPortal(page);
|
||||
const pending = page.waitForRequest('**/delayed-segment.ts');
|
||||
await liveChannels(page).nth(3).click();
|
||||
const request = await pending;
|
||||
const aborted = page.waitForEvent('requestfailed', (r) => r === request);
|
||||
await page.getByRole('link', { name: 'Open settings' }).click();
|
||||
await aborted;
|
||||
expect(media.some((r) => r.file === '10003.ts')).toBe(false);
|
||||
await expect(page.locator('app-web-player-view')).toHaveCount(0);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { expect, type Page } from '@playwright/test';
|
||||
|
||||
export const liveFormatMock = `http://localhost:${process.env['XTREAM_MOCK_PORT'] ?? '3211'}`;
|
||||
export type LiveFormatPlayer = 'html5' | 'videojs' | 'artplayer';
|
||||
|
||||
export async function configureLiveFormat(
|
||||
page: Page,
|
||||
player: LiveFormatPlayer,
|
||||
format = 'auto'
|
||||
) {
|
||||
await page.getByRole('link', { name: 'Open settings' }).click();
|
||||
await page.locator('[data-test-id="settings-section-playback"]').click();
|
||||
await page.locator('[data-test-id="select-video-player"]').click();
|
||||
await page
|
||||
.getByRole('option', {
|
||||
name: {
|
||||
html5: 'HTML5 video player',
|
||||
videojs: 'Video.js player',
|
||||
artplayer: 'ArtPlayer',
|
||||
}[player],
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
await page.locator('[data-test-id="select-stream-format"]').click();
|
||||
await page.getByRole('option', { name: format, exact: true }).click();
|
||||
if (player !== 'videojs' || format !== 'auto') {
|
||||
const save = page.getByRole('button', { name: 'Save changes' });
|
||||
await expect(save).toBeVisible();
|
||||
await save.click();
|
||||
await expect(save).toBeHidden();
|
||||
}
|
||||
await page.getByRole('button', { name: 'Back', exact: true }).click();
|
||||
}
|
||||
|
||||
export async function addLiveFormatPortal(page: Page) {
|
||||
await page.getByRole('button', { name: 'Add playlist' }).click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.getByRole('radio', { name: /Xtream credentials/i }).click();
|
||||
await dialog.locator('#title').fill('Synthetic live');
|
||||
await dialog.locator('#serverUrl').fill(liveFormatMock);
|
||||
await dialog.locator('#username').fill('live-fallback');
|
||||
await dialog.locator('#password').fill('live-fallback');
|
||||
await dialog.getByRole('button', { name: 'Add', exact: true }).click();
|
||||
await page.waitForURL(/xtreams.*vod/);
|
||||
await page.getByRole('link', { name: 'Live TV', exact: true }).click();
|
||||
await page.locator('.context-panel .category-item').first().click();
|
||||
await expect(liveChannels(page).first()).toBeVisible();
|
||||
}
|
||||
|
||||
export const liveChannels = (page: Page) =>
|
||||
page.locator('app-live-stream-layout [data-test-id="channel-item"]');
|
||||
export function observeLiveFormatMedia(page: Page) {
|
||||
const media: { file: string; status: number }[] = [];
|
||||
page.on('response', (response) => {
|
||||
if (response.url().startsWith(`${liveFormatMock}/live/`))
|
||||
media.push({
|
||||
file: new URL(response.url()).pathname.split('/').pop() ?? '',
|
||||
status: response.status(),
|
||||
});
|
||||
});
|
||||
return media;
|
||||
}
|
||||
export async function expectLiveFormatPlaying(
|
||||
page: Page,
|
||||
player: LiveFormatPlayer
|
||||
) {
|
||||
await expect(
|
||||
page.locator(
|
||||
`app-${player === 'html5' ? 'html-video' : player === 'videojs' ? 'vjs' : 'art'}-player`
|
||||
)
|
||||
).toBeVisible();
|
||||
const video = page.locator('app-web-player-view video').first();
|
||||
await expect
|
||||
.poll(() => video.evaluate((v: HTMLVideoElement) => v.currentTime), {
|
||||
timeout: 50_000,
|
||||
})
|
||||
.toBeGreaterThan(0.5);
|
||||
return video;
|
||||
}
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
|
||||
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "قائمة القنوات مخفية",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "أعدها باستخدام الزر أدناه أو اضغط ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "إظهار القناة قيد التشغيل",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "تحميل أو اختيار قائمة أخرى",
|
||||
"ALL_CHANNELS": "جميع القنوات",
|
||||
"GROUPS": "المجموعات",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "دليل",
|
||||
"OPEN_GUIDE": "فتح دليل البرامج",
|
||||
"TODAY": "اليوم",
|
||||
"NOW": "الآن",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "نظرة عامة على اليوم",
|
||||
"ZOOM_HOURS": "حسب الساعة",
|
||||
"ZOOM_DETAIL": "تفصيلي",
|
||||
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير للضبط الدقيق",
|
||||
"GROUP_SHORT": "برامج قصيرة",
|
||||
"GROUP_EXPAND": "عرض ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "إخفاء قائمة القنوات",
|
||||
"SHOW_CHANNELS_LIST": "إظهار قائمة القنوات",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "تبديل قائمة القنوات (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "قائمة القنوات مخبية",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "رجّعها بالزر اللي تحت ولا ضغط على ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "بيّن القناة اللي خدامة",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "رفع ولا اختار قائمة تشغيل أخرى",
|
||||
"ALL_CHANNELS": "جميع القنوات",
|
||||
"GROUPS": "المجموعات",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "دليل",
|
||||
"OPEN_GUIDE": "حل دليل البرامج",
|
||||
"TODAY": "اليوم",
|
||||
"NOW": "دابا",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "نظرة عامة على النهار",
|
||||
"ZOOM_HOURS": "بالساعة",
|
||||
"ZOOM_DETAIL": "مفصّل",
|
||||
"ZOOM_TOOLTIP": "التكبير: {{level}} · ⌘/Ctrl + التمرير باش تضبط",
|
||||
"GROUP_SHORT": "برامج قصيرة",
|
||||
"GROUP_EXPAND": "وري ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Схаваць спіс каналаў",
|
||||
"SHOW_CHANNELS_LIST": "Паказаць спіс каналаў",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Пераключыць спіс каналаў (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Спіс каналаў схаваны",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Вярніце яго кнопкай ніжэй або націсніце ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Паказаць бягучы канал",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузіць або выбраць іншы плэйліст",
|
||||
"ALL_CHANNELS": "Усе каналы",
|
||||
"GROUPS": "Групы",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Праграма",
|
||||
"OPEN_GUIDE": "Адкрыць праграму перадач",
|
||||
"TODAY": "Сёння",
|
||||
"NOW": "Цяпер",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Агляд дня",
|
||||
"ZOOM_HOURS": "Па гадзінах",
|
||||
"ZOOM_DETAIL": "Падрабязна",
|
||||
"ZOOM_TOOLTIP": "Маштаб: {{level}} · ⌘/Ctrl + кола для дакладнай наладкі",
|
||||
"GROUP_SHORT": "кароткія перадачы",
|
||||
"GROUP_EXPAND": "паказаць ›"
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "Dockt den Serienplayer in breiten Fenstern links an und zeigt daneben die kommenden Folgen an.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Senderliste im Vollbild",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Bewege die Maus im Vollbild an den linken Rand oder drücke C, um die Senderliste einzublenden und den Sender zu wechseln, ohne das Vollbild zu verlassen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Standard-Streamformat auswählen (Xtream)",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto bevorzugt HLS. Schlägt Live-TV in MPV, VLC oder Embedded MPV fehl, den Player stoppen, ts wählen, speichern und den Sender erneut öffnen. Webplayer können bei einem anfänglichen HLS-HTTP-Fehler einmal angebotenes TS versuchen.",
|
||||
"LANGUAGE_DESCRIPTION": "Wählen Sie die Sprache der Anwendung aus",
|
||||
"THEME_DESCRIPTION": "Wählen Sie das visuelle Thema der Anwendung aus",
|
||||
"COVER_SIZE": "Cover-Größe",
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Senderliste ausblenden",
|
||||
"SHOW_CHANNELS_LIST": "Senderliste einblenden",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Senderliste umschalten (⌘/Strg+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Die Senderliste ist ausgeblendet",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Blenden Sie sie über die Schaltfläche unten oder mit ⌘/Strg+B wieder ein."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Laufenden Sender anzeigen",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Neue Playlist hochladen oder eine andere aussuchen",
|
||||
"ALL_CHANNELS": "Alle Sender",
|
||||
"GROUPS": "Gruppen",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guide",
|
||||
"OPEN_GUIDE": "Programmführer öffnen",
|
||||
"TODAY": "Heute",
|
||||
"NOW": "Jetzt",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Tagesübersicht",
|
||||
"ZOOM_HOURS": "Stundenweise",
|
||||
"ZOOM_DETAIL": "Detailliert",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Strg + Scrollen zum Feinabstimmen",
|
||||
"GROUP_SHORT": "kurze Sendungen",
|
||||
"GROUP_EXPAND": "anzeigen ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Απόκρυψη λίστας καναλιών",
|
||||
"SHOW_CHANNELS_LIST": "Εμφάνιση λίστας καναλιών",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Εναλλαγή λίστας καναλιών (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Η λίστα καναλιών είναι κρυφή",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Επαναφέρετέ την με το παρακάτω κουμπί ή πατήστε ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Εμφάνιση του καναλιού που παίζει",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Μεταφορτώστε ή επιλέξτε άλλη λίστα αναπαραγωγής",
|
||||
"ALL_CHANNELS": "Όλα τα κανάλια",
|
||||
"GROUPS": "Ομάδες",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Οδηγός",
|
||||
"OPEN_GUIDE": "Άνοιγμα του οδηγού προγράμματος",
|
||||
"TODAY": "Σήμερα",
|
||||
"NOW": "Τώρα",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Επισκόπηση ημέρας",
|
||||
"ZOOM_HOURS": "Ανά ώρα",
|
||||
"ZOOM_DETAIL": "Λεπτομερής",
|
||||
"ZOOM_TOOLTIP": "Ζουμ: {{level}} · ⌘/Ctrl + κύλιση για λεπτομερή ρύθμιση",
|
||||
"GROUP_SHORT": "σύντ. προγρ.",
|
||||
"GROUP_EXPAND": "εμφάνιση ›"
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "On wide windows, dock the series player to the left and show the upcoming episodes beside it.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Channel list in fullscreen",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Rest the mouse on the left edge or press C while in fullscreen to slide in the channel list and switch channels without leaving fullscreen.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Select default stream format (xtream)",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto prefers HLS. If live playback fails in MPV, VLC or Embedded MPV, stop the player, select ts, save and reopen the channel. Web players can try advertised TS once after an initial HLS HTTP failure.",
|
||||
"LANGUAGE_DESCRIPTION": "Select language of the application",
|
||||
"THEME_DESCRIPTION": "Select visual theme of the application",
|
||||
"COVER_SIZE": "Cover size",
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Hide channels list",
|
||||
"SHOW_CHANNELS_LIST": "Show channels list",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Toggle channels list (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "The channels list is hidden",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Bring it back with the button below or press ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Show playing channel",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload or select other playlist",
|
||||
"ALL_CHANNELS": "All channels",
|
||||
"GROUPS": "Groups",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guide",
|
||||
"OPEN_GUIDE": "Open the programme guide",
|
||||
"TODAY": "Today",
|
||||
"NOW": "Now",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Day overview",
|
||||
"ZOOM_HOURS": "By hour",
|
||||
"ZOOM_DETAIL": "Detailed",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scroll to fine-tune",
|
||||
"GROUP_SHORT": "short progs",
|
||||
"GROUP_EXPAND": "show ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Ocultar lista de canales",
|
||||
"SHOW_CHANNELS_LIST": "Mostrar lista de canales",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canales (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "La lista de canales está oculta",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Vuelve a mostrarla con el botón de abajo o pulsa ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostrar el canal en reproducción",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carga o selecciona una lista de reproducción",
|
||||
"ALL_CHANNELS": "Todos los canales",
|
||||
"GROUPS": "Grupos",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guía",
|
||||
"OPEN_GUIDE": "Abrir la guía de programas",
|
||||
"TODAY": "Hoy",
|
||||
"NOW": "Ahora",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Vista del día",
|
||||
"ZOOM_HOURS": "Por hora",
|
||||
"ZOOM_DETAIL": "Detallado",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rueda para ajustar",
|
||||
"GROUP_SHORT": "progs. cortos",
|
||||
"GROUP_EXPAND": "ver ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Masquer la liste des chaînes",
|
||||
"SHOW_CHANNELS_LIST": "Afficher la liste des chaînes",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Basculer la liste des chaînes (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "La liste des chaînes est masquée",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Réaffichez-la avec le bouton ci-dessous ou appuyez sur ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Afficher la chaîne en cours",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Transférer ou choisir une autre liste de lecture",
|
||||
"ALL_CHANNELS": "Toutes les chaînes",
|
||||
"GROUPS": "Groupes",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guide",
|
||||
"OPEN_GUIDE": "Ouvrir le guide des programmes",
|
||||
"TODAY": "Aujourd'hui",
|
||||
"NOW": "Maintenant",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Vue de la journée",
|
||||
"ZOOM_HOURS": "Par heure",
|
||||
"ZOOM_DETAIL": "Détaillé",
|
||||
"ZOOM_TOOLTIP": "Zoom : {{level}} · ⌘/Ctrl + molette pour ajuster",
|
||||
"GROUP_SHORT": "progs courts",
|
||||
"GROUP_EXPAND": "afficher ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Csatornalista elrejtése",
|
||||
"SHOW_CHANNELS_LIST": "Csatornalista megjelenítése",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Csatornalista megjelenítése vagy elrejtése (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "A csatornalista el van rejtve",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Az alábbi gombbal vagy a ⌘/Ctrl+B billentyűkkel jelenítheti meg újra."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Lejátszott csatorna megjelenítése",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Másik lejátszási lista feltöltése vagy kiválasztása",
|
||||
"ALL_CHANNELS": "Összes csatorna",
|
||||
"GROUPS": "Csoportok",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Műsorújság",
|
||||
"OPEN_GUIDE": "Műsorújság megnyitása",
|
||||
"TODAY": "Ma",
|
||||
"NOW": "Most",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Napi áttekintés",
|
||||
"ZOOM_HOURS": "Óránként",
|
||||
"ZOOM_DETAIL": "Részletes",
|
||||
"ZOOM_TOOLTIP": "Nagyítás: {{level}} · ⌘/Ctrl + görgetés a finomhangoláshoz",
|
||||
"GROUP_SHORT": "rövid műsorok",
|
||||
"GROUP_EXPAND": "megjelenítés ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Nascondi elenco canali",
|
||||
"SHOW_CHANNELS_LIST": "Mostra elenco canali",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Attiva/disattiva elenco canali (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "L'elenco canali è nascosto",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Mostralo di nuovo con il pulsante qui sotto o premi ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostra il canale in riproduzione",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Carica o seleziona un'altra playlist",
|
||||
"ALL_CHANNELS": "Tutti i canali",
|
||||
"GROUPS": "Gruppi",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guida",
|
||||
"OPEN_GUIDE": "Apri la guida programmi",
|
||||
"TODAY": "Oggi",
|
||||
"NOW": "Ora",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Panoramica giorno",
|
||||
"ZOOM_HOURS": "Per ora",
|
||||
"ZOOM_DETAIL": "Dettagliato",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + rotella per regolare",
|
||||
"GROUP_SHORT": "prog. brevi",
|
||||
"GROUP_EXPAND": "mostra ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "チャンネル一覧を非表示",
|
||||
"SHOW_CHANNELS_LIST": "チャンネル一覧を表示",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "チャンネル一覧の切り替え (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "チャンネル一覧は非表示です",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "下のボタンまたは ⌘/Ctrl+B で再表示できます。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "再生中のチャンネルを表示",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "アップロードまたは他のプレイリストを選択",
|
||||
"ALL_CHANNELS": "すべてのチャンネル",
|
||||
"GROUPS": "グループ",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "番組表",
|
||||
"OPEN_GUIDE": "番組表を開く",
|
||||
"TODAY": "今日",
|
||||
"NOW": "現在",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "1日表示",
|
||||
"ZOOM_HOURS": "時間単位",
|
||||
"ZOOM_DETAIL": "詳細",
|
||||
"ZOOM_TOOLTIP": "ズーム: {{level}} · ⌘/Ctrl + スクロールで微調整",
|
||||
"GROUP_SHORT": "短い番組",
|
||||
"GROUP_EXPAND": "表示 ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "채널 목록 숨기기",
|
||||
"SHOW_CHANNELS_LIST": "채널 목록 표시",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "채널 목록 전환 (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "채널 목록이 숨겨져 있습니다",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "아래 버튼을 누르거나 ⌘/Ctrl+B를 눌러 다시 표시하세요."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "재생 중인 채널 표시",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "다른 재생 목록을 업로드하거나 선택하십시오",
|
||||
"ALL_CHANNELS": "모든 채널",
|
||||
"GROUPS": "그룹",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "가이드",
|
||||
"OPEN_GUIDE": "프로그램 가이드 열기",
|
||||
"TODAY": "오늘",
|
||||
"NOW": "지금",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "하루 보기",
|
||||
"ZOOM_HOURS": "시간별",
|
||||
"ZOOM_DETAIL": "상세",
|
||||
"ZOOM_TOOLTIP": "확대/축소: {{level}} · ⌘/Ctrl + 스크롤로 미세 조정",
|
||||
"GROUP_SHORT": "짧은 프로그램",
|
||||
"GROUP_EXPAND": "보기 ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Zenderlijst verbergen",
|
||||
"SHOW_CHANNELS_LIST": "Zenderlijst tonen",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Zenderlijst aan-/uitzetten (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "De zenderlijst is verborgen",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Toon de lijst opnieuw met de knop hieronder of druk op ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Huidige zender tonen",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Upload of selecteer een andere afspeellijst",
|
||||
"ALL_CHANNELS": "Alle kanalen",
|
||||
"GROUPS": "Groepen",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Gids",
|
||||
"OPEN_GUIDE": "Programmagids openen",
|
||||
"TODAY": "Vandaag",
|
||||
"NOW": "Nu",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Dagoverzicht",
|
||||
"ZOOM_HOURS": "Per uur",
|
||||
"ZOOM_DETAIL": "Gedetailleerd",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + scrollen om fijn af te stellen",
|
||||
"GROUP_SHORT": "korte progr.",
|
||||
"GROUP_EXPAND": "toon ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Ukryj listę kanałów",
|
||||
"SHOW_CHANNELS_LIST": "Pokaż listę kanałów",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Przełącz listę kanałów (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Lista kanałów jest ukryta",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Przywróć ją przyciskiem poniżej lub naciśnij ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Pokaż odtwarzany kanał",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Prześlij lub wybierz inną listę odtwarzania",
|
||||
"ALL_CHANNELS": "Wszystkie kanały",
|
||||
"GROUPS": "Grupy",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Przewodnik",
|
||||
"OPEN_GUIDE": "Otwórz przewodnik po programach",
|
||||
"TODAY": "Dzisiaj",
|
||||
"NOW": "Teraz",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Przegląd dnia",
|
||||
"ZOOM_HOURS": "Co godzinę",
|
||||
"ZOOM_DETAIL": "Szczegółowo",
|
||||
"ZOOM_TOOLTIP": "Powiększenie: {{level}} · ⌘/Ctrl + kółko, aby dostroić",
|
||||
"GROUP_SHORT": "krótkie progr.",
|
||||
"GROUP_EXPAND": "pokaż ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Ocultar lista de canais",
|
||||
"SHOW_CHANNELS_LIST": "Mostrar lista de canais",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Alternar lista de canais (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "A lista de canais está oculta",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Volte a mostrá-la com o botão abaixo ou pressione ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Mostrar canal em reprodução",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Enviar ou selecionar outra playlist",
|
||||
"ALL_CHANNELS": "Todos os canais",
|
||||
"GROUPS": "Grupos",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Guia",
|
||||
"OPEN_GUIDE": "Abrir o guia de programação",
|
||||
"TODAY": "Hoje",
|
||||
"NOW": "Agora",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Visão geral do dia",
|
||||
"ZOOM_HOURS": "Por hora",
|
||||
"ZOOM_DETAIL": "Detalhado",
|
||||
"ZOOM_TOOLTIP": "Zoom: {{level}} · ⌘/Ctrl + roda para ajustar",
|
||||
"GROUP_SHORT": "progs. curtos",
|
||||
"GROUP_EXPAND": "mostrar ›"
|
||||
}
|
||||
|
||||
@@ -358,7 +358,7 @@
|
||||
"PLAYER_UP_NEXT_RAIL_DESCRIPTION": "В широких окнах закреплять плеер сериала слева и показывать следующие серии рядом с ним.",
|
||||
"FULLSCREEN_CHANNEL_PANEL": "Список каналов в полноэкранном режиме",
|
||||
"FULLSCREEN_CHANNEL_PANEL_DESCRIPTION": "Подведите мышь к левому краю или нажмите C в полноэкранном режиме, чтобы выдвинуть список каналов и переключить канал, не выходя из полноэкранного режима.",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Выбрать формат потока по умолчанию (Xtream)",
|
||||
"STREAM_FORMAT_DESCRIPTION": "Auto предпочитает HLS. При сбое в MPV, VLC или Embedded MPV остановите плеер, выберите ts, сохраните настройки и снова откройте канал. Веб-плееры могут один раз попробовать заявленный TS после первоначальной HTTP-ошибки HLS.",
|
||||
"LANGUAGE_DESCRIPTION": "Выбрать язык приложения",
|
||||
"THEME_DESCRIPTION": "Выбрать визуальную тему оформления",
|
||||
"COVER_SIZE": "Размер обложки",
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Скрыть список каналов",
|
||||
"SHOW_CHANNELS_LIST": "Показать список каналов",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Переключить список каналов (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Список каналов скрыт",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Верните его кнопкой ниже или нажмите ⌘/Ctrl+B."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Показать текущий канал",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Загрузить или выбрать другой плейлист",
|
||||
"ALL_CHANNELS": "Все каналы",
|
||||
"GROUPS": "Группы",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Гид",
|
||||
"OPEN_GUIDE": "Открыть программу передач",
|
||||
"TODAY": "Сегодня",
|
||||
"NOW": "Сейчас",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Обзор дня",
|
||||
"ZOOM_HOURS": "По часам",
|
||||
"ZOOM_DETAIL": "Детально",
|
||||
"ZOOM_TOOLTIP": "Масштаб: {{level}} · ⌘/Ctrl + колесо для точной настройки",
|
||||
"GROUP_SHORT": "короткие передачи",
|
||||
"GROUP_EXPAND": "показать ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "Kanal listesini gizle",
|
||||
"SHOW_CHANNELS_LIST": "Kanal listesini göster",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "Kanal listesini aç/kapat (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "Kanal listesi gizli",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "Aşağıdaki düğmeyle geri getirin veya ⌘/Ctrl+B tuşlarına basın."
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "Oynatılan kanalı göster",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "Dosya yükle veya başka bir oynatma listesi seçin",
|
||||
"ALL_CHANNELS": "Tüm kanallar",
|
||||
"GROUPS": "Gruplar",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "Rehber",
|
||||
"OPEN_GUIDE": "Program rehberini aç",
|
||||
"TODAY": "Bugün",
|
||||
"NOW": "Şimdi",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "Gün özeti",
|
||||
"ZOOM_HOURS": "Saatlik",
|
||||
"ZOOM_DETAIL": "Detaylı",
|
||||
"ZOOM_TOOLTIP": "Yakınlaştırma: {{level}} · İnce ayar için ⌘/Ctrl + kaydırma",
|
||||
"GROUP_SHORT": "kısa progr.",
|
||||
"GROUP_EXPAND": "göster ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "隐藏频道列表",
|
||||
"SHOW_CHANNELS_LIST": "显示频道列表",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切换频道列表 (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "频道列表已隐藏",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "点击下方按钮或按 ⌘/Ctrl+B 重新显示。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "显示正在播放的频道",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上传或选择其他播放列表",
|
||||
"ALL_CHANNELS": "所有频道",
|
||||
"GROUPS": "分组",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "指南",
|
||||
"OPEN_GUIDE": "打开节目指南",
|
||||
"TODAY": "今天",
|
||||
"NOW": "现在",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "全天概览",
|
||||
"ZOOM_HOURS": "按小时",
|
||||
"ZOOM_DETAIL": "详细",
|
||||
"ZOOM_TOOLTIP": "缩放:{{level}} · ⌘/Ctrl + 滚轮微调",
|
||||
"GROUP_SHORT": "短节目",
|
||||
"GROUP_EXPAND": "展开 ›"
|
||||
}
|
||||
|
||||
@@ -674,9 +674,12 @@
|
||||
"LAYOUT": {
|
||||
"HIDE_CHANNELS_LIST": "隱藏頻道列表",
|
||||
"SHOW_CHANNELS_LIST": "顯示頻道列表",
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)"
|
||||
"TOGGLE_SIDEBAR_TOOLTIP": "切換頻道列表 (⌘/Ctrl+B)",
|
||||
"CHANNELS_LIST_HIDDEN": "頻道列表已隱藏",
|
||||
"CHANNELS_LIST_HIDDEN_HINT": "點選下方按鈕或按 ⌘/Ctrl+B 重新顯示。"
|
||||
},
|
||||
"CHANNELS": {
|
||||
"SHOW_PLAYING_CHANNEL": "顯示正在播放的頻道",
|
||||
"UPLOAD_OR_SELECT_OTHER_PLAYLIST": "上傳或選擇其他播放清單",
|
||||
"ALL_CHANNELS": "所有頻道",
|
||||
"GROUPS": "群組",
|
||||
@@ -837,7 +840,6 @@
|
||||
}
|
||||
},
|
||||
"TIMELINE": {
|
||||
"GUIDE": "節目表",
|
||||
"OPEN_GUIDE": "開啟節目表",
|
||||
"TODAY": "今天",
|
||||
"NOW": "現在",
|
||||
@@ -871,6 +873,7 @@
|
||||
"ZOOM_DAY": "日總覽",
|
||||
"ZOOM_HOURS": "依小時",
|
||||
"ZOOM_DETAIL": "詳細",
|
||||
"ZOOM_TOOLTIP": "縮放:{{level}} · ⌘/Ctrl + 滾輪微調",
|
||||
"GROUP_SHORT": "短節目",
|
||||
"GROUP_EXPAND": "顯示 ›"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Express } from 'express';
|
||||
|
||||
/** Entirely local synthetic media. IDs exercise initial manifest and segment failures. */
|
||||
export function installLiveFormatFixture(app: Express): void {
|
||||
app.get('/live/live-fallback/live-fallback/:file', (request, response) => {
|
||||
const file = String(request.params['file']);
|
||||
response.set('Cache-Control', 'no-store');
|
||||
response.set('X-Fixture-User-Agent', request.get('user-agent') ?? '');
|
||||
if (file.endsWith('.m3u8')) {
|
||||
if (file === '10001.m3u8') {
|
||||
response.status(403).end();
|
||||
return;
|
||||
}
|
||||
response
|
||||
.type('application/vnd.apple.mpegurl')
|
||||
.send(
|
||||
[
|
||||
'#EXTM3U',
|
||||
'#EXT-X-VERSION:3',
|
||||
'#EXT-X-TARGETDURATION:6',
|
||||
'#EXT-X-MEDIA-SEQUENCE:0',
|
||||
'#EXTINF:6,',
|
||||
file === '10003.m3u8'
|
||||
? 'delayed-segment.ts'
|
||||
: 'denied-segment.ts',
|
||||
'#EXT-X-ENDLIST',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (file === 'delayed-segment.ts') {
|
||||
const timer = setTimeout(() => response.status(403).end(), 1500);
|
||||
response.on('close', () => clearTimeout(timer));
|
||||
return;
|
||||
}
|
||||
if (file === 'denied-segment.ts' || file === '10002.ts') {
|
||||
response.status(403).end();
|
||||
return;
|
||||
}
|
||||
response
|
||||
.type('video/mp2t')
|
||||
.send(
|
||||
readFileSync(
|
||||
join(
|
||||
process.cwd(),
|
||||
'apps/xtream-mock-server/src/fixtures/live.mpegts'
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,17 @@ export interface ScenarioConfig {
|
||||
* Unknown credential pairs use a hash of "username:password" as seed.
|
||||
*/
|
||||
export const SCENARIOS: Record<string, ScenarioConfig> = {
|
||||
'live-fallback:live-fallback': {
|
||||
name: 'live-format-fallback',
|
||||
description: 'Local HLS failures and playable TS',
|
||||
seed: 1513,
|
||||
categoryCount: { live: 1, vod: 1, series: 1 },
|
||||
itemsPerCategory: 4,
|
||||
seasonsPerSeries: 1,
|
||||
episodesPerSeason: 1,
|
||||
accountStatus: 'Active',
|
||||
expiryDate: '2099-12-31',
|
||||
},
|
||||
'user1:pass1': {
|
||||
name: 'default',
|
||||
description: 'Balanced portal — 8 categories, 40 items each',
|
||||
|
||||
@@ -51,6 +51,31 @@ describe('Xtream mock server factory', () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('serves the synthetic manifest-success/segment-failure and TS alternatives locally', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
);
|
||||
const base = `${running.origin}/live/live-fallback/live-fallback`;
|
||||
try {
|
||||
const manifest = await fetch(`${base}/10000.m3u8`);
|
||||
expect(manifest.status).toBe(200);
|
||||
expect(await manifest.text()).toContain('denied-segment.ts');
|
||||
expect((await fetch(`${base}/denied-segment.ts`)).status).toBe(403);
|
||||
expect((await fetch(`${base}/10001.m3u8`)).status).toBe(403);
|
||||
expect((await fetch(`${base}/10002.ts`)).status).toBe(403);
|
||||
const ts = await fetch(`${base}/10000.ts`, {
|
||||
headers: { 'User-Agent': 'synthetic' },
|
||||
});
|
||||
expect(ts.headers.get('x-fixture-user-agent')).toBe('synthetic');
|
||||
const bytes = new Uint8Array(await ts.arrayBuffer());
|
||||
expect(bytes.length).toBeGreaterThan(188);
|
||||
expect(bytes[0]).toBe(0x47);
|
||||
expect(bytes[188]).toBe(0x47);
|
||||
} finally {
|
||||
await running.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps performance controls absent unless explicitly enabled', async () => {
|
||||
const running = await startLoopbackServer(
|
||||
createXtreamMockApp({ host: '127.0.0.1', port: 0 })
|
||||
|
||||
@@ -6,6 +6,7 @@ import express, {
|
||||
type Request,
|
||||
type Response,
|
||||
} from 'express';
|
||||
import { installLiveFormatFixture } from './live-format-fixture.js';
|
||||
import { resetAll } from './data-store.js';
|
||||
import { renderMarketingAssetSvg } from './generators/marketing.generator.js';
|
||||
import { installPerformanceControlRoutes } from './performance-control-routes.js';
|
||||
@@ -107,6 +108,7 @@ export function createXtreamMockApp(
|
||||
dispatchAction(request, response)
|
||||
);
|
||||
});
|
||||
if (!controlEnabled) installLiveFormatFixture(app);
|
||||
installStreamRoutes(app, controlEnabled);
|
||||
app.get('/xtream', (request, response, next) => {
|
||||
dispatchWithControl(controller, request, response, next, 'proxy', () =>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Synthetic playback fixtures
|
||||
|
||||
`live.mpegts` is six seconds of generated color-pattern video and a sine wave,
|
||||
H.264 baseline + AAC, used by the local Xtream live-format regression account.
|
||||
No real provider media or credentials are included.
|
||||
|
||||
Regenerate from the repository root with FFmpeg:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i testsrc2=size=160x90:rate=25 -f lavfi -i sine=frequency=440:sample_rate=48000 -t 6 -c:v libx264 -preset ultrafast -pix_fmt yuv420p -g 25 -c:a aac -b:a 48k -f mpegts apps/xtream-mock-server/src/fixtures/live.mpegts
|
||||
```
|
||||
|
||||
The mock returns the fixture only for synthetic live-format account media;
|
||||
manifest and denied-segment routes return controlled 200/403 responses. Web and
|
||||
Electron E2E tests check advancing playback, not HTTP status alone.
|
||||
Binary file not shown.
@@ -494,7 +494,7 @@ Video.js HTTP error is `network-error` and shows its status. Because an HTTP
|
||||
status is server/network evidence rather than decoding evidence, external
|
||||
decoding is not presented as a likely fix.
|
||||
|
||||
Video.js `8.23.9`, the default web player, runs HTTP streaming through bundled
|
||||
Video.js `8.24.0`, the default web player, runs HTTP streaming through bundled
|
||||
VHS `3.17.5`. Its terminal `Player#error` crosses a separate allowlisted
|
||||
boundary built only from the public Video.js `MediaError` code/status,
|
||||
`metadata.errorType`, and the documented `player.tech().vhs` runtime property.
|
||||
@@ -841,7 +841,12 @@ original headers or credentials required to reconstruct a safe launch request.
|
||||
|
||||
No recommendation mutates `Settings.player` or another persisted setting.
|
||||
Recovery recommendations never auto-switch a player or source and do not
|
||||
replace the separate source-owner auto-failover feature. Attempts, overrides,
|
||||
replace the separate source-owner auto-failover feature. The narrow Xtream
|
||||
live Auto format contract is separate: a source owner may supply one advertised
|
||||
TS transport to the same web player on an initial terminal HTTP failure. It
|
||||
never chooses another engine, and its pending callback waits for the old
|
||||
transport's render teardown. See [Initial Auto HLS failure](./xtream-portal-compatibility.md#initial-auto-hls-failure)
|
||||
for eligibility, session ownership and the external/Embedded MPV/VHS limits. Attempts, overrides,
|
||||
resume handoff, and diagnostics are session-local: there is no persistent
|
||||
history, cross-session learning, correlation, or telemetry.
|
||||
|
||||
|
||||
@@ -267,8 +267,8 @@ remain local when the meaning is explicit.
|
||||
|
||||
### Collapsible Live Sidebar
|
||||
|
||||
- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle
|
||||
that hides the channels rail to give the player and EPG full width.
|
||||
- M3U, Xtream, and Stalker live layouts share a sidebar collapse toggle that
|
||||
hides the channels rail to give the player and EPG full width.
|
||||
- Xtream Live TV's root view (`/live` with no selected category) follows the
|
||||
same paginated `All Items` shell as VOD and Series: a widget header with the
|
||||
total channel count, page-size controls, and page navigation above the shared
|
||||
@@ -285,15 +285,41 @@ remain local when the meaning is explicit.
|
||||
is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites,
|
||||
and recent routes leave it untouched.
|
||||
- Collapsed state is owned by `LiveLayoutSidebarStateService`
|
||||
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util`. Every surface that
|
||||
participates injects the service and reads `isCollapsed`; any toggle calls
|
||||
`service.toggle()`. Persistence delegates to the existing
|
||||
`live-sidebar-state` helpers, so the localStorage key stays unchanged and
|
||||
missing/invalid values restore to expanded.
|
||||
- A `mat-icon-button` with `chevron_left` lives in the sidebar header and
|
||||
toggles state. While collapsed, a floating `chevron_right` mini-fab appears
|
||||
at the left edge of `.content-container` to restore the rail (and the
|
||||
categories rail, in Xtream/Stalker live).
|
||||
(`providedIn: 'root'`) in `@iptvnator/portal/shared/util` and kept **per
|
||||
surface** (`LiveSidebarSurface`): `m3u` (the M3U player), `portal` (Xtream
|
||||
and Stalker live layouts plus the shell categories rail) and `collection`
|
||||
(the unified favorites/recent live tab). Every participant injects the
|
||||
service, holds `isCollapsedFor(surface)` (a stable signal) and calls
|
||||
`toggle(surface)`; nothing reads localStorage directly. Persistence lives
|
||||
under `live-sidebar-state:<surface>`. Hiding the list is a per-context
|
||||
choice: it must not follow the user from a portal to an M3U playlist, nor
|
||||
from the desktop rail to the phone bottom drawer of another surface. The
|
||||
pre-split shared key `live-sidebar-state` is forgotten on service
|
||||
construction and never read — a stored `collapsed` there hid every channel
|
||||
list in the app behind a 32px chevron and survived restart, "Remove all
|
||||
playlists" and re-import (issue #1458).
|
||||
- The control never moves. Inside the rail a `mat-icon-button` with
|
||||
`chevron_left` hides it; while collapsed a floating `chevron_right` mini-fab
|
||||
sits at the left edge of `.content-container`. Because both of those live
|
||||
in the thing they hide, the workspace header additionally renders
|
||||
`view_sidebar` (`headerSidebarToggle`, `WorkspaceShellHeaderService`) on
|
||||
every route that renders its own rail — M3U `all`/`groups`, Xtream `live`,
|
||||
Stalker `itv`/`radio` (`resolveRouteLiveSidebarSurface`). It stays in place
|
||||
in both states, uses `aria-pressed` (pressed = rail visible) and tints
|
||||
primary only while the rail is hidden, since the hidden state is the
|
||||
exception that deserves the cue. Collection pages are deliberately excluded:
|
||||
only the page knows whether its live tab, and therefore the rail, is on
|
||||
screen, so its own header toggle beside the content switch stays the owner.
|
||||
At the phone breakpoint (≤640px) the header toggle is hidden: the rail is a
|
||||
bottom drawer there with its own toggle and the header has no spare width.
|
||||
- While the rail is collapsed and nothing is playing, every live host renders
|
||||
`app-channel-list-hidden-state` (`@iptvnator/portal/shared/ui`) instead of
|
||||
the "select a channel" empty state: a title that says the list is hidden, a
|
||||
one-line hint naming the shortcut, and a full-size "Show channels list"
|
||||
stroked button wired to the same toggle. The generic
|
||||
`app-portal-empty-state` grew optional `hint`, `actionLabel`, `actionIcon`
|
||||
inputs and an `action` output for this; the action keeps full opacity while
|
||||
icon and copy stay muted, because it is the way out of the state.
|
||||
- Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate
|
||||
inside `<input>`, `<textarea>`, `<select>`, or content-editable elements via
|
||||
the shared `isTypingInInput` helper.
|
||||
|
||||
@@ -836,12 +836,22 @@ activation, and the details dialog behave identically to the timeline.
|
||||
(**vertical title**, no time) → `micro` (just a marker); (C) a **hover/focus
|
||||
popover** revealing the full title + time + description for any non-`wide`
|
||||
block (it flips above the block when the panel is near the screen bottom);
|
||||
(D) a px-per-minute **zoom** slider (tick density adapts via
|
||||
`timelineTickStepForScale`); and (E) **grouping** of ≥4 consecutive short
|
||||
(D) a px-per-minute **zoom** (tick density adapts via
|
||||
`timelineTickStepForScale`): the toolbar's icon-only zoom button cycles
|
||||
three presets — day overview (`1`) → by hour (`1.75`, the default) →
|
||||
detailed (`3.4`, the scale a group chip expands to) — snapping a
|
||||
wheel-tuned scale to its band's successor (`TIMELINE_ZOOM_LEVELS`,
|
||||
`nextTimelineZoomScale`), while Ctrl/⌘ + wheel (and trackpad pinch) over
|
||||
the ribbon zooms continuously around the cursor within
|
||||
`TIMELINE_ZOOM_MIN..MAX` and `preventDefault`s so Chromium never page-zooms
|
||||
the same gesture; the current level lives in the tooltip/`aria-label` and
|
||||
a `data-zoom-level` attribute; and (E) **grouping** of ≥4 consecutive short
|
||||
(<10 min) programmes into one dashed "N short" chip when zoomed out
|
||||
(`scale < TIMELINE_GROUP_ZOOM_MAX`), expanded by clicking it. The ribbon
|
||||
canvas lives in the child `app-epg-timeline-track`; the parent owns the
|
||||
scroller, toolbar (incl. the zoom slider) and state.
|
||||
scroller, toolbar (icon-only "Now" + zoom buttons, both labelled through
|
||||
tooltip + `aria-label`, so the channel/programme heading takes every pixel
|
||||
the fixed-width controls leave) and state.
|
||||
- **Panel height & titles.** Block titles wrap onto as many lines as the card
|
||||
height allows and are clipped (not single-line ellipsis); the foot ("ON NOW"
|
||||
tag / "Watch") stays pinned at the bottom. With an inline player the guide is
|
||||
@@ -1518,7 +1528,6 @@ resolve another retained source sharing that channel ID. Legacy programmes with
|
||||
unknown (`NULL`) ownership are conservatively left alone; the existing database
|
||||
initialization backfill handles rows whose channel still identifies their owner.
|
||||
|
||||
|
||||
Renderer reconciliation fences lookups before its first asynchronous step.
|
||||
Imports wait for serialized reconciliation (including playlist migration), then
|
||||
filter against its committed owner set. Completion increments the data revision again
|
||||
|
||||
@@ -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'`
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -129,6 +129,58 @@ Manual `ts` and `m3u8` settings remain supported; when a manual setting is not
|
||||
allowed by the portal, URL construction falls back to the first
|
||||
provider-allowed format.
|
||||
|
||||
### Initial Auto HLS failure
|
||||
|
||||
The routed Xtream Live TV host attaches `ResolvedPortalPlayback.liveAutoTsUrl`
|
||||
only when the current user preference is Auto and the current account explicitly
|
||||
advertises both HLS and TS. `XtreamUrlService.constructAutoLiveTsUrl` uses the
|
||||
same credential encoding and provider-subpath URL builder as the initial URL;
|
||||
no URL string replacement, manifest preflight, stream download, or API probe is
|
||||
introduced. Unknown/empty formats and manual HLS/TS have no alternative.
|
||||
|
||||
`WebPlayerLiveAutoFormat` may consume that alternative once per mounted logical
|
||||
live session, in the same selected web player, after an owned terminal network
|
||||
diagnostic with HTTP 4xx/5xx, before the native video element reports `playing`.
|
||||
A successful manifest is not playback. HLS key/media/unknown-stage failures,
|
||||
DRM payloads, content-info VOD/episodes, and catch-up are excluded. Generic
|
||||
network failures, status zero, cancellation, timeouts without an HTTP response,
|
||||
and decode errors keep the normal explicit recovery surface.
|
||||
|
||||
The old application is first removed from the render tree. The next render
|
||||
callback starts TS only after the web engine's synchronous loader teardown and
|
||||
only while source, logical session and user intent still match. The TS payload
|
||||
keeps the original headers, title and playback metadata; Electron applies them
|
||||
through its existing scoped header handoff. HTTP-media requests retain the
|
||||
existing transport/trust rules and do not feed new evidence or admission into
|
||||
the separate portal-API host-connectivity guard. Recovery recommendations still
|
||||
require a user action and never auto-switch engines. No settings or playlist
|
||||
cache is changed. A TS failure displays normal recovery actions; Retry repeats
|
||||
TS without rearming the automatic attempt. Changing the actual logical channel
|
||||
or playlist, or mounting a new host, creates a new session; same-session metadata
|
||||
or provider-source refresh does not rearm it. Same-channel replay and metadata
|
||||
refresh preserve the chosen TS while the candidate URLs, headers and mode stay
|
||||
the same; an actual transport/provider change discards that selection. This deliberately avoids a sticky
|
||||
playlist preference and reevaluates advertised formats on the next channel.
|
||||
|
||||
| Player/path | Initial Auto failure support |
|
||||
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| HTML5 / ArtPlayer, hls.js | One advertised TS attempt after terminal HLS manifest/level/segment HTTP failure; waits for the engine's own bounded retries |
|
||||
| Video.js / VHS | Only an observable terminal HTTP diagnostic can trigger an attempt. A sole HLS rendition's failing segment can remain in VHS retry/exclusion cycles without such a diagnostic; use manual TS for that case |
|
||||
| External MPV / VLC | Manual TS only. Process launch/exit and best-effort playing telemetry do not reliably identify the initial HLS media HTTP failure |
|
||||
| Embedded MPV | Manual TS only; no equivalent structured HTTP diagnostic |
|
||||
| Unified Favorites/Recent live resolution | Manual TS; it does not own fresh session account-format evidence |
|
||||
|
||||
For manual recovery, stop the current player, choose **Settings > Playback >
|
||||
Stream Format > ts**, save, and reopen the channel. This keeps the chosen player
|
||||
and avoids a second uncorrelated external process. The settings description
|
||||
explains this route. This is partial playback coverage of #1513, not a claim
|
||||
that all external-player and VHS scenarios automatically recover.
|
||||
|
||||
Synthetic regression media lives in the mock's `live-fallback:live-fallback`
|
||||
account: HLS manifest 200 plus segment 403, manifest 403, a playable local
|
||||
H.264/AAC TS alternative, a failing TS alternative, and a delayed HLS segment.
|
||||
Tests assert actual video progress and the number of concrete TS requests.
|
||||
|
||||
If stored Xtream playback credentials contain an invalid server URL or blank
|
||||
username/password, stream URL construction returns an empty URL instead of
|
||||
throwing during playback.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,352 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, NO_ERRORS_SCHEMA, output, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { By } from '@angular/platform-browser';
|
||||
import { ActivatedRoute, Router } from '@angular/router';
|
||||
import { Overlay } from '@angular/cdk/overlay';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { StorageMap } from '@ngx-pwa/local-storage';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { BehaviorSubject, of } from 'rxjs';
|
||||
import {
|
||||
selectActive,
|
||||
selectActiveEpgProgram,
|
||||
selectActivePlaybackUrl,
|
||||
selectChannels,
|
||||
selectChannelsLoading,
|
||||
selectCurrentEpgProgram,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { EpgService } from '@iptvnator/epg/data-access';
|
||||
import { PlaylistContextFacade } from '@iptvnator/playlist/shared/util';
|
||||
import {
|
||||
LiveLayoutSidebarStateService,
|
||||
liveSidebarStateStorageKey,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
DataService,
|
||||
PlaylistsService,
|
||||
RuntimeCapabilitiesService,
|
||||
SettingsStore,
|
||||
TmdbEnrichmentService,
|
||||
} from '@iptvnator/services';
|
||||
import {
|
||||
Channel,
|
||||
EpgProgram,
|
||||
Settings,
|
||||
VideoPlayer,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import type { VideoPlayerComponent as VideoPlayerComponentInstance } from './video-player.component';
|
||||
|
||||
// The component's chain reaches video.js (ui/playback → VjsPlayer); the CJS
|
||||
// bundle breaks under the ESM jest environment, so it is mocked before the
|
||||
// dynamic import evaluates the chain.
|
||||
jest.unstable_mockModule('video.js', () => ({
|
||||
default: jest.fn(),
|
||||
}));
|
||||
jest.unstable_mockModule('@yangkghjh/videojs-aspect-ratio-panel', () => ({}));
|
||||
jest.unstable_mockModule('videojs-contrib-quality-levels', () => ({}));
|
||||
jest.unstable_mockModule('videojs-quality-selector-hls', () => ({}));
|
||||
|
||||
@Component({
|
||||
selector: 'app-channel-list-hidden-state',
|
||||
standalone: true,
|
||||
template: '',
|
||||
})
|
||||
class StubChannelListHiddenStateComponent {
|
||||
readonly restore = output<void>();
|
||||
}
|
||||
|
||||
/**
|
||||
* The collapsible channels rail of the M3U player. Separate from
|
||||
* `video-player.component.spec.ts` only because that file sits at the
|
||||
* max-lines test budget; the harness here is the minimal subset needed to
|
||||
* render the rail and the content-area empty states.
|
||||
*/
|
||||
describe('VideoPlayerComponent — collapsible channels rail', () => {
|
||||
let VideoPlayerComponent: typeof import('./video-player.component').VideoPlayerComponent;
|
||||
let fixture: ComponentFixture<VideoPlayerComponentInstance>;
|
||||
let component: VideoPlayerComponentInstance;
|
||||
let sidebarState: LiveLayoutSidebarStateService;
|
||||
|
||||
const playlistId = signal('playlist-1');
|
||||
const activeChannel = signal<Channel | null>(null);
|
||||
const activePlaybackUrl = signal<string | null>(null);
|
||||
const channels = signal<Channel[]>([]);
|
||||
const channelsLoading = signal(false);
|
||||
const currentEpgProgram = signal<EpgProgram | null>(null);
|
||||
const activeEpgProgram = signal<EpgProgram | null>(null);
|
||||
const channels$ = new BehaviorSubject<Channel[]>([]);
|
||||
const activeChannel$ = new BehaviorSubject<Channel | null>(null);
|
||||
const currentEpgProgram$ = new BehaviorSubject<EpgProgram | null>(null);
|
||||
const epgPrograms$ = new BehaviorSubject<EpgProgram[]>([]);
|
||||
|
||||
const player = signal<VideoPlayer>(VideoPlayer.VideoJs);
|
||||
const showCaptions = signal(false);
|
||||
const stripCountryPrefix = signal(false);
|
||||
|
||||
const storeMock = {
|
||||
dispatch: jest.fn(),
|
||||
selectSignal: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectActive:
|
||||
return activeChannel;
|
||||
case selectActivePlaybackUrl:
|
||||
return activePlaybackUrl;
|
||||
case selectChannels:
|
||||
return channels;
|
||||
case selectChannelsLoading:
|
||||
return channelsLoading;
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram;
|
||||
case selectActiveEpgProgram:
|
||||
return activeEpgProgram;
|
||||
default:
|
||||
return signal(null);
|
||||
}
|
||||
}),
|
||||
select: jest.fn((selector: unknown) => {
|
||||
switch (selector) {
|
||||
case selectChannels:
|
||||
return channels$.asObservable();
|
||||
case selectActive:
|
||||
return activeChannel$.asObservable();
|
||||
case selectCurrentEpgProgram:
|
||||
return currentEpgProgram$.asObservable();
|
||||
default:
|
||||
return of(null);
|
||||
}
|
||||
}),
|
||||
};
|
||||
|
||||
function clearSidebarStorage(): void {
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('m3u'));
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('collection'));
|
||||
}
|
||||
|
||||
function query(selector: string): Element | null {
|
||||
return fixture.nativeElement.querySelector(selector);
|
||||
}
|
||||
|
||||
function hiddenState() {
|
||||
return fixture.debugElement.query(
|
||||
By.directive(StubChannelListHiddenStateComponent)
|
||||
);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
({ VideoPlayerComponent } = await import('./video-player.component'));
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
clearSidebarStorage();
|
||||
activeChannel.set(null);
|
||||
activeChannel$.next(null);
|
||||
channels.set([]);
|
||||
channels$.next([]);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [VideoPlayerComponent],
|
||||
providers: [
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {
|
||||
params: of({ id: playlistId(), view: 'all' }),
|
||||
queryParams: of({}),
|
||||
snapshot: {
|
||||
data: { layout: 'workspace' },
|
||||
queryParams: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: Router,
|
||||
useValue: {
|
||||
url: '/workspace/playlists/playlist-1/all',
|
||||
navigate: jest.fn(),
|
||||
currentNavigation: jest.fn().mockReturnValue(null),
|
||||
},
|
||||
},
|
||||
{ provide: Store, useValue: storeMock },
|
||||
{
|
||||
provide: Overlay,
|
||||
useValue: { position: jest.fn(), create: jest.fn() },
|
||||
},
|
||||
{ provide: DataService, useValue: { sendIpcEvent: jest.fn() } },
|
||||
{
|
||||
provide: RuntimeCapabilitiesService,
|
||||
useValue: {
|
||||
supportsEpg: true,
|
||||
isElectron: false,
|
||||
supportsRemoteControl: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistsService,
|
||||
useValue: {
|
||||
getPlaylist: jest.fn(() =>
|
||||
of({ playlist: { items: [] }, favorites: [] })
|
||||
),
|
||||
getPlaylistWithGlobalFavorites: jest.fn(() =>
|
||||
of({ playlist: { items: [] }, favorites: [] })
|
||||
),
|
||||
addM3uRecentlyViewed: jest.fn(() =>
|
||||
of({ recentlyViewed: [] })
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: EpgService,
|
||||
useValue: {
|
||||
currentEpgPrograms$: epgPrograms$.asObservable(),
|
||||
getChannelMetadataForChannels: () => of(new Map()),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PlaylistContextFacade,
|
||||
useValue: { resolvedPlaylistId: playlistId },
|
||||
},
|
||||
{
|
||||
provide: SettingsStore,
|
||||
useValue: {
|
||||
player,
|
||||
showCaptions,
|
||||
stripCountryPrefix,
|
||||
m3uVodDetails: signal(true),
|
||||
resolvedEpgViewMode: signal('timeline'),
|
||||
resolvedEpgOffsetMinutes: signal(0),
|
||||
epgUrl: signal<string[]>([]),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: StorageMap,
|
||||
useValue: {
|
||||
get: jest.fn(() =>
|
||||
of({
|
||||
player: player(),
|
||||
showCaptions: showCaptions(),
|
||||
} as Partial<Settings>)
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: PORTAL_EXTERNAL_PLAYBACK,
|
||||
useValue: { activeSession: signal(null) },
|
||||
},
|
||||
{
|
||||
provide: TmdbEnrichmentService,
|
||||
useValue: { isEnabled: () => true },
|
||||
},
|
||||
],
|
||||
})
|
||||
// The rail and the content-area empty states are the subject;
|
||||
// everything else renders as inert unknown elements.
|
||||
.overrideComponent(VideoPlayerComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
StubChannelListHiddenStateComponent,
|
||||
MockPipe(
|
||||
TranslatePipe,
|
||||
(value: string | null | undefined) => value ?? ''
|
||||
),
|
||||
],
|
||||
schemas: [NO_ERRORS_SCHEMA],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(VideoPlayerComponent);
|
||||
component = fixture.componentInstance;
|
||||
sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fixture?.destroy();
|
||||
clearSidebarStorage();
|
||||
});
|
||||
|
||||
it('starts with the rail visible and the "select a channel" empty state', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSidebarCollapsed()).toBe(false);
|
||||
expect(query('.sidebar')?.classList.contains('sidebar-collapsed')).toBe(
|
||||
false
|
||||
);
|
||||
expect(query('.sidebar-restore')).toBeNull();
|
||||
expect(query('app-portal-empty-state')).not.toBeNull();
|
||||
expect(hiddenState()).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the rail through the shared per-surface state and swaps the empty state for a full-size way back', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
component.toggleSidebar();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSidebarCollapsed()).toBe(true);
|
||||
expect(query('.sidebar')?.classList.contains('sidebar-collapsed')).toBe(
|
||||
true
|
||||
);
|
||||
expect(query('.sidebar-restore')).not.toBeNull();
|
||||
expect(query('app-portal-empty-state')).toBeNull();
|
||||
expect(hiddenState()).not.toBeNull();
|
||||
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
hiddenState().componentInstance.restore.emit();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSidebarCollapsed()).toBe(false);
|
||||
expect(query('.sidebar-restore')).toBeNull();
|
||||
expect(hiddenState()).toBeNull();
|
||||
expect(query('app-portal-empty-state')).not.toBeNull();
|
||||
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
|
||||
'expanded'
|
||||
);
|
||||
});
|
||||
|
||||
it('reflects a toggle made elsewhere for the M3U surface (the header) and ignores other surfaces', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
sidebarState.setState('portal', 'collapsed');
|
||||
sidebarState.setState('collection', 'collapsed');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSidebarCollapsed()).toBe(false);
|
||||
expect(query('.sidebar-restore')).toBeNull();
|
||||
|
||||
sidebarState.toggle('m3u');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSidebarCollapsed()).toBe(true);
|
||||
expect(query('.sidebar-restore')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('does not show the hidden-list state while a channel is playing', () => {
|
||||
const liveChannel = {
|
||||
id: 'channel-1',
|
||||
url: 'http://localhost/live.m3u8',
|
||||
name: 'Sample TV',
|
||||
epgParams: '',
|
||||
radio: 'false',
|
||||
tvg: { id: '', logo: '', name: 'Sample TV' },
|
||||
} as Channel;
|
||||
activeChannel.set(liveChannel);
|
||||
activeChannel$.next(liveChannel);
|
||||
channels.set([liveChannel]);
|
||||
channels$.next([liveChannel]);
|
||||
fixture.detectChanges();
|
||||
|
||||
component.toggleSidebar();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.isSidebarCollapsed()).toBe(true);
|
||||
expect(query('.sidebar-restore')).not.toBeNull();
|
||||
expect(hiddenState()).toBeNull();
|
||||
expect(query('.video-player')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -251,6 +251,8 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else if (isSidebarCollapsed()) {
|
||||
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
|
||||
@@ -84,17 +84,18 @@ import {
|
||||
isTypingInInput,
|
||||
isWorkspaceLayoutRoute,
|
||||
LiveEpgPanelState,
|
||||
LiveSidebarState,
|
||||
LiveLayoutSidebarStateService,
|
||||
persistLiveEpgPanelState,
|
||||
persistLiveSidebarState,
|
||||
PORTAL_EXTERNAL_PLAYBACK,
|
||||
isLiveExternalPlayerSession,
|
||||
REMOTE_CONTROL_RESET_STATUS,
|
||||
restoreLiveEpgPanelState,
|
||||
restoreLiveSidebarState,
|
||||
WorkspaceHeaderContextService,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
FULLSCREEN_CHANNEL_PANEL,
|
||||
@@ -204,6 +205,7 @@ function isInsideScrollableRegion(
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatTooltipModule,
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
SidebarComponent,
|
||||
@@ -237,6 +239,9 @@ export class VideoPlayerComponent
|
||||
private readonly storage = inject(StorageMap);
|
||||
private readonly store = inject(Store);
|
||||
private readonly epgService = inject(EpgService);
|
||||
private readonly liveSidebarStateService = inject(
|
||||
LiveLayoutSidebarStateService
|
||||
);
|
||||
private readonly tmdbEnrichment = inject(TmdbEnrichmentService);
|
||||
private readonly externalPlayback = inject(PORTAL_EXTERNAL_PLAYBACK);
|
||||
private readonly workspaceHeaderContext = inject(
|
||||
@@ -523,12 +528,9 @@ export class VideoPlayerComponent
|
||||
readonly isLiveEpgPanelCollapsed = computed(
|
||||
() => this.liveEpgPanelState() === 'collapsed'
|
||||
);
|
||||
readonly liveSidebarState = signal<LiveSidebarState>(
|
||||
restoreLiveSidebarState()
|
||||
);
|
||||
readonly isSidebarCollapsed = computed(
|
||||
() => this.liveSidebarState() === 'collapsed'
|
||||
);
|
||||
/** Shared with the workspace header toggle; persisted per surface. */
|
||||
readonly isSidebarCollapsed =
|
||||
this.liveSidebarStateService.isCollapsedFor('m3u');
|
||||
|
||||
/** Channels list */
|
||||
readonly channels$: Observable<Channel[]> = this.store.select(
|
||||
@@ -1075,11 +1077,7 @@ export class VideoPlayerComponent
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
const next: LiveSidebarState = this.isSidebarCollapsed()
|
||||
? 'expanded'
|
||||
: 'collapsed';
|
||||
this.liveSidebarState.set(next);
|
||||
persistLiveSidebarState(next);
|
||||
this.liveSidebarStateService.toggle('m3u');
|
||||
}
|
||||
|
||||
onLiveEpgDateNavigation(direction: EpgDateNavigationDirection): void {
|
||||
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export * from './lib/components/grid-list/grid-list.component';
|
||||
export * from './lib/components/playlist-error-view/playlist-error-view.component';
|
||||
export { ProgressCapsuleComponent } from '@iptvnator/ui/components';
|
||||
export * from './lib/components/portal-empty-state/portal-empty-state.component';
|
||||
export * from './lib/components/channel-list-hidden-state/channel-list-hidden-state.component';
|
||||
export * from './lib/components/search-form/search-form.component';
|
||||
export * from './lib/components/search-layout/search-layout.component';
|
||||
export { WatchedBadgeComponent } from '@iptvnator/ui/components';
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { MockPipe } from 'ng-mocks';
|
||||
import { ChannelListHiddenStateComponent } from './channel-list-hidden-state.component';
|
||||
|
||||
describe('ChannelListHiddenStateComponent', () => {
|
||||
let fixture: ComponentFixture<ChannelListHiddenStateComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ChannelListHiddenStateComponent],
|
||||
})
|
||||
.overrideComponent(ChannelListHiddenStateComponent, {
|
||||
remove: { imports: [TranslatePipe] },
|
||||
add: {
|
||||
imports: [MockPipe(TranslatePipe, (key: string) => key)],
|
||||
},
|
||||
})
|
||||
.compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(ChannelListHiddenStateComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('explains that the list is hidden instead of asking to pick a channel', () => {
|
||||
const host: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(host.querySelector('.empty-state-title')?.textContent).toContain(
|
||||
'LAYOUT.CHANNELS_LIST_HIDDEN'
|
||||
);
|
||||
expect(host.querySelector('.empty-state-hint')?.textContent).toContain(
|
||||
'LAYOUT.CHANNELS_LIST_HIDDEN_HINT'
|
||||
);
|
||||
expect(host.textContent).not.toContain('SELECT_CHANNEL_PLAYBACK');
|
||||
});
|
||||
|
||||
it('offers a full-size restore action that emits once per click', () => {
|
||||
const restore = jest.fn();
|
||||
fixture.componentInstance.restore.subscribe(restore);
|
||||
const button: HTMLButtonElement | null =
|
||||
fixture.nativeElement.querySelector('button.empty-state-action');
|
||||
|
||||
expect(button?.textContent).toContain('LAYOUT.SHOW_CHANNELS_LIST');
|
||||
|
||||
button?.click();
|
||||
|
||||
expect(restore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
import { ChangeDetectionStrategy, Component, output } from '@angular/core';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
|
||||
/**
|
||||
* Content-area placeholder for a live layout whose channel rail is collapsed
|
||||
* and nothing is playing. It replaces the "select a channel" empty state,
|
||||
* which would otherwise ask the user to pick from a list they cannot see,
|
||||
* and gives the collapsed state a full-size way back next to the 32px
|
||||
* restore chevron. Every live host (M3U player, Xtream/Stalker live layouts,
|
||||
* unified live tab) renders this same component so the copy stays identical.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-channel-list-hidden-state',
|
||||
imports: [PortalEmptyStateComponent, TranslatePipe],
|
||||
template: `
|
||||
<app-portal-empty-state
|
||||
icon="view_sidebar"
|
||||
[message]="'LAYOUT.CHANNELS_LIST_HIDDEN' | translate"
|
||||
[hint]="'LAYOUT.CHANNELS_LIST_HIDDEN_HINT' | translate"
|
||||
[actionLabel]="'LAYOUT.SHOW_CHANNELS_LIST' | translate"
|
||||
actionIcon="chevron_right"
|
||||
(action)="restore.emit()"
|
||||
/>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ChannelListHiddenStateComponent {
|
||||
readonly restore = output<void>();
|
||||
}
|
||||
+16
@@ -1,4 +1,20 @@
|
||||
<div class="empty-state-container">
|
||||
<mat-icon class="empty-state-icon">{{ icon() }}</mat-icon>
|
||||
<h2 class="empty-state-title">{{ message() }}</h2>
|
||||
@if (hint(); as hint) {
|
||||
<p class="empty-state-hint">{{ hint }}</p>
|
||||
}
|
||||
@if (actionLabel(); as label) {
|
||||
<button
|
||||
type="button"
|
||||
mat-stroked-button
|
||||
class="empty-state-action"
|
||||
(click)="action.emit()"
|
||||
>
|
||||
@if (actionIcon(); as actionIcon) {
|
||||
<mat-icon>{{ actionIcon }}</mat-icon>
|
||||
}
|
||||
{{ label }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
+21
@@ -33,6 +33,27 @@
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.empty-state-hint {
|
||||
margin: 8px 0 0;
|
||||
max-width: 36ch;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.4;
|
||||
text-wrap: balance;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
// The action is the one element here that must not fade into the backdrop:
|
||||
// it is the way out of the state, so it keeps full opacity and a real
|
||||
// hit target while the icon and copy stay muted.
|
||||
.empty-state-action {
|
||||
margin-top: 20px;
|
||||
min-height: 40px;
|
||||
|
||||
mat-icon {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes portal-empty-state-fade-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { PortalEmptyStateComponent } from './portal-empty-state.component';
|
||||
|
||||
describe('PortalEmptyStateComponent', () => {
|
||||
let fixture: ComponentFixture<PortalEmptyStateComponent>;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PortalEmptyStateComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PortalEmptyStateComponent);
|
||||
fixture.componentRef.setInput('message', 'Nothing here');
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('renders only the icon and message by default', () => {
|
||||
const host: HTMLElement = fixture.nativeElement;
|
||||
|
||||
expect(host.querySelector('.empty-state-icon')?.textContent).toBe(
|
||||
'live_tv'
|
||||
);
|
||||
expect(host.querySelector('.empty-state-title')?.textContent).toBe(
|
||||
'Nothing here'
|
||||
);
|
||||
expect(host.querySelector('.empty-state-hint')).toBeNull();
|
||||
expect(host.querySelector('.empty-state-action')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders the hint and a labelled action that emits on click', () => {
|
||||
const action = jest.fn();
|
||||
fixture.componentInstance.action.subscribe(action);
|
||||
fixture.componentRef.setInput('hint', 'Try the button');
|
||||
fixture.componentRef.setInput('actionLabel', 'Do it');
|
||||
fixture.componentRef.setInput('actionIcon', 'chevron_right');
|
||||
fixture.detectChanges();
|
||||
|
||||
const host: HTMLElement = fixture.nativeElement;
|
||||
const button = host.querySelector<HTMLButtonElement>(
|
||||
'button.empty-state-action'
|
||||
);
|
||||
|
||||
expect(host.querySelector('.empty-state-hint')?.textContent).toBe(
|
||||
'Try the button'
|
||||
);
|
||||
expect(button?.textContent).toContain('Do it');
|
||||
expect(button?.querySelector('mat-icon')?.textContent).toBe(
|
||||
'chevron_right'
|
||||
);
|
||||
|
||||
button?.click();
|
||||
|
||||
expect(action).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+14
-2
@@ -1,4 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
input,
|
||||
output,
|
||||
} from '@angular/core';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { MatIcon } from '@angular/material/icon';
|
||||
|
||||
@Component({
|
||||
@@ -6,9 +12,15 @@ import { MatIcon } from '@angular/material/icon';
|
||||
templateUrl: './portal-empty-state.component.html',
|
||||
styleUrl: './portal-empty-state.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [MatIcon],
|
||||
imports: [MatButton, MatIcon],
|
||||
})
|
||||
export class PortalEmptyStateComponent {
|
||||
readonly icon = input<string>('live_tv');
|
||||
readonly message = input.required<string>();
|
||||
/** One short secondary sentence under the title. */
|
||||
readonly hint = input<string | null>(null);
|
||||
/** Renders the primary action button; the host reacts to `action`. */
|
||||
readonly actionLabel = input<string | null>(null);
|
||||
readonly actionIcon = input<string | null>(null);
|
||||
readonly action = output<void>();
|
||||
}
|
||||
+1
@@ -191,6 +191,7 @@
|
||||
(reorderItems)="onReorder($event)"
|
||||
(itemPlayed)="onItemPlayed($event)"
|
||||
(autoOpenHandled)="onLiveAutoOpenHandled()"
|
||||
(restoreSidebarRequested)="toggleSidebar()"
|
||||
/>
|
||||
}
|
||||
}
|
||||
|
||||
+63
-8
@@ -20,12 +20,17 @@ import {
|
||||
ScopeToggleService,
|
||||
UnifiedCollectionItem,
|
||||
WorkspaceViewCommandService,
|
||||
LiveLayoutSidebarStateService,
|
||||
liveSidebarStateStorageKey,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import {
|
||||
UnifiedFavoritesDataService,
|
||||
UnifiedRecentDataService,
|
||||
} from '@iptvnator/portal/shared/data-access';
|
||||
import { selectAllPlaylistsMeta, selectPlaylistsLoadingFlag } from '@iptvnator/m3u-state';
|
||||
import {
|
||||
selectAllPlaylistsMeta,
|
||||
selectPlaylistsLoadingFlag,
|
||||
} from '@iptvnator/m3u-state';
|
||||
import { RuntimeCapabilitiesService } from '@iptvnator/services';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
@@ -53,6 +58,7 @@ class StubUnifiedLiveTabComponent {
|
||||
readonly reorderItems = output<UnifiedCollectionItem[]>();
|
||||
readonly itemPlayed = output<UnifiedCollectionItem>();
|
||||
readonly autoOpenHandled = output<void>();
|
||||
readonly restoreSidebarRequested = output<void>();
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -507,19 +513,70 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.removeFavorite).toHaveBeenCalledWith(recentItem);
|
||||
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
|
||||
.toBe(false);
|
||||
expect(
|
||||
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
|
||||
).toBe(false);
|
||||
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
|
||||
|
||||
liveTab.favoriteToggled.emit(recentItem);
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(favoritesData.addFavorite).toHaveBeenCalledWith(recentItem);
|
||||
expect(fixture.componentInstance.favoriteUidSet().has(recentItem.uid))
|
||||
.toBe(true);
|
||||
expect(
|
||||
fixture.componentInstance.favoriteUidSet().has(recentItem.uid)
|
||||
).toBe(true);
|
||||
expect(fixture.componentInstance.allItems()).toEqual([recentItem]);
|
||||
});
|
||||
|
||||
it('toggles the collection live rail with Cmd/Ctrl+B only while the live tab is on screen', async () => {
|
||||
const recentItem = {
|
||||
uid: 'm3u::playlist-1::https://example.com/one.m3u8',
|
||||
name: 'Recent One',
|
||||
contentType: 'live',
|
||||
sourceType: 'm3u',
|
||||
playlistId: 'playlist-1',
|
||||
playlistName: 'Playlist One',
|
||||
streamUrl: 'https://example.com/one.m3u8',
|
||||
} satisfies UnifiedCollectionItem;
|
||||
recentData.getRecentItems.mockResolvedValueOnce([recentItem]);
|
||||
const sidebarState = TestBed.inject(LiveLayoutSidebarStateService);
|
||||
sidebarState.setState('collection', 'expanded');
|
||||
|
||||
fixture.componentRef.setInput('mode', 'recent');
|
||||
fixture.detectChanges();
|
||||
await fixture.whenStable();
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
fixture.componentInstance.isLoading.set(false);
|
||||
fixture.componentInstance.selectedContentType.set('live');
|
||||
fixture.componentInstance.allItems.set([recentItem]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const press = () => {
|
||||
const event = new KeyboardEvent('keydown', {
|
||||
key: 'b',
|
||||
ctrlKey: true,
|
||||
cancelable: true,
|
||||
bubbles: true,
|
||||
});
|
||||
document.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
expect(press().defaultPrevented).toBe(true);
|
||||
expect(sidebarState.isCollapsedFor('collection')()).toBe(true);
|
||||
expect(sidebarState.isCollapsedFor('m3u')()).toBe(false);
|
||||
|
||||
// Not on the movies grid: there is no rail to hide there.
|
||||
fixture.componentInstance.selectedContentType.set('movie');
|
||||
fixture.detectChanges();
|
||||
expect(press().defaultPrevented).toBe(false);
|
||||
expect(sidebarState.isCollapsedFor('collection')()).toBe(true);
|
||||
|
||||
sidebarState.setState('collection', 'expanded');
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('collection'));
|
||||
});
|
||||
|
||||
it('removes one recent live row through the live tab remove event', async () => {
|
||||
const recentItems = [
|
||||
{
|
||||
@@ -826,9 +883,7 @@ describe('UnifiedCollectionPageComponent', () => {
|
||||
},
|
||||
}
|
||||
);
|
||||
expect(
|
||||
fixture.componentInstance.selectedDetailItem()
|
||||
).toBeNull();
|
||||
expect(fixture.componentInstance.selectedDetailItem()).toBeNull();
|
||||
} finally {
|
||||
router.url = originalUrl;
|
||||
}
|
||||
|
||||
+30
-2
@@ -7,6 +7,8 @@ import {
|
||||
contentChild,
|
||||
DestroyRef,
|
||||
effect,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
linkedSignal,
|
||||
input,
|
||||
@@ -38,6 +40,7 @@ import {
|
||||
getOpenLiveCollectionItemState,
|
||||
getUnifiedCollectionNavigation,
|
||||
isWorkspaceLayoutRoute,
|
||||
isTypingInInput,
|
||||
LiveLayoutSidebarStateService,
|
||||
OPEN_COLLECTION_DETAIL_STATE_KEY,
|
||||
OPEN_LIVE_COLLECTION_ITEM_STATE_KEY,
|
||||
@@ -96,6 +99,7 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
private readonly router = inject(Router);
|
||||
private readonly store = inject(Store);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly hostElement = inject(ElementRef<HTMLElement>);
|
||||
private readonly scopeService = inject(ScopeToggleService);
|
||||
private readonly favoritesData = inject(UnifiedFavoritesDataService);
|
||||
private readonly recentData = inject(UnifiedRecentDataService);
|
||||
@@ -276,7 +280,8 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
this.selectedContentType() === 'live' &&
|
||||
this.hasLive()
|
||||
);
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly isSidebarCollapsed =
|
||||
this.liveSidebarStateService.isCollapsedFor('collection');
|
||||
readonly showSidebarToggle = computed(
|
||||
() => this.selectedContentType() === 'live' && this.hasLive()
|
||||
);
|
||||
@@ -469,7 +474,30 @@ export class UnifiedCollectionPageComponent implements AfterContentInit {
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.liveSidebarStateService.toggle();
|
||||
this.liveSidebarStateService.toggle('collection');
|
||||
}
|
||||
|
||||
/**
|
||||
* Cmd/Ctrl+B mirrors the routed live layouts (M3U, Xtream, Stalker): the
|
||||
* hidden-list state advertises the shortcut, so the collection live tab
|
||||
* must honour it too. Only while that tab, and therefore the rail, is on
|
||||
* screen; the movies/series grids have nothing to hide.
|
||||
*/
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleSidebarShortcut(event: KeyboardEvent): void {
|
||||
if (
|
||||
this.showSidebarToggle() &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
event.key.toLowerCase() === 'b' &&
|
||||
!isTypingInInput(event) &&
|
||||
// Behind the workspace's phone context drawer the route content
|
||||
// is inert; this document-level listener still fires, so it
|
||||
// opts out itself instead of toggling an obscured rail.
|
||||
!this.hostElement.nativeElement.closest('[inert]')
|
||||
) {
|
||||
event.preventDefault();
|
||||
this.toggleSidebar();
|
||||
}
|
||||
}
|
||||
|
||||
setFavSortMode(mode: FavoritesChannelSortMode): void {
|
||||
|
||||
+4
@@ -148,6 +148,10 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} @else if (isSidebarCollapsed()) {
|
||||
<app-channel-list-hidden-state
|
||||
(restore)="restoreSidebarRequested.emit()"
|
||||
/>
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
|
||||
+4
@@ -58,6 +58,7 @@ import {
|
||||
toLiveEpgPanelSummary,
|
||||
} from './unified-live-epg-summary.util';
|
||||
import { GlobalFavoritesListComponent } from '../global-favorites-list/global-favorites-list.component';
|
||||
import { ChannelListHiddenStateComponent } from '../channel-list-hidden-state/channel-list-hidden-state.component';
|
||||
import { PortalEmptyStateComponent } from '../portal-empty-state/portal-empty-state.component';
|
||||
import {
|
||||
AudioPlayerComponent,
|
||||
@@ -100,6 +101,7 @@ import { createUnifiedLivePlaybackSessionKey } from './unified-live-playback-ses
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressSpinnerModule,
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
@@ -130,6 +132,8 @@ export class UnifiedLiveTabComponent implements FullscreenChannelPanelHost {
|
||||
readonly itemPlayed = output<UnifiedCollectionItem>();
|
||||
readonly autoOpenHandled = output<void>();
|
||||
readonly isSidebarCollapsed = input(false);
|
||||
/** The rail is owned by the page header toggle; ask it to expand. */
|
||||
readonly restoreSidebarRequested = output<void>();
|
||||
|
||||
private readonly streamResolver = inject(StreamResolverService);
|
||||
private readonly recentData = inject(UnifiedRecentDataService);
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LiveLayoutSidebarStateService } from './live-layout-sidebar-state.service';
|
||||
import {
|
||||
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
LIVE_SIDEBAR_SURFACES,
|
||||
liveSidebarStateStorageKey,
|
||||
} from './live-sidebar-state';
|
||||
|
||||
describe('LiveLayoutSidebarStateService', () => {
|
||||
function clearStorage(): void {
|
||||
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
for (const surface of LIVE_SIDEBAR_SURFACES) {
|
||||
localStorage.removeItem(liveSidebarStateStorageKey(surface));
|
||||
}
|
||||
}
|
||||
|
||||
function createService(): LiveLayoutSidebarStateService {
|
||||
TestBed.resetTestingModule();
|
||||
return TestBed.inject(LiveLayoutSidebarStateService);
|
||||
}
|
||||
|
||||
beforeEach(clearStorage);
|
||||
afterEach(clearStorage);
|
||||
|
||||
it('starts every surface expanded', () => {
|
||||
const service = createService();
|
||||
|
||||
for (const surface of LIVE_SIDEBAR_SURFACES) {
|
||||
expect(service.stateOf(surface)()).toBe('expanded');
|
||||
expect(service.isCollapsedFor(surface)()).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps surfaces independent: collapsing the M3U rail leaves portals and collections alone', () => {
|
||||
const service = createService();
|
||||
|
||||
service.toggle('m3u');
|
||||
|
||||
expect(service.isCollapsedFor('m3u')()).toBe(true);
|
||||
expect(service.isCollapsedFor('portal')()).toBe(false);
|
||||
expect(service.isCollapsedFor('collection')()).toBe(false);
|
||||
expect(localStorage.getItem(liveSidebarStateStorageKey('m3u'))).toBe(
|
||||
'collapsed'
|
||||
);
|
||||
expect(
|
||||
localStorage.getItem(liveSidebarStateStorageKey('portal'))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the same signal instance per surface so components can hold it', () => {
|
||||
const service = createService();
|
||||
|
||||
expect(service.isCollapsedFor('portal')).toBe(
|
||||
service.isCollapsedFor('portal')
|
||||
);
|
||||
expect(service.isCollapsedFor('portal')).not.toBe(
|
||||
service.isCollapsedFor('collection')
|
||||
);
|
||||
});
|
||||
|
||||
it('restores each surface from its own storage key', () => {
|
||||
localStorage.setItem(liveSidebarStateStorageKey('portal'), 'collapsed');
|
||||
|
||||
const service = createService();
|
||||
|
||||
expect(service.isCollapsedFor('portal')()).toBe(true);
|
||||
expect(service.isCollapsedFor('m3u')()).toBe(false);
|
||||
|
||||
service.setState('portal', 'expanded');
|
||||
|
||||
expect(localStorage.getItem(liveSidebarStateStorageKey('portal'))).toBe(
|
||||
'expanded'
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores and removes the legacy shared key instead of inheriting it', () => {
|
||||
localStorage.setItem(
|
||||
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
'collapsed'
|
||||
);
|
||||
|
||||
const service = createService();
|
||||
|
||||
for (const surface of LIVE_SIDEBAR_SURFACES) {
|
||||
expect(service.isCollapsedFor(surface)()).toBe(false);
|
||||
}
|
||||
expect(
|
||||
localStorage.getItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,76 @@
|
||||
import { computed, Injectable, signal } from '@angular/core';
|
||||
import {
|
||||
computed,
|
||||
Injectable,
|
||||
Signal,
|
||||
signal,
|
||||
WritableSignal,
|
||||
} from '@angular/core';
|
||||
import {
|
||||
forgetLegacyLiveSidebarState,
|
||||
LiveSidebarState,
|
||||
LiveSidebarSurface,
|
||||
liveSidebarStateStorageKey,
|
||||
persistLiveSidebarState,
|
||||
restoreLiveSidebarState,
|
||||
} from './live-sidebar-state';
|
||||
|
||||
function bySurface<T>(
|
||||
create: (surface: LiveSidebarSurface) => T
|
||||
): Record<LiveSidebarSurface, T> {
|
||||
return {
|
||||
m3u: create('m3u'),
|
||||
portal: create('portal'),
|
||||
collection: create('collection'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared collapse state for the live-TV sidebar across the workspace shell
|
||||
* categories rail (Xtream/Stalker), the inline channels rail, and the
|
||||
* unified-collection live tab. A single signal keeps all surfaces in sync
|
||||
* within a session; localStorage persistence is delegated to the existing
|
||||
* `live-sidebar-state` helpers so the storage key stays unchanged.
|
||||
* Single owner of the live-TV sidebar collapse state. Every surface that
|
||||
* renders a collapsible channel rail — the M3U player, the Xtream/Stalker
|
||||
* live layouts together with the workspace shell categories rail, the
|
||||
* unified favorites/recent live tab, and the workspace header toggle — reads
|
||||
* and writes through this service, so a toggle in one place is reflected
|
||||
* everywhere the same surface is rendered within the session.
|
||||
*
|
||||
* State is kept per surface (`live-sidebar-state:<surface>` in
|
||||
* localStorage). The pre-split shared key is forgotten on construction.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LiveLayoutSidebarStateService {
|
||||
private readonly _state = signal<LiveSidebarState>(
|
||||
restoreLiveSidebarState()
|
||||
);
|
||||
readonly state = this._state.asReadonly();
|
||||
readonly isCollapsed = computed(() => this._state() === 'collapsed');
|
||||
private readonly states: Record<
|
||||
LiveSidebarSurface,
|
||||
WritableSignal<LiveSidebarState>
|
||||
>;
|
||||
private readonly collapsed: Record<LiveSidebarSurface, Signal<boolean>>;
|
||||
|
||||
toggle(): void {
|
||||
this.setState(this.isCollapsed() ? 'expanded' : 'collapsed');
|
||||
constructor() {
|
||||
forgetLegacyLiveSidebarState();
|
||||
this.states = bySurface((surface) =>
|
||||
signal(restoreLiveSidebarState(liveSidebarStateStorageKey(surface)))
|
||||
);
|
||||
this.collapsed = bySurface((surface) =>
|
||||
computed(() => this.states[surface]() === 'collapsed')
|
||||
);
|
||||
}
|
||||
|
||||
setState(state: LiveSidebarState): void {
|
||||
this._state.set(state);
|
||||
persistLiveSidebarState(state);
|
||||
stateOf(surface: LiveSidebarSurface): Signal<LiveSidebarState> {
|
||||
return this.states[surface].asReadonly();
|
||||
}
|
||||
|
||||
/** Stable per-surface signal; safe to assign to a component field. */
|
||||
isCollapsedFor(surface: LiveSidebarSurface): Signal<boolean> {
|
||||
return this.collapsed[surface];
|
||||
}
|
||||
|
||||
toggle(surface: LiveSidebarSurface): void {
|
||||
this.setState(
|
||||
surface,
|
||||
this.collapsed[surface]() ? 'expanded' : 'collapsed'
|
||||
);
|
||||
}
|
||||
|
||||
setState(surface: LiveSidebarSurface, state: LiveSidebarState): void {
|
||||
this.states[surface].set(state);
|
||||
persistLiveSidebarState(state, liveSidebarStateStorageKey(surface));
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,21 @@
|
||||
import {
|
||||
DEFAULT_LIVE_SIDEBAR_STATE,
|
||||
LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
forgetLegacyLiveSidebarState,
|
||||
isLiveSidebarState,
|
||||
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
LIVE_SIDEBAR_SURFACES,
|
||||
liveSidebarStateStorageKey,
|
||||
persistLiveSidebarState,
|
||||
resolveRouteLiveSidebarSurface,
|
||||
restoreLiveSidebarState,
|
||||
} from './live-sidebar-state';
|
||||
|
||||
describe('live sidebar state', () => {
|
||||
const key = liveSidebarStateStorageKey('m3u');
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem('custom-live-sidebar-state');
|
||||
localStorage.removeItem(key);
|
||||
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('accepts only known sidebar states', () => {
|
||||
@@ -19,32 +25,73 @@ describe('live sidebar state', () => {
|
||||
expect(isLiveSidebarState(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('restores expanded as the default for missing or invalid storage', () => {
|
||||
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
|
||||
it('derives one storage key per surface under the legacy prefix', () => {
|
||||
const keys = LIVE_SIDEBAR_SURFACES.map(liveSidebarStateStorageKey);
|
||||
|
||||
localStorage.setItem(LIVE_SIDEBAR_STATE_STORAGE_KEY, 'hidden');
|
||||
|
||||
expect(restoreLiveSidebarState()).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
|
||||
expect(keys).toEqual([
|
||||
'live-sidebar-state:m3u',
|
||||
'live-sidebar-state:portal',
|
||||
'live-sidebar-state:collection',
|
||||
]);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(keys).not.toContain(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
it('restores and persists collapsed state', () => {
|
||||
persistLiveSidebarState('collapsed');
|
||||
it('restores expanded as the default for missing or invalid storage', () => {
|
||||
expect(restoreLiveSidebarState(key)).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
|
||||
|
||||
expect(localStorage.getItem(LIVE_SIDEBAR_STATE_STORAGE_KEY)).toBe(
|
||||
localStorage.setItem(key, 'hidden');
|
||||
|
||||
expect(restoreLiveSidebarState(key)).toBe(DEFAULT_LIVE_SIDEBAR_STATE);
|
||||
});
|
||||
|
||||
it('restores and persists collapsed state under the given key', () => {
|
||||
persistLiveSidebarState('collapsed', key);
|
||||
|
||||
expect(localStorage.getItem(key)).toBe('collapsed');
|
||||
expect(restoreLiveSidebarState(key)).toBe('collapsed');
|
||||
expect(restoreLiveSidebarState(key, 'expanded')).toBe('collapsed');
|
||||
});
|
||||
|
||||
it('never reads the legacy shared key and can forget it', () => {
|
||||
localStorage.setItem(
|
||||
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
'collapsed'
|
||||
);
|
||||
expect(restoreLiveSidebarState()).toBe('collapsed');
|
||||
|
||||
expect(restoreLiveSidebarState(key)).toBe('expanded');
|
||||
|
||||
forgetLegacyLiveSidebarState();
|
||||
|
||||
expect(
|
||||
localStorage.getItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('supports custom storage keys and fallback values', () => {
|
||||
expect(
|
||||
restoreLiveSidebarState('custom-live-sidebar-state', 'collapsed')
|
||||
).toBe('collapsed');
|
||||
|
||||
persistLiveSidebarState('expanded', 'custom-live-sidebar-state');
|
||||
|
||||
expect(restoreLiveSidebarState('custom-live-sidebar-state')).toBe(
|
||||
'expanded'
|
||||
it('maps only routes that render their own live rail to a surface', () => {
|
||||
expect(resolveRouteLiveSidebarSurface('playlists', 'all')).toBe('m3u');
|
||||
expect(resolveRouteLiveSidebarSurface('playlists', 'groups')).toBe(
|
||||
'm3u'
|
||||
);
|
||||
expect(resolveRouteLiveSidebarSurface('xtreams', 'live')).toBe(
|
||||
'portal'
|
||||
);
|
||||
expect(resolveRouteLiveSidebarSurface('stalker', 'itv')).toBe('portal');
|
||||
expect(resolveRouteLiveSidebarSurface('stalker', 'radio')).toBe(
|
||||
'portal'
|
||||
);
|
||||
|
||||
// Collection pages own their toggle: only they know whether the live
|
||||
// tab is on screen.
|
||||
expect(
|
||||
resolveRouteLiveSidebarSurface('playlists', 'recent')
|
||||
).toBeNull();
|
||||
expect(
|
||||
resolveRouteLiveSidebarSurface('xtreams', 'favorites')
|
||||
).toBeNull();
|
||||
expect(resolveRouteLiveSidebarSurface('xtreams', 'vod')).toBeNull();
|
||||
expect(resolveRouteLiveSidebarSurface('stalker', 'series')).toBeNull();
|
||||
expect(resolveRouteLiveSidebarSurface(null, 'live')).toBeNull();
|
||||
expect(resolveRouteLiveSidebarSurface('xtreams', null)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,48 @@
|
||||
import type {
|
||||
PortalProvider,
|
||||
PortalRailSection,
|
||||
} from './navigation/portal-rail-links';
|
||||
|
||||
export type LiveSidebarState = 'expanded' | 'collapsed';
|
||||
|
||||
export const LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
|
||||
/**
|
||||
* Collapsible live-channel rails. Each surface remembers its own state:
|
||||
* hiding the list in the M3U player must not hide the channel rail of an
|
||||
* Xtream/Stalker portal or of the favorites/recent collection pages — the
|
||||
* user made that choice in one context, not in all of them.
|
||||
*/
|
||||
export type LiveSidebarSurface = 'm3u' | 'portal' | 'collection';
|
||||
|
||||
export const LIVE_SIDEBAR_SURFACES: readonly LiveSidebarSurface[] = [
|
||||
'm3u',
|
||||
'portal',
|
||||
'collection',
|
||||
];
|
||||
|
||||
/**
|
||||
* Key every live surface shared before the per-surface split. It is no longer
|
||||
* read: a stored `collapsed` left every playlist and portal without a channel
|
||||
* list and only a 32px chevron to recover it (issue #1458). The state service
|
||||
* removes it once, so the update itself restores the list for everyone.
|
||||
*/
|
||||
export const LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY = 'live-sidebar-state';
|
||||
/** @deprecated The legacy shared key; use `liveSidebarStateStorageKey()`. */
|
||||
export const LIVE_SIDEBAR_STATE_STORAGE_KEY =
|
||||
LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY;
|
||||
export const DEFAULT_LIVE_SIDEBAR_STATE: LiveSidebarState = 'expanded';
|
||||
|
||||
export function liveSidebarStateStorageKey(
|
||||
surface: LiveSidebarSurface
|
||||
): string {
|
||||
return `${LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY}:${surface}`;
|
||||
}
|
||||
|
||||
export function isLiveSidebarState(value: unknown): value is LiveSidebarState {
|
||||
return value === 'expanded' || value === 'collapsed';
|
||||
}
|
||||
|
||||
export function restoreLiveSidebarState(
|
||||
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
storageKey: string,
|
||||
fallback: LiveSidebarState = DEFAULT_LIVE_SIDEBAR_STATE
|
||||
): LiveSidebarState {
|
||||
const storedValue = localStorage.getItem(storageKey);
|
||||
@@ -17,7 +51,41 @@ export function restoreLiveSidebarState(
|
||||
|
||||
export function persistLiveSidebarState(
|
||||
state: LiveSidebarState,
|
||||
storageKey: string = LIVE_SIDEBAR_STATE_STORAGE_KEY
|
||||
storageKey: string
|
||||
): void {
|
||||
localStorage.setItem(storageKey, state);
|
||||
}
|
||||
|
||||
export function forgetLegacyLiveSidebarState(): void {
|
||||
try {
|
||||
localStorage.removeItem(LEGACY_LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage unavailable (private mode, blocked site data): nothing to forget.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The rail a portal route renders itself, for a toggle that lives outside the
|
||||
* rail (the workspace header). Favorites/recent routes return `null` on
|
||||
* purpose: the collection page owns that toggle, because only it knows
|
||||
* whether the live tab, and therefore the rail, is on screen.
|
||||
*/
|
||||
export function resolveRouteLiveSidebarSurface(
|
||||
provider: PortalProvider | null | undefined,
|
||||
section: PortalRailSection | null | undefined
|
||||
): LiveSidebarSurface | null {
|
||||
if (!provider || !section) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (provider) {
|
||||
case 'playlists':
|
||||
return section === 'all' || section === 'groups' ? 'm3u' : null;
|
||||
case 'xtreams':
|
||||
return section === 'live' ? 'portal' : null;
|
||||
case 'stalker':
|
||||
return section === 'itv' || section === 'radio' ? 'portal' : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+273
@@ -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('portal', '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');
|
||||
});
|
||||
});
|
||||
+344
@@ -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('portal', '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);
|
||||
}
|
||||
}
|
||||
+21
-3
@@ -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
|
||||
@@ -129,6 +142,8 @@
|
||||
<ng-container *ngTemplateOutlet="epgPanelContent" />
|
||||
</div>
|
||||
}
|
||||
} @else if (isSidebarCollapsed()) {
|
||||
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
@@ -262,6 +277,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 +306,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)"
|
||||
/>
|
||||
|
||||
+76
-43
@@ -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,15 +78,16 @@ import {
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
createLogger,
|
||||
getAdjacentChannelItem,
|
||||
getChannelItemByNumber,
|
||||
isTypingInInput,
|
||||
LiveEpgPanelState,
|
||||
persistLiveEpgPanelState,
|
||||
REMOTE_CONTROL_RESET_STATUS,
|
||||
restoreLiveEpgPanelState,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PortalEmptyStateComponent } from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
ACTIVE_EPG_FALLBACK_SIZE,
|
||||
StalkerFavoriteItem,
|
||||
@@ -134,6 +136,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',
|
||||
@@ -151,6 +155,7 @@ function matchesStalkerChannelTerm(
|
||||
MatProgressSpinnerModule,
|
||||
MatTooltipModule,
|
||||
NgTemplateOutlet,
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
StalkerItvAllItemsComponent,
|
||||
@@ -267,9 +272,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 +320,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 +330,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
() =>
|
||||
!!this.stalkerStore.selectedCategoryId() &&
|
||||
!this.searchTerm() &&
|
||||
this.channels().length === 0 &&
|
||||
this.visibleChannels().length === 0 &&
|
||||
!this.isInitialChannelsLoading()
|
||||
);
|
||||
|
||||
@@ -481,7 +488,8 @@ export class StalkerLiveStreamLayoutComponent
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly isSidebarCollapsed =
|
||||
this.liveSidebarStateService.isCollapsedFor('portal');
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(this.currentProgram())
|
||||
);
|
||||
@@ -566,6 +574,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 +760,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 +829,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.navigation.reset();
|
||||
this.unsubscribeRemoteChannelChange?.();
|
||||
this.unsubscribeRemoteCommand?.();
|
||||
// Leaving the live view would otherwise keep the last channel
|
||||
@@ -842,8 +864,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 +931,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);
|
||||
}
|
||||
|
||||
@@ -1131,7 +1160,7 @@ export class StalkerLiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.liveSidebarStateService.toggle();
|
||||
this.liveSidebarStateService.toggle('portal');
|
||||
}
|
||||
|
||||
handleRadioChannelSwitch(direction: 'next' | 'previous'): void {
|
||||
@@ -1710,24 +1739,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 +1750,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 {
|
||||
|
||||
+4
-1
@@ -165,7 +165,10 @@ describe('StalkerLiveStreamLayoutComponent EPG fallback races', () => {
|
||||
},
|
||||
{
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
|
||||
useValue: {
|
||||
isCollapsedFor: () => signal(false),
|
||||
toggle: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: EpgRuntimeBridgeService, useValue: {} },
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
|
||||
+4
-1
@@ -141,7 +141,10 @@ describe('StalkerLiveStreamLayoutComponent fullscreen panel search', () => {
|
||||
},
|
||||
{
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
|
||||
useValue: {
|
||||
isCollapsedFor: () => signal(false),
|
||||
toggle: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: EpgRuntimeBridgeService, useValue: {} },
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
|
||||
+4
-1
@@ -138,7 +138,10 @@ describe('StalkerLiveStreamLayoutComponent playback session ownership', () => {
|
||||
},
|
||||
{
|
||||
provide: LiveLayoutSidebarStateService,
|
||||
useValue: { isCollapsed: signal(false), toggle: jest.fn() },
|
||||
useValue: {
|
||||
isCollapsedFor: () => signal(false),
|
||||
toggle: jest.fn(),
|
||||
},
|
||||
},
|
||||
{ provide: EpgRuntimeBridgeService, useValue: {} },
|
||||
{ provide: MatDialog, useValue: { open: jest.fn() } },
|
||||
|
||||
+57
-6
@@ -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',
|
||||
|
||||
@@ -120,6 +120,48 @@ describe('XtreamUrlService', () => {
|
||||
expect(url).toBe('http://demo.example/live/demo/secret/101.ts');
|
||||
});
|
||||
|
||||
it('builds an Auto TS alternative with encoded credentials and the provider subpath', () => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
expect(
|
||||
service.constructAutoLiveTsUrl(
|
||||
{
|
||||
serverUrl:
|
||||
'https://demo.example/panel/player_api.php?token=unused',
|
||||
username: 'a/b',
|
||||
password: 'x?y#z',
|
||||
allowedOutputFormats: [' M3U8 ', ' TS '],
|
||||
},
|
||||
101
|
||||
)
|
||||
).toBe('https://demo.example/panel/live/a%2Fb/x%3Fy%23z/101.ts');
|
||||
});
|
||||
|
||||
it.each([undefined, [], ['m3u8'], ['rtmp'], ['ts']])(
|
||||
'does not guess an HLS-to-TS alternative for %j',
|
||||
(allowedOutputFormats) => {
|
||||
streamFormat.set(StreamFormat.AutoStreamFormat);
|
||||
expect(
|
||||
service.constructAutoLiveTsUrl(
|
||||
{ ...credentials, allowedOutputFormats },
|
||||
101
|
||||
)
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it.each([StreamFormat.TsStreamFormat, StreamFormat.M3u8StreamFormat])(
|
||||
'respects manual %s even with both formats advertised',
|
||||
(format) => {
|
||||
streamFormat.set(format);
|
||||
expect(
|
||||
service.constructAutoLiveTsUrl(
|
||||
{ ...credentials, allowedOutputFormats: ['m3u8', 'ts'] },
|
||||
101
|
||||
)
|
||||
).toBeUndefined();
|
||||
}
|
||||
);
|
||||
|
||||
it('constructs a VOD URL from catalog fields when metadata is empty', () => {
|
||||
const vodItem = {
|
||||
info: [],
|
||||
|
||||
@@ -113,6 +113,29 @@ export class XtreamUrlService {
|
||||
return `${normalizedCredentials.serverUrl}/live/${normalizedCredentials.username}/${normalizedCredentials.password}/${xtreamId}.${streamFormat}`;
|
||||
}
|
||||
|
||||
/** No probe: eligibility is explicit account evidence plus the user's Auto intent. */
|
||||
constructAutoLiveTsUrl(
|
||||
credentials: XtreamCredentials,
|
||||
xtreamId: number
|
||||
): string | undefined {
|
||||
const requested =
|
||||
this.settingsStore.streamFormat() ?? StreamFormat.AutoStreamFormat;
|
||||
const formats = this.getNormalizedAllowedOutputFormats(credentials);
|
||||
if (
|
||||
requested !== StreamFormat.AutoStreamFormat ||
|
||||
!formats?.includes('m3u8') ||
|
||||
!formats.includes('ts')
|
||||
)
|
||||
return undefined;
|
||||
return (
|
||||
this.constructLiveUrl(
|
||||
credentials,
|
||||
xtreamId,
|
||||
StreamFormat.TsStreamFormat
|
||||
) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct VOD stream URL
|
||||
* Format: {serverUrl}/movie/{username}/{password}/{streamId}.{extension}
|
||||
|
||||
+184
@@ -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);
|
||||
});
|
||||
});
|
||||
+23
-2
@@ -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)"
|
||||
@@ -156,6 +170,8 @@
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
} @else if (isSidebarCollapsed()) {
|
||||
<app-channel-list-hidden-state (restore)="toggleSidebar()" />
|
||||
} @else {
|
||||
<app-portal-empty-state
|
||||
icon="live_tv"
|
||||
@@ -215,11 +231,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>
|
||||
+48
-10
@@ -13,7 +13,7 @@ import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { BehaviorSubject, Subject, of } from 'rxjs';
|
||||
import {
|
||||
LIVE_EPG_PANEL_STATE_STORAGE_KEY,
|
||||
LIVE_SIDEBAR_STATE_STORAGE_KEY,
|
||||
liveSidebarStateStorageKey,
|
||||
LiveLayoutSidebarStateService,
|
||||
PORTAL_PLAYER,
|
||||
ResizableDirective,
|
||||
@@ -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>();
|
||||
}
|
||||
@@ -194,6 +196,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
getFavorites: jest.fn().mockReturnValue(of([])),
|
||||
};
|
||||
const xtreamUrlService = {
|
||||
constructAutoLiveTsUrl: jest.fn(() => undefined),
|
||||
resolveCatchupUrl: jest
|
||||
.fn()
|
||||
.mockResolvedValue('https://example.com/timeshift.ts'),
|
||||
@@ -219,7 +222,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
settingsStore.resolvedEpgViewMode.set('timeline');
|
||||
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
settingsStore.openStreamOnDoubleClick.set(false);
|
||||
|
||||
window.electron = {
|
||||
@@ -342,19 +345,29 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
fixture = TestBed.createComponent(LiveStreamLayoutComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
fixture.destroy();
|
||||
jest.useRealTimers();
|
||||
localStorage.removeItem(LIVE_CHANNEL_SORT_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_EPG_PANEL_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem(LIVE_SIDEBAR_STATE_STORAGE_KEY);
|
||||
localStorage.removeItem(liveSidebarStateStorageKey('portal'));
|
||||
window.electron = originalElectron;
|
||||
});
|
||||
|
||||
it('retires committed live playback when the playlist owner changes', () => {
|
||||
fixture.detectChanges();
|
||||
component.playLive({ xtream_id: 10000, title: 'Synthetic' });
|
||||
expect(component.activePlayback()).not.toBeNull();
|
||||
currentPlaylist.set({ ...playlist, id: 'other-playlist' });
|
||||
fixture.detectChanges();
|
||||
expect(component.activePlayback()).toBeNull();
|
||||
expect(component.playbackSessionKey()).toBe('');
|
||||
});
|
||||
|
||||
it('renders the controlled epg list for electron playback', () => {
|
||||
epgItems.set([
|
||||
buildEpgItem(
|
||||
@@ -545,9 +558,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 +1038,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 +1072,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;
|
||||
@@ -1115,9 +1149,13 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// Collapsing with a selected category and no stream renders the real
|
||||
// `app-channel-list-hidden-state` (its TranslatePipe needs a
|
||||
// TranslateService); this spec is at the max-lines budget, so that branch
|
||||
// is covered by video-player-sidebar.spec.ts and the Electron E2E instead.
|
||||
it('shows the floating restore button when the sidebar is collapsed even without a selected category', () => {
|
||||
selectedCategoryId.set(null);
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('collapsed');
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'collapsed');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
@@ -1127,7 +1165,7 @@ describe('LiveStreamLayoutComponent', () => {
|
||||
|
||||
it('hides the floating restore button when the sidebar is expanded', () => {
|
||||
selectedCategoryId.set(1);
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('expanded');
|
||||
TestBed.inject(LiveLayoutSidebarStateService).setState('portal', 'expanded');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
|
||||
+57
-27
@@ -26,6 +26,7 @@ import { ResizableDirective } from '@iptvnator/ui/components';
|
||||
import {
|
||||
GridListComponent,
|
||||
InfiniteScrollDirective,
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
} from '@iptvnator/portal/shared/ui';
|
||||
import {
|
||||
@@ -94,20 +95,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 +108,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).
|
||||
{
|
||||
@@ -134,6 +128,7 @@ interface XtreamLiveChannelItem {
|
||||
GridListComponent,
|
||||
InfiniteScrollDirective,
|
||||
PortalChannelsListComponent,
|
||||
ChannelListHiddenStateComponent,
|
||||
PortalEmptyStateComponent,
|
||||
ResizableDirective,
|
||||
TranslatePipe,
|
||||
@@ -167,12 +162,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
|
||||
@@ -327,7 +322,8 @@ export class LiveStreamLayoutComponent
|
||||
/** Live EPG panel layout chosen in settings; hosts swap timeline ↔ list. */
|
||||
readonly epgViewMode = this.settingsStore.resolvedEpgViewMode;
|
||||
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
|
||||
readonly isSidebarCollapsed = this.liveSidebarStateService.isCollapsed;
|
||||
readonly isSidebarCollapsed =
|
||||
this.liveSidebarStateService.isCollapsedFor('portal');
|
||||
readonly liveEpgPanelSummary = computed(() =>
|
||||
this.toLiveEpgPanelSummary(
|
||||
this.activeCatchupProgram() ?? this.currentEpgItem()
|
||||
@@ -379,6 +375,7 @@ export class LiveStreamLayoutComponent
|
||||
private unsubscribeRemoteChannelChange?: () => void;
|
||||
private unsubscribeRemoteCommand?: () => void;
|
||||
private playbackRequestId = 0;
|
||||
private activePlaylistId: string | null = null;
|
||||
|
||||
readonly usesEmbeddedPlayer = computed(() =>
|
||||
this.portalPlayer.isEmbeddedPlayer()
|
||||
@@ -398,6 +395,16 @@ export class LiveStreamLayoutComponent
|
||||
favorites = new Map<number, boolean>();
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const playlistId = this.xtreamStore.currentPlaylist()?.id ?? null;
|
||||
if (this.activePlaylistId && this.activePlaylistId !== playlistId) {
|
||||
this.playbackRequestId += 1;
|
||||
this.activePlaylistId = null;
|
||||
this.activePlayback.set(null);
|
||||
this.activeLiveItemId.set(null);
|
||||
this.activeCatchupProgram.set(null);
|
||||
}
|
||||
});
|
||||
effect((onCleanup) => {
|
||||
const intervalId = window.setInterval(() => {
|
||||
this.currentTimeMs.set(Date.now());
|
||||
@@ -444,7 +451,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,18 +548,32 @@ 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);
|
||||
const playlist = this.xtreamStore.currentPlaylist();
|
||||
this.activePlaylistId = playlist?.id ?? null;
|
||||
this.activePlayback.set({
|
||||
streamUrl,
|
||||
liveAutoTsUrl: playlist
|
||||
? this.xtreamUrlService.constructAutoLiveTsUrl(
|
||||
playlist,
|
||||
item.xtream_id
|
||||
)
|
||||
: undefined,
|
||||
userAgent: playlist?.userAgent,
|
||||
referer: playlist?.referrer,
|
||||
origin: playlist?.origin,
|
||||
title: item.title ?? item.name ?? '',
|
||||
thumbnail: item.poster_url ?? item.stream_icon ?? null,
|
||||
isLive: true,
|
||||
@@ -587,7 +612,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 {
|
||||
@@ -597,7 +626,7 @@ export class LiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.liveSidebarStateService.toggle();
|
||||
this.liveSidebarStateService.toggle('portal');
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
@@ -664,7 +693,7 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.playLive(nextItem, true);
|
||||
this.playLive(nextItem, true, undefined, true);
|
||||
}
|
||||
|
||||
private handleRemoteControlCommand(command: {
|
||||
@@ -685,7 +714,7 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.playLive(channel, true);
|
||||
this.playLive(channel, true, undefined, true);
|
||||
}
|
||||
|
||||
handleExternalFallbackRequest(request: PlaybackFallbackRequest): void {
|
||||
@@ -702,7 +731,7 @@ export class LiveStreamLayoutComponent
|
||||
}
|
||||
|
||||
private getVisibleChannels(): XtreamLiveChannelItem[] {
|
||||
return this.xtreamStore.selectItemsFromSelectedCategory() as XtreamLiveChannelItem[];
|
||||
return [...this.channelNavigation.remoteChannels()];
|
||||
}
|
||||
|
||||
private selectLiveItemCategory(item: XtreamLiveChannelItem): void {
|
||||
@@ -757,6 +786,7 @@ export class LiveStreamLayoutComponent
|
||||
return;
|
||||
}
|
||||
|
||||
this.activePlaylistId = playlist.id;
|
||||
this.activeCatchupProgram.set(program);
|
||||
this.activePlayback.set({
|
||||
streamUrl: catchupUrl,
|
||||
|
||||
+282
@@ -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('portal', '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();
|
||||
});
|
||||
});
|
||||
+245
@@ -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('portal', 'expanded');
|
||||
this.pendingReveal.set({
|
||||
channelId: target.xtream_id,
|
||||
categoryId,
|
||||
sequence,
|
||||
});
|
||||
} catch {
|
||||
// Cancelled/failed navigation leaves playback and browse state intact.
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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(
|
||||
|
||||
+35
@@ -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(
|
||||
(
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface PlayerContentInfo extends Omit<
|
||||
|
||||
export interface ResolvedPortalPlayback {
|
||||
streamUrl: string;
|
||||
/** Source-owner supplied, advertised TS alternative for Xtream live Auto only. */
|
||||
liveAutoTsUrl?: string;
|
||||
title: string;
|
||||
thumbnail?: string | null;
|
||||
isLive?: boolean;
|
||||
|
||||
@@ -11,10 +11,30 @@ import { TIMELINE_MINUTE_MS, TimelineBlock } from './epg-timeline.utils';
|
||||
export const TIMELINE_MIN_BLOCK_WIDTH_PX = 40;
|
||||
/** Gap subtracted from each block so neighbours don't touch. */
|
||||
export const TIMELINE_BLOCK_GAP_PX = 4;
|
||||
/** Zoom (px/min) slider bounds. */
|
||||
/** Zoom (px/min) bounds for the Ctrl/⌘ + wheel fine-tune gesture. */
|
||||
export const TIMELINE_ZOOM_MIN = 0.8;
|
||||
export const TIMELINE_ZOOM_MAX = 6;
|
||||
export const TIMELINE_ZOOM_STEP = 0.1;
|
||||
/**
|
||||
* Multiplicative zoom per wheel pixel: one mouse notch (~100px) is about
|
||||
* ×1.22, and a trackpad pinch (a few px per event) stays smooth.
|
||||
*/
|
||||
export const TIMELINE_WHEEL_ZOOM_RATE = 0.002;
|
||||
|
||||
export type TimelineZoomLevel = 'day' | 'hours' | 'detail';
|
||||
|
||||
/**
|
||||
* The three zoom presets the toolbar button cycles through. `hours` is the
|
||||
* default scale; `detail` matches the zoom a group chip expands to, so the
|
||||
* cycle keeps reading consistently after an expand.
|
||||
*/
|
||||
export const TIMELINE_ZOOM_LEVELS: readonly {
|
||||
readonly level: TimelineZoomLevel;
|
||||
readonly scale: number;
|
||||
}[] = [
|
||||
{ level: 'day', scale: 1 },
|
||||
{ level: 'hours', scale: 1.75 },
|
||||
{ level: 'detail', scale: 3.4 },
|
||||
];
|
||||
/** Below this zoom, dense runs of short programmes collapse into a group chip. */
|
||||
export const TIMELINE_GROUP_ZOOM_MAX = 1.3;
|
||||
/** Zoom applied when a group chip is expanded. */
|
||||
@@ -69,6 +89,24 @@ export function tierFor(widthPx: number): TimelineTier {
|
||||
return 'micro';
|
||||
}
|
||||
|
||||
/** Which preset band a (possibly wheel-tuned) scale falls into. */
|
||||
export function timelineZoomLevelForScale(scale: number): TimelineZoomLevel {
|
||||
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'day';
|
||||
if (scale < 3) return 'hours';
|
||||
return 'detail';
|
||||
}
|
||||
|
||||
/** The preset after the current band, wrapping detail → day. */
|
||||
export function nextTimelineZoomScale(scale: number): number {
|
||||
const current = timelineZoomLevelForScale(scale);
|
||||
const index = TIMELINE_ZOOM_LEVELS.findIndex(
|
||||
(entry) => entry.level === current
|
||||
);
|
||||
const next =
|
||||
TIMELINE_ZOOM_LEVELS[(index + 1) % TIMELINE_ZOOM_LEVELS.length];
|
||||
return next.scale;
|
||||
}
|
||||
|
||||
/** Adaptive tick spacing (minutes) — denser as the user zooms in. */
|
||||
export function timelineTickStepForScale(scale: number): number {
|
||||
if (scale < 2.2) return 120;
|
||||
@@ -151,7 +189,8 @@ export function buildTimelineRenderItems(
|
||||
if (run.length >= TIMELINE_GROUP_MIN_RUN) {
|
||||
const first = run[0];
|
||||
const last = run[run.length - 1];
|
||||
const spanPx = ((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
|
||||
const spanPx =
|
||||
((last.stopMs - first.startMs) / TIMELINE_MINUTE_MS) * scale;
|
||||
items.push({
|
||||
kind: 'group',
|
||||
key: `group-${first.key}-${last.key}`,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { TimelineZoomController } from './epg-timeline-zoom.controller';
|
||||
import {
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
} from './epg-timeline-render.util';
|
||||
|
||||
function fakeScroller(clientWidth: number, scrollLeft: number): HTMLElement {
|
||||
return {
|
||||
clientWidth,
|
||||
scrollLeft,
|
||||
getBoundingClientRect: () => ({ left: 100 }),
|
||||
} as unknown as HTMLElement;
|
||||
}
|
||||
|
||||
describe('TimelineZoomController', () => {
|
||||
let scale: number;
|
||||
let scroller: HTMLElement | undefined;
|
||||
let controller: TimelineZoomController;
|
||||
let rafCallbacks: FrameRequestCallback[];
|
||||
|
||||
beforeEach(() => {
|
||||
scale = 2;
|
||||
scroller = fakeScroller(600, 1200);
|
||||
rafCallbacks = [];
|
||||
jest.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
|
||||
rafCallbacks.push(cb);
|
||||
return rafCallbacks.length;
|
||||
});
|
||||
controller = new TimelineZoomController({
|
||||
ribbon: () => scroller,
|
||||
scale: () => scale,
|
||||
setScale: (next) => (scale = next),
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
function flushFrames(): void {
|
||||
for (const cb of rafCallbacks.splice(0)) cb(0);
|
||||
}
|
||||
|
||||
it('keeps the viewport centre minute fixed on a programmatic zoom', () => {
|
||||
// centre = (1200 + 300) / 2 px/min = 750 min
|
||||
controller.zoomTo(4);
|
||||
flushFrames();
|
||||
expect(scale).toBe(4);
|
||||
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
|
||||
});
|
||||
|
||||
it('clamps to the zoom bounds and ignores non-finite input', () => {
|
||||
controller.zoomTo(99);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MAX);
|
||||
controller.zoomTo(-1);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MIN);
|
||||
controller.zoomTo(Number.NaN);
|
||||
expect(scale).toBe(TIMELINE_ZOOM_MIN);
|
||||
});
|
||||
|
||||
it('does nothing when the clamped scale is unchanged', () => {
|
||||
controller.zoomTo(2);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('zooms around the cursor on Ctrl + wheel', () => {
|
||||
// cursor 150px into the 600px viewport → anchor 0.25
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
ctrlKey: true,
|
||||
clientX: 250,
|
||||
cancelable: true,
|
||||
});
|
||||
controller.onWheel(event);
|
||||
flushFrames();
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(scale).toBeCloseTo(2 * Math.exp(0.2), 6);
|
||||
// minute under the cursor before: (1200 + 150) / 2 = 675
|
||||
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
|
||||
});
|
||||
|
||||
it('keeps the cursor minute anchored across a burst of wheel events', () => {
|
||||
const burst = () =>
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -50,
|
||||
ctrlKey: true,
|
||||
clientX: 250,
|
||||
cancelable: true,
|
||||
});
|
||||
// Three events before any frame runs: the DOM scrollLeft is stale for
|
||||
// the 2nd and 3rd, so the anchor must come from the logical position.
|
||||
controller.onWheel(burst());
|
||||
controller.onWheel(burst());
|
||||
controller.onWheel(burst());
|
||||
expect(rafCallbacks).toHaveLength(1); // coalesced into one frame
|
||||
flushFrames();
|
||||
// minute under the cursor before the burst: (1200 + 150) / 2 = 675
|
||||
expect(scale).toBeCloseTo(2 * Math.exp(0.3), 6);
|
||||
expect(scroller?.scrollLeft).toBeCloseTo(675 * scale - 150, 6);
|
||||
});
|
||||
|
||||
it('re-anchors on the DOM position once a frame has flushed', () => {
|
||||
controller.zoomTo(4);
|
||||
flushFrames();
|
||||
expect(scroller?.scrollLeft).toBe(750 * 4 - 300);
|
||||
controller.zoomTo(2);
|
||||
flushFrames();
|
||||
expect(scroller?.scrollLeft).toBe(750 * 2 - 300);
|
||||
});
|
||||
|
||||
it('leaves plain wheel events alone', () => {
|
||||
const event = new WheelEvent('wheel', {
|
||||
deltaY: -100,
|
||||
cancelable: true,
|
||||
});
|
||||
controller.onWheel(event);
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(scale).toBe(2);
|
||||
});
|
||||
|
||||
it('zooms without a rendered ribbon', () => {
|
||||
scroller = undefined;
|
||||
controller.cycle();
|
||||
expect(scale).toBe(3.4);
|
||||
expect(rafCallbacks).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
nextTimelineZoomScale,
|
||||
TIMELINE_WHEEL_ZOOM_RATE,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
} from './epg-timeline-render.util';
|
||||
|
||||
export interface TimelineZoomContext {
|
||||
/** The horizontal scroller hosting the track (undefined until rendered). */
|
||||
readonly ribbon: () => HTMLElement | undefined;
|
||||
readonly scale: () => number;
|
||||
readonly setScale: (scale: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ribbon zoom (px per minute) owned by the toolbar button and the
|
||||
* Ctrl/⌘ + wheel gesture. Every change keeps one ribbon minute anchored on
|
||||
* screen: the viewport centre for button/programmatic zooms, the cursor for
|
||||
* wheel zooms.
|
||||
*/
|
||||
export class TimelineZoomController {
|
||||
/**
|
||||
* Scroll position the next animation frame will apply. Rapid wheel/pinch
|
||||
* events arrive faster than frames, so the DOM `scrollLeft` still reflects
|
||||
* the previous scale by the time the next event computes its anchor; the
|
||||
* logical position keeps every event anchored on the same minute.
|
||||
*/
|
||||
private pendingScrollLeft: number | null = null;
|
||||
private frame = 0;
|
||||
|
||||
constructor(private readonly ctx: TimelineZoomContext) {}
|
||||
|
||||
/** Clamp + apply a scale, keeping the viewport centre stable. */
|
||||
zoomTo(value: number): void {
|
||||
this.applyScale(value, 0.5);
|
||||
}
|
||||
|
||||
/** Toolbar zoom button: step to the next preset (day → hours → detail). */
|
||||
cycle(): void {
|
||||
this.applyScale(nextTimelineZoomScale(this.ctx.scale()), 0.5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ctrl/⌘ + wheel (and trackpad pinch, which Chromium reports the same
|
||||
* way) zooms around the cursor. Plain wheel keeps scrolling.
|
||||
* `preventDefault` also stops Chromium's page zoom for the same gesture.
|
||||
*/
|
||||
onWheel(event: WheelEvent): void {
|
||||
if (!event.ctrlKey && !event.metaKey) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const scroller = this.ctx.ribbon();
|
||||
const anchor = scroller
|
||||
? (event.clientX - scroller.getBoundingClientRect().left) /
|
||||
scroller.clientWidth
|
||||
: 0.5;
|
||||
const factor = Math.exp(-event.deltaY * TIMELINE_WHEEL_ZOOM_RATE);
|
||||
this.applyScale(this.ctx.scale() * factor, anchor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the scale so the ribbon minute under `anchorFrac` (0 = left edge,
|
||||
* 1 = right edge of the viewport) stays put.
|
||||
*/
|
||||
private applyScale(value: number, anchorFrac: number): void {
|
||||
const requested = Number(value);
|
||||
const prev = this.ctx.scale();
|
||||
const next = Number.isFinite(requested)
|
||||
? Math.min(
|
||||
TIMELINE_ZOOM_MAX,
|
||||
Math.max(TIMELINE_ZOOM_MIN, requested)
|
||||
)
|
||||
: prev;
|
||||
if (next === prev) {
|
||||
return;
|
||||
}
|
||||
const scroller = this.ctx.ribbon();
|
||||
this.ctx.setScale(next);
|
||||
if (!scroller) {
|
||||
return;
|
||||
}
|
||||
const anchorPx = scroller.clientWidth * anchorFrac;
|
||||
const currentLeft = this.pendingScrollLeft ?? scroller.scrollLeft;
|
||||
const anchorMin = (currentLeft + anchorPx) / prev;
|
||||
this.pendingScrollLeft = anchorMin * next - anchorPx;
|
||||
if (this.frame === 0) {
|
||||
this.frame = requestAnimationFrame(() => this.flushScroll());
|
||||
}
|
||||
}
|
||||
|
||||
/** One frame applies the last coalesced position for a burst of events. */
|
||||
private flushScroll(): void {
|
||||
this.frame = 0;
|
||||
const left = this.pendingScrollLeft;
|
||||
this.pendingScrollLeft = null;
|
||||
const scroller = this.ctx.ribbon();
|
||||
if (scroller && left !== null) {
|
||||
scroller.scrollLeft = left;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,39 +103,36 @@
|
||||
@if (guideAvailable()) {
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__guide"
|
||||
class="epg-timeline__iconbtn epg-timeline__guide"
|
||||
(click)="openGuide.emit()"
|
||||
[matTooltip]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
|
||||
[attr.aria-label]="'EPG.TIMELINE.OPEN_GUIDE' | translate"
|
||||
>
|
||||
<mat-icon>grid_view</mat-icon>
|
||||
{{ 'EPG.TIMELINE.GUIDE' | translate }}
|
||||
</button>
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__jump"
|
||||
class="epg-timeline__iconbtn epg-timeline__jump"
|
||||
(click)="jumpToNow()"
|
||||
[matTooltip]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
[attr.aria-label]="'EPG.TIMELINE.JUMP_NOW' | translate"
|
||||
>
|
||||
<mat-icon>my_location</mat-icon>
|
||||
{{ 'EPG.TIMELINE.NOW' | translate }}
|
||||
</button>
|
||||
<label
|
||||
class="epg-timeline__zoom"
|
||||
[matTooltip]="zoomLabelKey() | translate"
|
||||
@let zoomTooltip =
|
||||
'EPG.TIMELINE.ZOOM_TOOLTIP'
|
||||
| translate: { level: (zoomLabelKey() | translate) };
|
||||
<button
|
||||
type="button"
|
||||
class="epg-timeline__iconbtn epg-timeline__zoom"
|
||||
[attr.data-zoom-level]="zoomLevel()"
|
||||
(click)="cycleZoom()"
|
||||
[matTooltip]="zoomTooltip"
|
||||
[attr.aria-label]="zoomTooltip"
|
||||
>
|
||||
<mat-icon>zoom_in</mat-icon>
|
||||
<input
|
||||
#zoomInput
|
||||
type="range"
|
||||
[min]="zoomMin"
|
||||
[max]="zoomMax"
|
||||
[step]="zoomStep"
|
||||
[value]="scale()"
|
||||
(input)="onZoom(zoomInput.valueAsNumber)"
|
||||
[attr.aria-label]="'EPG.TIMELINE.ZOOM' | translate"
|
||||
/>
|
||||
</label>
|
||||
<mat-icon>{{ zoomIcon() }}</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (showDateStepper()) {
|
||||
<div class="epg-timeline__stepper">
|
||||
@@ -205,6 +202,7 @@
|
||||
#ribbon
|
||||
class="epg-timeline__ribbon app-scrollbar"
|
||||
(scroll)="onRibbonScroll()"
|
||||
(wheel)="onRibbonWheel($event)"
|
||||
>
|
||||
<app-epg-timeline-track
|
||||
[items]="renderItems()"
|
||||
|
||||
@@ -53,8 +53,10 @@
|
||||
padding: 4px 6px;
|
||||
border-radius: 7px;
|
||||
font: inherit;
|
||||
/* Shrinks only when the toolbar overflows: the channel + programme title
|
||||
take every pixel the fixed-width controls leave (no hard width cap). */
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 60%;
|
||||
|
||||
&:hover {
|
||||
background: $surface-2;
|
||||
@@ -161,35 +163,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.epg-timeline__jump,
|
||||
.epg-timeline__guide {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
/* icon-only toolbar buttons: "Now" + zoom level (label lives in the tooltip
|
||||
and aria-label, so the channel/programme title keeps the toolbar width) */
|
||||
.epg-timeline__iconbtn {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
padding: 0 13px;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line-strong;
|
||||
color: $text-secondary;
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 14px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: $text-primary;
|
||||
border-color: $accent-blue;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid $accent-blue;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.epg-timeline__zoom[data-zoom-level='detail'] {
|
||||
color: $accent-text;
|
||||
}
|
||||
|
||||
/* the Guide button opens the multi-channel grid; accent so it reads as the
|
||||
way "out" of the single-channel ribbon */
|
||||
.epg-timeline__guide {
|
||||
color: $accent-text;
|
||||
border-color: $accent-blue;
|
||||
@@ -345,32 +356,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ── zoom slider ── */
|
||||
.epg-timeline__zoom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: $surface-2;
|
||||
border: 1px solid $line;
|
||||
color: $text-tertiary;
|
||||
flex: 0 0 auto;
|
||||
|
||||
mat-icon {
|
||||
font-size: 16px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
input[type='range'] {
|
||||
width: 96px;
|
||||
accent-color: $accent-text;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ribbon ── */
|
||||
.epg-timeline__ribbon {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -230,6 +230,60 @@ describe('EpgTimelineComponent', () => {
|
||||
expect(component.renderItems().length).toBe(before);
|
||||
});
|
||||
|
||||
it('cycles the zoom button through day → hours → detail → day', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
expect(component.zoomLevel()).toBe('hours'); // default scale
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('detail');
|
||||
expect(component.zoomIcon()).toBe('zoom_in');
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('day');
|
||||
expect(component.zoomIcon()).toBe('zoom_out');
|
||||
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('hours');
|
||||
expect(component.zoomLabelKey()).toBe('EPG.TIMELINE.ZOOM_HOURS');
|
||||
});
|
||||
|
||||
it('snaps a wheel-tuned scale to the next preset band when cycling', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
component.onZoom(2.6); // still "hours" band, but off-preset
|
||||
expect(component.zoomLevel()).toBe('hours');
|
||||
component.cycleZoom();
|
||||
expect(component.zoomLevel()).toBe('detail');
|
||||
expect(component.scale()).toBe(3.4);
|
||||
});
|
||||
|
||||
function wheel(init: WheelEventInit): WheelEvent {
|
||||
return new WheelEvent('wheel', { cancelable: true, ...init });
|
||||
}
|
||||
|
||||
it('zooms the ribbon with Ctrl/⌘ + wheel and blocks page zoom', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
const before = component.scale();
|
||||
|
||||
const zoomIn = wheel({ deltaY: -100, ctrlKey: true });
|
||||
component.onRibbonWheel(zoomIn);
|
||||
expect(zoomIn.defaultPrevented).toBe(true);
|
||||
expect(component.scale()).toBeGreaterThan(before);
|
||||
|
||||
const zoomOut = wheel({ deltaY: 300, metaKey: true });
|
||||
component.onRibbonWheel(zoomOut);
|
||||
expect(component.scale()).toBeLessThan(before);
|
||||
expect(component.scale()).toBeGreaterThanOrEqual(component.zoomMin);
|
||||
});
|
||||
|
||||
it('leaves a plain wheel to native scrolling', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
const before = component.scale();
|
||||
const plain = wheel({ deltaY: -100 });
|
||||
component.onRibbonWheel(plain);
|
||||
expect(plain.defaultPrevented).toBe(false);
|
||||
expect(component.scale()).toBe(before);
|
||||
});
|
||||
|
||||
it('expands a group chip by zooming in', () => {
|
||||
setInputs({ programs: [programAt(-60, 120, 'Wide')] });
|
||||
component.onGroupExpand({
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
EpgTimelineEmptyStateComponent,
|
||||
} from './epg-timeline-empty-state.component';
|
||||
import { TimelineScrollController } from './epg-timeline-scroll.controller';
|
||||
import { TimelineZoomController } from './epg-timeline-zoom.controller';
|
||||
import { EpgTimelineTrackComponent } from './epg-timeline-track.component';
|
||||
import {
|
||||
buildTimelineAxis,
|
||||
@@ -59,10 +60,10 @@ import {
|
||||
TIMELINE_MINUTE_MS,
|
||||
TIMELINE_ZOOM_MAX,
|
||||
TIMELINE_ZOOM_MIN,
|
||||
TIMELINE_ZOOM_STEP,
|
||||
TimelineBlock,
|
||||
TimelineRenderGroup,
|
||||
timelineTickStepForScale,
|
||||
timelineZoomLevelForScale,
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
type RenderState = 'loading' | 'ribbon' | EpgTimelineEmptyReason;
|
||||
@@ -118,7 +119,6 @@ export class EpgTimelineComponent {
|
||||
readonly scale = signal(TIMELINE_DEFAULT_SCALE);
|
||||
readonly zoomMin = TIMELINE_ZOOM_MIN;
|
||||
readonly zoomMax = TIMELINE_ZOOM_MAX;
|
||||
readonly zoomStep = TIMELINE_ZOOM_STEP;
|
||||
readonly skeletonWidths = [120, 170, 150, 200, 140, 180];
|
||||
|
||||
private readonly nowMs = signal(Date.now());
|
||||
@@ -130,6 +130,13 @@ export class EpgTimelineComponent {
|
||||
return key ? key : getTodayEpgDateKey();
|
||||
});
|
||||
|
||||
/** Zoom button + Ctrl/⌘ wheel, anchored so a ribbon minute stays put. */
|
||||
private readonly zoom = new TimelineZoomController({
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
scale: () => this.scale(),
|
||||
setScale: (scale) => this.scale.set(scale),
|
||||
});
|
||||
|
||||
/** Ribbon scrolling + channel-select auto-focus, extracted from the view. */
|
||||
private readonly scroll = new TimelineScrollController({
|
||||
ribbon: () => this.ribbon()?.nativeElement,
|
||||
@@ -199,12 +206,23 @@ export class EpgTimelineComponent {
|
||||
((this.nowMs() - axis.startMs) / TIMELINE_MINUTE_MS) * this.scale()
|
||||
);
|
||||
});
|
||||
readonly zoomLevel = computed(() =>
|
||||
timelineZoomLevelForScale(this.scale())
|
||||
);
|
||||
readonly zoomLabelKey = computed(() => {
|
||||
const scale = this.scale();
|
||||
if (scale < TIMELINE_GROUP_ZOOM_MAX) return 'EPG.TIMELINE.ZOOM_DAY';
|
||||
if (scale < 3) return 'EPG.TIMELINE.ZOOM_HOURS';
|
||||
return 'EPG.TIMELINE.ZOOM_DETAIL';
|
||||
switch (this.zoomLevel()) {
|
||||
case 'day':
|
||||
return 'EPG.TIMELINE.ZOOM_DAY';
|
||||
case 'hours':
|
||||
return 'EPG.TIMELINE.ZOOM_HOURS';
|
||||
default:
|
||||
return 'EPG.TIMELINE.ZOOM_DETAIL';
|
||||
}
|
||||
});
|
||||
/** `zoom_out` reads as "overview"; both finer bands share `zoom_in`. */
|
||||
readonly zoomIcon = computed(() =>
|
||||
this.zoomLevel() === 'day' ? 'zoom_out' : 'zoom_in'
|
||||
);
|
||||
readonly nowLabel = computed(() => formatClockTime(this.nowMs()));
|
||||
|
||||
readonly viewDate = computed(() => parseEpgDateKey(this.viewDayKey()));
|
||||
@@ -304,24 +322,19 @@ export class EpgTimelineComponent {
|
||||
this.collapsedChange.emit(!this.collapsed());
|
||||
}
|
||||
|
||||
/** Clamp + apply a scale, keeping the viewport centre stable. */
|
||||
onZoom(value: number): void {
|
||||
const requested = Number(value);
|
||||
const next = Number.isFinite(requested)
|
||||
? Math.min(this.zoomMax, Math.max(this.zoomMin, requested))
|
||||
: this.scale();
|
||||
// Keep the viewport centre stable across a zoom change.
|
||||
const scroller = this.ribbon()?.nativeElement;
|
||||
const prev = this.scale();
|
||||
const centreMin = scroller
|
||||
? (scroller.scrollLeft + scroller.clientWidth / 2) / prev
|
||||
: null;
|
||||
this.scale.set(next);
|
||||
if (scroller && centreMin !== null) {
|
||||
requestAnimationFrame(() => {
|
||||
scroller.scrollLeft =
|
||||
centreMin * next - scroller.clientWidth / 2;
|
||||
});
|
||||
}
|
||||
this.zoom.zoomTo(value);
|
||||
}
|
||||
|
||||
/** Toolbar zoom button: next preset (day → hours → detail → day). */
|
||||
cycleZoom(): void {
|
||||
this.zoom.cycle();
|
||||
}
|
||||
|
||||
/** Ctrl/⌘ + wheel over the ribbon zooms around the cursor. */
|
||||
onRibbonWheel(event: WheelEvent): void {
|
||||
this.zoom.onWheel(event);
|
||||
}
|
||||
|
||||
onGroupExpand(group: TimelineRenderGroup): void {
|
||||
|
||||
@@ -9,12 +9,16 @@ import {
|
||||
dayKeyAtOffset,
|
||||
hasProgramsForDateKey,
|
||||
nearestDateKeyWithPrograms,
|
||||
nextTimelineZoomScale,
|
||||
TIMELINE_GROUP_EXPAND_ZOOM,
|
||||
TIMELINE_MINUTE_MS,
|
||||
TIMELINE_MIN_BLOCK_WIDTH_PX,
|
||||
TIMELINE_ZOOM_LEVELS,
|
||||
TimelineRenderBlock,
|
||||
TimelineRenderGroup,
|
||||
tierFor,
|
||||
timelineTickStepForScale,
|
||||
timelineZoomLevelForScale,
|
||||
} from './epg-timeline.utils';
|
||||
|
||||
function localIso(
|
||||
@@ -66,15 +70,15 @@ describe('epg-timeline.utils', () => {
|
||||
|
||||
describe('classifyTimelineWhen', () => {
|
||||
it('classifies past, now and future relative to now', () => {
|
||||
expect(classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)).toBe(
|
||||
'past'
|
||||
);
|
||||
expect(
|
||||
classifyTimelineWhen(NOW - 7200000, NOW - 3600000, NOW)
|
||||
).toBe('past');
|
||||
expect(classifyTimelineWhen(NOW - 60000, NOW + 60000, NOW)).toBe(
|
||||
'now'
|
||||
);
|
||||
expect(classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)).toBe(
|
||||
'future'
|
||||
);
|
||||
expect(
|
||||
classifyTimelineWhen(NOW + 3600000, NOW + 7200000, NOW)
|
||||
).toBe('future');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,18 +95,30 @@ describe('epg-timeline.utils', () => {
|
||||
const block = blocks[0];
|
||||
expect(block.when).toBe('now');
|
||||
expect(block.durationMin).toBe(120);
|
||||
const expectedOffset = (block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
const expectedOffset =
|
||||
(block.startMs - axis.startMs) / TIMELINE_MINUTE_MS;
|
||||
expect(block.offsetMin).toBeCloseTo(expectedOffset, 5);
|
||||
});
|
||||
|
||||
it('sorts blocks chronologically', () => {
|
||||
const programs = [
|
||||
program(localIso(2026, 6, 28, 18), localIso(2026, 6, 28, 19), 'late'),
|
||||
program(localIso(2026, 6, 28, 8), localIso(2026, 6, 28, 9), 'early'),
|
||||
program(
|
||||
localIso(2026, 6, 28, 18),
|
||||
localIso(2026, 6, 28, 19),
|
||||
'late'
|
||||
),
|
||||
program(
|
||||
localIso(2026, 6, 28, 8),
|
||||
localIso(2026, 6, 28, 9),
|
||||
'early'
|
||||
),
|
||||
];
|
||||
const axis = buildTimelineAxis(programs, NOW);
|
||||
const blocks = buildTimelineBlocks(programs, axis, NOW);
|
||||
expect(blocks.map((b) => b.program.title)).toEqual(['early', 'late']);
|
||||
expect(blocks.map((b) => b.program.title)).toEqual([
|
||||
'early',
|
||||
'late',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -167,7 +183,8 @@ describe('epg-timeline.utils', () => {
|
||||
[program(localIso(2026, 6, 28, 10), localIso(2026, 6, 28, 11))],
|
||||
NOW
|
||||
);
|
||||
const offsetForNoon = (12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
|
||||
const offsetForNoon =
|
||||
(12 * 60 * TIMELINE_MINUTE_MS) / TIMELINE_MINUTE_MS;
|
||||
expect(dayKeyAtOffset(axis, offsetForNoon)).toBe('2026-06-28');
|
||||
});
|
||||
|
||||
@@ -177,7 +194,9 @@ describe('epg-timeline.utils', () => {
|
||||
program(localIso(2026, 6, 30, 10), localIso(2026, 6, 30, 11)),
|
||||
];
|
||||
// Jun 30 10:00 (~1d22h away) is closer to Jun 28 12:00 than Jun 25.
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe('2026-06-30');
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW)).toBe(
|
||||
'2026-06-30'
|
||||
);
|
||||
});
|
||||
|
||||
it('keys, positions and picks days in display time when an offset is set', () => {
|
||||
@@ -189,8 +208,12 @@ describe('epg-timeline.utils', () => {
|
||||
'Late'
|
||||
),
|
||||
];
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(false);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(true);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-29', 60)).toBe(
|
||||
false
|
||||
);
|
||||
expect(hasProgramsForDateKey(programs, '2026-06-30', 60)).toBe(
|
||||
true
|
||||
);
|
||||
expect(nearestDateKeyWithPrograms(programs, NOW, 60)).toBe(
|
||||
'2026-06-30'
|
||||
);
|
||||
@@ -205,6 +228,35 @@ describe('epg-timeline.utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('zoom presets', () => {
|
||||
it('maps every preset scale back to its own level', () => {
|
||||
for (const { level, scale } of TIMELINE_ZOOM_LEVELS) {
|
||||
expect(timelineZoomLevelForScale(scale)).toBe(level);
|
||||
}
|
||||
});
|
||||
|
||||
it('classifies off-preset scales by band', () => {
|
||||
expect(timelineZoomLevelForScale(0.8)).toBe('day');
|
||||
expect(timelineZoomLevelForScale(1.29)).toBe('day');
|
||||
expect(timelineZoomLevelForScale(1.3)).toBe('hours');
|
||||
expect(timelineZoomLevelForScale(2.99)).toBe('hours');
|
||||
expect(timelineZoomLevelForScale(3)).toBe('detail');
|
||||
expect(timelineZoomLevelForScale(6)).toBe('detail');
|
||||
});
|
||||
|
||||
it('cycles to the next preset and wraps from detail to day', () => {
|
||||
expect(nextTimelineZoomScale(1)).toBe(1.75);
|
||||
expect(nextTimelineZoomScale(1.75)).toBe(3.4);
|
||||
expect(nextTimelineZoomScale(3.4)).toBe(1);
|
||||
// an expanded group chip sits on the detail preset, so the cycle
|
||||
// continues from there instead of re-entering "detail"
|
||||
expect(nextTimelineZoomScale(TIMELINE_GROUP_EXPAND_ZOOM)).toBe(1);
|
||||
// wheel-tuned values snap to the band's successor
|
||||
expect(nextTimelineZoomScale(2.6)).toBe(3.4);
|
||||
expect(nextTimelineZoomScale(5)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('short-programme strategy', () => {
|
||||
it('classifies content tiers by rendered width', () => {
|
||||
expect(tierFor(200)).toBe('wide');
|
||||
@@ -220,11 +272,19 @@ describe('epg-timeline.utils', () => {
|
||||
expect(timelineTickStepForScale(6)).toBe(15);
|
||||
});
|
||||
|
||||
function blocksFor(durations: number[]): ReturnType<typeof buildTimelineBlocks> {
|
||||
function blocksFor(
|
||||
durations: number[]
|
||||
): ReturnType<typeof buildTimelineBlocks> {
|
||||
let startMin = 6 * 60; // 06:00
|
||||
const programs = durations.map((dur) => {
|
||||
const p = program(
|
||||
localIso(2026, 6, 28, Math.floor(startMin / 60), startMin % 60),
|
||||
localIso(
|
||||
2026,
|
||||
6,
|
||||
28,
|
||||
Math.floor(startMin / 60),
|
||||
startMin % 60
|
||||
),
|
||||
localIso(
|
||||
2026,
|
||||
6,
|
||||
@@ -278,7 +338,10 @@ describe('epg-timeline.utils', () => {
|
||||
eh: number,
|
||||
em: number
|
||||
): ReturnType<typeof program> =>
|
||||
program(localIso(2026, 6, 28, sh, sm), localIso(2026, 6, 28, eh, em));
|
||||
program(
|
||||
localIso(2026, 6, 28, sh, sm),
|
||||
localIso(2026, 6, 28, eh, em)
|
||||
);
|
||||
const programs = [
|
||||
mk(11, 38, 11, 43),
|
||||
mk(11, 43, 11, 48),
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ArtPlayerVideoSessionConfig {
|
||||
emitPlaybackIssue: (issue: PlaybackDiagnostic | null) => void;
|
||||
emitTimeUpdate: (value: { currentTime: number; duration: number }) => void;
|
||||
emitPlaybackEnded: () => void;
|
||||
emitPlaybackStarted?: () => void;
|
||||
}
|
||||
|
||||
/** Owns native video and ArtPlayer event listeners for one player instance. */
|
||||
@@ -35,6 +36,11 @@ export class ArtPlayerVideoSession {
|
||||
);
|
||||
};
|
||||
|
||||
private readonly handlePlaying = (): void => {
|
||||
this.clearPlaybackIssue();
|
||||
this.config.emitPlaybackStarted?.();
|
||||
};
|
||||
|
||||
private readonly clearPlaybackIssue = (): void => {
|
||||
this.config.emitPlaybackIssue(null);
|
||||
};
|
||||
@@ -73,7 +79,7 @@ export class ArtPlayerVideoSession {
|
||||
> = [
|
||||
['error', this.handleNativePlaybackError],
|
||||
['loadeddata', this.clearPlaybackIssue],
|
||||
['playing', this.clearPlaybackIssue],
|
||||
['playing', this.handlePlaying],
|
||||
['volumechange', this.handleVolumeChange],
|
||||
['ended', this.handlePlaybackEnded],
|
||||
];
|
||||
|
||||
@@ -66,6 +66,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
}>();
|
||||
readonly playbackIssue = output<PlaybackDiagnostic | null>();
|
||||
readonly playbackEnded = output<void>();
|
||||
readonly playbackStarted = output<void>();
|
||||
readonly previousEpisodeRequested = output<void>();
|
||||
readonly nextEpisodeRequested = output<void>();
|
||||
|
||||
@@ -187,6 +188,7 @@ export class ArtPlayerComponent implements OnInit, OnDestroy, OnChanges {
|
||||
emitPlaybackIssue: (issue) => this.playbackIssue.emit(issue),
|
||||
emitTimeUpdate: (value) => this.timeUpdate.emit(value),
|
||||
emitPlaybackEnded: () => this.playbackEnded.emit(),
|
||||
emitPlaybackStarted: () => this.playbackStarted.emit(),
|
||||
});
|
||||
this.videoSession = videoSession;
|
||||
videoSession.attach();
|
||||
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user