fix(workspace): move detail Back into the header and drop the rail brand

The detail Back lane from #1763 reserved a 72px column beside a sticky
arrow on every detail page. Beside the rail and the category panel that
is about 8% of the pane, and in watch state the player is width-bound,
so it also cost video height. A single button in an empty full-height
column read as a layout fault.

The shell now registers its Back action with WorkspaceBackNavigationService
(a stack where the newest registration wins and each release removes only
its own target), and the workspace header renders it in its leading slot,
where browsers, desktop apps and Material's top app bar keep navigation.
Detail columns get their full width back; the lane, the phone bar and their
ResizeObserver are gone. Escape behaviour is unchanged, and Escape on the
focused header Back keeps the advertised shortcut. At phone width Back takes
the context drawer toggle's slot.

The rail brand mark is removed: it linked to the page the first workspace
link opens, and the phone layout already hid it for that reason.

The web detail-scroll E2E now waits for the loaded heading: PageDown sent to
the scrollable loading skeleton was lost when the loaded shell replaced it
(also flaky on master).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-02 20:37:09 +02:00
1 parent b6357da6af
commit c031a150d8
64 files changed
+795 -861

No files matched your search

+1 -1
View File
@@ -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.
+6
View File
@@ -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.
@@ -590,12 +590,9 @@ async function firstVisibleGridTitle(page: Page): Promise<string> {
}
async function goBackFromDetail(page: Page): Promise<void> {
// 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 {
@@ -306,12 +306,9 @@ function dashboardRailCardByTitle(
}
async function goBackFromDetail(page: Page): Promise<void> {
// 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<void> {
@@ -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<Sweep> {
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<BarSweep> {
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<number> {
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<void> {
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<void> {
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<void> {
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<void> {
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);
}
});
});
@@ -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<number> {
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<string[]> {
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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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<void> {
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<void> {
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);
}
});
});
@@ -1013,7 +1013,7 @@ export async function saveSettings(page: Page): Promise<void> {
export async function goToDashboard(page: Page): Promise<void> {
const dashboardLink = page
.locator('a.brand[href$="/workspace/dashboard"]')
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
.first();
await expect(dashboardLink).toBeVisible();
@@ -606,12 +606,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
}
async function goBackFromDetail(page: Page): Promise<void> {
// 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<void> {
@@ -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(
+6 -9
View File
@@ -694,12 +694,9 @@ async function expectUnifiedLiveDetailOpen(
}
async function goBackFromDetail(page: Page): Promise<void> {
// 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 {
@@ -725,9 +722,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<void> {
+3 -6
View File
@@ -1540,12 +1540,9 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
}
async function goBackFromDetail(page: Page): Promise<void> {
// 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();
@@ -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);
}
@@ -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);
}
+5 -5
View File
@@ -406,18 +406,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')
@@ -90,8 +90,7 @@ async function openUnsavedDialog(page: Page, locale: Locale): Promise<Locator> {
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"]')
+22 -19
View File
@@ -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();
-3
View File
@@ -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": "بحث عام",
-3
View File
@@ -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": "بحث عام",
-3
View File
@@ -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": "Глабальны пошук",
-3
View File
@@ -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",
-3
View File
@@ -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": "Καθολική αναζήτηση",
-3
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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": "グローバル検索",
-3
View File
@@ -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": "전역 검색",
-3
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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",
-3
View File
@@ -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": "Глобальный поиск",
-3
View File
@@ -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",
-3
View File
@@ -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": "全局搜索",
-3
View File
@@ -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": "全域搜尋",
@@ -162,8 +162,9 @@ Contracts:
player subtree, so shell state changes cannot recreate the `<video>`.
- **External MPV/VLC sessions do not flip the layout to watch** — browse
layout stays, and the primary CTA keeps its "Stop <player>" behavior.
- The shell's sticky arrow emits the host-owned `backClicked` in browse and
watch alike (unless `backAvailable=false`, when it is not rendered at all):
- The shell's Back, rendered by the workspace header, emits the host-owned
`backClicked` in browse and watch alike (unless `backAvailable=false`, when
the shell registers none):
hosts wire it to their route-level `goBack()`, straight back to the list —
everything browse offers is also visible in watch, so a two-step unwind
would be ceremony. Escape alone unwinds one level: in watch it emits
@@ -174,7 +175,7 @@ Contracts:
[Portal Detail Navigation](./portal-detail-navigation.md).
- The now-playing bar has one exit of its own: the "Close player" button
emits `closed` and returns to browse without navigating. It carries no back
arrow — a second arrow beside the sticky one, with a different meaning,
arrow — a second arrow beside the header's, with a different meaning,
was the duplicate this contract removes.
- Entering watch scrolls the shell to the top; leaving keeps the scroll
position.
+2 -2
View File
@@ -644,8 +644,8 @@ partial so Xtream and Stalker share the same behavior.
The season header's actions wrap onto their own row, starting under the
"Seasons and Episodes" heading, before the heading itself would wrap. The
detail pane is narrower than the window (context panel, the sticky Back lane),
so the header's own width decides, not a viewport breakpoint. A translation
detail pane is narrower than the window (rail and context panel), so the
header's own width decides, not a viewport breakpoint. A translation
wider than the pane itself wraps rather than ellipsizing: unlike a fixed-height
panel title, a content heading has room to wrap and should not lose words.
+27 -29
View File
@@ -23,30 +23,28 @@ do not reach global player shortcuts. Descendant controls retain their native
keys and Tab order. Entering watch still scrolls to the top; Back and saved
catalog scroll positions retain the existing navigation contract below.
The shell owns a single sticky Back control, outside the collapsing hero. Its
zero-height wrapper is a direct child of the scroll owner, so the control stays
16 px from the top throughout long episode lists without shifting the hero.
The button has an opaque app-themed surface, visible keyboard focus, an Escape
shortcut hint via native `title` and Electron `no-drag` hit testing. The hint
does not create an overlay that could consume the first Escape press.
The shell owns the page's Back action, but the workspace header renders it.
While `backAvailable()` is true, the shell registers a target with
`WorkspaceBackNavigationService` (`@iptvnator/portal/shared/util`): a label (the
host's `backLabel`, else the translated "Back"), whether Escape currently runs
it, and `run()`, which emits `backClicked`. The service keeps a stack in which
the newest registration wins, and each release removes only its own target. A
loading shell replaced by the loaded one therefore cannot clear its successor,
whichever is destroyed first. The header shows the target as an `arrow_back`
icon button in its leading slot (`data-test-id="workspace-header-back"`), to
the right of the macOS traffic lights. That is where desktop apps and Material's
top app bar keep navigation. The header never scrolls, so the control stays
visible over long episode lists, and nothing floats over the scroll owner:
detail columns keep symmetric insets and their full width. The button has
Electron `no-drag` hit testing. In browse its tooltip and `aria-keyshortcuts`
advertise Escape, and an Escape pressed on the focused button runs Back
itself, because the shell's browse Escape requires focus inside the page. At
≤640 px Back takes the context drawer toggle's slot (one navigation icon); the
list it returns to shows the toggle again. This replaced #1763's 72 px lane
reserved beside a sticky in-page arrow, along with its phone bar. Electron E2E
`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch.
The control floats, so the content makes room for it: while it is shown, every
content column (hero content, watch-state player, episodes, About and extras)
starts after a 72 px lane — 16 px inset, 40 px button, 16 px gap — and no scroll
position puts the arrow over a heading or the player. The shell derives the lane
from the button's geometry and publishes it as `--detail-back-lane`, which
`content-hero` and `content-about` read too; hosts with `backAvailable=false`
keep their own inset. The lane is horizontal because watch state is short of
height, not width. A pane narrower than 400 px is the exception — a phone, or a
desktop pane beside the context panel — because a 72 px column there would clip
the player's controls. The pane decides, not the viewport: a `ResizeObserver`
on the shell's border box (scrollbar-independent) sets `shell-host--compact`,
and the wrapper becomes an in-flow 56 px sticky bar on `--app-header-bg` that
content scrolls beneath and that takes the taps over it. Electron E2E
(`detail-back-lane.e2e.ts`) sweeps both states with the lane at 1280 and
780 px and with the bar at 700 and 375 px.
The sticky control is route-level Back in both states: it emits `backClicked`
The header Back is route-level in both states: it emits `backClicked`
whether or not inline playback is active, so the arrow keeps one meaning and
the list is one click away while watching. Only Escape unwinds one level: watch
emits `closePlayerRequested`, browse emits `backClicked`. Hosts retain their
@@ -61,13 +59,13 @@ Escape bubbles through the shell before Material's body-level tooltip dispatcher
so focused detail actions return with one press even while their tooltip is open.
The document listener remains the outside-shell watch fallback; `defaultPrevented`
prevents duplicate actions and preserves descendant handlers' priority.
After Escape closes a player, lost focus moves to the sticky control (or the
shell when there is no browse Back), without scrolling or stealing existing
focus.
After Escape closes a player, lost focus moves to the shell itself, without
scrolling or stealing existing focus, so the next Escape and the scroll keys
keep working on the page.
Hosts without browse navigation set `backAvailable=false`: M3U uses its channel
sidebar, and collection bootstrap placeholders have no return handler. They
render no sticky arrow in either state and have no browse Escape action; their
register no header Back in either state and have no browse Escape action; their
watch exits are the bar's Close player button and Escape. Loading/error shells
with a return handler keep Back available.
@@ -77,8 +75,8 @@ with a return handler keep Back available.
- Stalker uses an inline/store-state detail model.
- Detail pages themselves are two-state (browse ↔ watch) inside
`PortalDetailShellComponent`; entering/leaving watch is a layout state,
not a navigation. Route-level back semantics are unchanged; the one
sticky arrow returns to the list from either state, while Escape and the
not a navigation. Route-level back semantics are unchanged; the header's
one Back arrow returns to the list from either state, while Escape and the
now-playing bar's Close button close the inline player. See
[Embedded Inline Playback](./embedded-inline-playback.md).
- Favorites and recently viewed collections now use collection-owned inline detail
+11 -5
View File
@@ -90,12 +90,18 @@ The shell is intentionally split into four persistent regions:
because its data source is the SQLite worker bridge.
2. Provider-aware context links derived from the active or current playlist.
3. Settings remains a persistent footer shortcut in the rail.
4. No brand mark: it only repeated the first workspace link (Dashboard,
or Sources when the dashboard is off).
2. Top header:
1. Playlist switcher.
2. Route-aware search input and command palette trigger.
3. Add source action.
4. Optional playlist refresh and route-specific shortcut actions.
5. Downloads shortcut in Electron.
1. Leading Back slot, shown while the current page registers a target
with `WorkspaceBackNavigationService` (detail pages today). At
≤640 px it takes the context drawer toggle's place. See
[Portal Detail Navigation](./portal-detail-navigation.md).
2. Playlist switcher.
3. Route-aware search input and command palette trigger.
4. Add source action.
5. Optional playlist refresh and route-specific shortcut actions.
6. Downloads shortcut in Electron.
3. Main body:
1. Optional left context panel.
2. Main router outlet content.
@@ -10,7 +10,10 @@ import {
SettingsStore,
} from '@iptvnator/services';
import type { DownloadMetadataSnapshot } from '@iptvnator/shared/interfaces';
import type { WorkspaceNavigationTarget } from '@iptvnator/portal/shared/util';
import {
WorkspaceBackNavigationService,
type WorkspaceNavigationTarget,
} from '@iptvnator/portal/shared/util';
import { BehaviorSubject } from 'rxjs';
import type {
DownloadActionResult,
@@ -1076,11 +1079,10 @@ describe('DownloadOfflineDetailComponent', () => {
router.url = detailUrl;
await render([download(17)]);
(
(fixture.nativeElement as HTMLElement).querySelector(
'.shell__back-button'
) as HTMLButtonElement
).click();
// The workspace header renders the page's Back.
expect(
TestBed.inject(WorkspaceBackNavigationService).goBack()
).toBe(true);
await fixture.whenStable();
expect(location.back).toHaveBeenCalledTimes(1);
@@ -1096,11 +1098,9 @@ describe('DownloadOfflineDetailComponent', () => {
router.url = '/workspace/stalker/playlist-a/downloads/17';
await render([download(17)]);
(
(fixture.nativeElement as HTMLElement).querySelector(
'.shell__back-button'
) as HTMLButtonElement
).click();
expect(TestBed.inject(WorkspaceBackNavigationService).goBack()).toBe(
true
);
await fixture.whenStable();
expect(location.back).not.toHaveBeenCalled();
+1
View File
@@ -23,6 +23,7 @@ export * from './lib/category-sort-state.service';
export * from './lib/keyboard';
export * from './lib/keyboard-shortcuts';
export * from './lib/remote-channel-navigation';
export * from './lib/workspace-back-navigation.service';
export * from './lib/workspace-header-context.service';
export * from './lib/workspace-view-command.types';
export * from './lib/workspace-view-command.service';
@@ -0,0 +1,79 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import {
WorkspaceBackNavigationService,
WorkspaceBackTarget,
} from './workspace-back-navigation.service';
describe('WorkspaceBackNavigationService', () => {
function createService(): WorkspaceBackNavigationService {
TestBed.resetTestingModule();
return TestBed.inject(WorkspaceBackNavigationService);
}
function createTarget(run = jest.fn()): WorkspaceBackTarget {
return {
label: signal<string | null>(null),
escapeShortcut: signal(true),
run,
};
}
it('has no target until a page registers one', () => {
const service = createService();
expect(service.target()).toBeNull();
expect(service.goBack()).toBe(false);
});
it('runs the most recently registered target', () => {
const service = createService();
const first = createTarget();
const second = createTarget();
service.register(first);
service.register(second);
expect(service.target()).toBe(second);
expect(service.goBack()).toBe(true);
expect(second.run).toHaveBeenCalledTimes(1);
expect(first.run).not.toHaveBeenCalled();
});
it('hands the slot back to the previous target on release', () => {
const service = createService();
const first = createTarget();
const second = createTarget();
service.register(first);
const releaseSecond = service.register(second);
releaseSecond();
expect(service.target()).toBe(first);
});
it('keeps a replacement page when the replaced one releases afterwards', () => {
const service = createService();
const loading = createTarget();
const loaded = createTarget();
const releaseLoading = service.register(loading);
service.register(loaded);
releaseLoading();
expect(service.target()).toBe(loaded);
});
it('moves a re-registered target to the top without duplicating it', () => {
const service = createService();
const first = createTarget();
const second = createTarget();
const releaseFirst = service.register(first);
service.register(second);
service.register(first);
releaseFirst();
expect(service.target()).toBe(second);
});
});
@@ -0,0 +1,49 @@
import { computed, Injectable, Signal, signal } from '@angular/core';
/** A page's Back action, rendered in the workspace header's leading slot. */
export interface WorkspaceBackTarget {
/** Accessible name and tooltip; null falls back to the generic "Back". */
readonly label: Signal<string | null>;
/** True while Escape on the page runs the same action. */
readonly escapeShortcut: Signal<boolean>;
run(): void;
}
/**
* Owns the header's Back slot. Pages register while they offer Back; the most
* recent registration wins, so a page opened above another one takes the slot
* and hands it back when it goes away.
*/
@Injectable({ providedIn: 'root' })
export class WorkspaceBackNavigationService {
private readonly targets = signal<readonly WorkspaceBackTarget[]>([]);
readonly target = computed(() => {
const targets = this.targets();
return targets[targets.length - 1] ?? null;
});
/**
* Returns the release function. It removes only this target: when one
* page replaces another (a loading shell by the loaded one), creation and
* destruction can interleave in either order.
*/
register(target: WorkspaceBackTarget): () => void {
this.targets.update((targets) => [
...targets.filter((entry) => entry !== target),
target,
]);
return () =>
this.targets.update((targets) =>
targets.filter((entry) => entry !== target)
);
}
/** Runs the current target; false when no page offers Back. */
goBack(): boolean {
const target = this.target();
if (!target) return false;
target.run();
return true;
}
}
@@ -144,8 +144,6 @@
gap: 32px;
align-items: flex-start;
padding: 140px 40px 60px;
// Reserved by the detail shell while its sticky Back control shows.
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
max-width: var(--content-max-width);
margin: 0 auto;
box-sizing: border-box;
@@ -513,7 +511,6 @@
&__content {
padding: 60px 20px 40px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
gap: 16px;
}
}
@@ -555,7 +552,6 @@
flex-direction: column;
align-items: stretch;
padding-inline: 16px;
padding-inline-start: max(16px, var(--detail-back-lane, 0px));
}
.poster {
@@ -6,8 +6,6 @@
max-width: var(--content-max-width, 1400px);
margin: 0 auto;
padding: 24px 40px 40px;
// The detail shell's sticky Back lane (0 when there is no Back).
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
&__heading {
@@ -129,7 +127,6 @@
@media (max-width: 800px) {
.about {
padding: 20px 20px 32px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
&__poster {
width: 92px;
@@ -1,22 +1,3 @@
@if (backAvailable()) {
@let label = backLabel() || ('BACK' | translate);
<!-- Route-level Back in both states. Watch keeps the arrow's meaning;
closing the player belongs to the player's own Close button and Escape. -->
<div class="shell__navigation">
<button
#backButton
class="shell__back-button"
type="button"
[attr.aria-label]="label"
[attr.aria-keyshortcuts]="isWatch() ? null : 'Escape'"
[title]="isWatch() ? label : label + ' (Esc)'"
(click)="backClicked.emit()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
</div>
}
<!-- Hero (browse state) — collapses when playback is active -->
<div class="shell__hero" [class.shell__hero--collapsed]="isWatch()">
<div class="shell__hero-inner">
@@ -13,13 +13,6 @@
--tag-border: rgba(0, 0, 0, 0.12);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--content-max-width: 1400px;
// The sticky Back control floats over the scroll owner. While it is shown,
// every content column (hero, player, episodes, About, extras) starts
// after its lane — inset + button + the same inset again — so nothing
// scrolls beneath it. content-hero and content-about read the lane too.
--shell-back-inset: 16px;
--shell-back-size: 40px;
--detail-back-lane: 0px;
display: block;
height: 100%;
@@ -37,12 +30,6 @@
}
}
:host(.shell-host--back) {
--detail-back-lane: calc(
2 * var(--shell-back-inset) + var(--shell-back-size)
);
}
:host-context(.dark-theme) {
--surface-bg: #141414;
--text-primary: #fff;
@@ -53,48 +40,6 @@
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.shell__navigation {
// Direct child of the scroll owner: sticky lasts through the entire page.
position: sticky;
top: var(--shell-back-inset);
height: 0;
z-index: 10;
pointer-events: none;
}
.shell__back-button {
position: absolute;
inset-inline-start: var(--shell-back-inset);
pointer-events: auto;
app-region: no-drag;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: var(--shell-back-size);
height: var(--shell-back-size);
border-radius: 50%;
background: var(--app-widget-bg, var(--surface-bg));
color: var(--app-on-surface, var(--text-primary));
border: 1px solid var(--app-separator, var(--tag-border));
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg, var(--surface-bg));
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// ============================================================================
// Hero collapse (browse ↔ watch morph, ~300ms, no height measuring)
// ============================================================================
@@ -135,7 +80,6 @@
&--active {
padding: 16px 40px 0;
padding-inline-start: max(40px, var(--detail-back-lane));
animation: shell-fade-in 300ms ease 50ms both;
}
}
@@ -144,7 +88,6 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 40px;
padding-inline-start: max(40px, var(--detail-back-lane));
}
.shell__about {
@@ -158,7 +101,6 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 40px 40px;
padding-inline-start: max(40px, var(--detail-back-lane));
}
@keyframes shell-fade-in {
@@ -175,38 +117,14 @@
@media (max-width: 800px) {
.shell__player--active {
padding: 8px 12px 0;
padding-inline-start: max(12px, var(--detail-back-lane));
}
.shell__episodes {
padding: 0 20px;
padding-inline-start: max(20px, var(--detail-back-lane));
}
.shell__extras {
padding: 0 20px 32px;
padding-inline-start: max(20px, var(--detail-back-lane));
}
}
// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside
// the context panel; the component sets the class from the pane's width)
// would clip the player's controls. There the control takes a row instead —
// an opaque sticky bar in flow that content scrolls beneath, never alongside.
:host(.shell-host--compact) {
--detail-back-lane: 0px;
.shell__navigation {
top: 0;
height: calc(var(--shell-back-size) + 16px);
background: var(--app-header-bg);
// The bar hides what scrolls beneath it, so it must not let taps
// through to that hidden content either.
pointer-events: auto;
}
.shell__back-button {
top: 8px;
}
}
@@ -1,6 +1,7 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/util';
import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -69,6 +70,9 @@ describe('PortalDetailShellComponent', () => {
return element;
};
const backTarget = () =>
TestBed.inject(WorkspaceBackNavigationService).target();
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
@@ -130,13 +134,13 @@ describe('PortalDetailShellComponent', () => {
expect(query('.details__meta .details__meta-item')).toBeTruthy();
expect(query('.action-buttons .play-btn')).toBeTruthy();
expect(query('app-content-about')).toBeNull();
expect(query('.shell__back-button')?.getAttribute('aria-label')).toBe(
'Return to downloads'
);
// The header renders Back; the page itself keeps no arrow.
expect(query('button[aria-label="Return to downloads"]')).toBeNull();
expect(backTarget()?.label()).toBe('Return to downloads');
});
it.each([{ isLoading: true }, { errorMessage: 'Unavailable' }])(
'keeps the translated fallback Back available in loading/error states',
'keeps the generic Back available in loading/error states',
(state) => {
fixture.destroy();
const shellFixture = TestBed.createComponent(
@@ -145,16 +149,14 @@ describe('PortalDetailShellComponent', () => {
for (const [key, value] of Object.entries(state))
shellFixture.componentRef.setInput(key, value);
shellFixture.detectChanges();
const element = shellFixture.nativeElement as HTMLElement;
const button = element.querySelector<HTMLButtonElement>(
'.shell__back-button'
);
expect(button?.type).toBe('button');
expect(button?.getAttribute('aria-label')).toBe('Go back');
// No host label: the header falls back to the translated "Back".
expect(backTarget()?.label()).toBeNull();
const back = jest.fn();
shellFixture.componentInstance.backClicked.subscribe(back);
button?.click();
backTarget()?.run();
expect(back).toHaveBeenCalledTimes(1);
shellFixture.destroy();
expect(backTarget()).toBeNull();
}
);
@@ -277,30 +279,33 @@ describe('PortalDetailShellComponent', () => {
expect(host.closeRequests).toBe(0);
});
it('keeps one route-back control outside the collapsing hero in both states', () => {
const back = requiredQuery('.shell__back-button');
expect(back.closest('app-content-hero')).toBeNull();
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
back.click();
it('offers one route-level Back to the header in both states', () => {
const target = backTarget();
expect(target?.label()).toBe('Return to downloads');
expect(target?.escapeShortcut()).toBe(true);
target?.run();
expect(host.backRequests).toBe(1);
host.playbackActive.set(true);
fixture.detectChanges();
expect(query('.shell__back-button')).toBe(back);
expect(backTarget()).toBe(target);
// Watch keeps the arrow's meaning: it leaves the page, it does not
// close the player. Escape is the close shortcut, so the hint goes.
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
expect(back.getAttribute('aria-keyshortcuts')).toBeNull();
expect(back.getAttribute('title')).toBe('Return to downloads');
back.click();
expect(target?.escapeShortcut()).toBe(false);
target?.run();
expect(host.backRequests).toBe(2);
expect(host.closeRequests).toBe(0);
});
it('moves lost focus to the back control after Escape closes the player', async () => {
it('releases the header Back when the page goes away', () => {
expect(backTarget()).not.toBeNull();
fixture.destroy();
expect(backTarget()).toBeNull();
});
it('keeps focus on the page after Escape closes the player', async () => {
host.playbackActive.set(true);
fixture.detectChanges();
const back = requiredQuery('.shell__back-button');
const shell = requiredQuery('app-portal-detail-shell');
const player = requiredQuery('.fake-player');
player.tabIndex = 0;
player.focus();
@@ -315,13 +320,13 @@ describe('PortalDetailShellComponent', () => {
await fixture.whenStable();
expect(host.closeRequests).toBe(1);
expect(host.backRequests).toBe(0);
expect(document.activeElement).toBe(back);
expect(document.activeElement).toBe(shell);
});
it('has no dead-end browse action for a host without back navigation', () => {
host.backAvailable.set(false);
fixture.detectChanges();
expect(query('.shell__back-button')).toBeNull();
expect(backTarget()).toBeNull();
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
@@ -334,7 +339,7 @@ describe('PortalDetailShellComponent', () => {
fixture.detectChanges();
// No route to go back to, so no arrow in watch either; the player's
// own Close button and Escape remain the exits.
expect(query('.shell__back-button')).toBeNull();
expect(backTarget()).toBeNull();
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
@@ -451,67 +456,3 @@ describe('PortalDetailShellComponent', () => {
expect(host.closeRequests).toBe(0);
});
});
describe('PortalDetailShellComponent pane width', () => {
const original = globalThis.ResizeObserver;
const disconnect = jest.fn();
// The hero observes itself too, so keep each callback with its target.
let observers: { callback: ResizeObserverCallback; target?: Element }[];
beforeEach(() => {
observers = [];
disconnect.mockClear();
globalThis.ResizeObserver = class {
private readonly entry: (typeof observers)[number];
constructor(callback: ResizeObserverCallback) {
this.entry = { callback };
observers.push(this.entry);
}
observe(target: Element): void {
this.entry.target = target;
}
unobserve = jest.fn();
disconnect = disconnect;
} as unknown as typeof ResizeObserver;
TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
});
});
afterEach(() => {
globalThis.ResizeObserver = original;
});
const resize = (shell: HTMLElement, inlineSize: number): void => {
const observer = observers.find(({ target }) => target === shell);
if (!observer) throw new Error('The shell does not observe itself.');
observer.callback(
[
{
borderBoxSize: [{ inlineSize, blockSize: 800 }],
} as unknown as ResizeObserverEntry,
],
{} as ResizeObserver
);
};
it('trades the Back lane for a bar when the pane itself is narrow', () => {
const fixture = TestBed.createComponent(HostComponent);
fixture.detectChanges();
const shell = (fixture.nativeElement as HTMLElement).querySelector(
'app-portal-detail-shell'
) as HTMLElement;
// A 780px window leaves the pane ~402px beside the context panel.
resize(shell, 402);
expect(shell.classList).not.toContain('shell-host--compact');
// A 641px window leaves it ~260px: too narrow for the lane.
resize(shell, 260);
expect(shell.classList).toContain('shell-host--compact');
resize(shell, 1280);
expect(shell.classList).not.toContain('shell-host--compact');
fixture.destroy();
expect(disconnect).toHaveBeenCalled();
});
});
@@ -2,7 +2,6 @@ import { NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
Injector,
computed,
@@ -11,11 +10,12 @@ import {
inject,
input,
output,
viewChild,
ChangeDetectionStrategy,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import {
WorkspaceBackNavigationService,
WorkspaceBackTarget,
} from '@iptvnator/portal/shared/util';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { ContentAboutComponent } from './content-about.component';
import {
@@ -24,14 +24,6 @@ import {
DetailTagsTemplateDirective,
} from './detail-template.directives';
/**
* Below this pane width the Back lane (16 + 40 + 16px) would leave the player
* card under ~316px, where its control row clips, so the control takes a
* sticky bar instead. The pane decides, not the viewport: beside the context
* panel a desktop pane can be narrower than a phone.
*/
const COMPACT_SHELL_WIDTH = 400;
/**
* Two-state layout shell for portal VOD/series detail pages.
*
@@ -41,22 +33,17 @@ const COMPACT_SHELL_WIDTH = 400;
* About block below the episodes slot.
*
* The shell owns the page scroll, the browse↔watch animation, Escape
* handling, the one sticky Back control (route-level in both states; closing
* the player is the player's own Close button and Escape), and never
* conditionally wraps the `[detail-player]` slot — the
* host's own `@if (inlinePlayback())` is the only thing that creates or
* destroys the player, so shell state changes cannot recreate it.
* handling and the page's Back action, which the workspace header renders in
* its leading slot (route-level in both states; closing the player is the
* player's own Close button and Escape). It never conditionally wraps the
* `[detail-player]` slot — the host's own `@if (inlinePlayback())` is the
* only thing that creates or destroys the player, so shell state changes
* cannot recreate it.
*/
@Component({
selector: 'app-portal-detail-shell',
standalone: true,
imports: [
ContentHeroComponent,
ContentAboutComponent,
NgTemplateOutlet,
MatIconModule,
TranslateModule,
],
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
templateUrl: './portal-detail-shell.component.html',
styleUrls: ['./portal-detail-shell.component.scss'],
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
@@ -70,17 +57,13 @@ const COMPACT_SHELL_WIDTH = 400;
// on body, while preserving already-handled events and overlay guards.
'(keydown.escape)': 'onEscape($event)',
'[class.shell-host--watch]': 'isWatch()',
// Content columns reserve the sticky Back control's lane.
'[class.shell-host--back]': 'backAvailable()',
'(document:keydown.escape)': 'onEscape($event)',
},
})
export class PortalDetailShellComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly destroyRef = inject(DestroyRef);
private readonly backButton =
viewChild<ElementRef<HTMLButtonElement>>('backButton');
private readonly backNavigation = inject(WorkspaceBackNavigationService);
readonly title = input<string>();
readonly description = input<string>();
@@ -94,7 +77,7 @@ export class PortalDetailShellComponent {
/** True while inline playback is active — flips the layout to watch state. */
readonly playbackActive = input(false);
/** The sticky control in either state, or Escape in browse. */
/** The header's Back in either state, or Escape in browse. */
readonly backClicked = output<void>();
/** Emitted by Escape during inline playback. */
readonly closePlayerRequested = output<void>();
@@ -107,6 +90,12 @@ export class PortalDetailShellComponent {
readonly isWatch = computed(() => this.playbackActive());
private readonly backTarget: WorkspaceBackTarget = {
label: computed(() => this.backLabel() || null),
escapeShortcut: computed(() => !this.isWatch()),
run: () => this.backClicked.emit(),
};
constructor() {
afterNextRender(() => {
const element = this.host.nativeElement;
@@ -120,7 +109,10 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
this.observeCompactWidth();
effect((onCleanup) => {
if (!this.backAvailable()) return;
onCleanup(this.backNavigation.register(this.backTarget));
});
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -227,15 +219,15 @@ export class PortalDetailShellComponent {
afterNextRender(
() => {
const element = this.host.nativeElement;
// The page keeps focus, so the next Escape still unwinds it
// and the arrow keys still scroll it.
if (
element.isConnected &&
!element.closest('[inert]') &&
element.ownerDocument.activeElement ===
element.ownerDocument.body
) {
(this.backButton()?.nativeElement ?? element).focus({
preventScroll: true,
});
element.focus({ preventScroll: true });
}
},
{ injector: this.injector }
@@ -254,24 +246,4 @@ export class PortalDetailShellComponent {
}
element.scrollTo({ top: 0, behavior: 'auto' });
}
/**
* Toggles `shell-host--compact` straight on the host, so the class lands
* in the same frame as the resize instead of after a change-detection
* pass. The border box keeps the threshold independent of scrollbar width.
*/
private observeCompactWidth(): void {
if (typeof ResizeObserver === 'undefined') return;
const element = this.host.nativeElement;
const observer = new ResizeObserver(([entry]) => {
const width =
entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth;
element.classList.toggle(
'shell-host--compact',
width < COMPACT_SHELL_WIDTH
);
});
observer.observe(element);
this.destroyRef.onDestroy(() => observer.disconnect());
}
}
@@ -1,8 +1,31 @@
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
<!-- The page's Back, at the leading edge where desktop apps and Material's
top app bar keep navigation (beside the macOS traffic lights). The
header never scrolls, so the control stays put over long pages. -->
@if (backTarget(); as back) {
@let backLabel = back.label() || ('BACK' | translate);
<button
type="button"
class="header-back"
mat-icon-button
data-test-id="workspace-header-back"
(click)="backRequested.emit()"
(keydown.escape)="onBackEscape($event)"
[matTooltip]="
back.escapeShortcut() ? backLabel + ' (Esc)' : backLabel
"
[attr.aria-label]="backLabel"
[attr.aria-keyshortcuts]="back.escapeShortcut() ? 'Escape' : null"
>
<mat-icon>arrow_back</mat-icon>
</button>
}
<!-- Phone-only (≤640px, CSS-hidden above): on phones the context panel
is an off-canvas drawer, and this button is the only way to open
it, so it must never be dropped while the route has a panel. -->
@if (showContextDrawerToggle()) {
it. A page with Back takes its slot (one navigation icon, as in
Material's top app bar); the list Back returns to shows it again. -->
@if (showContextDrawerToggle() && !backTarget()) {
<button
#contextDrawerToggle
type="button"
@@ -20,6 +20,14 @@
app-region: no-drag;
}
// Pulled toward the edge so the arrow glyph, not the button's padding, sits
// on the header's inset.
.header-back {
flex-shrink: 0;
margin-inline-start: -8px;
app-region: no-drag;
}
// Above the phone breakpoint the context panel is always visible beside the
// content, so its drawer toggle would open nothing — it exists only at ≤640px.
.context-drawer-toggle {
@@ -4,6 +4,7 @@ import {
Component,
input,
output,
signal,
ChangeDetectionStrategy,
} from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -14,6 +15,7 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
@Component({
@@ -376,6 +378,109 @@ describe('WorkspaceShellHeaderComponent', () => {
);
});
describe('page Back', () => {
const backButton = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="workspace-header-back"]'
);
function setBackTarget(
label: string | null,
escapeShortcut: boolean
): WorkspaceBackTarget {
const target: WorkspaceBackTarget = {
label: signal(label),
escapeShortcut: signal(escapeShortcut),
run: jest.fn(),
};
fixture.componentRef.setInput('backTarget', target);
fixture.detectChanges();
return target;
}
it('renders nothing while the page has no Back', () => {
expect(backButton()).toBeNull();
});
it('leads the header and emits Back requests', () => {
const requested = jest.fn();
component.backRequested.subscribe(requested);
setBackTarget('Back to Downloads', true);
const button = backButton();
expect(
fixture.nativeElement.querySelector('header')?.firstElementChild
).toBe(button);
expect(button?.type).toBe('button');
button?.click();
expect(requested).toHaveBeenCalledTimes(1);
});
it('names the page Back and announces Escape only while it applies', () => {
setBackTarget('Back to Downloads', true);
expect(backButton()?.getAttribute('aria-label')).toBe(
'Back to Downloads'
);
expect(backButton()?.getAttribute('aria-keyshortcuts')).toBe(
'Escape'
);
setBackTarget(null, false);
// No host label: the generic translated Back.
expect(backButton()?.getAttribute('aria-label')).toBe('BACK');
expect(backButton()?.hasAttribute('aria-keyshortcuts')).toBe(false);
});
it('runs the advertised Escape from the focused Back only while it applies', () => {
const requested = jest.fn();
component.backRequested.subscribe(requested);
const press = (init: KeyboardEventInit = {}) => {
const event = new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
...init,
});
backButton()?.dispatchEvent(event);
return event;
};
setBackTarget(null, true);
press({ repeat: true });
press({ shiftKey: true });
expect(requested).not.toHaveBeenCalled();
expect(press().defaultPrevented).toBe(true);
expect(requested).toHaveBeenCalledTimes(1);
// In watch, Escape belongs to the player's close shortcut.
setBackTarget(null, false);
expect(press().defaultPrevented).toBe(false);
expect(requested).toHaveBeenCalledTimes(1);
});
it('takes the drawer toggle slot while the page offers Back', () => {
fixture.componentRef.setInput('showContextDrawerToggle', true);
setBackTarget(null, true);
expect(
fixture.nativeElement.querySelector(
'[data-test-id="context-drawer-toggle"]'
)
).toBeNull();
fixture.componentRef.setInput('backTarget', null);
fixture.detectChanges();
expect(backButton()).toBeNull();
expect(
fixture.nativeElement.querySelector(
'[data-test-id="context-drawer-toggle"]'
)
).not.toBeNull();
});
});
it('uses the paired Material primary tokens for the download badge', () => {
const styleSource = readFileSync(
join(__dirname, 'workspace-shell-header.component.scss'),
@@ -12,7 +12,10 @@ import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import { PlaylistSwitcherComponent } from '@iptvnator/playlist/shared/ui';
import { WorkspaceHeaderAction } from '@iptvnator/portal/shared/util';
import {
WorkspaceBackTarget,
WorkspaceHeaderAction,
} from '@iptvnator/portal/shared/util';
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
import {
WorkspaceHeaderBulkAction,
@@ -46,6 +49,8 @@ export class WorkspaceShellHeaderComponent {
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
readonly commandShortcutLabel = this.isMac ? '⌘K' : 'Ctrl+K';
/** The current page's Back action; null when the page has none. */
readonly backTarget = input<WorkspaceBackTarget | null>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
@@ -105,6 +110,7 @@ export class WorkspaceShellHeaderComponent {
'WORKSPACE.SHELL.CONTEXT_DRAWER_CATEGORIES_TOOLTIP'
);
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -148,6 +154,27 @@ export class WorkspaceShellHeaderComponent {
this.searchSubmitted.emit(target?.value ?? this.searchQuery());
}
/**
* The tooltip advertises Escape for Back. The page only handles it with
* focus inside the page, so the button keeps that promise itself.
*/
onBackEscape(event: Event): void {
const keyboard = event as KeyboardEvent;
if (
!this.backTarget()?.escapeShortcut() ||
keyboard.defaultPrevented ||
keyboard.repeat ||
keyboard.altKey ||
keyboard.ctrlKey ||
keyboard.metaKey ||
keyboard.shiftKey
) {
return;
}
keyboard.preventDefault();
this.backRequested.emit();
}
onPlaylistInfoRequested(): void {
this.playlistInfoRequested.emit();
}
@@ -1,17 +1,6 @@
<!-- No brand mark: it linked to the page the first workspace link opens.
The page's Back lives in the header's leading slot. -->
<aside class="app-rail" [class.is-macos]="isMacOS()">
<a
class="brand"
[routerLink]="brandLink()"
[matTooltip]="brandTooltipKey() | translate"
[matTooltipPosition]="'right'"
[attr.aria-label]="brandAriaLabelKey() | translate"
>
<img
src="./assets/icons/icon-tv-256.png"
[attr.alt]="'WORKSPACE.SHELL.BRAND_ALT' | translate"
/>
</a>
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
@if (primaryContextLinks().length > 0) {
@@ -98,22 +98,6 @@
}
}
.brand {
margin-bottom: 10px;
width: 40px;
height: 40px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.brand img {
width: 24px;
height: 24px;
object-fit: contain;
}
.rail-context-region {
--region-accent: var(--mat-sys-outline-variant);
display: flex;
@@ -239,12 +223,6 @@
padding-top: 6px;
}
// The brand links to the dashboard, which the first workspace link
// already does. On a bar this narrow that duplicate costs a section.
.brand {
display: none;
}
a {
flex: 0 0 auto;
}
@@ -61,11 +61,6 @@ describe('WorkspaceShellRailComponent', () => {
});
it('renders provider context region and active settings shortcut state', () => {
fixture.componentRef.setInput('brandLink', '/workspace/sources');
fixture.componentRef.setInput(
'brandAriaLabelKey',
'WORKSPACE.SHELL.OPEN_SOURCES'
);
fixture.componentRef.setInput('primaryContextLinks', [
{
icon: 'movie',
@@ -87,13 +82,15 @@ describe('WorkspaceShellRailComponent', () => {
expect(
fixture.nativeElement.querySelector('.rail-shortcut.is-active')
).not.toBeNull();
});
it('has no brand mark duplicating the first workspace link', () => {
fixture.detectChanges();
expect(fixture.nativeElement.querySelector('img')).toBeNull();
expect(
fixture.nativeElement.querySelector('.brand')?.getAttribute('href')
).toContain('/workspace/sources');
expect(
fixture.nativeElement
.querySelector('.brand')
?.getAttribute('aria-label')
).toBe('WORKSPACE.SHELL.OPEN_SOURCES');
fixture.nativeElement.querySelector('.app-rail')?.firstElementChild
?.tagName
).toBe('APP-WORKSPACE-SHELL-RAIL-LINKS');
});
});
@@ -1,8 +1,4 @@
import {
ChangeDetectionStrategy,
Component,
input,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
@@ -28,9 +24,6 @@ import { WorkspaceShellRailLinksComponent } from '../workspace-shell-rail-links/
})
export class WorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<PortalRailLink[]>([]);
readonly primaryContextLinks = input<PortalRailLink[]>([]);
readonly secondaryContextLinks = input<PortalRailLink[]>([]);
@@ -55,21 +55,6 @@ export class WorkspaceShellRouteStateService {
readonly showDashboard = computed(() =>
this.startupPreferences.showDashboard()
);
readonly brandLink = computed(() =>
this.startupPreferences.getFirstAvailableWorkspacePath(
this.showDashboard()
)
);
readonly brandTooltipKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.RAIL_DASHBOARD'
: 'WORKSPACE.SHELL.RAIL_SOURCES'
);
readonly brandAriaLabelKey = computed(() =>
this.showDashboard()
? 'WORKSPACE.SHELL.OPEN_DASHBOARD'
: 'WORKSPACE.SHELL.OPEN_SOURCES'
);
readonly workspaceLinks = computed<PortalRailLink[]>(() => {
this.languageTick();
@@ -777,7 +777,6 @@ describe('WorkspaceShellFacade', () => {
exact: true,
},
]);
expect(facade.brandLink()).toBe('/workspace/sources');
});
it('hides the Electron-only global search rail link in the web runtime', () => {
@@ -5,6 +5,7 @@ import { TranslateService } from '@ngx-translate/core';
import { startWith } from 'rxjs';
import {
PORTAL_EXTERNAL_PLAYBACK,
WorkspaceBackNavigationService,
WorkspaceHeaderContextService,
WorkspaceResolvedCommandItem,
} from '@iptvnator/portal/shared/util';
@@ -47,6 +48,7 @@ export class WorkspaceShellFacade {
private readonly header = inject(WorkspaceShellHeaderService);
private readonly xtreamImport = inject(WorkspaceShellXtreamImportService);
readonly headerContext = inject(WorkspaceHeaderContextService);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
private readonly languageTick = toSignal(
this.translate.onLangChange.pipe(startWith(null)),
@@ -84,9 +86,7 @@ export class WorkspaceShellFacade {
readonly currentUrl = this.routeState.currentUrl;
readonly currentRoute = this.routeState.currentRoute;
readonly showDashboard = this.routeState.showDashboard;
readonly brandLink = this.routeState.brandLink;
readonly brandTooltipKey = this.routeState.brandTooltipKey;
readonly brandAriaLabelKey = this.routeState.brandAriaLabelKey;
readonly backTarget = this.backNavigation.target;
readonly currentContext = this.routeState.currentContext;
readonly currentSection = this.routeState.currentSection;
readonly commandPaletteCommands = computed<WorkspaceResolvedCommandItem[]>(
@@ -218,6 +218,10 @@ export class WorkspaceShellFacade {
this.header.toggleLiveSidebar();
}
goBack(): void {
this.backNavigation.goBack();
}
navigateToGlobalFavorites(): void {
this.header.navigateToGlobalFavorites();
}
@@ -14,9 +14,6 @@
<app-workspace-shell-rail
[attr.inert]="contextDrawer.isOpen() ? '' : null"
[isMacOS]="facade.isMacOS"
[brandLink]="facade.brandLink()"
[brandTooltipKey]="facade.brandTooltipKey()"
[brandAriaLabelKey]="facade.brandAriaLabelKey()"
[workspaceLinks]="facade.workspaceLinks()"
[primaryContextLinks]="facade.primaryContextLinks()"
[secondaryContextLinks]="facade.secondaryContextLinks()"
@@ -28,6 +25,7 @@
<app-workspace-shell-header
#workspaceHeader
[attr.inert]="contextDrawer.isOpen() ? '' : null"
[backTarget]="facade.backTarget()"
[playlistTitle]="facade.playlistTitle()"
[playlistSubtitle]="facade.playlistSubtitle()"
[canOpenPlaylistInfo]="facade.canOpenPlaylistInfo()"
@@ -52,6 +50,7 @@
[isContextDrawerOpen]="contextDrawer.isOpen()"
[contextDrawerToggleAriaKey]="facade.contextDrawerLabelKeys().aria"
[contextDrawerTooltipKey]="facade.contextDrawerLabelKeys().tooltip"
(backRequested)="facade.goBack()"
(contextDrawerToggleRequested)="contextDrawer.toggle()"
(refreshPlaylistRequested)="facade.refreshCurrentPlaylist()"
(searchChanged)="facade.onSearchInput($event)"
@@ -33,9 +33,6 @@ import { WorkspaceKeyboardShortcutsService } from '../workspace-keyboard-shortcu
})
class MockWorkspaceShellRailComponent {
readonly isMacOS = input(false);
readonly brandLink = input('/workspace/dashboard');
readonly brandTooltipKey = input('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = input('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly workspaceLinks = input<unknown[]>([]);
readonly primaryContextLinks = input<unknown[]>([]);
readonly secondaryContextLinks = input<unknown[]>([]);
@@ -51,6 +48,7 @@ class MockWorkspaceShellRailComponent {
standalone: true,
})
class MockWorkspaceShellHeaderComponent {
readonly backTarget = input<unknown>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
readonly canOpenPlaylistInfo = input(false);
@@ -75,6 +73,7 @@ class MockWorkspaceShellHeaderComponent {
readonly headerBulkAction = input<WorkspaceHeaderBulkAction | null>(null);
readonly headerSidebarToggle = input<unknown>(null);
readonly parentalLockState = input<'off' | 'locked' | 'unlocked'>('off');
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();
readonly commandPaletteRequested = output<void>();
@@ -152,9 +151,8 @@ class MockWorkspaceKeyboardShortcutsService {
}
class MockWorkspaceShellFacade {
readonly brandLink = signal('/workspace/dashboard');
readonly brandTooltipKey = signal('WORKSPACE.SHELL.RAIL_DASHBOARD');
readonly brandAriaLabelKey = signal('WORKSPACE.SHELL.OPEN_DASHBOARD');
readonly backTarget = signal(null);
goBack = jest.fn();
readonly workspaceLinks = signal([]);
readonly primaryContextLinks = signal([]);
readonly secondaryContextLinks = signal([]);
+5 -2
View File
@@ -108,13 +108,16 @@ export async function dismissDialogs(page: Page): Promise<void> {
/* Navigation moves */
/* ------------------------------------------------------------------ */
/** Returns to the dashboard via the always-visible brand link. */
/** Returns to the dashboard via the rail's always-visible Dashboard link. */
export async function goHome(page: Page): Promise<void> {
if (/\/workspace\/dashboard/.test(page.url())) {
return;
}
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
await page
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await settleUi(page);
}
@@ -38,7 +38,10 @@ export async function openSettings(page: Page): Promise<void> {
}
async function openDashboard(page: Page): Promise<void> {
await page.locator('a.brand[href$="/workspace/dashboard"]').first().click();
await page
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard/, { timeout: 20_000 });
await page
.locator('[data-test-id="dashboard-hero"]')