Merge remote-tracking branch 'origin/master' into claude/fervent-cannon-196cb2

This commit is contained in:
4gray committed 2026-10-12 20:30:09 +02:00
commit eef1e58142
33 files changed
+1595 -36

No files matched your search

@@ -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'
"
@@ -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);
@@ -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)
);
}
@@ -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
@@ -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
@@ -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,
@@ -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,