fix(detail): keep the Back button from covering detail content

The shell's sticky Back control floated over its scroll owner: it covered
the start of the "Seasons and Episodes" heading and the inline player's
top-left corner. While Back is shown, every content column (hero, player,
episodes, About, extras) now starts after a 72px lane (16 + 40 + 16),
published as --detail-back-lane so content-hero and content-about honour
it too; hosts without Back (M3U) keep their inset. At the 640px phone
breakpoint a column that wide would clip the player controls, so there
the wrapper becomes an in-flow 56px sticky bar on --app-header-bg.

The season header now wraps its actions onto their own row before the
heading wraps (the pane, not the viewport, decides), and the narrow
inline player's title ellipsizes inside its card instead of spilling past
both edges.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-09-30 08:38:36 +02:00
1 parent 388fa9e29d
commit 377b22124b
11 files changed
+278 -7

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 the heading stays on one line in narrow windows.
@@ -0,0 +1,183 @@
import type { Locator, Page } from '@playwright/test';
import {
addXtreamPortal,
clickFirstGridListCard,
closeElectronApp,
expect,
launchElectronApp,
resetMockServers,
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.
// ---------------------------------------------------------------------------
const widths = [1280, 780];
const playerCorner = 56;
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);
}
/** 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 and the player's fade-in settle. Bounded, so a
* paused animation elsewhere in the player cannot stall the test.
*/
async function settle(shell: Locator): Promise<void> {
await shell.evaluate((element) =>
Promise.race([
Promise.all(
element
.getAnimations({ subtree: true })
.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 });
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);
}
}
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 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 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');
} 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();
@@ -627,6 +627,11 @@ 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.
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,18 @@ 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. The phone breakpoint (640 px) is the exception: a 72 px column
would clip the player's controls, so there the wrapper becomes an in-flow 56 px
sticky bar on `--app-header-bg` that content scrolls beneath. Electron E2E
(`detail-back-lane.e2e.ts`) sweeps both states at 1280 and 780 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
@@ -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,39 @@
@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 phone cannot spare a 72px column: the player's controls would clip and
// the hero actions would wrap. There the control takes a row instead — an
// opaque sticky bar in flow that content scrolls beneath, never alongside.
@media (max-width: 640px) {
:host(.shell-host--back) {
--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;
}
}
@@ -61,6 +61,8 @@ 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)',
},
})
@@ -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 {