diff --git a/.changes/epg-timeline-compact-controls.md b/.changes/epg-timeline-compact-controls.md new file mode 100644 index 000000000..6acf207c3 --- /dev/null +++ b/.changes/epg-timeline-compact-controls.md @@ -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. diff --git a/.changes/live-tv-hidden-channel-list.md b/.changes/live-tv-hidden-channel-list.md new file mode 100644 index 000000000..108e7fee0 --- /dev/null +++ b/.changes/live-tv-hidden-channel-list.md @@ -0,0 +1,7 @@ +--- +type: fix +area: live-tv +issues: [1458] +--- + +A hidden channel list no longer looks like a playlist that lost its channels: the player now says the list is hidden and offers a "Show channels list" button, a toggle in the workspace header stays in place in both states, and hiding the list in one place (M3U player, portal Live TV, favorites/recent) no longer hides it everywhere else. diff --git a/.changes/portals-live-channel-return.md b/.changes/portals-live-channel-return.md new file mode 100644 index 000000000..a5e093a3e --- /dev/null +++ b/.changes/portals-live-channel-return.md @@ -0,0 +1,7 @@ +--- +type: fix +area: portals +issues: [1520] +--- + +Xtream and Stalker keep remote channel order while you browse other categories or search. Use Show playing channel to return to the current channel without restarting playback. Stalker radio supports the same behavior. diff --git a/.changes/xtream-auto-live-ts-fallback.md b/.changes/xtream-auto-live-ts-fallback.md new file mode 100644 index 000000000..097814030 --- /dev/null +++ b/.changes/xtream-auto-live-ts-fallback.md @@ -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. diff --git a/AGENTS.md b/AGENTS.md index e00bab066..87948701d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -240,6 +240,16 @@ preventing destination failures from penalizing the initial endpoint. Contracts: copy and Retry now; Stalker preserves cached account data on a failed refresh. Contract: `docs/architecture/host-connectivity-guard.md`. +## Live Channel Return + +Xtream and Stalker (including radio) capture displayed playback order on explicit +selection. Remote up/down, numbers and status use that queue while browsing +categories or search. Stalker commits after successful current URL resolution +and extends only loaded pages of the original scope. The conditional channel +header action clears search, returns to the accessible playing category and +focuses its row without changing playback. Contract: +`docs/architecture/remote-control.md` (Live channel return and playback order). + ## Stalker Live Search ITV sidebar and fullscreen searches independently filter the complete selected @@ -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: diff --git a/CLAUDE.md b/CLAUDE.md index c675a2041..8855a8850 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -504,6 +504,8 @@ The M3U playlist module handles traditional M3U/M3U8 playlists with support for The live EPG panel is a horizontal **timeline ribbon** under the player (`app-epg-timeline`, `libs/ui/epg/src/lib/epg-timeline/`), not a right-side drawer (reworked in PR #1102). See `docs/architecture/m3u-playlist-module.md` for the timeline's controllers and scroll behavior. +**Collapsible live channel rail** (M3U player, Xtream/Stalker live layouts, unified favorites/recent live tab): collapse state is owned by `LiveLayoutSidebarStateService` (`@iptvnator/portal/shared/util`) and kept per surface (`m3u` / `portal` / `collection`, localStorage `live-sidebar-state:`); the pre-split shared key `live-sidebar-state` is forgotten on startup and never read (issue #1458: one stored `collapsed` hid every channel list in the app behind a 32px chevron and survived restart, "Remove all playlists" and re-import). The workspace header renders a `view_sidebar` toggle on every route that renders its own rail (`resolveRouteLiveSidebarSurface`) so the control exists in both states, and a collapsed rail with nothing playing shows `app-channel-list-hidden-state` (title + hint + "Show channels list" button) instead of "select a channel". Contract: "Collapsible Live Sidebar" in `docs/architecture/iptvnator-ui-guidelines.md`. + **Radio Channel Layout** (when `channel.radio === 'true'`): ``` @@ -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 diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index e89f5e484..034825f12 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -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 = { + 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); diff --git a/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts new file mode 100644 index 000000000..9c99fcad8 --- /dev/null +++ b/apps/electron-backend-e2e/src/live-sidebar-collapse.e2e.ts @@ -0,0 +1,386 @@ +import { Page } from '@playwright/test'; +import { + addXtreamPortal, + channelItemByTitle, + clickCategoryByNameExact, + closeElectronApp, + expect, + importM3uPlaylistFromNativeDialog, + launchElectronApp, + LaunchedElectronApp, + openPlaylistRecent, + openSources, + openWorkspaceSection, + resetMockServers, + restartElectronApp, + sourceRowByTitle, + test, + waitForM3uCatalog, + waitForXtreamWorkspaceReady, + writeTemporaryM3uFile, +} from './electron-test-fixtures'; +import { fetchXtreamLiveFixture } from './portal-mock-fixtures'; + +/** + * Issue #1458, second report: "all channels disappear after clearing the + * playback history; a reset does not bring them back". The history write + * never touched the channels — the reporter's screenshot shows a collapsed + * channel rail, a state that used to be persisted under one key shared by + * every live surface and that survived restart, "Remove all playlists" and + * re-import. These scenarios pin both halves: the data path stays intact, + * and the collapsed rail is discoverable, scoped, and restorable. + */ + +const CHANNELS = [ + { + groupTitle: 'News', + name: 'Channel Alpha', + url: 'https://streams.example.test/alpha.m3u8', + }, + { + groupTitle: 'News', + name: 'Channel Beta', + url: 'https://streams.example.test/beta.m3u8', + }, + { + groupTitle: 'Sports', + name: 'Channel Gamma', + url: 'https://streams.example.test/gamma.m3u8', + }, +]; + +const M3U_STATE_KEY = 'live-sidebar-state:m3u'; +const PORTAL_STATE_KEY = 'live-sidebar-state:portal'; +const LEGACY_STATE_KEY = 'live-sidebar-state'; + +function playlistIdFromUrl(page: Page): string { + const match = page.url().match(/\/workspace\/playlists\/([^/?#]+)\//); + if (!match) { + throw new Error(`Not on a playlist route: ${page.url()}`); + } + return match[1]; +} + +async function storedItemCount( + page: Page, + playlistId: string +): Promise { + return page.evaluate(async (id) => { + const bridge = ( + window as unknown as { + electron: { + dbGetAppPlaylist: ( + playlistId: string + ) => Promise<{ playlist?: { items?: unknown[] } } | null>; + }; + } + ).electron; + const playlist = await bridge.dbGetAppPlaylist(id); + return playlist?.playlist?.items?.length ?? -1; + }, playlistId); +} + +async function reopenPlaylist(page: Page, sourceTitle: string): Promise { + await openSources(page); + await sourceRowByTitle(page, sourceTitle).first().click(); + await page.waitForURL(/\/workspace\/playlists\/.+\/all$/); +} + +function readStorage(page: Page, key: string): Promise { + return page.evaluate((storageKey) => localStorage.getItem(storageKey), key); +} + +function headerToggle(page: Page) { + return page.locator('app-workspace-shell-header .header-sidebar-toggle'); +} + +function hiddenStateRestoreButton(page: Page) { + return page.locator( + 'app-channel-list-hidden-state button.empty-state-action' + ); +} + +/** + * The collapsed rail keeps its children in the DOM (overflow-clipped inside + * a zero-width container), so Playwright still reports them "visible"; + * assert on the container and its restore affordances instead. + */ +async function expectRailCollapsed(page: Page): Promise { + const sidebar = page.locator('.sidebar').first(); + await expect(sidebar).toHaveClass(/sidebar-collapsed/); + // width: 0 !important, but the 1px (transparent) border still counts. + await expect + .poll(async () => (await sidebar.boundingBox())?.width ?? -1) + .toBeLessThanOrEqual(1); + await expect(page.locator('.sidebar-restore')).toBeVisible(); + await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'false'); +} + +async function expectRailExpanded(page: Page): Promise { + await expect(page.locator('.sidebar').first()).not.toHaveClass( + /sidebar-collapsed/ + ); + await expect(page.locator('.sidebar-restore')).toHaveCount(0); + await expect(page.getByTestId('channel-item').first()).toBeVisible(); + await expect(headerToggle(page)).toHaveAttribute('aria-pressed', 'true'); +} + +test.describe('Live channel rail collapse (#1458)', () => { + test('clearing the playlist history keeps every channel in SQLite and in the All channels view, also after restart', async ({ + dataDir, + }) => { + const sourceTitle = 'rail-history'; + const filePath = writeTemporaryM3uFile( + dataDir, + `${sourceTitle}.m3u`, + CHANNELS + ); + const app: LaunchedElectronApp = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, filePath); + await waitForM3uCatalog(app.mainWindow); + const playlistId = playlistIdFromUrl(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + + await channelItemByTitle(app.mainWindow, 'Channel Alpha') + .first() + .click(); + + await openPlaylistRecent(app.mainWindow); + await expect( + channelItemByTitle(app.mainWindow, 'Channel Alpha').first() + ).toBeVisible({ timeout: 20000 }); + + await app.mainWindow + .getByRole('button', { name: 'Clear recently viewed Live TV' }) + .click(); + await app.mainWindow.getByRole('button', { name: 'Yes' }).click(); + await expect( + channelItemByTitle(app.mainWindow, 'Channel Alpha') + ).toHaveCount(0); + + await reopenPlaylist(app.mainWindow, sourceTitle); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(3); + await expectRailExpanded(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await reopenPlaylist(app.mainWindow, sourceTitle); + await expect( + app.mainWindow.getByTestId('channel-item') + ).toHaveCount(3); + await expectRailExpanded(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + } finally { + await closeElectronApp(app); + } + }); + + test('a collapsed rail stays discoverable and restorable: hidden-list state, header toggle, restart, and a fresh import', async ({ + dataDir, + }) => { + const firstTitle = 'rail-collapse-a'; + const secondTitle = 'rail-collapse-b'; + const firstPath = writeTemporaryM3uFile( + dataDir, + `${firstTitle}.m3u`, + CHANNELS + ); + const secondPath = writeTemporaryM3uFile( + dataDir, + `${secondTitle}.m3u`, + CHANNELS + ); + const app: LaunchedElectronApp = await launchElectronApp(dataDir); + + try { + await importM3uPlaylistFromNativeDialog(app, firstPath); + await waitForM3uCatalog(app.mainWindow); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).not.toBe( + 'collapsed' + ); + + // The chevron in the "All channels" header hides the rail. With + // nothing playing, the content area must say so instead of + // asking the user to pick from a list they cannot see. + await app.mainWindow + .locator('.all-channels-sidebar-toggle') + .first() + .click(); + await expectRailCollapsed(app.mainWindow); + await expect( + app.mainWindow.locator('app-channel-list-hidden-state') + ).toBeVisible(); + await expect( + hiddenStateRestoreButton(app.mainWindow) + ).toBeVisible(); + await expect( + app.mainWindow.locator('app-portal-empty-state', { + hasText: 'select a channel', + }) + ).toHaveCount(0); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'collapsed' + ); + + // The full-size action restores the rail. + await hiddenStateRestoreButton(app.mainWindow).click(); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'expanded' + ); + + // The header toggle exists in both states and drives the same + // rail. + await headerToggle(app.mainWindow).click(); + await expectRailCollapsed(app.mainWindow); + await headerToggle(app.mainWindow).click(); + await expectRailExpanded(app.mainWindow); + + // A hidden rail is a preference the user made, so it survives a + // restart and applies to a second playlist — but the way back + // survives with it. + await headerToggle(app.mainWindow).click(); + await expectRailCollapsed(app.mainWindow); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await reopenPlaylist(app.mainWindow, firstTitle); + const playlistId = playlistIdFromUrl(app.mainWindow); + expect(await storedItemCount(app.mainWindow, playlistId)).toBe(3); + await expectRailCollapsed(app.mainWindow); + await expect( + hiddenStateRestoreButton(app.mainWindow) + ).toBeVisible(); + + await importM3uPlaylistFromNativeDialog(app, secondPath); + await app.mainWindow.waitForURL(/\/workspace\/playlists\/.+\/all$/); + await expectRailCollapsed(app.mainWindow); + + // Ctrl/Cmd+B still works and is reflected by the header toggle. + await app.mainWindow.keyboard.press('Control+b'); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'expanded' + ); + } finally { + await closeElectronApp(app); + } + }); + + test('the collapsed state is scoped per surface and the legacy shared key is forgotten', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const liveFixture = await fetchXtreamLiveFixture(request, { + username: 'user1', + password: 'pass1', + }); + const m3uTitle = 'rail-scope'; + const m3uPath = writeTemporaryM3uFile( + dataDir, + `${m3uTitle}.m3u`, + CHANNELS + ); + const app: LaunchedElectronApp = await launchElectronApp(dataDir); + + try { + // A leftover pre-split value must not hide anything after the + // update, and it must be cleaned up. + await app.mainWindow.evaluate((key) => { + localStorage.setItem(key, 'collapsed'); + }, LEGACY_STATE_KEY); + + await importM3uPlaylistFromNativeDialog(app, m3uPath); + await waitForM3uCatalog(app.mainWindow); + await expectRailExpanded(app.mainWindow); + + const restarted = await restartElectronApp(app, dataDir); + app.electronApp = restarted.electronApp; + app.mainWindow = restarted.mainWindow; + + await reopenPlaylist(app.mainWindow, m3uTitle); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, LEGACY_STATE_KEY)).toBe( + null + ); + + // Hide the M3U rail, then open an Xtream portal's Live TV: its + // rail (and the header toggle for it) must still be expanded. + await headerToggle(app.mainWindow).click(); + await expectRailCollapsed(app.mainWindow); + + await addXtreamPortal(app.mainWindow, { + name: 'Rail Scope Portal', + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openWorkspaceSection(app.mainWindow, 'Live TV'); + await clickCategoryByNameExact( + app.mainWindow, + liveFixture.categoryName + ); + + await expect(headerToggle(app.mainWindow)).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect( + app.mainWindow.locator('.sidebar-restore') + ).toHaveCount(0); + expect( + await readStorage(app.mainWindow, PORTAL_STATE_KEY) + ).not.toBe('collapsed'); + expect(await readStorage(app.mainWindow, M3U_STATE_KEY)).toBe( + 'collapsed' + ); + + // And the other way round: hiding the portal rail leaves the + // M3U preference untouched. With a category open and nothing + // playing, the portal host shows the same hidden-list state and + // its button restores the rail. + await headerToggle(app.mainWindow).click(); + await expect(headerToggle(app.mainWindow)).toHaveAttribute( + 'aria-pressed', + 'false' + ); + await expect(hiddenStateRestoreButton(app.mainWindow)).toBeVisible(); + expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe( + 'collapsed' + ); + + await hiddenStateRestoreButton(app.mainWindow).click(); + await expect(headerToggle(app.mainWindow)).toHaveAttribute( + 'aria-pressed', + 'true' + ); + await expect( + app.mainWindow.locator('app-channel-list-hidden-state') + ).toHaveCount(0); + + await headerToggle(app.mainWindow).click(); + expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe( + 'collapsed' + ); + + await reopenPlaylist(app.mainWindow, m3uTitle); + await expectRailCollapsed(app.mainWindow); + await headerToggle(app.mainWindow).click(); + await expectRailExpanded(app.mainWindow); + expect(await readStorage(app.mainWindow, PORTAL_STATE_KEY)).toBe( + 'collapsed' + ); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/remote-control.e2e.ts b/apps/electron-backend-e2e/src/remote-control.e2e.ts index 7fda35064..82d079cfe 100644 --- a/apps/electron-backend-e2e/src/remote-control.e2e.ts +++ b/apps/electron-backend-e2e/src/remote-control.e2e.ts @@ -2,23 +2,30 @@ import { APIRequestContext, Page } from '@playwright/test'; import { AddressInfo, createServer as createNetServer } from 'net'; import { + addStalkerPortal, + addXtreamPortal, channelItemByTitle, closeElectronApp, enableRemoteControl, expect, + fillWorkspaceSearch, goToDashboard, importM3uPlaylistFromNativeDialog, launchElectronApp, openSettings, openSettingsSection, saveSettings, + resetMockServers, test, waitForM3uCatalog, + waitForStalkerCatalog, + waitForXtreamCatalog, writeTemporaryM3uFile, } from './electron-test-fixtures'; type RemoteControlStatus = { channelName?: string; + channelNumber?: number; isLiveView: boolean; muted?: boolean; portal: 'm3u' | 'xtream' | 'stalker' | 'unknown'; @@ -27,6 +34,235 @@ type RemoteControlStatus = { }; test.describe('Electron Remote Control', () => { + for (const mode of ['xtream', 'stalker', 'radio'] as const) { + test(`@remote-control @electron ${mode} keeps playback order while browsing and reveals the playing channel`, async ({ + dataDir, + request, + }) => { + test.setTimeout(120000); + const provider = mode === 'xtream' ? 'xtream' : 'stalker'; + await resetMockServers(request, [provider]); + const remotePort = await reserveFreePort(); + const app = await launchElectronApp(dataDir); + try { + const page = app.mainWindow; + await openSettings(page); + await openSettingsSection(page, 'playback'); + await selectSettingsOption( + page, + 'select-video-player', + 'artplayer' + ); + await enableRemoteControl(page, remotePort); + await saveSettings(page); + await waitForRemoteControlServer(request, remotePort); + await goToDashboard(page); + if (provider === 'xtream') { + await addXtreamPortal(page); + await waitForXtreamCatalog(page); + } else { + await addStalkerPortal(page); + await waitForStalkerCatalog(page); + } + await page + .getByRole('link', { + name: mode === 'radio' ? 'Radio' : 'Live TV', + exact: true, + }) + .click(); + const categories = page.locator( + '.context-panel .category-item:not([data-category-id="*"])' + ); + await expect(categories.nth(1)).toBeVisible(); + const originalCategory = categories.first(); + const browsedCategory = categories.nth(1); + await originalCategory.click(); + const rows = page + .locator('#live-channels') + .getByTestId('channel-item'); + await expect(rows.nth(2)).toBeVisible(); + if (provider === 'xtream') { + await page + .getByRole('button', { + name: 'Sort channels', + exact: true, + }) + .click(); + await page + .getByRole('menuitem', { name: 'Name Z-A' }) + .click(); + } + const titles = await rows + .locator('.channel-name') + .allTextContents(); + const [first, second, third] = titles.map((title) => + title.trim() + ); + expect(first).toBeTruthy(); + expect(second).toBeTruthy(); + expect(third).toBeTruthy(); + await rows.first().click(); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.portal === provider && + status.channelName === first && + status.channelNumber === 1 + ); + await browsedCategory.click(); + await expect(browsedCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + if (provider === 'xtream') { + await page + .getByRole('button', { + name: 'Sort channels', + exact: true, + }) + .click(); + await page + .getByRole('menuitem', { name: 'Name A-Z' }) + .click(); + } + await fillWorkspaceSearch(page, '__no_playing_channel__'); + await page.waitForURL(/q=__no_playing_channel__/); + await expect(rows).toHaveCount(0); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.portal === provider && + status.channelName === first && + status.channelNumber === 1 + ); + await postRemoteCommand( + request, + remotePort, + '/channel/select-number', + { number: 2 } + ); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === second && + status.channelNumber === 2 + ); + await expect(browsedCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + await postRemoteCommand(request, remotePort, '/channel/down'); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === third && + status.channelNumber === 3 + ); + await postRemoteCommand(request, remotePort, '/channel/up'); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === second && + status.channelNumber === 2 + ); + await expect(browsedCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + const media = page + .locator( + mode === 'radio' + ? 'app-audio-player audio' + : 'app-web-player-view video' + ) + .first(); + await expect(media).toBeAttached(); + const originalMedia = await media.elementHandle(); + const originalSource = await media.evaluate( + (element: HTMLMediaElement) => element.src + ); + const reveal = page.getByRole('button', { + name: 'Show playing channel', + exact: true, + }); + await expect(reveal).toBeVisible(); + for (const theme of ['light', 'dark'] as const) { + await page.emulateMedia({ colorScheme: theme }); + if (theme === 'dark') { + await expect(page.locator('body')).toHaveClass( + /dark-theme/ + ); + } else { + await expect(page.locator('body')).not.toHaveClass( + /dark-theme/ + ); + } + await page.screenshot({ + path: test + .info() + .outputPath(`${mode}-show-playing-${theme}.png`), + }); + } + await reveal.click(); + await expect(originalCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + await expect( + page.locator( + 'app-workspace-shell-header input[type="search"]' + ) + ).toHaveValue(''); + await expect(page.locator('#live-channels')).toBeFocused(); + const activeRow = channelItemByTitle(page, second).first(); + await expect(activeRow).toBeVisible(); + await expect(activeRow).toHaveClass(/active/); + await expect(reveal).toHaveCount(0); + expect( + await media.evaluate( + (element, previous) => element === previous, + originalMedia + ) + ).toBe(true); + await expect(media).toHaveJSProperty('src', originalSource); + await waitForRemoteStatus( + request, + remotePort, + (status) => + status.channelName === second && + status.channelNumber === 2 + ); + await page.screenshot({ + path: test.info().outputPath(`${mode}-revealed-dark.png`), + }); + // Category-only root browsing does not change the URL; a + // router no-op must still reveal without restarting playback. + await browsedCategory.click(); + await expect(reveal).toBeVisible(); + await reveal.click(); + await expect(originalCategory).toHaveAttribute( + 'aria-current', + 'true' + ); + await expect(activeRow).toBeVisible(); + expect( + await media.evaluate( + (element, previous) => element === previous, + originalMedia + ) + ).toBe(true); + await originalMedia?.dispose(); + } finally { + await closeElectronApp(app); + } + }); + } + test('@remote-control @m3u @electron applies remote volume commands to the selected built-in video player', async ({ dataDir, request, @@ -247,10 +483,11 @@ async function getRemoteStatus( async function postRemoteCommand( request: APIRequestContext, port: number, - path: string + path: string, + data: Record = {} ): Promise { const response = await request.post(remoteControlUrl(port, path), { - data: {}, + data, }); expect(response.ok()).toBe(true); @@ -261,9 +498,12 @@ function remoteControlUrl(port: number, path: string): string { } async function readMediaVolume(page: Page, selector: string): Promise { - return page.locator(selector).first().evaluate((element) => { - return Number((element as HTMLMediaElement).volume.toFixed(2)); - }); + return page + .locator(selector) + .first() + .evaluate((element) => { + return Number((element as HTMLMediaElement).volume.toFixed(2)); + }); } function roundVolume(volume: number | undefined): number | null { diff --git a/apps/electron-backend-e2e/src/xtream-live-format.e2e.ts b/apps/electron-backend-e2e/src/xtream-live-format.e2e.ts new file mode 100644 index 000000000..c4087421c --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-live-format.e2e.ts @@ -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); + } +}); diff --git a/apps/electron-backend-e2e/src/xtream-live-format.fixture.ts b/apps/electron-backend-e2e/src/xtream-live-format.fixture.ts new file mode 100644 index 000000000..a86937ca7 --- /dev/null +++ b/apps/electron-backend-e2e/src/xtream-live-format.fixture.ts @@ -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; +} diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts index 71c1be549..c0535cdad 100644 --- a/apps/web-e2e/src/mobile-layout.e2e.ts +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -134,9 +134,7 @@ test.describe('portrait phone 375x812', () => { // sections; now the list scrolls and ends above the footer. const listBox = await boxOf(panel.locator('.settings-sections-list')); const footerBox = await boxOf(footer); - expect(listBox.y + listBox.height).toBeLessThanOrEqual( - footerBox.y + 1 - ); + expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1); // Tapping the backdrop (right of the drawer) closes it. await page @@ -216,7 +214,16 @@ test.describe('xtream portal routes on a phone', () => { const sidebar = page.locator('app-live-stream-layout .sidebar'); await expect(sidebar).toBeVisible(); + // The workspace header carries the live rail toggle at desktop + // width only; on the phone the rail is a bottom drawer with its own + // toggle and the header has no room for another permanent icon. + const headerRailToggle = page.locator( + 'app-workspace-shell-header .header-sidebar-toggle' + ); + await expect(headerRailToggle).toBeVisible(); + await page.setViewportSize(LANDSCAPE_PHONE); + await expect(headerRailToggle).toBeHidden(); // The sidebar stacks at full width above the player. await expect diff --git a/apps/web-e2e/src/stalker-category-search.fixture.ts b/apps/web-e2e/src/stalker-category-search.fixture.ts index 166380797..1b085a068 100644 --- a/apps/web-e2e/src/stalker-category-search.fixture.ts +++ b/apps/web-e2e/src/stalker-category-search.fixture.ts @@ -56,7 +56,9 @@ export async function verifyStalkerCategorySearch(page: Page): Promise { expect(await playerNode?.evaluate((element) => element.isConnected)).toBe( true ); - expect(await videoNode?.evaluate((element) => element.isConnected)).toBe(true); + expect(await videoNode?.evaluate((element) => element.isConnected)).toBe( + true + ); await sidebarSearch.fill(''); await sidebarSearch.press('Enter'); @@ -144,3 +146,71 @@ async function verifyStalkerPanelCategory( await expect(search).toHaveValue(names[0].trim()); await page.evaluate(() => document.exitFullscreen()); } + +/** A category change and return retain the exact live media element (#1520). */ +export async function verifyStalkerPlaybackCategoryReturn( + page: Page +): Promise { + await page.getByRole('link', { name: /live|itv/i }).click(); + await page.waitForURL(/stalker.*itv/); + + const categories = page.locator('.category-item'); + await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); + await categories.nth(1).click(); + + const sidebar = page.locator('app-stalker-live-stream-layout .sidebar'); + const sidebarTitle = sidebar.locator('.category-title'); + const channels = page.locator('[data-test-id="channel-item"]'); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + const scrollPane = sidebar.locator('#live-channels'); + await categories.nth(1).focus(); + await page.keyboard.press('ArrowRight'); + await expect(scrollPane).toBeFocused(); + await page.keyboard.press('ArrowLeft'); + await expect(categories.nth(1)).toBeFocused(); + const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; + expect(firstCategoryTitle).not.toBe(''); + + await channels.first().click(); + await expect(scrollPane).toBeFocused(); + await page.keyboard.press('PageDown'); + await expect + .poll(() => scrollPane.evaluate((el) => el.scrollTop)) + .toBeGreaterThan(0); + + await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); + const player = page.locator('app-web-player-view'); + await expect(player).toBeVisible({ timeout: 20_000 }); + + const media = await player.locator('video').first().elementHandle(); + expect(media).not.toBeNull(); + const activeName = await channels + .first() + .locator('.channel-name') + .textContent(); + await categories.nth(2).click(); + + // The sidebar re-filters to the new category (proves the click landed and + // change detection ran)… + await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, { + timeout: 20_000, + }); + await expect(channels.first()).toBeVisible({ timeout: 20_000 }); + // …while the channel picked from the previous category keeps playing. + await expect(player).toBeVisible(); + const reveal = page.getByRole('button', { + name: 'Show playing channel', + exact: true, + }); + await reveal.click(); + await expect(sidebarTitle).toHaveText(firstCategoryTitle); + await expect(scrollPane).toBeFocused(); + await expect(sidebar.locator('.active')).toContainText(activeName ?? ''); + expect( + await media?.evaluate( + (video) => + video === document.querySelector('app-web-player-view video') + ) + ).toBe(true); + await expect(reveal).toHaveCount(0); +} diff --git a/apps/web-e2e/src/stalker.e2e.ts b/apps/web-e2e/src/stalker.e2e.ts index 2cc2c0609..8f0f48bd3 100644 --- a/apps/web-e2e/src/stalker.e2e.ts +++ b/apps/web-e2e/src/stalker.e2e.ts @@ -2,6 +2,7 @@ import { type APIRequestContext, type Page } from '@playwright/test'; import { expectSeriesSurfacesInBothThemes, setInputValue } from './e2e-helpers'; import { verifyStalkerCategorySearch, + verifyStalkerPlaybackCategoryReturn, verifyUncachedStalkerSearch, } from './stalker-category-search.fixture'; import { verifyStalkerSeasonMarkers } from './stalker-season-markers.fixture'; @@ -467,53 +468,9 @@ test('@stalker PWA hides EPG for ITV channel', async ({ page }) => { }); test('@stalker ITV playback survives a category switch', async ({ page }) => { - // Regression: the shell context panel used to clear the selected Stalker - // item on every category click, tearing down the player for a channel the - // user never switched away from. Xtream live (#936) and M3U groups keep - // playing across a category/group switch; Stalker must too. await addStalkerPortal(page); - - await page.getByRole('link', { name: /live|itv/i }).click(); - await page.waitForURL(/stalker.*itv/); - - const categories = page.locator('.category-item'); - await expect(categories.nth(1)).toBeVisible({ timeout: 10_000 }); - await categories.nth(1).click(); - - const sidebar = page.locator('app-stalker-live-stream-layout .sidebar'); - const sidebarTitle = sidebar.locator('.category-title'); - const channels = page.locator('[data-test-id="channel-item"]'); - await expect(channels.first()).toBeVisible({ timeout: 20_000 }); - const scrollPane = sidebar.locator('#live-channels'); - await categories.nth(1).focus(); - await page.keyboard.press('ArrowRight'); - await expect(scrollPane).toBeFocused(); - await page.keyboard.press('ArrowLeft'); - await expect(categories.nth(1)).toBeFocused(); - const firstCategoryTitle = (await sidebarTitle.textContent())?.trim() ?? ''; - expect(firstCategoryTitle).not.toBe(''); - - await channels.first().click(); - await expect(scrollPane).toBeFocused(); - await page.keyboard.press('PageDown'); - await expect - .poll(() => scrollPane.evaluate((el) => el.scrollTop)) - .toBeGreaterThan(0); - - await expect(channels.first()).toHaveClass(/active/, { timeout: 20_000 }); - const player = page.locator('app-web-player-view'); - await expect(player).toBeVisible({ timeout: 20_000 }); - - await categories.nth(2).click(); - - // The sidebar re-filters to the new category (proves the click landed and - // change detection ran)… - await expect(sidebarTitle).not.toHaveText(firstCategoryTitle, { - timeout: 20_000, - }); - await expect(channels.first()).toBeVisible({ timeout: 20_000 }); - // …while the channel picked from the previous category keeps playing. - await expect(player).toBeVisible(); + await verifyStalkerPlaybackCategoryReturn(page); + await expect(page.locator('app-web-player-view')).toBeVisible(); }); test('@stalker radio — stations use the inline audio player without EPG', async ({ @@ -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 = ( diff --git a/apps/web-e2e/src/xtream-live-format.e2e.ts b/apps/web-e2e/src/xtream-live-format.e2e.ts new file mode 100644 index 000000000..71fe72182 --- /dev/null +++ b/apps/web-e2e/src/xtream-live-format.e2e.ts @@ -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); +}); diff --git a/apps/web-e2e/src/xtream-live-format.fixture.ts b/apps/web-e2e/src/xtream-live-format.fixture.ts new file mode 100644 index 000000000..a86937ca7 --- /dev/null +++ b/apps/web-e2e/src/xtream-live-format.fixture.ts @@ -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; +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index 8a6173bc9..aceaa75e8 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -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": "عرض ›" } diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index c3c30baeb..2254a2b59 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -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": "وري ›" } diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index 011fa5f67..40e2ac443 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -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": "паказаць ›" } diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 0dbd4f549..b1d753165 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index a7e5130ae..7813396ac 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -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": "εμφάνιση ›" } diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index c09bfa38d..be3cb0133 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 74b4c0cd9..7e5ebd1f6 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index e4683b897..4ff9f5920 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index 82d941bde..33d3c6de6 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index f5436bf0b..29c6c065b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index 205f28f2e..780c9c33a 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -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": "表示 ›" } diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 445283181..863697ccb 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -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": "보기 ›" } diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 6f4ae3ad0..e356035f9 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index fd76e4280..8d5e423f3 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -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ż ›" } diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index b7ea685a5..c05e2a90e 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index 389da086f..433e80590 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -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": "показать ›" } diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index b28bad69e..4d0640e9a 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -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 ›" } diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index b76aed579..9060bbd13 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -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": "展开 ›" } diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 16476c7db..26806ef6f 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -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": "顯示 ›" } diff --git a/apps/xtream-mock-server/src/app/live-format-fixture.ts b/apps/xtream-mock-server/src/app/live-format-fixture.ts new file mode 100644 index 000000000..863b32ed9 --- /dev/null +++ b/apps/xtream-mock-server/src/app/live-format-fixture.ts @@ -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' + ) + ) + ); + }); +} diff --git a/apps/xtream-mock-server/src/app/scenarios.ts b/apps/xtream-mock-server/src/app/scenarios.ts index 2452860c6..63b1b2dee 100644 --- a/apps/xtream-mock-server/src/app/scenarios.ts +++ b/apps/xtream-mock-server/src/app/scenarios.ts @@ -31,6 +31,17 @@ export interface ScenarioConfig { * Unknown credential pairs use a hash of "username:password" as seed. */ export const SCENARIOS: Record = { + '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', diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts index 4a67b8194..82b65fd24 100644 --- a/apps/xtream-mock-server/src/app/server.spec.ts +++ b/apps/xtream-mock-server/src/app/server.spec.ts @@ -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 }) diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts index aac33a82d..95d80cd37 100644 --- a/apps/xtream-mock-server/src/app/server.ts +++ b/apps/xtream-mock-server/src/app/server.ts @@ -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', () => diff --git a/apps/xtream-mock-server/src/fixtures/README.md b/apps/xtream-mock-server/src/fixtures/README.md new file mode 100644 index 000000000..330cf7f64 --- /dev/null +++ b/apps/xtream-mock-server/src/fixtures/README.md @@ -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. diff --git a/apps/xtream-mock-server/src/fixtures/live.mpegts b/apps/xtream-mock-server/src/fixtures/live.mpegts new file mode 100644 index 000000000..22c6149ac Binary files /dev/null and b/apps/xtream-mock-server/src/fixtures/live.mpegts differ diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index ed317b278..a2a4c8279 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -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. diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 8a168d498..546ac4c91 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -267,8 +267,8 @@ remain local when the meaning is explicit. ### Collapsible Live Sidebar -- M3U, Xtream, and Stalker live layouts share a single sidebar collapse toggle - that hides the channels rail to give the player and EPG full width. +- M3U, Xtream, and Stalker live layouts share a sidebar collapse toggle that + hides the channels rail to give the player and EPG full width. - Xtream Live TV's root view (`/live` with no selected category) follows the same paginated `All Items` shell as VOD and Series: a widget header with the total channel count, page-size controls, and page navigation above the shared @@ -285,15 +285,41 @@ remain local when the meaning is explicit. is `live` (Xtream) or `itv`/`radio` (Stalker); movies, series, favorites, and recent routes leave it untouched. - Collapsed state is owned by `LiveLayoutSidebarStateService` - (`providedIn: 'root'`) in `@iptvnator/portal/shared/util`. Every surface that - participates injects the service and reads `isCollapsed`; any toggle calls - `service.toggle()`. Persistence delegates to the existing - `live-sidebar-state` helpers, so the localStorage key stays unchanged and - missing/invalid values restore to expanded. -- A `mat-icon-button` with `chevron_left` lives in the sidebar header and - toggles state. While collapsed, a floating `chevron_right` mini-fab appears - at the left edge of `.content-container` to restore the rail (and the - categories rail, in Xtream/Stalker live). + (`providedIn: 'root'`) in `@iptvnator/portal/shared/util` and kept **per + surface** (`LiveSidebarSurface`): `m3u` (the M3U player), `portal` (Xtream + and Stalker live layouts plus the shell categories rail) and `collection` + (the unified favorites/recent live tab). Every participant injects the + service, holds `isCollapsedFor(surface)` (a stable signal) and calls + `toggle(surface)`; nothing reads localStorage directly. Persistence lives + under `live-sidebar-state:`. Hiding the list is a per-context + choice: it must not follow the user from a portal to an M3U playlist, nor + from the desktop rail to the phone bottom drawer of another surface. The + pre-split shared key `live-sidebar-state` is forgotten on service + construction and never read — a stored `collapsed` there hid every channel + list in the app behind a 32px chevron and survived restart, "Remove all + playlists" and re-import (issue #1458). +- The control never moves. Inside the rail a `mat-icon-button` with + `chevron_left` hides it; while collapsed a floating `chevron_right` mini-fab + sits at the left edge of `.content-container`. Because both of those live + in the thing they hide, the workspace header additionally renders + `view_sidebar` (`headerSidebarToggle`, `WorkspaceShellHeaderService`) on + every route that renders its own rail — M3U `all`/`groups`, Xtream `live`, + Stalker `itv`/`radio` (`resolveRouteLiveSidebarSurface`). It stays in place + in both states, uses `aria-pressed` (pressed = rail visible) and tints + primary only while the rail is hidden, since the hidden state is the + exception that deserves the cue. Collection pages are deliberately excluded: + only the page knows whether its live tab, and therefore the rail, is on + screen, so its own header toggle beside the content switch stays the owner. + At the phone breakpoint (≤640px) the header toggle is hidden: the rail is a + bottom drawer there with its own toggle and the header has no spare width. +- While the rail is collapsed and nothing is playing, every live host renders + `app-channel-list-hidden-state` (`@iptvnator/portal/shared/ui`) instead of + the "select a channel" empty state: a title that says the list is hidden, a + one-line hint naming the shortcut, and a full-size "Show channels list" + stroked button wired to the same toggle. The generic + `app-portal-empty-state` grew optional `hint`, `actionLabel`, `actionIcon` + inputs and an `action` output for this; the action keeps full opacity while + icon and copy stay muted, because it is the way out of the state. - Keyboard shortcut: `Cmd/Ctrl+B`. The handler ignores events that originate inside ``, `