diff --git a/.changes/detail-back-button-lane.md b/.changes/detail-back-button-lane.md index 21a1e6da9..b22c088e6 100644 --- a/.changes/detail-back-button-lane.md +++ b/.changes/detail-back-button-lane.md @@ -3,4 +3,4 @@ type: fix area: detail --- -On movie and series pages, the Back button no longer covers the start of the "Seasons and Episodes" heading or the corner of the player while you scroll, and in narrow windows the heading no longer gets squeezed onto several lines beside its buttons. +On movie and series pages in narrow windows, the "Seasons and Episodes" heading no longer gets squeezed onto several lines beside its buttons. diff --git a/.changes/workspace-header-back.md b/.changes/workspace-header-back.md new file mode 100644 index 000000000..8fd5fcefb --- /dev/null +++ b/.changes/workspace-header-back.md @@ -0,0 +1,6 @@ +--- +type: fix +area: workspace +--- + +The Back button on movie and series pages now sits at the start of the top bar, so it never covers the page and the page keeps its full width. The IPTVnator logo is gone from the side rail: it opened the same page as the Dashboard button right below it. diff --git a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts index c6029fc95..98485b1da 100644 --- a/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts +++ b/apps/electron-backend-e2e/src/catalog-sorting.e2e.ts @@ -590,12 +590,9 @@ async function firstVisibleGridTitle(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { diff --git a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts index 5dfb3e3e0..51eb37830 100644 --- a/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts +++ b/apps/electron-backend-e2e/src/dashboard-activation.e2e.ts @@ -306,12 +306,9 @@ function dashboardRailCardByTitle( } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -350,9 +347,9 @@ async function expectInlineCollectionDetail( await expectPathname(page, params.pathname); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); - await expect( - page.locator('app-portal-detail-shell .shell__back-button').first() - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('workspace-header-back')).toBeVisible({ + timeout: 20000, + }); } async function playCurrentDetail(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts b/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts deleted file mode 100644 index 607303622..000000000 --- a/apps/electron-backend-e2e/src/detail-back-lane.e2e.ts +++ /dev/null @@ -1,336 +0,0 @@ -import type { Locator, Page } from '@playwright/test'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { - addXtreamPortal, - clickFirstGridListCard, - closeElectronApp, - expect, - launchElectronApp, - openSettings, - resetMockServers, - saveSettings, - test, - waitForXtreamWorkspaceReady, -} from './electron-test-fixtures'; - -// --------------------------------------------------------------------------- -// The detail shell's sticky Back control floats over its scroll owner. While -// it is shown, every content column reserves the control's lane, so no -// scroll position may put it over the "Seasons and Episodes" heading or the -// inline player's top-left corner. Tested at a wide and a narrow window; the -// workspace rail and category panel leave the detail pane far narrower than -// either, and the heading used to wrap beside its actions (two lines at -// 1280px, three at 780px) with its first word under the arrow. -// -// A pane under 400px cannot spare the lane: a 700px window beside the -// category panel, or a phone. There the control sits in an opaque sticky bar, -// and whatever reaches the arrow's band must be hidden beneath the bar, never -// visible beside the arrow. -// --------------------------------------------------------------------------- - -const widths = [1280, 780]; -const compactWidths = [700, 375]; -const playerCorner = 56; -/** The widest translation of the heading; it must fit wherever English does. */ -const widestLocale = 'nl'; -const widestHeading = ( - JSON.parse( - readFileSync( - join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`), - 'utf8' - ) - ) as { PORTALS: { SEASONS_AND_EPISODES: string } } -).PORTALS.SEASONS_AND_EPISODES; - -type Sweep = { - overlaps: string[]; - /** Scroll positions where the target shared the arrow's vertical band. */ - beside: { player: number; title: number }; -}; - -/** - * Scrolls the shell from top to bottom in steps shorter than the arrow and - * records every position where the arrow's box intersects the heading or the - * player's top-left corner. Reads layout synchronously after each scroll - * write, so a hidden or throttled window cannot skip frames. - */ -function sweepDetail(shell: Locator): Promise { - return shell.evaluate((element, corner) => { - const back = element.querySelector('.shell__back-button'); - if (!back) throw new Error('The detail shows no Back control.'); - const sweep = { - overlaps: [] as string[], - beside: { player: 0, title: 0 }, - }; - const max = element.scrollHeight - element.clientHeight; - for (let top = 0; ; top = Math.min(max, top + 24)) { - element.scrollTop = top; - const arrow = back.getBoundingClientRect(); - const title = element - .querySelector('.section-title') - ?.getBoundingClientRect(); - const player = element - .querySelector('app-portal-inline-player') - ?.getBoundingClientRect(); - const targets = { - title, - player: player && { - left: player.left, - top: player.top, - right: player.left + corner, - bottom: player.top + corner, - }, - }; - for (const [name, box] of Object.entries(targets)) { - if (!box) continue; - const sameBand = - box.top < arrow.bottom && arrow.top < box.bottom; - if (!sameBand) continue; - sweep.beside[name as keyof Sweep['beside']] += 1; - if (box.left < arrow.right && arrow.left < box.right) { - sweep.overlaps.push(`${name} at scrollTop ${top}`); - } - } - if (top >= max) break; - } - element.scrollTop = 0; - return sweep; - }, playerCorner); -} - -type BarSweep = { - background: string; - /** Scroll positions where a target reached the bar's band. */ - beneath: number; - /** Points of a target in the bar's band that hit content, not the bar. */ - exposed: string[]; -}; - -/** - * Compact counterpart of {@link sweepDetail}: the bar sits in flow and - * content scrolls beneath it, so boxes do intersect. Instead, every sampled - * point of the heading or the player's corner that is inside the bar's band - * must hit-test to the bar: hidden beneath it, and unreachable by a tap. - */ -function sweepBar(shell: Locator): Promise { - return shell.evaluate((element, corner) => { - const bar = element.querySelector('.shell__navigation'); - if (!bar) throw new Error('The detail shows no Back control.'); - const sweep = { - background: getComputedStyle(bar).backgroundColor, - beneath: 0, - exposed: [] as string[], - }; - const max = element.scrollHeight - element.clientHeight; - for (let top = 0; ; top = Math.min(max, top + 24)) { - element.scrollTop = top; - const band = bar.getBoundingClientRect(); - const title = element - .querySelector('.section-title') - ?.getBoundingClientRect(); - const player = element - .querySelector('app-portal-inline-player') - ?.getBoundingClientRect(); - const targets = { - title, - player: player && { - left: player.left, - top: player.top, - right: player.left + corner, - bottom: player.top + corner, - }, - }; - for (const [name, box] of Object.entries(targets)) { - if (!box) continue; - const from = Math.max(box.top, band.top); - const to = Math.min(box.bottom, band.bottom); - if (to <= from) continue; - sweep.beneath += 1; - const y = (from + to) / 2; - for (const x of [box.left + 2, box.right - 2]) { - const hit = element.ownerDocument.elementFromPoint(x, y); - if (!hit || !bar.contains(hit)) { - sweep.exposed.push( - `${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}` - ); - } - } - } - if (top >= max) break; - } - element.scrollTop = 0; - return sweep; - }, playerCorner); -} - -/** Line boxes of the heading's text; 1 means it did not wrap. */ -function headingLineCount(page: Page): Promise { - return page.locator('.section-title').evaluate((heading) => { - const range = document.createRange(); - range.selectNodeContents(heading); - const lines = [...range.getClientRects()] - .filter((rect) => rect.width > 0) - .map((rect) => Math.round(rect.top)); - return new Set(lines).size; - }); -} - -/** - * Lets the browse↔watch morph, the player's fade-in and the workspace's own - * transitions settle — crossing into the phone layout slides the category - * drawer out over the page for 200ms. Bounded, so a paused animation - * elsewhere cannot stall the test. - */ -async function settle(shell: Locator): Promise { - await shell.evaluate((element) => - Promise.race([ - Promise.all( - element.ownerDocument - .getAnimations() - .filter( - (animation) => - animation.effect?.getTiming().iterations !== - Infinity - ) - .map((animation) => - animation.finished.catch(() => undefined) - ) - ), - new Promise((resolve) => setTimeout(resolve, 2_000)), - ]) - ); -} - -async function expectBackClearOfContent( - page: Page, - state: 'browse' | 'watch' -): Promise { - const shell = page.locator('app-portal-detail-shell'); - for (const width of widths) { - await page.setViewportSize({ width, height: 800 }); - // Fail on the mode first: a lane assertion against the bar would - // only report confusing intersections. - await expect(shell, `${state} at ${width}px`).not.toHaveClass( - /shell-host--compact/ - ); - await settle(shell); - const sweep = await sweepDetail(shell); - - expect(sweep.overlaps, `${state} at ${width}px`).toEqual([]); - // The sweep has to carry the heading (and the player) past the arrow, - // or the empty overlap list proves nothing. - expect(sweep.beside.title, `${state} at ${width}px`).toBeGreaterThan(0); - if (state === 'watch') { - expect( - sweep.beside.player, - `${state} at ${width}px` - ).toBeGreaterThan(0); - } - expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1); - } - - for (const width of compactWidths) { - await page.setViewportSize({ width, height: 800 }); - await expect(shell, `${state} at ${width}px`).toHaveClass( - /shell-host--compact/ - ); - await settle(shell); - const sweep = await sweepBar(shell); - - expect(sweep.exposed, `${state} at ${width}px`).toEqual([]); - expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0); - // Opaque, or what scrolls beneath would show through. - expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/); - expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1); - } -} - -/** - * Re-checks the heading in the widest translation at the lane and bar widths. - * Below them (a ~220px header beside the category panel) a translation wider - * than the pane itself wraps by design rather than losing words to an - * ellipsis. - */ -async function expectWidestHeadingOnOneLine( - page: Page, - detailUrl: string -): Promise { - await page.setViewportSize({ width: widths[0], height: 800 }); - await openSettings(page); - await page.getByTestId('select-language').click(); - await page.getByTestId(widestLocale).click(); - await saveSettings(page); - await page.goBack(); - await page.waitForURL(detailUrl); - await expect(page.locator('.section-title')).toHaveText(widestHeading, { - timeout: 20_000, - }); - for (const width of [...widths, ...compactWidths]) { - await page.setViewportSize({ width, height: 800 }); - expect( - await headingLineCount(page), - `${widestLocale} at ${width}px` - ).toBe(1); - } -} - -/** - * The actions move onto their own row before the heading wraps, at every - * pane width that can hold the heading at all — including the widths where - * the lane gives way to the bar. - */ -async function expectHeadingOnOneLine(page: Page): Promise { - const wrapped: number[] = []; - for (let width = 680; width <= 1600; width += 20) { - await page.setViewportSize({ width, height: 800 }); - if ((await headingLineCount(page)) !== 1) wrapped.push(width); - } - expect(wrapped).toEqual([]); -} - -test.describe('Portal detail Back lane', () => { - test('@xtream @electron keeps the Back arrow off the heading and the player while scrolling', async ({ - dataDir, - request, - }) => { - await resetMockServers(request, ['xtream']); - const app = await launchElectronApp(dataDir); - - try { - const page = app.mainWindow; - await addXtreamPortal(page); - await waitForXtreamWorkspaceReady(page); - await page - .getByRole('link', { name: 'Series', exact: true }) - .click(); - await clickFirstGridListCard(page); - await page.waitForURL( - /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/ - ); - - const detailUrl = page.url(); - const shell = page.locator('app-portal-detail-shell'); - const episodes = page.locator('.episode-card'); - await expect(page.locator('.section-title')).toBeVisible({ - timeout: 20_000, - }); - await expect(episodes.first()).toBeVisible({ timeout: 20_000 }); - - await expectBackClearOfContent(page, 'browse'); - await expectHeadingOnOneLine(page); - - await page.setViewportSize({ width: widths[0], height: 800 }); - await episodes.first().click(); - await expect(shell).toHaveClass(/shell-host--watch/); - await expect( - shell.locator('app-portal-inline-player app-web-player-view') - ).toBeVisible({ timeout: 20_000 }); - - await expectBackClearOfContent(page, 'watch'); - await expectWidestHeadingOnOneLine(page, detailUrl); - } finally { - await closeElectronApp(app); - } - }); -}); diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts new file mode 100644 index 000000000..fbe3b4a73 --- /dev/null +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -0,0 +1,283 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { + addXtreamPortal, + clickFirstGridListCard, + closeElectronApp, + expect, + launchElectronApp, + openSettings, + resetMockServers, + saveSettings, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +// --------------------------------------------------------------------------- +// A detail page's Back lives in the workspace header's leading slot, not in +// the page. Nothing floats over the scroll owner, so the content keeps its +// full width: every column has symmetric insets instead of a reserved Back +// lane. Checked at a wide and a narrow desktop window and at phone width, +// where Back takes the context drawer toggle's slot. +// +// The "Seasons and Episodes" heading must also stay on one line: the detail +// pane is far narrower than the window beside the rail and category panel, +// and the heading used to wrap beside its actions. +// --------------------------------------------------------------------------- + +const widths = [1280, 780, 375]; +const phoneWidth = 640; +/** The widest translation of the heading; it must fit wherever English does. */ +const widestLocale = 'nl'; +const widestHeading = ( + JSON.parse( + readFileSync( + join(__dirname, `../../web/src/assets/i18n/${widestLocale}.json`), + 'utf8' + ) + ) as { PORTALS: { SEASONS_AND_EPISODES: string } } +).PORTALS.SEASONS_AND_EPISODES; + +const detailUrlPattern = /\/workspace\/xtreams\/[^/]+\/series\/[^/]+\/[^/]+$/; + +function headerBack(page: Page): Locator { + return page.getByTestId('workspace-header-back'); +} + +/** Line boxes of the heading's text; 1 means it did not wrap. */ +function headingLineCount(page: Page): Promise { + return page.locator('.section-title').evaluate((heading) => { + const range = document.createRange(); + range.selectNodeContents(heading); + const lines = [...range.getClientRects()] + .filter((rect) => rect.width > 0) + .map((rect) => Math.round(rect.top)); + return new Set(lines).size; + }); +} + +/** + * Columns whose start inset differs from their end inset. A reserved Back + * lane shows up here as a wider start inset. + */ +function asymmetricColumns(shell: Locator): Promise { + return shell.evaluate((element) => + [ + '.hero__content', + '.shell__player--active', + '.shell__episodes', + '.shell__extras', + 'app-content-about .about', + ].flatMap((selector) => { + const column = element.querySelector(selector); + if (!column || !column.getClientRects().length) return []; + const style = getComputedStyle(column); + return style.paddingLeft === style.paddingRight + ? [] + : [`${selector}: ${style.paddingLeft} / ${style.paddingRight}`]; + }) + ); +} + +/** + * Lets the browse↔watch morph, the player's fade-in and the workspace's own + * transitions settle — crossing into the phone layout slides the category + * drawer out over the page for 200ms. Bounded, so a paused animation + * elsewhere cannot stall the test. + */ +async function settle(shell: Locator): Promise { + await shell.evaluate((element) => + Promise.race([ + Promise.all( + element.ownerDocument + .getAnimations() + .filter( + (animation) => + animation.effect?.getTiming().iterations !== + Infinity + ) + .map((animation) => + animation.finished.catch(() => undefined) + ) + ), + new Promise((resolve) => setTimeout(resolve, 2_000)), + ]) + ); +} + +async function expectBackInHeader( + page: Page, + state: 'browse' | 'watch' +): Promise { + const shell = page.locator('app-portal-detail-shell'); + for (const width of widths) { + const label = `${state} at ${width}px`; + await page.setViewportSize({ width, height: 800 }); + await settle(shell); + + const back = headerBack(page); + await expect(back, label).toBeVisible(); + await expect(back, label).toHaveAccessibleName('Back'); + // Escape unwinds the page in browse; in watch it closes the player. + if (state === 'browse') { + await expect(back, label).toHaveAttribute( + 'aria-keyshortcuts', + 'Escape' + ); + } else { + await expect(back, label).not.toHaveAttribute('aria-keyshortcuts'); + } + await expect( + page + .locator('app-workspace-shell-header') + .getByTestId('workspace-header-back'), + label + ).toHaveCount(1); + // The page itself carries no second arrow. + await expect( + shell.getByRole('button', { name: 'Back', exact: true }), + label + ).toHaveCount(0); + expect(await asymmetricColumns(shell), label).toEqual([]); + // On a phone, Back takes the drawer toggle's slot in the header. + await expect( + page.getByTestId('context-drawer-toggle'), + label + ).toHaveCount(0); + if (width <= phoneWidth) { + const [backBox, switcherBox] = await Promise.all([ + back.boundingBox(), + page.locator('app-playlist-switcher').boundingBox(), + ]); + expect(backBox?.x ?? Infinity, label).toBeLessThan( + switcherBox?.x ?? -Infinity + ); + } + expect(await headingLineCount(page), label).toBe(1); + } +} + +/** + * Re-checks the heading in the widest translation. Below these widths (a + * ~220px header beside the category panel) a translation wider than the pane + * itself wraps by design rather than losing words to an ellipsis. + */ +async function expectWidestHeadingOnOneLine( + page: Page, + detailUrl: string +): Promise { + await page.setViewportSize({ width: widths[0], height: 800 }); + await openSettings(page); + await page.getByTestId('select-language').click(); + await page.getByTestId(widestLocale).click(); + await saveSettings(page); + await page.goBack(); + await page.waitForURL(detailUrl); + await expect(page.locator('.section-title')).toHaveText(widestHeading, { + timeout: 20_000, + }); + for (const width of [...widths, 700]) { + await page.setViewportSize({ width, height: 800 }); + expect( + await headingLineCount(page), + `${widestLocale} at ${width}px` + ).toBe(1); + } +} + +/** + * The actions move onto their own row before the heading wraps, at every + * pane width that can hold the heading at all. + */ +async function expectHeadingOnOneLine(page: Page): Promise { + const wrapped: number[] = []; + for (let width = 680; width <= 1600; width += 20) { + await page.setViewportSize({ width, height: 800 }); + if ((await headingLineCount(page)) !== 1) wrapped.push(width); + } + expect(wrapped).toEqual([]); +} + +async function openFirstSeries(page: Page): Promise { + await page.getByRole('link', { name: 'Series', exact: true }).click(); + await clickFirstGridListCard(page); + await page.waitForURL(detailUrlPattern); + await expect(page.locator('.section-title')).toBeVisible({ + timeout: 20_000, + }); + await expect(page.locator('.episode-card').first()).toBeVisible({ + timeout: 20_000, + }); + return page.url(); +} + +async function startFirstEpisode(page: Page): Promise { + const shell = page.locator('app-portal-detail-shell'); + await page.locator('.episode-card').first().click(); + await expect(shell).toHaveClass(/shell-host--watch/); + await expect( + shell.locator('app-portal-inline-player app-web-player-view') + ).toBeVisible({ timeout: 20_000 }); +} + +/** The header Back leaves the detail and is gone from the list it opens. */ +async function expectHeaderBackReturnsToList(page: Page): Promise { + await headerBack(page).click(); + await expect(page).not.toHaveURL(detailUrlPattern); + await expect(page.locator('app-portal-detail-shell')).toHaveCount(0); + await expect(headerBack(page)).toHaveCount(0); +} + +test.describe('Portal detail header Back', () => { + test('@xtream @electron keeps Back in the header and the detail columns at full width', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await expect(headerBack(page)).toHaveCount(0); + + const detailUrl = await openFirstSeries(page); + await expectBackInHeader(page, 'browse'); + await expectHeadingOnOneLine(page); + + await page.setViewportSize({ width: widths[0], height: 800 }); + await startFirstEpisode(page); + await expectBackInHeader(page, 'watch'); + await expectWidestHeadingOnOneLine(page, detailUrl); + } finally { + await closeElectronApp(app); + } + }); + + test('@xtream @electron returns to the list from the header in browse and watch', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + + try { + const page = app.mainWindow; + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + + await openFirstSeries(page); + await expectHeaderBackReturnsToList(page); + + await clickFirstGridListCard(page); + await page.waitForURL(detailUrlPattern); + await startFirstEpisode(page); + // Route-level in watch too: it leaves the page, not just the player. + await expectHeaderBackReturnsToList(page); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/electron-test-fixtures.ts b/apps/electron-backend-e2e/src/electron-test-fixtures.ts index 661cceb0b..01295d7c6 100644 --- a/apps/electron-backend-e2e/src/electron-test-fixtures.ts +++ b/apps/electron-backend-e2e/src/electron-test-fixtures.ts @@ -1013,7 +1013,7 @@ export async function saveSettings(page: Page): Promise { export async function goToDashboard(page: Page): Promise { const dashboardLink = page - .locator('a.brand[href$="/workspace/dashboard"]') + .locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]') .first(); await expect(dashboardLink).toBeVisible(); diff --git a/apps/electron-backend-e2e/src/favorites.e2e.ts b/apps/electron-backend-e2e/src/favorites.e2e.ts index a1b82b105..3a0bf7dfd 100644 --- a/apps/electron-backend-e2e/src/favorites.e2e.ts +++ b/apps/electron-backend-e2e/src/favorites.e2e.ts @@ -606,12 +606,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -637,9 +634,9 @@ async function expectInlineCollectionDetail( ); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); - await expect( - page.locator('app-portal-detail-shell .shell__back-button').first() - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('workspace-header-back')).toBeVisible({ + timeout: 20000, + }); } async function expectInlinePlayerWithoutDialog(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts index 5d382c55c..ed920dc3d 100644 --- a/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts +++ b/apps/electron-backend-e2e/src/performance/xtream-ui-action-probe.ts @@ -87,7 +87,7 @@ export async function runXtreamBackgroundUiActions( async () => { const start = await clickSelector( page, - 'a.brand[href$="/workspace/dashboard"]' + 'app-workspace-shell-rail a[href$="/workspace/dashboard"]' ); await page.waitForFunction(() => { const overlay = document.querySelector( diff --git a/apps/electron-backend-e2e/src/recent.e2e.ts b/apps/electron-backend-e2e/src/recent.e2e.ts index 98f8fe6d7..c313acb6e 100644 --- a/apps/electron-backend-e2e/src/recent.e2e.ts +++ b/apps/electron-backend-e2e/src/recent.e2e.ts @@ -697,12 +697,9 @@ async function expectUnifiedLiveDetailOpen( } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); try { @@ -728,9 +725,9 @@ async function expectInlineCollectionDetail( ); await expect(page.locator('app-workspace-context-panel')).toHaveCount(0); await expect(page.locator('app-content-hero')).toContainText(params.title); - await expect( - page.locator('app-portal-detail-shell .shell__back-button').first() - ).toBeVisible({ timeout: 20000 }); + await expect(page.getByTestId('workspace-header-back')).toBeVisible({ + timeout: 20000, + }); } async function expectInlinePlayerWithoutDialog(page: Page): Promise { diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index b9bdee229..257c903f3 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -1540,12 +1540,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise { } async function goBackFromDetail(page: Page): Promise { - // Return to the list: the shell's sticky Back is route-level in browse - // and watch alike (closing the player is the bar's own Close button). - const backButton = page - .locator('app-portal-detail-shell') - .first() - .getByRole('button', { name: 'Back', exact: true }); + // Return to the list: the header's Back is route-level in browse and + // watch alike (closing the player is the bar's own Close button). + const backButton = page.getByTestId('workspace-header-back'); await expect(backButton).toBeVisible({ timeout: 20000 }); await backButton.click(); diff --git a/apps/electron-backend-e2e/src/settings.e2e.ts b/apps/electron-backend-e2e/src/settings.e2e.ts index 4e70c10a6..8784a7d09 100644 --- a/apps/electron-backend-e2e/src/settings.e2e.ts +++ b/apps/electron-backend-e2e/src/settings.e2e.ts @@ -657,10 +657,11 @@ test.describe('Electron Settings', () => { exact: true, }) ).toHaveCount(0); - await expect(secondLaunch.mainWindow.locator('a.brand')).toHaveAttribute( - 'href', - /\/workspace\/sources$/ - ); + await expect( + secondLaunch.mainWindow + .locator('app-workspace-shell-rail a.nav-item') + .first() + ).toHaveAttribute('href', /\/workspace\/sources$/); } finally { await closeElectronApp(secondLaunch); } diff --git a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts index 046c784a6..97dd918f8 100644 --- a/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts +++ b/apps/electron-backend-e2e/src/xtream-vod-details.e2e.ts @@ -247,10 +247,10 @@ for (const theme of ['light', 'dark']) { await expect .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); - await page.keyboard.press('Tab'); - await expect( - shell.locator('.shell__back-button') - ).toBeFocused(); + // Back lives in the header, which never scrolls away. + const back = page.getByTestId('workspace-header-back'); + await expect(back).toBeInViewport(); + await back.focus(); await page.keyboard.press('Enter'); await expect(shell).toHaveCount(0); } diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 6849c5ef0..da81d5fae 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -404,18 +404,18 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({ ) ) .toBe(0.25); - // M3U has no browse Back target, so the shell shows no arrow in either + // M3U has no browse Back target, so the header shows no arrow in either // state; the now-playing bar's own Close button returns to browse. const shell = detail(page).locator('app-portal-detail-shell'); - await expect(shell.locator('.shell__back-button')).toHaveCount(0); + const headerBack = page.locator('[data-test-id="workspace-header-back"]'); + await expect(headerBack).toHaveCount(0); await shell .locator('app-portal-inline-player') .getByRole('button', { name: 'Close player', exact: true }) .click(); await expect(inlineVideo(page)).toHaveCount(0); - await expect(shell.locator('.shell__back-button')).toHaveCount(0); - // Without the arrow there is no lane to reserve: the hero keeps its own - // inset instead of the 72px column portal details give the Back control. + await expect(headerBack).toHaveCount(0); + // The hero keeps its own inset. expect( await shell .locator('.hero__content') diff --git a/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts index c8fbdae50..4d92d7314 100644 --- a/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts +++ b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts @@ -90,8 +90,7 @@ async function openUnsavedDialog(page: Page, locale: Locale): Promise { page.locator('[data-test-id="settings-unsaved-bar"]') ).toBeVisible(); - // The rail's Dashboard link stays visible in the phone top bar, unlike - // the brand link. + // The rail's Dashboard link stays visible in the phone top bar. await page .getByRole('navigation') .locator('a[href$="/workspace/dashboard"]') diff --git a/apps/web-e2e/src/xtream.e2e.ts b/apps/web-e2e/src/xtream.e2e.ts index ce7514ab7..0e4329496 100644 --- a/apps/web-e2e/src/xtream.e2e.ts +++ b/apps/web-e2e/src/xtream.e2e.ts @@ -1127,7 +1127,11 @@ for (const theme of ['light', 'dark']) { .click(); await page.locator('app-grid-list mat-card').first().click(); const shell = page.locator('app-portal-detail-shell'); - await expect(shell).toBeVisible(); + // The loading skeleton is a scrollable shell of its own; a key + // sent to it is lost when the loaded shell replaces it. + await expect( + shell.getByRole('heading', { level: 1 }) + ).toBeVisible(); await page.evaluate( (dark) => document.body.classList.toggle('dark-theme', dark), theme === 'dark' @@ -1149,7 +1153,7 @@ for (const theme of ['light', 'dark']) { .toBeGreaterThan(0); }); - test(`@xtream sticky detail Back ${section} (${theme})`, async ({ + test(`@xtream header detail Back ${section} (${theme})`, async ({ page, }, testInfo) => { await page.setViewportSize({ width: 1200, height: 540 }); @@ -1167,30 +1171,28 @@ for (const theme of ['light', 'dark']) { (dark) => document.body.classList.toggle('dark-theme', dark), theme === 'dark' ); - const back = shell.getByRole('button', { - name: 'Back', - exact: true, - }); + // Back is the header's leading control, not a page overlay. + const back = page.locator( + 'app-workspace-shell-header [data-test-id="workspace-header-back"]' + ); await expect(back).toBeVisible(); - const backOffset = () => - back.evaluate((el) => { - const owner = el.closest('app-portal-detail-shell'); - return owner - ? el.getBoundingClientRect().top - - owner.getBoundingClientRect().top - : NaN; - }); - await expect.poll(backOffset).toBeCloseTo(16, 0); + await expect(back).toHaveAccessibleName('Back'); + await expect( + shell.getByRole('button', { name: 'Back', exact: true }) + ).toHaveCount(0); + const backTop = () => + back.evaluate((el) => el.getBoundingClientRect().top); + const initialTop = await backTop(); await page.keyboard.press('End'); await expect .poll(() => shell.evaluate((el) => el.scrollTop)) .toBeGreaterThan(0); await waitForScrollIdle(shell); - await expect.poll(backOffset).toBeCloseTo(16, 0); + expect(await backTop()).toBe(initialTop); await expect(back).toBeInViewport(); - await shell.screenshot({ + await page.screenshot({ path: testInfo.outputPath( - `sticky-back-${section}-${theme}.png` + `header-back-${section}-${theme}.png` ), }); await back.click(); @@ -1201,7 +1203,8 @@ for (const theme of ['light', 'dark']) { ).toBeVisible(); await shell.focus(); await page.keyboard.press('End'); - // Hover/focus must not make a tooltip consume the advertised Esc. + // Hover/focus must not make a tooltip consume the advertised Esc, + // even with focus on the header rather than inside the page. await page.clock.install(); await back.focus(); await back.hover(); diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index f09483744..33a06d272 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "تشغيل العرض" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "لوحة التحكم", - "OPEN_DASHBOARD": "فتح لوحة التحكم", "RAIL_SOURCES": "المصادر", - "OPEN_SOURCES": "فتح المصادر", "RAIL_GLOBAL_FAVORITES": "المفضلة", "RAIL_GLOBAL_RECENT": "تمت مشاهدتها مؤخرًا", "RAIL_GLOBAL_SEARCH": "بحث عام", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index 7e672bb78..33ca7aa9c 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "شغّل العرض" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "لوحة التحكم", - "OPEN_DASHBOARD": "حل لوحة التحكم", "RAIL_SOURCES": "المصادر", - "OPEN_SOURCES": "حل المصادر", "RAIL_GLOBAL_FAVORITES": "المفضلة", "RAIL_GLOBAL_RECENT": "متشافين مؤخراً", "RAIL_GLOBAL_SEARCH": "بحث عام", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index bd06e3b88..038ecd593 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Аднавіць паказ" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Панэль", - "OPEN_DASHBOARD": "Адкрыць панэль", "RAIL_SOURCES": "Крыніцы", - "OPEN_SOURCES": "Адкрыць крыніцы", "RAIL_GLOBAL_FAVORITES": "Выбранае", "RAIL_GLOBAL_RECENT": "Нядаўна праглянутае", "RAIL_GLOBAL_SEARCH": "Глабальны пошук", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 12ca756f3..f04dad852 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Diashow abspielen" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Dashboard", - "OPEN_DASHBOARD": "Dashboard öffnen", "RAIL_SOURCES": "Quellen", - "OPEN_SOURCES": "Quellen öffnen", "RAIL_GLOBAL_FAVORITES": "Favoriten", "RAIL_GLOBAL_RECENT": "Zuletzt angesehen", "RAIL_GLOBAL_SEARCH": "Globale Suche", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index d4065b7b7..9ddc094c3 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Αναπαραγωγή προβολής" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Πίνακας ελέγχου", - "OPEN_DASHBOARD": "Άνοιγμα πίνακα ελέγχου", "RAIL_SOURCES": "Πηγές", - "OPEN_SOURCES": "Άνοιγμα πηγών", "RAIL_GLOBAL_FAVORITES": "Αγαπημένα", "RAIL_GLOBAL_RECENT": "Πρόσφατα προβληθέντα", "RAIL_GLOBAL_SEARCH": "Καθολική αναζήτηση", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 401da3613..351600a96 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Play slideshow" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Dashboard", - "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Sources", - "OPEN_SOURCES": "Open sources", "RAIL_GLOBAL_FAVORITES": "Favorites", "RAIL_GLOBAL_RECENT": "Recently viewed", "RAIL_GLOBAL_SEARCH": "Global search", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index 0e460f3ae..310ab1eb9 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Reproducir presentación" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Panel", - "OPEN_DASHBOARD": "Abrir panel", "RAIL_SOURCES": "Fuentes", - "OPEN_SOURCES": "Abrir fuentes", "RAIL_GLOBAL_FAVORITES": "Favoritos", "RAIL_GLOBAL_RECENT": "Vistos recientemente", "RAIL_GLOBAL_SEARCH": "Búsqueda global", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 54eb60961..b3ac72d9e 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Lancer le diaporama" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Tableau de bord", - "OPEN_DASHBOARD": "Ouvrir le tableau de bord", "RAIL_SOURCES": "Sources", - "OPEN_SOURCES": "Ouvrir les sources", "RAIL_GLOBAL_FAVORITES": "Favoris", "RAIL_GLOBAL_RECENT": "Récemment consultés", "RAIL_GLOBAL_SEARCH": "Recherche globale", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index aee8487dd..427137556 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Diavetítés lejátszása" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Vezérlőpult", - "OPEN_DASHBOARD": "Vezérlőpult megnyitása", "RAIL_SOURCES": "Források", - "OPEN_SOURCES": "Források megnyitása", "RAIL_GLOBAL_FAVORITES": "Kedvencek", "RAIL_GLOBAL_RECENT": "Megtekintési előzmények", "RAIL_GLOBAL_SEARCH": "Globális keresés", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index 886d12389..c7e8fcd4b 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Avvia la presentazione" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Dashboard", - "OPEN_DASHBOARD": "Apri dashboard", "RAIL_SOURCES": "Sorgenti", - "OPEN_SOURCES": "Apri sorgenti", "RAIL_GLOBAL_FAVORITES": "Preferiti", "RAIL_GLOBAL_RECENT": "Visti di recente", "RAIL_GLOBAL_SEARCH": "Ricerca globale", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index fc86eae05..dca4c554b 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "スライドショーを再生" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "ダッシュボード", - "OPEN_DASHBOARD": "ダッシュボードを開く", "RAIL_SOURCES": "ソース", - "OPEN_SOURCES": "ソースを開く", "RAIL_GLOBAL_FAVORITES": "お気に入り", "RAIL_GLOBAL_RECENT": "最近表示した項目", "RAIL_GLOBAL_SEARCH": "グローバル検索", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index a6450e937..aead89ba8 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "슬라이드쇼 재생" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "대시보드", - "OPEN_DASHBOARD": "대시보드 열기", "RAIL_SOURCES": "소스", - "OPEN_SOURCES": "소스 열기", "RAIL_GLOBAL_FAVORITES": "즐겨찾기", "RAIL_GLOBAL_RECENT": "최근 본 항목", "RAIL_GLOBAL_SEARCH": "전역 검색", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 81146e2d8..388287481 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Diavoorstelling afspelen" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Dashboard", - "OPEN_DASHBOARD": "Open dashboard", "RAIL_SOURCES": "Bronnen", - "OPEN_SOURCES": "Open bronnen", "RAIL_GLOBAL_FAVORITES": "Favorieten", "RAIL_GLOBAL_RECENT": "Recent bekeken", "RAIL_GLOBAL_SEARCH": "Globaal zoeken", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index 27e2db41c..8de5b8750 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Odtwórz pokaz" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Pulpit", - "OPEN_DASHBOARD": "Otwórz pulpit", "RAIL_SOURCES": "Źródła", - "OPEN_SOURCES": "Otwórz źródła", "RAIL_GLOBAL_FAVORITES": "Ulubione", "RAIL_GLOBAL_RECENT": "Ostatnio oglądane", "RAIL_GLOBAL_SEARCH": "Wyszukiwanie globalne", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index afb27e027..fe944d5bc 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Reproduzir apresentação" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Dashboard", - "OPEN_DASHBOARD": "Abrir dashboard", "RAIL_SOURCES": "Fontes", - "OPEN_SOURCES": "Abrir fontes", "RAIL_GLOBAL_FAVORITES": "Favoritos", "RAIL_GLOBAL_RECENT": "Visualizados recentemente", "RAIL_GLOBAL_SEARCH": "Pesquisa global", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index c8e73edd5..92c1d33b6 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Возобновить показ" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Панель", - "OPEN_DASHBOARD": "Открыть панель", "RAIL_SOURCES": "Источники", - "OPEN_SOURCES": "Открыть источники", "RAIL_GLOBAL_FAVORITES": "Избранное", "RAIL_GLOBAL_RECENT": "Недавно просмотренные", "RAIL_GLOBAL_SEARCH": "Глобальный поиск", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index a4c3c71b9..5d75bcb44 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "Slayt gösterisini oynat" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "Pano", - "OPEN_DASHBOARD": "Panoyu aç", "RAIL_SOURCES": "Kaynaklar", - "OPEN_SOURCES": "Kaynakları aç", "RAIL_GLOBAL_FAVORITES": "Favoriler", "RAIL_GLOBAL_RECENT": "Son izlenenler", "RAIL_GLOBAL_SEARCH": "Genel arama", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index 5b09ac96c..45dd2a781 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "播放轮播" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "仪表盘", - "OPEN_DASHBOARD": "打开仪表盘", "RAIL_SOURCES": "源", - "OPEN_SOURCES": "打开源", "RAIL_GLOBAL_FAVORITES": "收藏", "RAIL_GLOBAL_RECENT": "最近观看", "RAIL_GLOBAL_SEARCH": "全局搜索", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 8e9cfad4f..3c445db9a 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1586,11 +1586,8 @@ "HERO_PLAY": "播放輪播" }, "SHELL": { - "BRAND_ALT": "IPTVnator", "RAIL_DASHBOARD": "儀表板", - "OPEN_DASHBOARD": "開啟儀表板", "RAIL_SOURCES": "來源", - "OPEN_SOURCES": "開啟來源", "RAIL_GLOBAL_FAVORITES": "我的最愛", "RAIL_GLOBAL_RECENT": "最近觀看", "RAIL_GLOBAL_SEARCH": "全域搜尋", diff --git a/docs/architecture/embedded-inline-playback.md b/docs/architecture/embedded-inline-playback.md index 22753815b..e34f48ff6 100644 --- a/docs/architecture/embedded-inline-playback.md +++ b/docs/architecture/embedded-inline-playback.md @@ -162,8 +162,9 @@ Contracts: player subtree, so shell state changes cannot recreate the `