mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 05:06:16 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-bug-1894-041e7d
This commit is contained in:
commit
5bd1812cba
17 files changed
+587
-20
No files matched your search
@@ -0,0 +1,9 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
Opening a source from the dashboard no longer makes the page jump: the content
|
||||
slides aside for the category panel, and the header controls slide over for
|
||||
the Back button. The category list's search and sort buttons are in place
|
||||
while the list loads.
|
||||
@@ -1288,15 +1288,49 @@ measured iterations of the 11 `master` runs from 2026-10-03 08:25 to
|
||||
| `launch/renderer.layoutShiftScoreSettled` | 0 | 0.235 in some iterations of 8 runs up to 2026-10-02 (hero flicker, #1782) |
|
||||
| `open-source/main.mockHttpRequestsToSettled` | 1 | identical |
|
||||
| `open-source/renderer.ipcCallsToFirstPage` | 17 | identical |
|
||||
| `open-source/renderer.layoutShiftScore` | 0.233 | 0.221, then 0.222; 0.233 since #1814, never mixed within a run |
|
||||
| `open-source/renderer.layoutShiftScore` | 0 | 0.221, then 0.222; 0.233 since #1814; 0 since #1895 (see below) |
|
||||
| `playback/renderer.httpRequestsToPlaying` | 2 | identical |
|
||||
| `playback/renderer.layoutShiftScore` | 0.001 | identical |
|
||||
|
||||
`open-source/renderer.layoutShiftScore` read 0.222 in that window and 0.233
|
||||
in every iteration of every `master` run from 84aef83a6 (#1814, page Back
|
||||
buttons moved into the header) on, so its value is 0.233 with `evidenceRun`
|
||||
37372780064 (b78224376). The 0.011 that #1814 added is not explained yet;
|
||||
lowering it back is a separate change.
|
||||
buttons moved into the header) on, so its value was 0.233 with `evidenceRun`
|
||||
37372780064 (b78224376). Since #1895 it is 0 (`evidenceRun` 38147072417:
|
||||
all 5 measured iterations on the runner read 0 with no shift recorded).
|
||||
|
||||
Where the 0.233 came from (`evidence.layoutShift.shifts`, a 1280×720 window;
|
||||
the score of a shift is the area of every node that moved times the largest
|
||||
distance any of them moved, as a fraction of the viewport):
|
||||
|
||||
| Shift, ms after the click | Nodes that moved | Score |
|
||||
| ------------------------- | ---------------------------------------------------------- | ------ |
|
||||
| ~40 (page swap) | `main.workspace-content` x 60 → 383, width −323 | 0.222 |
|
||||
| | switcher, `label.search-field`, `div.header-actions` x +44 | 0.011 |
|
||||
| ~50-70 (categories) | `div.context-header__actions` x −60, width +60 | 0.0001 |
|
||||
|
||||
The first two are one frame: the frame that replaces the dashboard with the
|
||||
portal page also inserts the 323 px context panel the dashboard has no room
|
||||
for, and the history Back that #1814 shows once an in-app previous entry
|
||||
exists. The header items move only 44 px, but a shift is scored with the
|
||||
largest distance in it, the content pane's 323 px, which is why Back added
|
||||
0.011 rather than about 0.0015. The third is the category search and sort
|
||||
buttons appearing when the categories arrive. The skeletons of the category
|
||||
list and the grid are replaced by content without any shift. Nothing was
|
||||
hidden behind the startup overlay: the "Syncing playlist" overlay of the
|
||||
cached catalog load flashes for about two frames after the shift and covers
|
||||
no further one.
|
||||
|
||||
The content frame and the header controls now slide into place instead of
|
||||
jumping ([Layout slides](./workspace-shell.md#layout-slides)): the new layout
|
||||
applies in the same frame and only a transform animates, which Chromium does
|
||||
not record as a layout shift. The category search and sort buttons render
|
||||
disabled while the categories load. Locally (macOS, 2026-10-11) and on the runner
|
||||
J2 then read 0 in every iteration with no shift recorded, J1 0 / 0, J3 0.001
|
||||
and J4 0;
|
||||
`renderer.domMutationsToFirstPage` rose by 22 (the slide's class and style
|
||||
changes) and the wall-clock entries did not move. Under
|
||||
`prefers-reduced-motion` there is no slide, so a user with that preference
|
||||
still sees the jump that the journey no longer measures.
|
||||
|
||||
Being deterministic is not the same as being validated. Principle 3 of the
|
||||
plan promotes a counter to a guardrail once a PR has shown that lowering it
|
||||
|
||||
@@ -127,6 +127,57 @@ 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.
|
||||
|
||||
### Layout slides
|
||||
|
||||
When a route change adds or removes the context panel, the content frame
|
||||
moves by the panel's width (323 px on desktop), and when Back or the phone
|
||||
drawer toggle appears or leaves, the header controls after it move by the
|
||||
slot's width (44 px). Both happen in the frame that swaps the page, so they
|
||||
used to be one jump: a layout shift of 0.233 in the open-source journey,
|
||||
0.222 of it the content frame (see
|
||||
[performance journeys](./performance-journeys.md#j2-open-source-open-a-source-to-a-browsable-list)).
|
||||
|
||||
`LayoutSlideDirective` (`workspace-shell/layout-slide.directive.ts`) turns
|
||||
these jumps into a 180 ms slide, the duration of the context panel's own
|
||||
width transition. The new layout applies at once; each target starts
|
||||
translated back to the position it had and animates its transform to none
|
||||
(FLIP), so nothing waits for the animation and Chromium records no layout
|
||||
shift for a transform. The shell's `.workspace-body` slides
|
||||
`.workspace-content-frame` when `showContextPanel` changes; the header slides
|
||||
the drawer toggle, switcher, search and action groups when its leading
|
||||
navigation (Back, drawer toggle) changes.
|
||||
|
||||
1. **Previous positions.** `offsetLeft` values, which ignore transforms, are
|
||||
recorded after every render in which the slide key or the record key
|
||||
changed (the header's record key covers the other inputs that move its
|
||||
controls: settings route, no playlists, parental lock, shortcuts) and
|
||||
when the host or a target resizes (window resize, a context panel folding
|
||||
or being dragged wider). A slide therefore starts where the target was
|
||||
last laid out, and a target that has no recorded position or did not move
|
||||
is left alone.
|
||||
2. **The uncovered strip.** The frame is narrower beside the panel than
|
||||
without it, so the side it moves away from would show the body colour
|
||||
during the slide. While a slide runs the target carries
|
||||
`layout-slide-active` and the uncovered widths as `--layout-slide-start`
|
||||
and `--layout-slide-end`; the frame's stylesheet extends the content
|
||||
surface over that strip with an `::after` below the scroller.
|
||||
3. **Back fades in** over the same 180 ms, so the switcher still sliding away
|
||||
from its slot does not draw over the arrow.
|
||||
4. **Reduced motion.** Under `prefers-reduced-motion` there is no animation:
|
||||
the jump (and its layout shift) remains.
|
||||
5. **Compositing.** While it runs, each slid target is its own composited
|
||||
layer (`ActiveTransformAnimation`): the frame at its width plus the
|
||||
covered strip (1220×664 CSS pixels at 1280×720), the header controls a
|
||||
few small ones. No synthesized clip mask appears, because no ancestor of
|
||||
the frame clips with a radius ([content surface
|
||||
corner](#content-surface-corner)), and the layers go away when the slide
|
||||
ends: `perf:compositing` reads the same layer and mask counts on every
|
||||
route as before the slides (#1895).
|
||||
6. **Not covered.** A slide does not change what the route renders. Going
|
||||
back from a portal to the dashboard still shows one frame with the new
|
||||
dashboard below the old page before the old page leaves; that jump is a
|
||||
routing issue, not a layout slide.
|
||||
|
||||
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
|
||||
kept as a thin template-facing API and delegates ownership to component-scoped
|
||||
services:
|
||||
@@ -215,9 +266,10 @@ return logic (`backClicked`).
|
||||
|
||||
When there is nowhere to go, the slot is empty rather than a disabled arrow.
|
||||
Sessions often start on a page that never navigates (an M3U playlist or live
|
||||
TV), where a disabled arrow would stay for the whole session. The cost is one
|
||||
shift of the switcher and search when Back first appears or leaves, which
|
||||
happens only at the start of the history and together with a route change.
|
||||
TV), where a disabled arrow would stay for the whole session. When Back first
|
||||
appears or leaves, which happens only at the start of the history and together
|
||||
with a route change, the switcher and search slide by the slot's width instead
|
||||
of jumping (see [Layout slides](#layout-slides)).
|
||||
There is no Forward button: Stalker inline details are store state, not
|
||||
history entries, so Forward would skip them.
|
||||
|
||||
@@ -263,7 +315,10 @@ Xtream and Stalker category panels preserve provider/server category order by
|
||||
default. The panel header exposes a sort menu next to category search with
|
||||
`Server sorting`, `A-Z`, and `Z-A`; when alphabetical sorting is active,
|
||||
synthetic "all categories" entries stay pinned before sorted provider
|
||||
categories.
|
||||
categories. Search and sort render disabled while the categories load (beside
|
||||
the disabled Manage categories button), so the header row has its final width
|
||||
under the skeleton instead of growing by two buttons when the list arrives;
|
||||
they are hidden only once a load ended with no categories.
|
||||
|
||||
On live sections the category panel can be folded away independently of the
|
||||
channels list; the folded panel is reachable as a popover from the channels
|
||||
|
||||
+6
-2
@@ -3,12 +3,15 @@
|
||||
<div class="context-header__top">
|
||||
<h2>{{ title() | translate }}</h2>
|
||||
<div class="context-header__actions">
|
||||
@if (canSearchCategories()) {
|
||||
<!-- Rendered disabled while the categories load, so the
|
||||
row has its final width before they arrive. -->
|
||||
@if (showCategoryListActions()) {
|
||||
<button
|
||||
#firstHeaderAction
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
[disabled]="!canSearchCategories()"
|
||||
[class.context-header__action--active]="isSearchOpen()"
|
||||
(click)="toggleCategorySearch()"
|
||||
[matTooltip]="'WORKSPACE.CONTEXT.SEARCH' | translate"
|
||||
@@ -19,11 +22,12 @@
|
||||
<mat-icon>search</mat-icon>
|
||||
</button>
|
||||
}
|
||||
@if (canSearchCategories()) {
|
||||
@if (showCategoryListActions()) {
|
||||
<button
|
||||
type="button"
|
||||
mat-icon-button
|
||||
class="context-header__action"
|
||||
[disabled]="!canSearchCategories()"
|
||||
[class.context-header__action--active]="
|
||||
categorySortMode() !== 'server'
|
||||
"
|
||||
|
||||
+29
@@ -240,6 +240,35 @@ describe('WorkspaceContextPanelComponent', () => {
|
||||
expect(router.navigate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders the category search and sort actions disabled while the categories load', () => {
|
||||
// Present from the skeleton on, so the header row keeps its width
|
||||
// when the categories arrive instead of growing by two buttons.
|
||||
const loaded = xtreamCategories();
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamCategories.set([]);
|
||||
xtreamIsLoadingCategories.set(true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const actions = () =>
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll(
|
||||
'.context-header__action'
|
||||
)
|
||||
) as HTMLButtonElement[];
|
||||
expect(actions()).toHaveLength(3);
|
||||
expect(actions().every((button) => button.disabled)).toBe(true);
|
||||
expect(fixture.componentInstance.focusTarget()).toBeNull();
|
||||
|
||||
xtreamCategories.set(loaded);
|
||||
xtreamIsLoadingCategories.set(false);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(actions()).toHaveLength(3);
|
||||
expect(actions()[0].disabled).toBe(false);
|
||||
expect(actions()[1].disabled).toBe(false);
|
||||
expect(fixture.componentInstance.focusTarget()).toBe(actions()[0]);
|
||||
});
|
||||
|
||||
it('keeps local xtream loading states quiet when no import is running', () => {
|
||||
fixture.componentRef.setInput('section', 'vod');
|
||||
xtreamIsImporting.set(false);
|
||||
|
||||
+14
-5
@@ -384,6 +384,13 @@ export class WorkspaceContextPanelComponent {
|
||||
(this.isXtreamCategories() && this.xtreamCategories().length > 0) ||
|
||||
(this.isStalkerCategories() && this.stalkerCategories().length > 0)
|
||||
);
|
||||
/** Search and sort show while the list loads (disabled) or has items. */
|
||||
readonly showCategoryListActions = computed(
|
||||
() =>
|
||||
this.canSearchCategories() ||
|
||||
this.isXtreamCategoryLoading() ||
|
||||
(this.isStalkerCategories() && this.isStalkerCategoryLoading())
|
||||
);
|
||||
|
||||
readonly filteredXtreamCategories = computed(() => {
|
||||
this.languageTick();
|
||||
@@ -472,15 +479,17 @@ export class WorkspaceContextPanelComponent {
|
||||
* The control the shell sidebar hands focus to once this rail has
|
||||
* unfolded (the button the user activated to bring it back is gone by
|
||||
* then): the hide chevron when it is offered, else the first header
|
||||
* action. `null` while neither is rendered — categories still loading,
|
||||
* or a failed Stalker load — and the sidebar falls back to the rail
|
||||
* itself.
|
||||
* action. `null` while neither is usable — categories still loading
|
||||
* (the action is rendered but disabled), or a failed Stalker load — and
|
||||
* the sidebar falls back to the rail itself.
|
||||
*/
|
||||
focusTarget(): HTMLElement | null {
|
||||
const firstAction = this.firstHeaderAction()?.nativeElement;
|
||||
return (
|
||||
this.hideCategoriesButton()?.nativeElement ??
|
||||
this.firstHeaderAction()?.nativeElement ??
|
||||
null
|
||||
(firstAction && !firstAction.hasAttribute('disabled')
|
||||
? firstAction
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -1,4 +1,10 @@
|
||||
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
|
||||
<header
|
||||
class="workspace-header"
|
||||
[class.is-settings-route]="isSettingsRoute()"
|
||||
[appLayoutSlide]="leadingNavigationKey()"
|
||||
[layoutSlideRecordOn]="controlsLayoutKey()"
|
||||
layoutSlideTargets=".context-drawer-toggle, .playlist-switcher, .search-field, .header-actions"
|
||||
>
|
||||
<!-- The page's Back, or browser history while an in-app previous page
|
||||
exists, at the leading edge where desktop apps and Material's top
|
||||
app bar keep navigation (beside the macOS traffic lights). The
|
||||
|
||||
+13
@@ -32,6 +32,19 @@
|
||||
flex-shrink: 0;
|
||||
margin-inline-start: -8px;
|
||||
app-region: no-drag;
|
||||
// Fades in while the controls after it slide aside (LayoutSlideDirective)
|
||||
// instead of showing under the switcher that is still moving away.
|
||||
animation: header-back-enter 180ms ease;
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes header-back-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Above the phone breakpoint the context panel is always visible beside the
|
||||
|
||||
+2
@@ -20,6 +20,7 @@ import {
|
||||
WorkspaceBackTarget,
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
|
||||
import { LayoutSlideDirective } from '../../layout-slide.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-playlist-switcher',
|
||||
@@ -67,6 +68,7 @@ describe('WorkspaceShellHeaderComponent', () => {
|
||||
.overrideComponent(WorkspaceShellHeaderComponent, {
|
||||
set: {
|
||||
imports: [
|
||||
LayoutSlideDirective,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
|
||||
+15
@@ -18,10 +18,12 @@ import {
|
||||
} from '@iptvnator/portal/shared/util';
|
||||
import { PlaylistMeta } from '@iptvnator/shared/interfaces';
|
||||
import { WorkspaceHeaderSidebarToggle } from '../../services/helpers/workspace-shell-constants';
|
||||
import { LayoutSlideDirective } from '../../layout-slide.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-workspace-shell-header',
|
||||
imports: [
|
||||
LayoutSlideDirective,
|
||||
MatIcon,
|
||||
MatIconButton,
|
||||
MatTooltip,
|
||||
@@ -119,6 +121,19 @@ export class WorkspaceShellHeaderComponent {
|
||||
readonly backYieldsToDrawerToggle = computed(
|
||||
() => this.showDrawerToggle() && this.backPhoneSlot() === 'yield'
|
||||
);
|
||||
/**
|
||||
* The leading navigation: when Back or the drawer toggle appears or
|
||||
* leaves, the controls after it slide instead of jumping.
|
||||
*/
|
||||
readonly leadingNavigationKey = computed(
|
||||
() =>
|
||||
`${this.backTarget() !== null}|${this.showDrawerToggle()}|${this.backYieldsToDrawerToggle()}`
|
||||
);
|
||||
/** Everything else that moves the controls; recorded, not slid. */
|
||||
readonly controlsLayoutKey = computed(
|
||||
() =>
|
||||
`${this.isSettingsRoute()}|${this.hasNoPlaylists()}|${this.isElectron()}|${this.parentalLockState()}|${this.headerSidebarToggle() !== null}|${this.headerShortcut() !== null}`
|
||||
);
|
||||
|
||||
readonly backRequested = output<void>();
|
||||
readonly searchChanged = output<string>();
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import {
|
||||
LAYOUT_SLIDE_ACTIVE_CLASS,
|
||||
LAYOUT_SLIDE_DURATION_MS,
|
||||
LayoutSlideDirective,
|
||||
translateX,
|
||||
} from './layout-slide.directive';
|
||||
|
||||
@Component({
|
||||
imports: [LayoutSlideDirective],
|
||||
template: `
|
||||
<div
|
||||
[appLayoutSlide]="slideKey()"
|
||||
[layoutSlideRecordOn]="recordKey()"
|
||||
layoutSlideTargets=".target"
|
||||
>
|
||||
<span class="target"></span>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
class HostComponent {
|
||||
readonly slideKey = signal('panel');
|
||||
readonly recordKey = signal(0);
|
||||
}
|
||||
|
||||
interface FakeAnimation {
|
||||
readonly keyframes: Keyframe[];
|
||||
readonly options: KeyframeAnimationOptions;
|
||||
cancelled: boolean;
|
||||
finish(): void;
|
||||
}
|
||||
|
||||
describe('LayoutSlideDirective', () => {
|
||||
let fixture: ComponentFixture<HostComponent>;
|
||||
let target: HTMLElement;
|
||||
let left: number;
|
||||
let animations: FakeAnimation[];
|
||||
let reducedMotion: boolean;
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
beforeEach(() => {
|
||||
left = 60;
|
||||
animations = [];
|
||||
reducedMotion = false;
|
||||
window.matchMedia = ((query: string) => ({
|
||||
matches: reducedMotion && query.includes('reduce'),
|
||||
})) as unknown as typeof window.matchMedia;
|
||||
|
||||
fixture = TestBed.createComponent(HostComponent);
|
||||
target = fixture.nativeElement.querySelector('.target');
|
||||
Object.defineProperty(target, 'offsetLeft', {
|
||||
configurable: true,
|
||||
get: () => left,
|
||||
});
|
||||
target.animate = ((
|
||||
keyframes: Keyframe[],
|
||||
options: KeyframeAnimationOptions
|
||||
) => {
|
||||
const listeners: (() => void)[] = [];
|
||||
const animation = {
|
||||
keyframes,
|
||||
options,
|
||||
cancelled: false,
|
||||
addEventListener: (_type: string, listener: () => void) =>
|
||||
listeners.push(listener),
|
||||
finish: () => listeners.forEach((listener) => listener()),
|
||||
cancel: () => {
|
||||
animation.cancelled = true;
|
||||
animation.finish();
|
||||
},
|
||||
};
|
||||
animations.push(animation);
|
||||
return animation;
|
||||
}) as unknown as HTMLElement['animate'];
|
||||
render();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
});
|
||||
|
||||
function render(): void {
|
||||
fixture.detectChanges();
|
||||
TestBed.tick();
|
||||
}
|
||||
|
||||
it('does not animate the first render', () => {
|
||||
expect(animations).toEqual([]);
|
||||
});
|
||||
|
||||
it('slides a target from its previous position when the key changes', () => {
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
|
||||
expect(animations).toHaveLength(1);
|
||||
expect(animations[0].keyframes).toEqual([
|
||||
{ transform: 'translateX(-323px)' },
|
||||
{ transform: 'none' },
|
||||
]);
|
||||
expect(animations[0].options.duration).toBe(LAYOUT_SLIDE_DURATION_MS);
|
||||
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
|
||||
'0px'
|
||||
);
|
||||
expect(target.style.getPropertyValue('--layout-slide-end')).toBe(
|
||||
'323px'
|
||||
);
|
||||
|
||||
animations[0].finish();
|
||||
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
expect(target.style.getPropertyValue('--layout-slide-end')).toBe('');
|
||||
});
|
||||
|
||||
it('records moves of other changes instead of sliding them', () => {
|
||||
left = 120;
|
||||
fixture.componentInstance.recordKey.set(1);
|
||||
render();
|
||||
expect(animations).toEqual([]);
|
||||
|
||||
left = 80;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
expect(animations[0].keyframes[0]).toEqual({
|
||||
transform: 'translateX(40px)',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a finished slide from clearing a newer one', () => {
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
left = 60;
|
||||
fixture.componentInstance.slideKey.set('panel');
|
||||
render();
|
||||
|
||||
animations[0].finish();
|
||||
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
expect(target.style.getPropertyValue('--layout-slide-start')).toBe(
|
||||
'323px'
|
||||
);
|
||||
});
|
||||
|
||||
it('starts an interrupted slide from where the target is drawn', () => {
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
// 40 ms in, the first slide draws the target at 172 px.
|
||||
target.style.transform = 'matrix(1, 0, 0, 1, -211, 0)';
|
||||
left = 60;
|
||||
fixture.componentInstance.slideKey.set('panel');
|
||||
render();
|
||||
|
||||
expect(animations).toHaveLength(2);
|
||||
expect(animations[0].cancelled).toBe(true);
|
||||
expect(animations[1].keyframes[0]).toEqual({
|
||||
transform: 'translateX(112px)',
|
||||
});
|
||||
expect(target.classList).toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
});
|
||||
|
||||
it('reads the x translation of computed transforms', () => {
|
||||
expect(translateX('none')).toBe(0);
|
||||
expect(translateX('matrix(1, 0, 0, 1, -211.5, 0)')).toBe(-211.5);
|
||||
expect(
|
||||
translateX(
|
||||
'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 44, 0, 0, 1)'
|
||||
)
|
||||
).toBe(44);
|
||||
});
|
||||
|
||||
it('does not animate when the target did not move', () => {
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
expect(animations).toEqual([]);
|
||||
});
|
||||
|
||||
it('jumps under prefers-reduced-motion', () => {
|
||||
reducedMotion = true;
|
||||
left = 383;
|
||||
fixture.componentInstance.slideKey.set('single-pane');
|
||||
render();
|
||||
expect(animations).toEqual([]);
|
||||
expect(target.classList).not.toContain(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import {
|
||||
afterRenderEffect,
|
||||
DestroyRef,
|
||||
Directive,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
} from '@angular/core';
|
||||
|
||||
/** Matches the context panel's own width transition. */
|
||||
export const LAYOUT_SLIDE_DURATION_MS = 180;
|
||||
|
||||
export const LAYOUT_SLIDE_ACTIVE_CLASS = 'layout-slide-active';
|
||||
|
||||
const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
||||
|
||||
/**
|
||||
* Slides shell chrome from where it was to where a layout change put it
|
||||
* (FLIP) instead of letting it jump in one frame. The new layout applies at
|
||||
* once: each target only starts translated back to its previous position
|
||||
* and animates its transform to none, so nothing waits for the animation,
|
||||
* and Chromium records no layout shift for a transform.
|
||||
*
|
||||
* A change of `appLayoutSlide` slides the targets (`layoutSlideTargets`,
|
||||
* selectors inside the host; the host itself when empty). A change of
|
||||
* `layoutSlideRecordOn`, and a resize of the host or a target, only records
|
||||
* where the targets are, so the next slide starts from where they are
|
||||
* really drawn. Positions are `offsetLeft` values, which ignore transforms.
|
||||
* Under `prefers-reduced-motion` the change stays an instant jump.
|
||||
* Contract: docs/architecture/workspace-shell.md ("Layout slides").
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[appLayoutSlide]',
|
||||
})
|
||||
export class LayoutSlideDirective {
|
||||
readonly appLayoutSlide = input.required<unknown>();
|
||||
readonly layoutSlideRecordOn = input<unknown>(null);
|
||||
readonly layoutSlideTargets = input('');
|
||||
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly positions = new WeakMap<HTMLElement, number>();
|
||||
private readonly running = new WeakMap<HTMLElement, Animation>();
|
||||
|
||||
constructor() {
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let observed: readonly HTMLElement[] = [];
|
||||
let previousKey: unknown;
|
||||
let initialized = false;
|
||||
afterRenderEffect({
|
||||
read: () => {
|
||||
const key = this.appLayoutSlide();
|
||||
this.layoutSlideRecordOn();
|
||||
const targets = this.targets();
|
||||
if (initialized && key !== previousKey) {
|
||||
this.slide(targets);
|
||||
} else {
|
||||
this.record(targets);
|
||||
}
|
||||
previousKey = key;
|
||||
initialized = true;
|
||||
if (typeof ResizeObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
resizeObserver ??= new ResizeObserver(() =>
|
||||
this.record(this.targets())
|
||||
);
|
||||
const next = [this.host.nativeElement, ...targets];
|
||||
if (!sameElements(observed, next)) {
|
||||
resizeObserver.disconnect();
|
||||
next.forEach((element) => resizeObserver?.observe(element));
|
||||
observed = next;
|
||||
}
|
||||
},
|
||||
});
|
||||
inject(DestroyRef).onDestroy(() => resizeObserver?.disconnect());
|
||||
}
|
||||
|
||||
private targets(): HTMLElement[] {
|
||||
const host = this.host.nativeElement;
|
||||
const selector = this.layoutSlideTargets();
|
||||
return selector
|
||||
? Array.from(host.querySelectorAll<HTMLElement>(selector))
|
||||
: [host];
|
||||
}
|
||||
|
||||
private record(targets: readonly HTMLElement[]): void {
|
||||
for (const target of targets) {
|
||||
this.positions.set(target, target.offsetLeft);
|
||||
}
|
||||
}
|
||||
|
||||
private slide(targets: readonly HTMLElement[]): void {
|
||||
// Every position is read before the first animation starts, so the
|
||||
// layout is computed once.
|
||||
const moves: [HTMLElement, number][] = [];
|
||||
for (const target of targets) {
|
||||
const previous = this.positions.get(target);
|
||||
const next = target.offsetLeft;
|
||||
this.positions.set(target, next);
|
||||
// A slide still running is interrupted: the new one starts where
|
||||
// the target is drawn now, not where the old one was heading.
|
||||
const drawn = this.running.has(target)
|
||||
? translateX(getComputedStyle(target).transform)
|
||||
: 0;
|
||||
if (previous !== undefined && previous + drawn !== next) {
|
||||
moves.push([target, previous + drawn - next]);
|
||||
}
|
||||
}
|
||||
if (prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
for (const [target, offset] of moves) {
|
||||
if (typeof target.animate === 'function') {
|
||||
this.animate(target, offset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While it runs the target carries `layout-slide-active`, and the width
|
||||
* it uncovers on either side as `--layout-slide-start` /
|
||||
* `--layout-slide-end`, so its stylesheet can fill that strip.
|
||||
*/
|
||||
private animate(target: HTMLElement, offset: number): void {
|
||||
const superseded = this.running.get(target);
|
||||
const animation = target.animate(
|
||||
[{ transform: `translateX(${offset}px)` }, { transform: 'none' }],
|
||||
{ duration: LAYOUT_SLIDE_DURATION_MS, easing: 'ease' }
|
||||
);
|
||||
this.running.set(target, animation);
|
||||
superseded?.cancel();
|
||||
target.classList.add(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
target.style.setProperty(
|
||||
'--layout-slide-start',
|
||||
`${Math.max(offset, 0)}px`
|
||||
);
|
||||
target.style.setProperty(
|
||||
'--layout-slide-end',
|
||||
`${Math.max(-offset, 0)}px`
|
||||
);
|
||||
const settle = () => {
|
||||
if (this.running.get(target) !== animation) {
|
||||
return;
|
||||
}
|
||||
this.running.delete(target);
|
||||
target.classList.remove(LAYOUT_SLIDE_ACTIVE_CLASS);
|
||||
target.style.removeProperty('--layout-slide-start');
|
||||
target.style.removeProperty('--layout-slide-end');
|
||||
};
|
||||
animation.addEventListener('finish', settle);
|
||||
animation.addEventListener('cancel', settle);
|
||||
}
|
||||
}
|
||||
|
||||
function sameElements(
|
||||
a: readonly HTMLElement[],
|
||||
b: readonly HTMLElement[]
|
||||
): boolean {
|
||||
return a.length === b.length && a.every((element, i) => element === b[i]);
|
||||
}
|
||||
|
||||
/** The x translation of a computed `transform` value (0 for `none`). */
|
||||
export function translateX(transform: string): number {
|
||||
const values = /^matrix(3d)?\((.+)\)$/.exec(transform.trim());
|
||||
if (!values) {
|
||||
return 0;
|
||||
}
|
||||
const parts = values[2].split(',').map(Number);
|
||||
const x = values[1] ? parts[12] : parts[4];
|
||||
return Number.isFinite(x) ? x : 0;
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return (
|
||||
typeof matchMedia === 'function' &&
|
||||
matchMedia(REDUCED_MOTION_QUERY).matches
|
||||
);
|
||||
}
|
||||
@@ -71,9 +71,13 @@
|
||||
(accountInfoForPlaylistRequested)="facade.openAccountInfoFor($event)"
|
||||
/>
|
||||
|
||||
<!-- The content frame slides to make room for the context panel
|
||||
instead of jumping (see LayoutSlideDirective). -->
|
||||
<div
|
||||
class="workspace-body"
|
||||
[class.single-pane]="!facade.showContextPanel()"
|
||||
[appLayoutSlide]="facade.showContextPanel()"
|
||||
layoutSlideTargets=".workspace-content-frame"
|
||||
>
|
||||
@if (facade.showContextPanel()) {
|
||||
<!-- Phone-only (CSS-hidden above 640px): tapping the scrim is
|
||||
|
||||
@@ -114,6 +114,20 @@ app-workspace-shell-context-sidebar {
|
||||
clip-path: inset(0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
// While the frame slides into place beside a context panel that appeared
|
||||
// or left (LayoutSlideDirective), its old width is wider than the new
|
||||
// one, so the side it moves away from would show the body colour. The
|
||||
// content surface extends over that strip until the slide ends.
|
||||
&.layout-slide-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0 calc(-1 * var(--layout-slide-end, 0px)) 0
|
||||
calc(-1 * var(--layout-slide-start, 0px));
|
||||
z-index: -1;
|
||||
background: var(--app-content-bg, var(--mat-sys-surface));
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.workspace-content {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
WorkspacePortalContext,
|
||||
WorkspaceShellContextPanel,
|
||||
} from '@iptvnator/workspace/shell/util';
|
||||
import { LayoutSlideDirective } from './layout-slide.directive';
|
||||
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
|
||||
import { WorkspaceShellComponent } from './workspace-shell.component';
|
||||
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
|
||||
@@ -265,6 +266,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -361,6 +363,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -439,6 +442,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -513,6 +517,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -613,6 +618,7 @@ describe('WorkspaceShellComponent', () => {
|
||||
MockWorkspaceShellImportOverlayComponent,
|
||||
MockWorkspaceShellRailComponent,
|
||||
TrafficLightsClearanceDirective,
|
||||
LayoutSlideDirective,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ import { WorkspaceShellContextSidebarComponent } from './components/workspace-sh
|
||||
import { WorkspaceShellHeaderComponent } from './components/workspace-shell-header/workspace-shell-header.component';
|
||||
import { WorkspaceShellImportOverlayComponent } from './components/workspace-shell-import-overlay/workspace-shell-import-overlay.component';
|
||||
import { WorkspaceShellRailComponent } from './components/workspace-shell-rail/workspace-shell-rail.component';
|
||||
import { LayoutSlideDirective } from './layout-slide.directive';
|
||||
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
|
||||
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
|
||||
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
|
||||
@@ -39,6 +40,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
|
||||
imports: [
|
||||
CdkTrapFocus,
|
||||
ExternalPlaybackDockComponent,
|
||||
LayoutSlideDirective,
|
||||
TranslatePipe,
|
||||
PlaylistDropOverlayComponent,
|
||||
PlaylistDropZoneDirective,
|
||||
|
||||
@@ -81,14 +81,14 @@
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in all 55 measured iterations of 11 master runs, 2026-10-03 to 2026-10-04"
|
||||
},
|
||||
"renderer.layoutShiftScore": {
|
||||
"value": 0.233,
|
||||
"value": 0,
|
||||
"unit": "score",
|
||||
"slack": 0,
|
||||
"note": "guard only, not validated: no PR has yet shown that lowering this counter lowers the journey wall-clock (Principle 3)",
|
||||
"updatedAt": "2026-10-06",
|
||||
"updatedAt": "2026-10-11",
|
||||
"evidencePr": null,
|
||||
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/37372780064",
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), identical in every measured iteration of the 7 master runs from 84aef83a6 (#1814, which moved it from 0.222) to b78224376"
|
||||
"evidenceRun": "https://github.com/4gray/iptvnator/actions/runs/38147072417",
|
||||
"measuredWith": "pnpm run perf:journeys (ubuntu-latest, xvfb), 0 with no shift recorded in all 5 measured iterations of PR #1895 (d2cb9445d), which slides the content frame and header controls instead of jumping (was 0.233)"
|
||||
}
|
||||
},
|
||||
"playback": {
|
||||
|
||||
Reference in new issue
Block a user