Merge remote-tracking branch 'origin/master' into claude/interesting-lederberg-58280b

This commit is contained in:
4gray committed 2026-10-01 06:29:38 +02:00
commit 001262d12d
18 files changed
+699 -12

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
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.
@@ -0,0 +1,7 @@
---
type: fix
area: portals
---
Switching to another playlist's copy of a movie no longer warns that the dub
may differ when one copy's audio language is simply unknown (`und`).
@@ -0,0 +1,336 @@
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);
}
});
});
@@ -416,6 +416,13 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
.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.
expect(
await shell
.locator('.hero__content')
.evaluate((el) => getComputedStyle(el).paddingInlineStart)
).toBe('40px');
await shell.focus();
await page.keyboard.press('Escape');
await expect(playButton).toBeVisible();
@@ -87,3 +87,99 @@ describe('Playwright mock-server launch', () => {
}
);
});
describe('Nx E2E task dependencies', () => {
type Dependency =
| string
| {
projects?: string | string[];
dependencies?: boolean;
target?: string;
};
type TargetConfig = {
dependsOn?: Dependency[];
continuous?: boolean;
filter?: { projects?: string[] };
};
type ProjectJson = { targets: Record<string, TargetConfig> };
const readJson = <T>(path: string) =>
JSON.parse(readFileSync(join(process.cwd(), path), 'utf8')) as T;
const nxJson = readJson<{
targetDefaults: Record<string, TargetConfig | TargetConfig[]>;
}>('nx.json');
const appNames = readdirSync(join(process.cwd(), 'apps'));
const e2eProjectFiles = appNames
.filter((name) => name.endsWith('-e2e'))
.map((name) => `apps/${name}/project.json`);
// The E2E projects list the mocks as implicit dependencies, so a `^serve`
// dependency schedules the mock serve tasks as well.
const mockContinuousTargets = new Set(
appNames
.filter((name) => name.endsWith('-mock-server'))
.flatMap((name) =>
Object.entries(
readJson<ProjectJson>(`apps/${name}/project.json`).targets
)
.filter(([, target]) => target.continuous)
.map(([targetName]) => targetName)
)
);
const dependsOnMock = (dependency: Dependency) =>
typeof dependency === 'string'
? dependency.includes('-mock-server:') ||
(dependency.startsWith('^') &&
mockContinuousTargets.has(dependency.slice(1)))
: [dependency.projects ?? []]
.flat()
.some((project) => project.includes('-mock-server')) ||
(dependency.dependencies === true &&
mockContinuousTargets.has(dependency.target ?? ''));
const perFileDefaultFor = (project: string) =>
[nxJson.targetDefaults['e2e-ci--src/*.e2e.ts'] ?? []]
.flat()
.find((entry) => entry.filter?.projects?.includes(project));
// The Playwright configs start the mocks themselves, so @nx/playwright
// infers their E2E targets as non-parallel, and Nx refuses to run a
// non-parallel task that depends on a continuous `serve` task.
it('never makes an E2E target depend on a mock-server task', () => {
const e2eDefaults = Object.entries(nxJson.targetDefaults)
.filter(([targetName]) => targetName.startsWith('e2e'))
.flatMap(([targetName, config]) =>
[config].flat().map((entry) => ({
source: `nx.json ${targetName}`,
config: entry,
}))
);
const e2eProjectTargets = e2eProjectFiles.flatMap((path) =>
Object.entries(readJson<ProjectJson>(path).targets).map(
([targetName, config]) => ({
source: `${path} ${targetName}`,
config,
})
)
);
const mockDependencies = [...e2eDefaults, ...e2eProjectTargets]
.map(({ source, config }) => ({
source,
dependencies: (config.dependsOn ?? []).filter(dependsOnMock),
}))
.filter(({ dependencies }) => dependencies.length > 0);
expect(e2eProjectFiles).toEqual(
expect.arrayContaining([
'apps/electron-backend-e2e/project.json',
'apps/web-e2e/project.json',
])
);
expect([...mockContinuousTargets]).toContain('serve');
expect(mockDependencies).toEqual([]);
});
it('builds the Electron app only before Electron per-file E2E targets', () => {
expect(perFileDefaultFor('electron-backend-e2e')?.dependsOn).toEqual([
{ projects: ['electron-backend'], target: 'build-e2e' },
]);
expect(perFileDefaultFor('web-e2e')?.dependsOn).toEqual([]);
});
});
@@ -627,6 +627,13 @@ and `--app-selection-color`; hover uses the app's neutral surface treatment.
Keep these treatments in the shared season components and detail-action
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
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.
Browser regression coverage measures the composited neutral edges and selected
toggle fill, in addition to capturing light/dark grid and list screenshots.
Hero action edges use matching pixels from rendered screenshots with the border
@@ -30,6 +30,22 @@ 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 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`
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
+5
View File
@@ -776,6 +776,11 @@ tag a language at all, so in practice it is usually silent — which is the
honest state, and the same one the rest of this feature takes when it does not
know.
Undetermined language tags (`und`, including regional forms such as `und-US`)
are explicitly treated as unknown after normalization. This must hold whether
the runtime's `Intl.Locale` returns no language or preserves `und`: neither
case establishes a factual dub difference against a known language.
Switching sources through `startResolvedPlayback` closes the external session
it LAUNCHED first — tracked separately so refreshes and overlapping handoffs
cannot disown it within the same route session. The retained identity is ignored
+5 -1
View File
@@ -579,7 +579,11 @@ for the serve target's `--tsconfig` flag and is required for the `@iptvnator/*`
path aliases. `project-config.spec.ts` pins which configs start which mock,
and fails if any of them launches a mock through Nx or a new config starts one
without being listed there. The `serve` targets remain the entry point
for starting a mock by hand. The web-e2e `web-backend` entry uses the same
for starting a mock by hand, and no E2E target may depend on them: with no
Nx-launched server, `@nx/playwright` infers the atomized `e2e-ci--*` targets
as non-parallel, and Nx refuses to run a non-parallel task that depends on a
continuous `serve` task. The same spec guards the `e2e*` entries of `nx.json`
`targetDefaults` and every target in the `apps/*-e2e` `project.json` files. The web-e2e `web-backend` entry uses the same
launch form; see [PWA web backend](pwa-self-hosted.md#web-backend).
Playwright, not Nx, starts every E2E server, including the web dev server.
@@ -180,8 +180,10 @@ describe('applyApiMetadata', () => {
});
it.each([
// ffprobe's marker for "we do not know".
// ffprobe's marker for "we do not know", regardless of whether
// this runtime's Intl.Locale preserves the language subtag.
['und'],
['und-US'],
['Russian'],
['en_US'],
[''],
@@ -360,6 +362,45 @@ describe('audioDiffersFactually', () => {
expect(audioDiffersFactually(from, to)).toBe(false);
});
it('stays silent when one side reports an undetermined language', () => {
// `und` is ffprobe admitting it does not know; measured against a
// stated English it is not a dub change.
const from = candidate(
applyApiMetadata(candidate(), { audioLanguage: 'und' })
);
const to = candidate(
applyApiMetadata(candidate(), { audioLanguage: 'eng' })
);
expect(audioDiffersFactually(from, to)).toBe(false);
});
it.each(['und', 'und-US', 'UND'])(
'stays silent for %s when Intl.Locale preserves und',
(raw) => {
const known = applyApiMetadata(candidate(), {
audioLanguage: 'eng',
});
// Exercise the newer runtime behavior even on Node 22. The
// captured Locale constructor still reads this prototype getter.
const language = jest
.spyOn(Intl.Locale.prototype, 'language', 'get')
.mockReturnValue('und');
try {
const unknown = applyApiMetadata(candidate(), {
audioLanguage: raw,
});
expect(unknown.audioLanguage).toBeUndefined();
expect(audioDiffersFactually(unknown, known)).toBe(false);
expect(audioDiffersFactually(known, unknown)).toBe(false);
} finally {
language.mockRestore();
}
}
);
it('stays silent when both sides state the same track', () => {
const from = candidate({
audioLanguage: { value: 'rus', provenance: 'api' },
@@ -217,8 +217,11 @@ function keepFactual(field?: VodSourceField): VodSourceField | undefined {
*
* `Intl.Locale` does the canonicalizing: 639-2 collapses to 639-1 where one
* exists, both German forms land on `de`, and regions drop away. `und` —
* ffprobe's marker for undetermined — canonicalizes to nothing, which is
* exactly right: it means the provider does not know either.
* ffprobe's marker for undetermined — is declined explicitly: it means the
* provider does not know either. Some runtimes return no language for this
* tag; others (including Electron 43 and Node 26) preserve `und`. Declining
* it in either case prevents "unknown vs. English" from becoming a dub
* change, independently of the runtime's Intl implementation.
*
* Anything that survives with more than three characters is not a language
* code (`Russian` parses as the subtag `russian`), so the comparison is
@@ -232,7 +235,11 @@ function canonicalLanguage(raw: string | null | undefined): string | null {
try {
const language = new LocaleCtor(value).language;
return language && language.length <= 3 ? language : null;
return language &&
language.length <= 3 &&
language !== UNDETERMINED_LANGUAGE
? language
: null;
} catch {
// Not a well-formed tag at all; saying nothing beats comparing junk.
return null;
@@ -253,6 +260,9 @@ const LocaleCtor = (
}
).Locale;
/** ISO 639-2 "undetermined"; see `canonicalLanguage`. */
const UNDETERMINED_LANGUAGE = 'und';
function cleanString(raw: string | null | undefined): string | null {
const trimmed = typeof raw === 'string' ? raw.trim() : '';
return trimmed === '' ? null : trimmed;
@@ -144,6 +144,8 @@
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;
@@ -511,6 +513,7 @@
&__content {
padding: 60px 20px 40px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
gap: 16px;
}
}
@@ -552,6 +555,7 @@
flex-direction: column;
align-items: stretch;
padding-inline: 16px;
padding-inline-start: max(16px, var(--detail-back-lane, 0px));
}
.poster {
@@ -6,6 +6,8 @@
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 {
@@ -127,6 +129,7 @@
@media (max-width: 800px) {
.about {
padding: 20px 20px 32px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
&__poster {
width: 92px;
@@ -13,6 +13,13 @@
--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%;
@@ -30,6 +37,12 @@
}
}
: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;
@@ -43,7 +56,7 @@
.shell__navigation {
// Direct child of the scroll owner: sticky lasts through the entire page.
position: sticky;
top: 16px;
top: var(--shell-back-inset);
height: 0;
z-index: 10;
pointer-events: none;
@@ -51,15 +64,15 @@
.shell__back-button {
position: absolute;
left: 16px;
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: 40px;
height: 40px;
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));
@@ -122,6 +135,7 @@
&--active {
padding: 16px 40px 0;
padding-inline-start: max(40px, var(--detail-back-lane));
animation: shell-fade-in 300ms ease 50ms both;
}
}
@@ -130,6 +144,7 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 40px;
padding-inline-start: max(40px, var(--detail-back-lane));
}
.shell__about {
@@ -143,6 +158,7 @@
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 {
@@ -159,14 +175,38 @@
@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;
}
}
@@ -451,3 +451,67 @@ 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,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
Injector,
computed,
@@ -23,6 +24,14 @@ 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.
*
@@ -61,12 +70,15 @@ import {
// 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');
@@ -108,6 +120,7 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
this.observeCompactWidth();
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -241,4 +254,24 @@ 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());
}
}
@@ -4,6 +4,10 @@
.section-header {
display: flex;
// The actions move onto their own row before the title has to wrap. The
// pane is narrower than the window (context panel, Back lane), so a
// viewport breakpoint cannot tell when the two stop fitting side by side.
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
@@ -14,14 +18,16 @@
display: flex;
align-items: center;
gap: 16px;
flex: 1;
flex: 1 1 auto;
}
// Beside the title the header's space-between keeps the actions at the
// end; once they move to their own row they start under the title.
&__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
gap: 8px;
}
@@ -117,7 +123,6 @@
@media (max-width: 560px) {
.section-header {
align-items: flex-start;
flex-wrap: wrap;
&__actions {
width: 100%;
@@ -203,6 +203,9 @@
.player-shell__title-row {
flex-direction: column;
// Centered, the nowrap heading took its full text width and spilled
// past both card edges; stretched, the title ellipsizes in the card.
align-items: stretch;
}
.player-shell__actions {