Merge remote-tracking branch 'origin/master' into wt/pr-1830

This commit is contained in:
4gray committed 2026-10-06 22:38:28 +02:00
commit e7e61da5f3
116 files changed
+4519 -554

No files matched your search

@@ -45,7 +45,7 @@ interface WorkspaceCommandGroupSection {
selector: 'app-workspace-command-palette',
imports: [MatDialogModule, MatIconModule, TranslatePipe],
templateUrl: './workspace-command-palette.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-command-palette.component.scss',
})
export class WorkspaceCommandPaletteComponent implements AfterViewInit {
@@ -54,7 +54,7 @@ import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/u
/>
</div>
`,
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceCollectionContextPanelComponent {
@@ -82,7 +82,7 @@ interface WorkspaceCategoryLike {
WorkspaceContextErrorViewComponent,
],
templateUrl: './workspace-context-panel.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-context-panel.component.scss',
})
export class WorkspaceContextPanelComponent {
@@ -16,7 +16,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings
selector: 'app-workspace-settings-context-panel',
imports: [MatIconModule, RouterLink, RouterLinkActive, TranslateModule],
styleUrls: ['./workspace-settings-context-panel.component.scss'],
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<h2 class="panel-title">{{ 'SETTINGS.TITLE' | translate }}</h2>
<div class="settings-panel-body">
@@ -19,7 +19,7 @@ export interface WorkspaceKeyboardShortcutsDialogData {
selector: 'app-workspace-keyboard-shortcuts-dialog',
imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslatePipe],
templateUrl: './workspace-keyboard-shortcuts-dialog.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-keyboard-shortcuts-dialog.component.scss',
})
export class WorkspaceKeyboardShortcutsDialogComponent {
@@ -7,8 +7,12 @@
display: flex;
align-items: center;
gap: 12px;
height: 56px;
// The shell's header band: 56px, taller on macOS when zoomed out.
height: 100%;
padding: 0 24px;
// macOS: app zoom does not scale the traffic lights, so zoomed out the
// leading controls keep their 100 % distance from them (see the shell).
padding-inline-start: max(24px, var(--workspace-header-lights-inset, 0px));
border-bottom: 1px solid var(--mat-sys-outline-variant);
background: var(--app-header-bg, var(--mat-sys-surface-container-low));
app-region: drag;
@@ -318,6 +322,10 @@
@media (max-width: 780px) {
.workspace-header {
padding-inline: 10px;
padding-inline-start: max(
10px,
var(--workspace-header-lights-inset, 0px)
);
gap: 10px;
}
@@ -1,10 +1,6 @@
<!-- No brand mark: it linked to the page the first workspace link opens.
The page's Back lives in the header's leading slot. -->
<aside
class="app-rail"
[class.is-macos]="isMacOS()"
[style.--rail-zoom-factor]="isMacOS() ? zoomFactor() : null"
>
<aside class="app-rail" [class.is-macos]="isMacOS()">
<app-workspace-shell-rail-links [links]="workspaceLinks()" />
@if (primaryContextLinks().length > 0) {
@@ -29,13 +29,12 @@
--rail-link-active-border: var(--app-selection-border);
--rail-link-active-glow: var(--app-selection-glow);
// The traffic lights sit in the header band (56px, see the shell grid).
// The first link starts below that band, where the content area and the
// The traffic lights sit in the header band (see the shell grid). The
// first link starts below that band, where the content area and the
// dashboard hero start, so its hover surface never touches the lights.
// App zoom scales CSS pixels but not the lights: zoomed out, the inset
// keeps 48 window pixels (lights' bottom plus a gap) instead.
// Zoomed out, the band grows to keep the lights inside it.
&.is-macos {
padding-top: max(56px, calc(48px / var(--rail-zoom-factor, 1)));
padding-top: var(--workspace-header-band, 56px);
}
&::-webkit-scrollbar {
@@ -84,48 +84,6 @@ describe('WorkspaceShellRailComponent', () => {
).not.toBeNull();
});
describe('macOS zoom inset', () => {
const setWindowWidths = (outer: number, inner: number) => {
Object.defineProperty(window, 'outerWidth', {
configurable: true,
value: outer,
});
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: inner,
});
};
const zoomVar = () =>
(
fixture.nativeElement.querySelector('.app-rail') as HTMLElement
).style.getPropertyValue('--rail-zoom-factor');
afterEach(() => setWindowWidths(1024, 1024));
it('publishes the page zoom factor on macOS and follows resize', () => {
setWindowWidths(1200, 1200);
fixture.componentRef.setInput('isMacOS', true);
fixture.detectChanges();
TestBed.tick();
fixture.detectChanges();
expect(zoomVar()).toBe('1');
// Zoomed out to 50%: the viewport holds twice the CSS pixels.
setWindowWidths(1200, 2400);
window.dispatchEvent(new Event('resize'));
fixture.detectChanges();
expect(zoomVar()).toBe('0.5');
});
it('leaves the inset alone off macOS', () => {
setWindowWidths(1200, 2400);
fixture.detectChanges();
TestBed.tick();
fixture.detectChanges();
expect(zoomVar()).toBe('');
});
});
it('has no brand mark duplicating the first workspace link', () => {
fixture.detectChanges();
@@ -1,12 +1,4 @@
import { DOCUMENT } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
effect,
inject,
input,
signal,
} from '@angular/core';
import { ChangeDetectionStrategy, Component, input } from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { RouterLink } from '@angular/router';
@@ -40,29 +32,4 @@ export class WorkspaceShellRailComponent {
>(null);
readonly railProviderClass = input('rail-context-region');
readonly isSettingsRoute = input(false);
/**
* The page zoom factor. App zoom (`webFrame.setZoomLevel`) scales CSS
* pixels but not the native traffic lights, so the macOS top inset is
* kept in window pixels when zoomed out. Chromium reports the window in
* window pixels and the viewport in CSS pixels, and fires `resize` when
* the zoom changes.
*/
protected readonly zoomFactor = signal(1);
constructor() {
const view = inject(DOCUMENT).defaultView;
effect((onCleanup) => {
if (!this.isMacOS() || !view) return;
const update = () =>
this.zoomFactor.set(
view.innerWidth > 0 && view.outerWidth > 0
? view.outerWidth / view.innerWidth
: 1
);
update();
view.addEventListener('resize', update);
onCleanup(() => view.removeEventListener('resize', update));
});
}
}
@@ -0,0 +1,157 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import {
ZOOM_LEVEL_MAX,
ZOOM_LEVEL_MIN,
ZOOM_LEVEL_STEP,
} from '@iptvnator/shared/interfaces';
import {
pageZoomFactor,
TrafficLightsClearanceDirective,
trafficLightsClearance,
} from './traffic-lights-clearance.directive';
@Component({
template: `<div [appTrafficLightsClearance]="enabled()"></div>`,
imports: [TrafficLightsClearanceDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
class HostComponent {
readonly enabled = signal(true);
}
function setWindowWidths(outer: number, inner: number): void {
Object.defineProperty(window, 'outerWidth', {
configurable: true,
value: outer,
});
Object.defineProperty(window, 'innerWidth', {
configurable: true,
value: inner,
});
}
describe('trafficLightsClearance', () => {
it('matches the default layout at 100 %', () => {
// x: the rail (60px) plus the header's padding (24px), where the
// header content already starts; y: inside the 56px header band.
expect(trafficLightsClearance(1)).toEqual({ x: 84, y: 48 });
});
it('keeps the same window pixels at every supported zoom', () => {
for (
let level = ZOOM_LEVEL_MIN;
level <= ZOOM_LEVEL_MAX;
level += ZOOM_LEVEL_STEP
) {
const factor = 1.2 ** level;
const clearance = trafficLightsClearance(factor);
expect(clearance.x * factor).toBeCloseTo(84);
expect(clearance.y * factor).toBeCloseTo(48);
}
});
it('outgrows the default inset and band when zoomed out', () => {
const clearance = trafficLightsClearance(1.2 ** ZOOM_LEVEL_MIN);
// Past the 60px rail: wider than the header's 24px padding.
expect(clearance.x - 60).toBeGreaterThan(24);
expect(clearance.y).toBeGreaterThan(56);
});
it('treats an unusable factor as 100 %', () => {
for (const factor of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
expect(trafficLightsClearance(factor)).toEqual({ x: 84, y: 48 });
}
});
});
describe('pageZoomFactor', () => {
it('divides window pixels by CSS pixels', () => {
expect(pageZoomFactor({ outerWidth: 1200, innerWidth: 2400 })).toBe(
0.5
);
expect(pageZoomFactor({ outerWidth: 1200, innerWidth: 1000 })).toBe(
1.2
);
});
it('falls back to 100 % without a measured window', () => {
expect(pageZoomFactor({ outerWidth: 0, innerWidth: 1200 })).toBe(1);
expect(pageZoomFactor({ outerWidth: 1200, innerWidth: 0 })).toBe(1);
});
});
describe('TrafficLightsClearanceDirective', () => {
let fixture: ComponentFixture<HostComponent>;
const clearance = () => {
const style = (
fixture.nativeElement.querySelector('div') as HTMLElement
).style;
return {
x: style.getPropertyValue('--traffic-lights-clear-x'),
y: style.getPropertyValue('--traffic-lights-clear-y'),
};
};
const render = () => {
fixture.detectChanges();
TestBed.tick();
fixture.detectChanges();
};
beforeEach(() => {
setWindowWidths(1200, 1200);
fixture = TestBed.createComponent(HostComponent);
});
afterEach(() => setWindowWidths(1024, 1024));
it('publishes the clearance and follows zoom changes', () => {
render();
expect(clearance()).toEqual({ x: '84px', y: '48px' });
// Zoomed out to 50%: the viewport holds twice the CSS pixels.
setWindowWidths(1200, 2400);
window.dispatchEvent(new Event('resize'));
fixture.detectChanges();
expect(clearance()).toEqual({ x: '168px', y: '96px' });
});
it('measures the zoom before the first render', () => {
setWindowWidths(1200, 2400);
fixture.detectChanges();
expect(clearance()).toEqual({ x: '168px', y: '96px' });
});
it('publishes nothing off macOS and stops listening when disabled', () => {
render();
const removeListener = jest.spyOn(window, 'removeEventListener');
fixture.componentInstance.enabled.set(false);
render();
expect(clearance()).toEqual({ x: '', y: '' });
expect(removeListener).toHaveBeenCalledWith(
'resize',
expect.any(Function)
);
setWindowWidths(1200, 2400);
window.dispatchEvent(new Event('resize'));
fixture.componentInstance.enabled.set(true);
render();
// Re-enabled, it measures the current zoom.
expect(clearance()).toEqual({ x: '168px', y: '96px' });
removeListener.mockRestore();
});
it('stops listening when destroyed', () => {
render();
const removeListener = jest.spyOn(window, 'removeEventListener');
fixture.destroy();
expect(removeListener).toHaveBeenCalledWith(
'resize',
expect.any(Function)
);
removeListener.mockRestore();
});
});
@@ -0,0 +1,100 @@
import { DOCUMENT } from '@angular/common';
import {
computed,
Directive,
effect,
inject,
input,
signal,
} from '@angular/core';
import { MACOS_TRAFFIC_LIGHTS_POSITION } from '@iptvnator/shared/interfaces';
/**
* The three buttons are 14 pt circles. macOS 26 spaces them 23 pt apart
* (60 pt in all); earlier releases 20 pt (52 pt).
*/
const LIGHTS_WIDTH = 60;
const LIGHTS_HEIGHT = 14;
/**
* Window points from the window's left edge to where header content may
* start: the lights plus a gap. 84 is also where the header's content starts
* at 100 % (the 60 px rail plus 24 px padding), so default zoom is unchanged.
*/
const CLEAR_X = MACOS_TRAFFIC_LIGHTS_POSITION.x + LIGHTS_WIDTH + 8;
/** Window points from the top to where the content area may start. */
const CLEAR_Y = MACOS_TRAFFIC_LIGHTS_POSITION.y + LIGHTS_HEIGHT + 14;
/** CSS pixels from the window's top-left corner that clear the lights. */
export interface TrafficLightsClearance {
readonly x: number;
readonly y: number;
}
/**
* The page zoom factor. Chromium reports the window in window pixels and the
* viewport in CSS pixels, so their ratio is the app zoom.
*/
export function pageZoomFactor(
view: Pick<Window, 'innerWidth' | 'outerWidth'>
): number {
return view.innerWidth > 0 && view.outerWidth > 0
? view.outerWidth / view.innerWidth
: 1;
}
/**
* The clearance at `zoomFactor`. App zoom (`webFrame.setZoomLevel`) scales
* CSS pixels but not the native buttons: zoomed out, the same window points
* hold more CSS pixels.
*/
export function trafficLightsClearance(
zoomFactor: number
): TrafficLightsClearance {
const factor =
Number.isFinite(zoomFactor) && zoomFactor > 0 ? zoomFactor : 1;
return { x: CLEAR_X / factor, y: CLEAR_Y / factor };
}
/**
* Publishes the macOS traffic-light clearance on its host as
* `--traffic-lights-clear-x` and `--traffic-lights-clear-y`, refreshed on
* `resize` (Chromium fires it when the zoom changes). The shell grid, the
* rail and the header read them; without them (off macOS) they fall back to
* their default layout.
*/
@Directive({
selector: '[appTrafficLightsClearance]',
host: {
'[style.--traffic-lights-clear-x]': 'clearX()',
'[style.--traffic-lights-clear-y]': 'clearY()',
},
})
export class TrafficLightsClearanceDirective {
/** True where the native traffic lights overlay the window (macOS). */
readonly enabled = input(false, { alias: 'appTrafficLightsClearance' });
private readonly view = inject(DOCUMENT).defaultView;
private readonly zoomFactor = signal(
this.view ? pageZoomFactor(this.view) : 1
);
private readonly clearance = computed(() =>
this.enabled() ? trafficLightsClearance(this.zoomFactor()) : null
);
protected readonly clearX = computed(() => toPx(this.clearance()?.x));
protected readonly clearY = computed(() => toPx(this.clearance()?.y));
constructor() {
effect((onCleanup) => {
const view = this.view;
if (!this.enabled() || !view) return;
const update = () => this.zoomFactor.set(pageZoomFactor(view));
update();
view.addEventListener('resize', update);
onCleanup(() => view.removeEventListener('resize', update));
});
}
}
function toPx(value: number | undefined): string | null {
return value === undefined ? null : `${value}px`;
}
@@ -1,4 +1,9 @@
<div class="workspace-shell" appPlaylistDropZone #dropZone="playlistDropZone">
<div
class="workspace-shell"
appPlaylistDropZone
#dropZone="playlistDropZone"
[appTrafficLightsClearance]="facade.isMacOS"
>
<app-playlist-drop-overlay [state]="dropZone.overlayState()" />
@if (facade.showXtreamImportOverlay()) {
<app-workspace-shell-import-overlay />
@@ -6,9 +6,14 @@
}
.workspace-shell {
// The header band. On macOS it also holds the traffic lights, which app
// zoom does not scale: zoomed out, it stays tall enough to contain them
// (`TrafficLightsClearanceDirective` publishes the clearance), so no
// content starts beside them. The macOS rail starts its first link here.
--workspace-header-band: max(56px, var(--traffic-lights-clear-y, 0px));
display: grid;
grid-template-columns: 60px 1fr;
grid-template-rows: 56px minmax(0, 1fr) auto;
grid-template-rows: var(--workspace-header-band) minmax(0, 1fr) auto;
width: 100%;
height: 100%;
min-height: 0;
@@ -27,6 +32,12 @@ app-workspace-shell-header {
grid-column: 2;
grid-row: 1;
min-width: 0;
// The leading inset that keeps the header's controls clear of the macOS
// traffic lights: their clearance minus the rail column. It equals the
// header's 24px padding at 100 % zoom and is negative off macOS.
--workspace-header-lights-inset: calc(
var(--traffic-lights-clear-x, 0px) - 60px
);
}
.workspace-body {
@@ -72,11 +83,16 @@ app-workspace-shell-context-sidebar {
box-shadow: -4px 8px 20px var(--app-content-shadow, rgba(0, 0, 0, 0.18));
}
@media (max-width: 780px) {
.workspace-shell {
grid-template-columns: 56px 1fr;
grid-template-rows: 56px 1fr auto;
grid-template-rows: var(--workspace-header-band) 1fr auto;
}
app-workspace-shell-header {
--workspace-header-lights-inset: calc(
var(--traffic-lights-clear-x, 0px) - 56px
);
}
}
@@ -91,9 +107,11 @@ app-workspace-shell-context-sidebar {
grid-row: 1;
}
// Below the rail's row, the header never meets the traffic lights.
app-workspace-shell-header {
grid-column: 1;
grid-row: 2;
--workspace-header-lights-inset: 0px;
}
.workspace-body {
@@ -18,6 +18,7 @@ import {
WorkspacePortalContext,
WorkspaceShellContextPanel,
} from '@iptvnator/workspace/shell/util';
import { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
import { WorkspaceShellComponent } from './workspace-shell.component';
import {
WorkspaceHeaderBulkAction,
@@ -270,6 +271,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
],
providers: [
{
@@ -307,6 +309,14 @@ describe('WorkspaceShellComponent', () => {
By.directive(MockWorkspaceShellHeaderComponent)
).componentInstance as MockWorkspaceShellHeaderComponent;
expect(header.activeDownloadsCount()).toBe(3);
// macOS: the rail and header read the traffic-light clearance.
expect(
(
fixture.nativeElement.querySelector(
'.workspace-shell'
) as HTMLElement
).style.getPropertyValue('--traffic-lights-clear-x')
).toMatch(/px$/);
expect(
fixture.nativeElement.querySelector(
'app-workspace-shell-context-sidebar'
@@ -357,6 +367,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
],
providers: [
{
@@ -434,6 +445,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
],
providers: [
{
@@ -507,6 +519,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
],
providers: [
{
@@ -606,6 +619,7 @@ describe('WorkspaceShellComponent', () => {
MockWorkspaceShellHeaderComponent,
MockWorkspaceShellImportOverlayComponent,
MockWorkspaceShellRailComponent,
TrafficLightsClearanceDirective,
],
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 { TrafficLightsClearanceDirective } from './traffic-lights-clearance.directive';
import { WorkspaceShellFacade } from './services/workspace-shell.facade';
import { WorkspaceShellXtreamImportService } from './services/workspace-shell-xtream-import.service';
import { WorkspaceShellCommandPaletteService } from './services/workspace-shell-command-palette.service';
@@ -42,6 +43,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
PlaylistDropOverlayComponent,
PlaylistDropZoneDirective,
RouterOutlet,
TrafficLightsClearanceDirective,
WorkspaceShellContextSidebarComponent,
WorkspaceShellHeaderComponent,
WorkspaceShellImportOverlayComponent,
@@ -49,7 +51,7 @@ import { LIVE_CATEGORIES_POPOVER } from '@iptvnator/portal/shared/util';
],
templateUrl: './workspace-shell.component.html',
styleUrl: './workspace-shell.component.scss',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
WorkspaceShellFacade,
WorkspaceShellRouteStateService,
@@ -61,7 +61,7 @@ interface SortOption {
TranslatePipe,
],
templateUrl: './workspace-sources.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
changeDetection: ChangeDetectionStrategy.OnPush,
styleUrl: './workspace-sources.component.scss',
})
export class WorkspaceSourcesComponent {