diff --git a/.changes/workspace-content-corner-tile-memory.md b/.changes/workspace-content-corner-tile-memory.md new file mode 100644 index 000000000..4c345e249 --- /dev/null +++ b/.changes/workspace-content-corner-tile-memory.md @@ -0,0 +1,9 @@ +--- +type: perf +area: workspace +--- + +On Retina displays the dashboard and the movie and series pages no longer run +out of GPU tile memory: the content area's rounded corner is now painted +instead of clipped, so scrolling no longer leaves blank patches behind the +rails and heroes. diff --git a/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts b/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts new file mode 100644 index 000000000..156b51319 --- /dev/null +++ b/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts @@ -0,0 +1,157 @@ +import type { CDPSession, Page } from '@playwright/test'; +import { + addXtreamPortal, + closeElectronApp, + expect, + goToDashboard, + launchElectronApp, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; + +type CompositedLayer = { + backendNodeId?: number; + drawsContent: boolean; + height: number; + layerId: string; + width: number; +}; + +type CompositedLayerWithReasons = CompositedLayer & { + /** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */ + reasons: string[]; +}; + +/** + * The composited layers of the page with their compositing reasons. Blink + * fills the layer debug info (reasons, owner nodes) in its first + * layerization after the domain is enabled, so the snapshot is retried + * until a layer reports a reason. + */ +async function compositedLayers( + cdp: CDPSession, + page: Page +): Promise { + let layers: CompositedLayer[] = []; + const onChange = (event: { layers?: CompositedLayer[] }) => { + layers = event.layers ?? []; + }; + cdp.on('LayerTree.layerTreeDidChange', onChange); + let withReasons: CompositedLayerWithReasons[] = []; + const snapshot = async () => { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + const result: CompositedLayerWithReasons[] = []; + for (const layer of layers) { + let reasons: string[] = []; + try { + const answer = (await cdp.send('LayerTree.compositingReasons', { + layerId: layer.layerId, + })) as { compositingReasonIds?: string[] }; + reasons = answer.compositingReasonIds ?? []; + } catch { + // The layer went away between the snapshot and the query. + } + result.push({ ...layer, reasons }); + } + return result; + }; + try { + await cdp.send('LayerTree.enable'); + await expect + .poll(async () => { + withReasons = await snapshot(); + return withReasons.filter((layer) => layer.reasons.length > 0) + .length; + }) + .toBeGreaterThan(0); + await cdp.send('LayerTree.disable'); + } finally { + cdp.off('LayerTree.layerTreeDidChange', onChange); + } + return withReasons; +} + +test.describe('Dashboard compositing', () => { + // A `border-radius` on the content scroller made Blink clip every + // composited effect inside it (the rail chevrons' and hero controls' + // backdrop filters, running animations) through a synthesized mask layer + // the size of the whole content area: ~24 MB of tile memory each at 2x, + // a dozen on the dashboard, which took the renderer past Chromium's tile + // budget ("tile memory limits exceeded") and left blank tiles while + // scrolling. The corner is painted instead. A mask layer is synthesized, + // not painted for an element: it has no owner node and no compositing + // reason, unlike every content layer (a content layer's owner node can + // also be missing when its first paint chunk belongs to an anonymous + // box, so the node alone does not identify a mask). Blink synthesizes + // them while layerizing, before any GPU work: under `--disable-gpu` (the + // Linux CI launch, software compositing and raster) the controls are + // still composited and the old radius still produced seven masks, so the + // check guards the same path everywhere. + test('does not synthesize content-area-sized clip masks for the composited rail and hero controls', async ({ + dataDir, + request, + }) => { + await resetMockServers(request, ['xtream']); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await goToDashboard(page); + await expect(page.getByTestId('dashboard-hero')).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByTestId('dashboard-xtream-recently-added-rail') + ).toBeVisible({ timeout: 20_000 }); + // The composited effects this guards: every rail has two + // backdrop-filtered chevrons, the hero its controls. + expect(await page.locator('.rail__chev').count()).toBeGreaterThan( + 0 + ); + await expect(page.locator('.hero__controls')).toBeVisible(); + + const content = await page + .locator('main.workspace-content') + .evaluate((element) => ({ + width: element.clientWidth * window.devicePixelRatio, + height: element.clientHeight * window.devicePixelRatio, + })); + const cdp = await app.electronApp.context().newCDPSession(page); + const layers = await compositedLayers(cdp, page); + await cdp.detach(); + + // The effects really are composited here: without this the + // mask check below would pass on a page with nothing to clip. + expect( + layers.filter((layer) => + layer.reasons.includes('BackdropFilter') + ).length + ).toBeGreaterThan(0); + + const contentArea = content.width * content.height; + const clipMasks = layers.filter( + (layer) => + layer.drawsContent && + !layer.backendNodeId && + layer.reasons.length === 0 && + layer.width * layer.height >= contentArea / 2 + ); + expect( + clipMasks.map( + (layer) => + `${layer.layerId}: ${layer.width}x${layer.height}` + ) + ).toEqual([]); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index ed0c64e77..ce5bdf13f 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -1017,6 +1017,10 @@ Avoid these: - making entire panes scroll when only the list should scroll - using dark translucent fills unchanged in light theme - solving cramped sidebars with smaller fonts instead of shorter labels +- rounding a scroller's corners (`border-radius` with `overflow`) when it holds + composited effects such as `backdrop-filter`: each effect gets a clip mask + the size of the scroller; paint the corner instead (see the content surface + corner in the [workspace shell contract](workspace-shell.md#content-surface-corner)) ## Definition Of Done For UI Changes diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 829e6e5f9..88045070a 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -107,6 +107,26 @@ The shell is intentionally split into four persistent regions: 4. Optional footer: 1. External playback session bar when a docked session is visible. +### Content surface corner + +`main.workspace-content` (the scroller) sits in `.workspace-content-frame`. +The frame carries the surface's 16px top-left radius and its shadow and never +clips; the scroller clips to a rectangle and is a stacking context; a 16px +corner piece on the frame paints the notch in the body colour. The radius used +to sit on the scroller, which made Blink clip every composited effect inside it +(the rail chevrons' and hero controls' `backdrop-filter`, running transform +animations, isolated groups) through a mask layer synthesized per effect: its +shader path for rounded clips needs four equal radii on macOS and a +translation-only transform between the clip and the effect, and the fallback +mask is the size of the whole clipped area, about 24 MB of tile memory at 2x +on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB +tile budget (`tile memory limits exceeded`, blank tiles while scrolling). +`apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts` guards it: no +drawing layer without an owner node and without a compositing reason (a +synthesized mask, unlike any content layer) may span half the content area, +and the backdrop-filtered controls must really be composited, so the check +cannot pass on a page with nothing to clip. + `WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is kept as a thin template-facing API and delegates ownership to component-scoped services: diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html index 4506a2c18..3b61bd472 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.html @@ -114,17 +114,22 @@ /> } - -
- -
+ +
+ +
+ +
+
@if (facade.showExternalPlaybackBar()) { diff --git a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss index b95fa4dd2..1f7fa21a2 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss +++ b/libs/workspace/shell/feature/src/lib/workspace-shell/workspace-shell.component.scss @@ -74,13 +74,55 @@ app-workspace-shell-context-sidebar { background: var(--app-content-bg, var(--mat-sys-surface)); } +// The content surface's rounded top-left corner is painted, not clipped. +// +// A `border-radius` on the scroller itself clips every composited layer inside +// it (backdrop filters, running transform animations, isolated groups) through +// a mask layer Blink synthesizes per composited effect: its shader path for +// rounded clips needs four equal radii on macOS and a translation-only +// transform between the clip and the effect, and the fallback mask is the size +// of the whole clipped area (about 24 MB of tile memory at 2x on a 16" display, +// one per rail chevron and hero control). Twelve of them put the dashboard over +// Chromium's 512 MB tile budget ("tile memory limits exceeded", blank tiles +// while scrolling). The frame keeps the radius for the shadow only and never +// clips; the scroller clips to a rectangle; the frame's corner piece covers the +// 16px notch in the body colour. The scroller is a stacking context, so nothing +// scrolled under the corner can paint over the piece. +.workspace-content-frame { + --workspace-content-radius: 16px; + + position: relative; + display: grid; + min-width: 0; + min-height: 0; + border-radius: var(--workspace-content-radius) 0 0 0; + box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18)); + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + z-index: 1; + width: var(--workspace-content-radius); + height: var(--workspace-content-radius); + border-top-left-radius: var(--workspace-content-radius); + // The body colour outside the quarter circle: a transparent rounded + // box's spread shadow, clipped to the box. + box-shadow: 0 0 0 var(--workspace-content-radius) + var(--app-rail-bg, var(--mat-sys-surface-container-low)); + clip-path: inset(0); + pointer-events: none; + } +} + .workspace-content { + position: relative; + z-index: 0; min-width: 0; min-height: 0; overflow: auto; background: var(--app-content-bg, var(--mat-sys-surface)); - border-radius: 16px 0 0 0; - box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18)); } @media (max-width: 780px) { @@ -134,9 +176,13 @@ app-workspace-shell-context-sidebar { background: rgba(0, 0, 0, 0.4); } - .workspace-content { + .workspace-content-frame { border-radius: 0; box-shadow: none; + + &::before { + display: none; + } } .workspace-now-playing {