mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(workspace): keep the macOS header clear of the lights when zoomed out
App zoom scales CSS pixels but not the native traffic lights. At zoom -3/-4 the header column starts near 29 window pixels, so Back and the playlist switcher slid under the lights, and the 27px header band let the lights overlap the context panel below. A shell-level TrafficLightsClearanceDirective now publishes the lights' clearance in CSS pixels (84 x 48 window pixels, from the page zoom factor) on macOS. The header band grows to the vertical clearance (the rail starts its first link at the same band, replacing the rail's own zoom listener), and the header's leading padding grows to the horizontal clearance less the rail column. Both equal the default layout at 100 %; Windows/Linux and the phone layout are unchanged. The native position is shared with the main process as MACOS_TRAFFIC_LIGHTS_POSITION. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2738bc28a1
commit
ac971910fd
17 files changed
+511
-131
No files matched your search
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: fix
|
||||
area: workspace
|
||||
---
|
||||
|
||||
On macOS, zooming the app out no longer slides the Back button and the playlist switcher under the window's close, minimize and zoom buttons; the top bar also stays tall enough that the buttons never overlap the page below.
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
import {
|
||||
addXtreamPortal,
|
||||
clickFirstGridListCard,
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
LaunchedElectronApp,
|
||||
resetMockServers,
|
||||
test,
|
||||
waitForXtreamWorkspaceReady,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// Custom window controls are only rendered on Windows/Linux; macOS keeps
|
||||
@@ -188,6 +194,89 @@ test.describe('Custom window controls', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// The native buttons are 14pt circles. At 100 % zoom the first header
|
||||
// control starts 60pt right of their origin, where macOS 26 ends them
|
||||
// (earlier releases end them at 52pt).
|
||||
const lightsHeight = 14;
|
||||
const headerControlOffset = 60;
|
||||
|
||||
/** Where macOS drew the native window buttons, in window points. */
|
||||
async function trafficLights(
|
||||
app: LaunchedElectronApp
|
||||
): Promise<{ x: number; y: number }> {
|
||||
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
|
||||
);
|
||||
expect(lights, 'native window button position').toBeTruthy();
|
||||
return lights ?? { x: Number.NaN, y: Number.NaN };
|
||||
}
|
||||
|
||||
/** Window pixels per CSS pixel. */
|
||||
function zoomFactor(page: Page): Promise<number> {
|
||||
return page.evaluate(() => window.outerWidth / window.innerWidth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Steps the app zoom to its minimum (−4, ≈48 %). App zoom scales CSS pixels
|
||||
* but not the native buttons.
|
||||
*/
|
||||
async function zoomOutFully(page: Page): Promise<void> {
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('out'));
|
||||
}
|
||||
await expect.poll(() => zoomFactor(page)).toBeLessThan(0.6);
|
||||
}
|
||||
|
||||
async function resetZoom(page: Page): Promise<void> {
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
|
||||
await expect.poll(() => zoomFactor(page)).toBeCloseTo(1, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's first rendered control and the top of the content area, in
|
||||
* window pixels (CSS pixels times the zoom factor).
|
||||
*/
|
||||
function headerLayout(
|
||||
page: Page
|
||||
): Promise<{ control?: string; left: number; contentTop: number }> {
|
||||
return page.locator('.workspace-header').evaluate((header) => {
|
||||
const zoom = window.outerWidth / window.innerWidth;
|
||||
const first = [...header.children].find(
|
||||
(child) => child.getBoundingClientRect().width > 0
|
||||
);
|
||||
const body = document.querySelector('.workspace-body');
|
||||
return {
|
||||
control:
|
||||
first?.getAttribute('data-test-id') ??
|
||||
first?.tagName.toLowerCase(),
|
||||
left: (first?.getBoundingClientRect().left ?? 0) * zoom,
|
||||
contentTop: (body?.getBoundingClientRect().top ?? 0) * zoom,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The first header control starts right of the lights and the content area
|
||||
* below them. Polled: the layout follows a zoom change after its resize.
|
||||
*/
|
||||
async function expectHeaderClearOfLights(
|
||||
page: Page,
|
||||
lights: { x: number; y: number },
|
||||
control: string,
|
||||
label: string
|
||||
): Promise<void> {
|
||||
const layout = () => headerLayout(page);
|
||||
await expect
|
||||
.poll(async () => (await layout()).control, label)
|
||||
.toBe(control);
|
||||
await expect
|
||||
.poll(async () => (await layout()).left, `${label}: first control`)
|
||||
.toBeGreaterThanOrEqual(lights.x + headerControlOffset);
|
||||
await expect
|
||||
.poll(async () => (await layout()).contentTop, `${label}: content top`)
|
||||
.toBeGreaterThanOrEqual(lights.y + lightsHeight);
|
||||
}
|
||||
|
||||
test.describe('macOS traffic lights', () => {
|
||||
test.skip(
|
||||
process.platform !== 'darwin',
|
||||
@@ -205,49 +294,85 @@ test.describe('macOS traffic lights', () => {
|
||||
const firstLink = page.locator('.app-rail a').first();
|
||||
await expect(firstLink).toBeVisible();
|
||||
|
||||
const [linkBox, contentBox] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.locator('.workspace-content').boundingBox(),
|
||||
]);
|
||||
// Aligned with the content area, where the dashboard hero starts.
|
||||
expect(
|
||||
Math.abs((linkBox?.y ?? 0) - (contentBox?.y ?? -100))
|
||||
).toBeLessThanOrEqual(1);
|
||||
const linkOffsetFromContent = async (): Promise<number> => {
|
||||
const [linkBox, contentBox] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.locator('.workspace-content').boundingBox(),
|
||||
]);
|
||||
return Math.abs(
|
||||
(linkBox?.y ?? 0) - (contentBox?.y ?? Number.NaN)
|
||||
);
|
||||
};
|
||||
expect(await linkOffsetFromContent()).toBeLessThanOrEqual(1);
|
||||
|
||||
const lights = await app.electronApp.evaluate(({ BrowserWindow }) =>
|
||||
BrowserWindow.getAllWindows()[0]?.getWindowButtonPosition()
|
||||
);
|
||||
expect(lights, 'native window button position').toBeTruthy();
|
||||
const lightsY = lights?.y ?? Number.NaN;
|
||||
// The buttons are about 14pt tall; keep a visible gap below them,
|
||||
// measured in window pixels (CSS pixels times the zoom factor).
|
||||
const lights = await trafficLights(app);
|
||||
// Keep a visible gap below the buttons, measured in window pixels
|
||||
// (CSS pixels times the zoom factor).
|
||||
const linkTopInWindowPixels = async (): Promise<number> => {
|
||||
const [box, zoom] = await Promise.all([
|
||||
firstLink.boundingBox(),
|
||||
page.evaluate(() => window.outerWidth / window.innerWidth),
|
||||
zoomFactor(page),
|
||||
]);
|
||||
return (box?.y ?? Number.NaN) * zoom;
|
||||
};
|
||||
expect(await linkTopInWindowPixels()).toBeGreaterThanOrEqual(
|
||||
lightsY + 14 + 16
|
||||
lights.y + lightsHeight + 16
|
||||
);
|
||||
|
||||
// App zoom scales CSS pixels but not the native buttons: at the
|
||||
// smallest zoom the inset must still clear them.
|
||||
for (let step = 0; step < 8; step++) {
|
||||
await page.evaluate(() =>
|
||||
window.electron.adjustZoomLevel('out')
|
||||
);
|
||||
}
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => window.outerWidth / window.innerWidth)
|
||||
)
|
||||
.toBeLessThan(0.6);
|
||||
// At the smallest zoom the inset must still clear the buttons,
|
||||
// and the link still starts with the content area.
|
||||
await zoomOutFully(page);
|
||||
await expect
|
||||
.poll(linkTopInWindowPixels)
|
||||
.toBeGreaterThanOrEqual(lightsY + 14 + 8);
|
||||
await page.evaluate(() => window.electron.adjustZoomLevel('reset'));
|
||||
.toBeGreaterThanOrEqual(lights.y + lightsHeight + 8);
|
||||
await expect.poll(linkOffsetFromContent).toBeLessThanOrEqual(1);
|
||||
await resetZoom(page);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@xtream @electron the first header control clears the lights at default and minimum zoom', async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
const lights = await trafficLights(app);
|
||||
const switcher = 'app-playlist-switcher';
|
||||
const back = 'workspace-header-back';
|
||||
|
||||
await expectHeaderClearOfLights(page, lights, switcher, '100 %');
|
||||
await zoomOutFully(page);
|
||||
await expectHeaderClearOfLights(page, lights, switcher, 'min zoom');
|
||||
|
||||
// A detail page puts its Back first, pulled 8px toward the edge.
|
||||
await page
|
||||
.getByRole('link', { name: 'Series', exact: true })
|
||||
.click();
|
||||
await clickFirstGridListCard(page);
|
||||
await expect(page.getByTestId(back)).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expectHeaderClearOfLights(
|
||||
page,
|
||||
lights,
|
||||
back,
|
||||
'detail at min zoom'
|
||||
);
|
||||
await resetZoom(page);
|
||||
await expectHeaderClearOfLights(
|
||||
page,
|
||||
lights,
|
||||
back,
|
||||
'detail at 100 %'
|
||||
);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { app, BrowserWindow, Menu, screen, session, shell } from 'electron';
|
||||
import {
|
||||
ElectronBridgeWindowState,
|
||||
MACOS_TRAFFIC_LIGHTS_POSITION,
|
||||
WINDOW_STATE_CHANGED,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { join, resolve } from 'path';
|
||||
@@ -397,7 +398,7 @@ export default class App {
|
||||
return {
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 20 },
|
||||
trafficLightPosition: { ...MACOS_TRAFFIC_LIGHTS_POSITION },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -390,15 +390,31 @@ The Electron window hides the native title bar on all desktop platforms
|
||||
(`titleBarStyle: 'hidden'` in `apps/electron-backend/src/app/app.ts`):
|
||||
|
||||
1. macOS keeps the native traffic lights (`titleBarOverlay: true`,
|
||||
`trafficLightPosition`); the renderer draws no window buttons. The lights
|
||||
sit in the 56 px header band above the rail, so the macOS rail
|
||||
(`.app-rail.is-macos`) starts its first link at 56 px: level with the
|
||||
content area and the dashboard hero, with its hover surface clear of the
|
||||
lights. App zoom scales CSS pixels but not the lights, so the rail
|
||||
publishes the page zoom factor (`outerWidth / innerWidth`, refreshed on
|
||||
`resize`) as `--rail-zoom-factor` and keeps at least 48 window pixels when
|
||||
zoomed out. `window-controls.e2e.ts` checks the alignment and the gap at
|
||||
default and minimum zoom on macOS.
|
||||
`trafficLightPosition` from `MACOS_TRAFFIC_LIGHTS_POSITION` in
|
||||
`@iptvnator/shared/interfaces`); the renderer draws no window buttons.
|
||||
The lights sit in the header band (`--workspace-header-band`, 56 px) over
|
||||
the rail and the header's leading padding. The macOS rail
|
||||
(`.app-rail.is-macos`) starts its first link below the band: level with
|
||||
the content area and the dashboard hero, with its hover surface clear of
|
||||
the lights. The header's content starts 84 window pixels from the
|
||||
window's left edge (60 px rail plus 24 px padding). macOS 26 ends the
|
||||
lights at 76 (earlier releases at 68), which is where Back's left edge
|
||||
sits at 100 % because of its 8 px pull-in.
|
||||
|
||||
App zoom (see "Zoom level") scales CSS pixels but not the lights. On
|
||||
macOS, `TrafficLightsClearanceDirective` on `.workspace-shell` reads the
|
||||
page zoom factor (`outerWidth / innerWidth`, refreshed on `resize`) and
|
||||
publishes the clearance in CSS pixels as `--traffic-lights-clear-x` (84
|
||||
window pixels) and `--traffic-lights-clear-y` (48: the lights' bottom
|
||||
plus a gap). Zoomed out, the band grows to the vertical clearance, so the
|
||||
lights never overlap the content area. The header's leading padding grows
|
||||
to the horizontal clearance, less the rail column
|
||||
(`--workspace-header-lights-inset`). At 100 % both match the default
|
||||
layout. Off macOS nothing is published and the defaults apply. The phone
|
||||
layout, which puts the rail in a row above the header, ignores the
|
||||
inset. `window-controls.e2e.ts` ("macOS traffic lights") checks the rail
|
||||
alignment, the first header control (switcher, then a detail page's Back)
|
||||
and the content top at default and minimum zoom.
|
||||
2. Windows and Linux use renderer-drawn window controls
|
||||
(`app-window-controls`, `libs/ui/components/src/lib/window-controls/`).
|
||||
`frame` is intentionally left untouched so native resize borders and
|
||||
|
||||
@@ -9,6 +9,7 @@ export * from './lib/embedded-mpv-session.interface';
|
||||
export * from './lib/embedded-mpv-support-watch.util';
|
||||
export * from './lib/subtitle-style.util';
|
||||
export * from './lib/zoom-level.util';
|
||||
export * from './lib/macos-traffic-lights';
|
||||
export * from './lib/electron-api.interface';
|
||||
export * from './lib/epg-channel-metadata.model';
|
||||
export * from './lib/epg-channel-with-programs.interface';
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Where macOS draws the native window buttons (close, minimize, zoom), in
|
||||
* window points from the window's top-left corner. The main process places
|
||||
* them (`trafficLightPosition`); the workspace shell keeps its controls clear
|
||||
* of them at every app zoom, which scales CSS pixels but not the buttons.
|
||||
*/
|
||||
export const MACOS_TRAFFIC_LIGHTS_POSITION = { x: 16, y: 20 } as const;
|
||||
+9
-1
@@ -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
-5
@@ -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) {
|
||||
|
||||
+4
-5
@@ -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 {
|
||||
|
||||
-42
@@ -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
-34
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
+157
@@ -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();
|
||||
});
|
||||
});
|
||||
+100
@@ -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 />
|
||||
|
||||
+21
-3
@@ -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 {
|
||||
|
||||
+14
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user