mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 18:36:15 -08:00
feat(ui): add custom title bar window controls for Windows and Linux
Hide the native title bar on win32/linux (titleBarStyle: 'hidden', frame untouched so native resize borders and snapping keep working) and render minimize / maximize-restore / close buttons in the renderer, mirroring the existing macOS traffic-light setup. - New WINDOW:* IPC contract (minimize, toggle-maximize, close, get-state) handled in window.events.ts, resolved from the sender WebContents; close goes through win.close() so window-bounds persistence still runs. - WINDOW:STATE_CHANGED pushed on maximize/unmaximize/fullscreen so the maximize/restore glyph stays correct for OS-triggered changes; controls hide while fullscreen. - WindowControlsComponent mounts once in app-root as a manual popover so it stays in the browser top layer above CDK overlays (dialogs, multi-EPG) - same behavior as macOS traffic lights. - Theme-aware via CSS vars (--app-on-surface, --app-hover-overlay); Windows-red close hover. Drag regions get right padding through a body-level frameless-platform class. - Gated by RuntimeCapabilitiesService.usesCustomWindowControls; PWA and macOS never mount the controls. Includes unit specs for the component and IPC handlers, an Electron E2E suite (window-controls.e2e.ts), and a window-chrome section in docs/architecture/workspace-shell.md. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2be42257ab
commit
24c2da48ae
18 files changed
+939
-25
No files matched your search
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
closeElectronApp,
|
||||
expect,
|
||||
launchElectronApp,
|
||||
test,
|
||||
} from './electron-test-fixtures';
|
||||
|
||||
// Custom window controls are only rendered on Windows/Linux; macOS keeps
|
||||
// the native traffic lights.
|
||||
test.describe('Custom window controls', () => {
|
||||
test.skip(
|
||||
process.platform === 'darwin',
|
||||
'macOS uses native traffic lights instead of custom controls'
|
||||
);
|
||||
|
||||
test('@electron renders the window control buttons', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-minimize')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-maximize')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-close')
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-maximize-glyph')
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron maximize button toggles the native window state', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const isMaximized = () =>
|
||||
app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isMaximized() : false;
|
||||
});
|
||||
|
||||
await app.mainWindow.getByTestId('window-maximize').click();
|
||||
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(true);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-restore-glyph')
|
||||
).toBeVisible();
|
||||
|
||||
await app.mainWindow.getByTestId('window-maximize').click();
|
||||
await expect.poll(isMaximized, { timeout: 10_000 }).toBe(false);
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-maximize-glyph')
|
||||
).toBeVisible();
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron reflects window state changes triggered from the main process', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0]?.maximize();
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-restore-glyph')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0]?.unmaximize();
|
||||
});
|
||||
await expect(
|
||||
app.mainWindow.getByTestId('window-maximize-glyph')
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('@electron minimize button minimizes the window', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
|
||||
try {
|
||||
const isMinimized = () =>
|
||||
app.electronApp.evaluate(({ BrowserWindow }) => {
|
||||
const mainWindow = BrowserWindow.getAllWindows()[0];
|
||||
return mainWindow ? mainWindow.isMinimized() : false;
|
||||
});
|
||||
|
||||
await app.mainWindow.getByTestId('window-minimize').click();
|
||||
await expect.poll(isMinimized, { timeout: 10_000 }).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ElectronBridgePlaylistUpsertInput,
|
||||
ElectronBridgeRemoteControlCommand,
|
||||
ElectronBridgeRemoteControlStatus,
|
||||
ElectronBridgeWindowState,
|
||||
ElectronBridgeXtreamContentStream,
|
||||
ExternalPlayerSession,
|
||||
PlaybackPositionData,
|
||||
@@ -37,6 +38,11 @@ const EXTERNAL_PLAYER_SESSION_UPDATE = 'EXTERNAL_PLAYER_SESSION_UPDATE';
|
||||
const EMBEDDED_MPV_SESSION_UPDATE = 'EMBEDDED_MPV_SESSION_UPDATE';
|
||||
const DB_OPERATION_EVENT = 'DB_OPERATION_EVENT';
|
||||
const PLAYLIST_REFRESH_EVENT = 'PLAYLIST:REFRESH_EVENT';
|
||||
const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
|
||||
const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
|
||||
const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
|
||||
const dbSaveContentProgressListeners = new Set<
|
||||
(
|
||||
@@ -282,6 +288,20 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
platform: process.platform,
|
||||
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
|
||||
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
|
||||
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
|
||||
onWindowStateChange: (
|
||||
callback: (state: ElectronBridgeWindowState) => void
|
||||
) => {
|
||||
const handler = (
|
||||
_event: Electron.IpcRendererEvent,
|
||||
state: ElectronBridgeWindowState
|
||||
) => callback(state);
|
||||
ipcRenderer.on(WINDOW_STATE_CHANGED, handler);
|
||||
return () => ipcRenderer.off(WINDOW_STATE_CHANGED, handler);
|
||||
},
|
||||
fetchPlaylistByUrl: (url: string, title?: string) =>
|
||||
ipcRenderer.invoke('fetch-playlist-by-url', url, title),
|
||||
updatePlaylistFromFilePath: (filePath: string, title: string) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow, Menu, screen, shell } from 'electron';
|
||||
import { WINDOW_STATE_CHANGED } from '@iptvnator/shared/interfaces';
|
||||
import { join, resolve } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { rendererAppName, rendererAppPort } from './constants';
|
||||
@@ -237,6 +238,43 @@ export default class App {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the native title bar on every desktop platform. macOS keeps the
|
||||
* system traffic lights (overlay), while Windows/Linux rely on the
|
||||
* renderer-drawn window controls (`app-window-controls`) wired up via the
|
||||
* WINDOW:* IPC channels. `frame` stays untouched so native resize borders
|
||||
* and snapping keep working.
|
||||
*/
|
||||
private static getPlatformTitleBarOptions(): Electron.BrowserWindowConstructorOptions {
|
||||
if (process.platform === 'darwin') {
|
||||
return {
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 20 },
|
||||
};
|
||||
}
|
||||
|
||||
return { titleBarStyle: 'hidden' };
|
||||
}
|
||||
|
||||
private static attachWindowStateEvents(win: Electron.BrowserWindow): void {
|
||||
const sendWindowState = () => {
|
||||
if (win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
win.webContents.send(WINDOW_STATE_CHANGED, {
|
||||
isMaximized: win.isMaximized(),
|
||||
isFullScreen: win.isFullScreen(),
|
||||
});
|
||||
};
|
||||
|
||||
win.on('maximize', sendWindowState);
|
||||
win.on('unmaximize', sendWindowState);
|
||||
win.on('enter-full-screen', sendWindowState);
|
||||
win.on('leave-full-screen', sendWindowState);
|
||||
}
|
||||
|
||||
private static initMainWindow() {
|
||||
const workAreaSize = screen.getPrimaryDisplay().workAreaSize;
|
||||
const width = Math.min(1280, workAreaSize.width || 1280);
|
||||
@@ -254,16 +292,11 @@ export default class App {
|
||||
...savedWindowBounds,
|
||||
minHeight: 600,
|
||||
minWidth: 900,
|
||||
...(process.platform === 'darwin'
|
||||
? {
|
||||
titleBarStyle: 'hidden',
|
||||
titleBarOverlay: true,
|
||||
trafficLightPosition: { x: 16, y: 20 },
|
||||
}
|
||||
: {}),
|
||||
...App.getPlatformTitleBarOptions(),
|
||||
});
|
||||
App.mainWindow.setMenu(null);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
App.attachWindowStateEvents(App.mainWindow);
|
||||
if (!savedWindowBounds) {
|
||||
App.mainWindow.center();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
const mockHandlers = new Map<
|
||||
string,
|
||||
(event: unknown, ...args: unknown[]) => unknown
|
||||
>();
|
||||
const mockFromWebContents = jest.fn();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
ipcMain: {
|
||||
handle: jest.fn(
|
||||
(
|
||||
channel: string,
|
||||
handler: (event: unknown, ...args: unknown[]) => unknown
|
||||
) => {
|
||||
mockHandlers.set(channel, handler);
|
||||
}
|
||||
),
|
||||
},
|
||||
BrowserWindow: {
|
||||
fromWebContents: (...args: unknown[]) => mockFromWebContents(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
function createFakeWindow(
|
||||
initial: { maximized?: boolean; fullScreen?: boolean } = {}
|
||||
) {
|
||||
const state = {
|
||||
maximized: initial.maximized ?? false,
|
||||
fullScreen: initial.fullScreen ?? false,
|
||||
};
|
||||
|
||||
return {
|
||||
isDestroyed: jest.fn(() => false),
|
||||
isMaximized: jest.fn(() => state.maximized),
|
||||
isFullScreen: jest.fn(() => state.fullScreen),
|
||||
minimize: jest.fn(),
|
||||
maximize: jest.fn(() => {
|
||||
state.maximized = true;
|
||||
}),
|
||||
unmaximize: jest.fn(() => {
|
||||
state.maximized = false;
|
||||
}),
|
||||
close: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
const fakeEvent = { sender: {} };
|
||||
|
||||
describe('WindowEvents', () => {
|
||||
beforeAll(async () => {
|
||||
await import('./window.events');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockFromWebContents.mockReset();
|
||||
});
|
||||
|
||||
it('registers handlers for all window control channels', () => {
|
||||
expect([...mockHandlers.keys()].sort()).toEqual([
|
||||
'WINDOW:CLOSE',
|
||||
'WINDOW:GET_STATE',
|
||||
'WINDOW:MINIMIZE',
|
||||
'WINDOW:TOGGLE_MAXIMIZE',
|
||||
]);
|
||||
});
|
||||
|
||||
it('minimizes the sender window', () => {
|
||||
const win = createFakeWindow();
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent);
|
||||
|
||||
expect(win.minimize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('maximizes an unmaximized window and returns the new state', () => {
|
||||
const win = createFakeWindow({ maximized: false });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent);
|
||||
|
||||
expect(win.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(win.unmaximize).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ isMaximized: true, isFullScreen: false });
|
||||
});
|
||||
|
||||
it('unmaximizes a maximized window and returns the new state', () => {
|
||||
const win = createFakeWindow({ maximized: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent);
|
||||
|
||||
expect(win.unmaximize).toHaveBeenCalledTimes(1);
|
||||
expect(win.maximize).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
});
|
||||
|
||||
it('closes the sender window', () => {
|
||||
const win = createFakeWindow();
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
mockHandlers.get('WINDOW:CLOSE')!(fakeEvent);
|
||||
|
||||
expect(win.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns the current window state', () => {
|
||||
const win = createFakeWindow({ maximized: true, fullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent);
|
||||
|
||||
expect(result).toEqual({ isMaximized: true, isFullScreen: true });
|
||||
});
|
||||
|
||||
it('is a safe no-op when the sender has no window', () => {
|
||||
mockFromWebContents.mockReturnValue(null);
|
||||
|
||||
expect(() =>
|
||||
mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent)
|
||||
).not.toThrow();
|
||||
expect(mockHandlers.get('WINDOW:TOGGLE_MAXIMIZE')!(fakeEvent)).toEqual({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
expect(mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent)).toEqual({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a destroyed window like a missing window', () => {
|
||||
const win = createFakeWindow();
|
||||
win.isDestroyed.mockReturnValue(true);
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
mockHandlers.get('WINDOW:MINIMIZE')!(fakeEvent);
|
||||
|
||||
expect(win.minimize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* IPC handlers for the renderer-drawn window controls used with the custom
|
||||
* title bar on Windows/Linux (`titleBarStyle: 'hidden'`). Each handler
|
||||
* resolves the window from the calling WebContents so it works without
|
||||
* coupling to the static main-window reference.
|
||||
*/
|
||||
|
||||
import { BrowserWindow, ipcMain } from 'electron';
|
||||
import {
|
||||
WINDOW_CLOSE,
|
||||
WINDOW_GET_STATE,
|
||||
WINDOW_MINIMIZE,
|
||||
WINDOW_TOGGLE_MAXIMIZE,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
interface WindowState {
|
||||
isMaximized: boolean;
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
function getSenderWindow(
|
||||
event: Electron.IpcMainInvokeEvent
|
||||
): Electron.BrowserWindow | null {
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
return win && !win.isDestroyed() ? win : null;
|
||||
}
|
||||
|
||||
function getWindowState(win: Electron.BrowserWindow | null): WindowState {
|
||||
return {
|
||||
isMaximized: !!win?.isMaximized(),
|
||||
isFullScreen: !!win?.isFullScreen(),
|
||||
};
|
||||
}
|
||||
|
||||
export default class WindowEvents {
|
||||
static bootstrapWindowEvents(): Electron.IpcMain {
|
||||
return ipcMain;
|
||||
}
|
||||
}
|
||||
|
||||
ipcMain.handle(WINDOW_MINIMIZE, (event) => {
|
||||
getSenderWindow(event)?.minimize();
|
||||
});
|
||||
|
||||
ipcMain.handle(WINDOW_TOGGLE_MAXIMIZE, (event): WindowState => {
|
||||
const win = getSenderWindow(event);
|
||||
|
||||
if (win) {
|
||||
if (win.isMaximized()) {
|
||||
win.unmaximize();
|
||||
} else {
|
||||
win.maximize();
|
||||
}
|
||||
}
|
||||
|
||||
return getWindowState(win);
|
||||
});
|
||||
|
||||
// win.close() (instead of app.quit()) so the window's 'close' handler still
|
||||
// persists the window bounds before shutdown.
|
||||
ipcMain.handle(WINDOW_CLOSE, (event) => {
|
||||
getSenderWindow(event)?.close();
|
||||
});
|
||||
|
||||
ipcMain.handle(WINDOW_GET_STATE, (event): WindowState => {
|
||||
return getWindowState(getSenderWindow(event));
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import SquirrelEvents from './app/events/squirrel.events';
|
||||
import StalkerEvents from './app/events/stalker.events';
|
||||
import { isStartupTraceEnabled, trace } from './app/services/debug-trace';
|
||||
import { databaseWorkerClient } from './app/services/database-worker-client';
|
||||
import WindowEvents from './app/events/window.events';
|
||||
import XtreamEvents from './app/events/xtream.events';
|
||||
|
||||
app.setName('iptvnator');
|
||||
@@ -90,6 +91,7 @@ export default class Main {
|
||||
}
|
||||
|
||||
ElectronEvents.bootstrapElectronEvents();
|
||||
WindowEvents.bootstrapWindowEvents();
|
||||
EmbeddedMpvEvents.bootstrapEmbeddedMpvEvents();
|
||||
PlaylistEvents.bootstrapPlaylistEvents();
|
||||
SharedEvents.bootstrapSharedEvents();
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<router-outlet />
|
||||
|
||||
<app-epg-progress-panel />
|
||||
|
||||
@if (usesCustomWindowControls) {
|
||||
<app-window-controls />
|
||||
}
|
||||
@@ -4,9 +4,13 @@ import { Router, RouterOutlet } from '@angular/router';
|
||||
import { Actions, ofType } from '@ngrx/effects';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access';
|
||||
import {
|
||||
EpgRuntimeBridgeService,
|
||||
EpgService,
|
||||
} from '@iptvnator/epg/data-access';
|
||||
import { WORKSPACE_SHELL_ACTIONS } from '@iptvnator/workspace/shell/util';
|
||||
import { EpgProgressPanelComponent } from '@iptvnator/ui/epg/progress-panel';
|
||||
import { WindowControlsComponent } from '@iptvnator/ui/components';
|
||||
import { PlaylistActions, selectAllPlaylistsMeta } from '@iptvnator/m3u-state';
|
||||
import { filter, take } from 'rxjs';
|
||||
import {
|
||||
@@ -30,12 +34,15 @@ const debugAppComponent = createDevLogger('AppComponent');
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: './app.component.html',
|
||||
imports: [EpgProgressPanelComponent, RouterOutlet],
|
||||
imports: [EpgProgressPanelComponent, RouterOutlet, WindowControlsComponent],
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
@HostBinding('class.macos-platform') get isMacOS() {
|
||||
return this.runtime.isMacOS;
|
||||
}
|
||||
get usesCustomWindowControls() {
|
||||
return this.runtime.usesCustomWindowControls;
|
||||
}
|
||||
private actions$ = inject(Actions);
|
||||
private dataService = inject(DataService);
|
||||
private epgBridge = inject(EpgRuntimeBridgeService);
|
||||
@@ -53,6 +60,12 @@ export class AppComponent implements OnInit {
|
||||
private readonly DEFAULT_LANG = Language.ENGLISH;
|
||||
|
||||
constructor() {
|
||||
// Body-level class (like 'dark-theme') so layout adjustments also
|
||||
// reach content rendered outside app-root, e.g. cdk-overlay content.
|
||||
if (this.runtime.usesCustomWindowControls) {
|
||||
document.body.classList.add('frameless-platform');
|
||||
}
|
||||
|
||||
const electronProcess = this.dataService.remote?.process;
|
||||
if (
|
||||
this.dataService.isElectron &&
|
||||
|
||||
@@ -100,6 +100,17 @@ app-root.macos-platform app-navigation .portal-status-container {
|
||||
padding-top: 30px;
|
||||
}
|
||||
|
||||
// Windows/Linux frameless title bar: reserve room in top-aligned drag
|
||||
// regions for the renderer-drawn window controls (3 × 46px buttons).
|
||||
// Body-level class so it also reaches cdk-overlay content (multi-EPG).
|
||||
body.frameless-platform .workspace-header {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
body.frameless-platform #epg-navigation {
|
||||
padding-right: 150px;
|
||||
}
|
||||
|
||||
// ─── Base ─────────────────────────────────────────────────────────────────────
|
||||
body,
|
||||
html {
|
||||
|
||||
@@ -74,20 +74,20 @@ Provider route integration:
|
||||
The shell is intentionally split into four persistent regions:
|
||||
|
||||
1. Left rail:
|
||||
1. Static workspace links for dashboard, sources, global favorites, and recently viewed.
|
||||
2. Provider-aware context links derived from the active or current playlist.
|
||||
3. Settings remains a persistent footer shortcut in the rail.
|
||||
1. Static workspace links for dashboard, sources, global favorites, and recently viewed.
|
||||
2. Provider-aware context links derived from the active or current playlist.
|
||||
3. Settings remains a persistent footer shortcut in the rail.
|
||||
2. Top header:
|
||||
1. Playlist switcher.
|
||||
2. Route-aware search input and command palette trigger.
|
||||
3. Add source action.
|
||||
4. Optional playlist refresh and route-specific shortcut actions.
|
||||
5. Downloads shortcut in Electron.
|
||||
1. Playlist switcher.
|
||||
2. Route-aware search input and command palette trigger.
|
||||
3. Add source action.
|
||||
4. Optional playlist refresh and route-specific shortcut actions.
|
||||
5. Downloads shortcut in Electron.
|
||||
3. Main body:
|
||||
1. Optional left context panel.
|
||||
2. Main router outlet content.
|
||||
1. Optional left context panel.
|
||||
2. Main router outlet content.
|
||||
4. Optional footer:
|
||||
1. External playback session bar when a docked session is visible.
|
||||
1. External playback session bar when a docked session is visible.
|
||||
|
||||
`WorkspaceShellComponent` binds only to `WorkspaceShellFacade`. The facade is
|
||||
kept as a thin template-facing API and delegates ownership to component-scoped
|
||||
@@ -116,15 +116,15 @@ for the template unless the template contract itself intentionally changes.
|
||||
The shell decides which secondary panel to show from the current route:
|
||||
|
||||
1. `/workspace/sources`
|
||||
1. `WorkspaceSourcesFiltersPanelComponent`
|
||||
1. `WorkspaceSourcesFiltersPanelComponent`
|
||||
2. Xtream category sections (`live`, `vod`, `series`)
|
||||
1. `WorkspaceContextPanelComponent`
|
||||
1. `WorkspaceContextPanelComponent`
|
||||
3. Stalker category sections (`itv`, `radio`, `vod`, `series`)
|
||||
1. `WorkspaceContextPanelComponent`
|
||||
1. `WorkspaceContextPanelComponent`
|
||||
4. `/workspace/settings`
|
||||
1. `WorkspaceSettingsContextPanelComponent`
|
||||
1. `WorkspaceSettingsContextPanelComponent`
|
||||
5. Downloads sections
|
||||
1. `WorkspaceCollectionContextPanelComponent`
|
||||
1. `WorkspaceCollectionContextPanelComponent`
|
||||
|
||||
The context panel is part of the shell contract. New workspace-level routes
|
||||
should explicitly decide whether they need one rather than adding local
|
||||
@@ -197,6 +197,91 @@ Keyboard shortcut help is shell-owned:
|
||||
added. Do not include native browser/editor behavior such as `Tab` or
|
||||
platform text editing shortcuts.
|
||||
|
||||
## Window Chrome And Custom Title Bar
|
||||
|
||||
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.
|
||||
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
|
||||
window snapping keep working.
|
||||
|
||||
The controls are mounted once in `app-root` (not inside the workspace
|
||||
header) as a `position: fixed` top-right overlay so they stay clickable
|
||||
above full-window content such as the multi-EPG cdk overlay and Material
|
||||
dialog backdrops — the same behavior as the macOS traffic lights. Because
|
||||
CDK overlays render as popovers in the browser top layer (above any
|
||||
z-index), the component host is itself a `popover="manual"` element: it
|
||||
enters the top layer on init and re-enters it (hide + show) whenever
|
||||
another popover opens, so the controls always paint last. The
|
||||
`z-index: 10000` remains only as a fallback when the popover API is
|
||||
unavailable. They render only when
|
||||
`RuntimeCapabilitiesService.usesCustomWindowControls` is true (Windows/Linux
|
||||
Electron with the window-control bridge methods available); the PWA and
|
||||
macOS never mount them.
|
||||
|
||||
IPC contract (constants in `libs/shared/interfaces/src/lib/ipc-commands.ts`,
|
||||
handlers in `apps/electron-backend/src/app/events/window.events.ts`):
|
||||
|
||||
1. `WINDOW:MINIMIZE`, `WINDOW:TOGGLE_MAXIMIZE`, `WINDOW:CLOSE`,
|
||||
`WINDOW:GET_STATE` are `ipcMain.handle` channels resolved from the sender
|
||||
WebContents. Close goes through `win.close()` so the existing
|
||||
window-bounds persistence in `app.ts` still runs.
|
||||
2. `WINDOW:STATE_CHANGED` is pushed main → renderer on
|
||||
maximize/unmaximize/enter-full-screen/leave-full-screen so the
|
||||
maximize/restore glyph stays correct for externally triggered changes
|
||||
(double-click on a drag region, OS snap, F11). The controls hide
|
||||
themselves while the window is fullscreen.
|
||||
|
||||
Layout integration:
|
||||
|
||||
1. `document.body` gets a `frameless-platform` class (set in
|
||||
`AppComponent`, same mechanism as `dark-theme`) — body-level so rules
|
||||
also reach cdk-overlay content rendered outside `app-root`.
|
||||
2. `apps/web/src/styles.scss` reserves `padding-right: 150px` in
|
||||
top-aligned drag regions (`.workspace-header`, multi-EPG
|
||||
`#epg-navigation`) for the 3 × 46px button strip.
|
||||
3. Button colors follow the theme via CSS variables (`--app-on-surface`,
|
||||
`--app-hover-overlay`); the close button uses the Windows-style red
|
||||
hover (`#e81123`). No theme IPC is involved.
|
||||
|
||||
Window decorations on Linux (shadows, corners):
|
||||
|
||||
1. Hiding the title bar removes the window manager's decorations, so the
|
||||
shadow/rounded corners must come from client-side decorations (CSD).
|
||||
Electron only draws CSD on native Wayland, and frameless-window CSD
|
||||
(GTK drop shadow + extended resize boundaries, `hasShadow: true` by
|
||||
default) requires **Electron >= 41** — the reason the dependency was
|
||||
bumped from 39. Electron picks Wayland automatically on Wayland
|
||||
sessions since 38.2.
|
||||
2. On X11 sessions frameless windows stay undecorated (square, no
|
||||
shadow) — an upstream platform limitation shared by e.g. VS Code.
|
||||
3. Rounded corners for frameless Linux windows are not yet supported by
|
||||
Electron (tracked upstream as planned work); Windows 11 keeps its DWM
|
||||
rounded corners and shadow because the standard frame is retained.
|
||||
|
||||
Toolchain notes for the Electron 41 upgrade:
|
||||
|
||||
1. `better-sqlite3` is pinned to exactly `12.9.0` — the last release that
|
||||
ships prebuilt binaries for BOTH Node 20 (ABI 115, used by Jest) and
|
||||
Electron 41 (ABI 145, used at runtime). `12.10.0` dropped the Node 20
|
||||
prebuilds, which forces a from-source build that fails on machines
|
||||
without a C++ toolchain.
|
||||
2. The pnpm override `node-abi@3.85.0 -> 3.92.0` is required so
|
||||
`@electron/rebuild` (via `electron-builder install-app-deps`) can map
|
||||
Electron 41 to its ABI.
|
||||
|
||||
Known caveats:
|
||||
|
||||
1. DIY buttons cannot show the Windows 11 Snap Layouts flyout (only native
|
||||
caption buttons or the Window Controls Overlay get that).
|
||||
2. Double-click-to-maximize on drag regions is handled natively by
|
||||
Electron/Chromium; on Linux the exact behavior depends on the window
|
||||
manager.
|
||||
|
||||
## Maintenance Guidance
|
||||
|
||||
Use this document as the source of truth when changing workspace shell behavior.
|
||||
|
||||
@@ -38,6 +38,28 @@ export class RuntimeCapabilitiesService {
|
||||
return this.platform === 'darwin';
|
||||
}
|
||||
|
||||
get isWindows(): boolean {
|
||||
return this.platform === 'win32';
|
||||
}
|
||||
|
||||
get isLinux(): boolean {
|
||||
return this.platform === 'linux';
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the window has no native title bar and the renderer must
|
||||
* draw its own window-management buttons (Windows/Linux). macOS keeps
|
||||
* the native traffic lights instead.
|
||||
*/
|
||||
get usesCustomWindowControls(): boolean {
|
||||
return (
|
||||
(this.isWindows || this.isLinux) &&
|
||||
['minimizeWindow', 'toggleMaximizeWindow', 'closeWindow'].every(
|
||||
(methodName) => this.hasElectronMethod(methodName)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
get supportsEpg(): boolean {
|
||||
return (
|
||||
this.supportsEpgImport &&
|
||||
|
||||
@@ -135,6 +135,11 @@ export interface ElectronBridgeDialogFilter {
|
||||
extensions: string[];
|
||||
}
|
||||
|
||||
export interface ElectronBridgeWindowState {
|
||||
isMaximized: boolean;
|
||||
isFullScreen: boolean;
|
||||
}
|
||||
|
||||
export interface ElectronBridgeAiSettings {
|
||||
aiProvider: string;
|
||||
aiModelName: string;
|
||||
@@ -425,6 +430,13 @@ export interface ElectronBridgeApi {
|
||||
) => () => void;
|
||||
getAppVersion: () => Promise<string>;
|
||||
platform: string;
|
||||
minimizeWindow: () => Promise<void>;
|
||||
toggleMaximizeWindow: () => Promise<ElectronBridgeWindowState>;
|
||||
closeWindow: () => Promise<void>;
|
||||
getWindowState: () => Promise<ElectronBridgeWindowState>;
|
||||
onWindowStateChange: (
|
||||
callback: (state: ElectronBridgeWindowState) => void
|
||||
) => () => void;
|
||||
fetchPlaylistByUrl: (url: string, title?: string) => Promise<Playlist>;
|
||||
updatePlaylistFromFilePath: (
|
||||
filePath: string,
|
||||
|
||||
@@ -75,3 +75,10 @@ export const DELETE_ALL_PLAYLISTS = 'DELETE_ALL_PLAYLISTS';
|
||||
|
||||
// Remote Control
|
||||
export const REMOTE_CONTROL_CHANGE_CHANNEL = 'REMOTE_CONTROL_CHANGE_CHANNEL';
|
||||
|
||||
// Window controls (custom title bar on Windows/Linux)
|
||||
export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
|
||||
export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
|
||||
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
@@ -12,3 +12,4 @@ export * from './lib/progress-capsule/progress-capsule.component';
|
||||
export * from './lib/resizable/resizable.directive';
|
||||
export * from './lib/season-container/season-container.component';
|
||||
export * from './lib/watched-badge/watched-badge.component';
|
||||
export * from './lib/window-controls/window-controls.component';
|
||||
@@ -0,0 +1,55 @@
|
||||
<button
|
||||
type="button"
|
||||
class="window-control"
|
||||
data-test-id="window-minimize"
|
||||
aria-label="Minimize"
|
||||
title="Minimize"
|
||||
(click)="onMinimize()"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" class="crisp" aria-hidden="true">
|
||||
<path d="M0 5h10" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="window-control"
|
||||
data-test-id="window-maximize"
|
||||
[attr.aria-label]="isMaximized() ? 'Restore' : 'Maximize'"
|
||||
[attr.title]="isMaximized() ? 'Restore' : 'Maximize'"
|
||||
(click)="onToggleMaximize()"
|
||||
>
|
||||
@if (isMaximized()) {
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
class="crisp"
|
||||
data-test-id="window-restore-glyph"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0.5 2.5h7v7h-7z" />
|
||||
<path d="M2.5 2.5v-2h7v7h-2" />
|
||||
</svg>
|
||||
} @else {
|
||||
<svg
|
||||
viewBox="0 0 10 10"
|
||||
class="crisp"
|
||||
data-test-id="window-maximize-glyph"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0.5 0.5h9v9h-9z" />
|
||||
</svg>
|
||||
}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="window-control close"
|
||||
data-test-id="window-close"
|
||||
aria-label="Close"
|
||||
title="Close"
|
||||
(click)="onClose()"
|
||||
>
|
||||
<svg viewBox="0 0 10 10" aria-hidden="true">
|
||||
<path d="M0.5 0.5l9 9m0 -9l-9 9" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -0,0 +1,88 @@
|
||||
// Floats above all app content so the window stays controllable while
|
||||
// dialogs or the multi-EPG overlay are open — same behavior as the macOS
|
||||
// traffic lights. The host is a manual popover (see component) because CDK
|
||||
// overlays live in the browser top layer where z-index cannot compete; the
|
||||
// declarations below also neutralize the UA popover styles (inset, margin,
|
||||
// border, background) and keep the strip visible/positioned when the
|
||||
// popover API is unavailable.
|
||||
:host {
|
||||
position: fixed;
|
||||
inset: 0 0 auto auto;
|
||||
z-index: 10000;
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
color: inherit;
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
|
||||
:host(.is-hidden) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.window-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 46px;
|
||||
height: 32px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
color: var(--app-on-surface, #1d1b1e);
|
||||
opacity: 0.85;
|
||||
// Native caption buttons use the arrow cursor, not the hand.
|
||||
cursor: default;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
svg.crisp {
|
||||
shape-rendering: crispEdges;
|
||||
}
|
||||
|
||||
path {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--app-hover-overlay, rgba(0, 0, 0, 0.06));
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--app-on-surface, #1d1b1e) 14%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
&.close:hover {
|
||||
background: #e81123;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
&.close:active {
|
||||
background: #c50f1f;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
|
||||
import { WindowControlsComponent } from './window-controls.component';
|
||||
|
||||
describe('WindowControlsComponent', () => {
|
||||
let fixture: ComponentFixture<WindowControlsComponent>;
|
||||
let stateChangeCallback:
|
||||
| ((state: ElectronBridgeWindowState) => void)
|
||||
| undefined;
|
||||
|
||||
const unsubscribe = jest.fn();
|
||||
const electronMock = {
|
||||
getWindowState: jest.fn(),
|
||||
onWindowStateChange: jest.fn(),
|
||||
minimizeWindow: jest.fn(),
|
||||
toggleMaximizeWindow: jest.fn(),
|
||||
closeWindow: jest.fn(),
|
||||
};
|
||||
|
||||
const query = (testId: string): HTMLElement | null =>
|
||||
fixture.nativeElement.querySelector(`[data-test-id="${testId}"]`);
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
stateChangeCallback = undefined;
|
||||
electronMock.getWindowState.mockResolvedValue({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
electronMock.toggleMaximizeWindow.mockResolvedValue({
|
||||
isMaximized: true,
|
||||
isFullScreen: false,
|
||||
});
|
||||
electronMock.minimizeWindow.mockResolvedValue(undefined);
|
||||
electronMock.closeWindow.mockResolvedValue(undefined);
|
||||
electronMock.onWindowStateChange.mockImplementation(
|
||||
(callback: (state: ElectronBridgeWindowState) => void) => {
|
||||
stateChangeCallback = callback;
|
||||
return unsubscribe;
|
||||
}
|
||||
);
|
||||
(window as { electron?: unknown }).electron = electronMock;
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [WindowControlsComponent],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(WindowControlsComponent);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as { electron?: unknown }).electron;
|
||||
});
|
||||
|
||||
it('renders minimize, maximize and close buttons with the maximize glyph', () => {
|
||||
expect(query('window-minimize')).not.toBeNull();
|
||||
expect(query('window-maximize')).not.toBeNull();
|
||||
expect(query('window-close')).not.toBeNull();
|
||||
expect(query('window-maximize-glyph')).not.toBeNull();
|
||||
expect(query('window-restore-glyph')).toBeNull();
|
||||
expect(electronMock.getWindowState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls the bridge methods when the buttons are clicked', () => {
|
||||
query('window-minimize')?.click();
|
||||
expect(electronMock.minimizeWindow).toHaveBeenCalledTimes(1);
|
||||
|
||||
query('window-maximize')?.click();
|
||||
expect(electronMock.toggleMaximizeWindow).toHaveBeenCalledTimes(1);
|
||||
|
||||
query('window-close')?.click();
|
||||
expect(electronMock.closeWindow).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('swaps to the restore glyph when the window state reports maximized', () => {
|
||||
stateChangeCallback?.({ isMaximized: true, isFullScreen: false });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('window-restore-glyph')).not.toBeNull();
|
||||
expect(query('window-maximize-glyph')).toBeNull();
|
||||
expect(query('window-maximize')?.getAttribute('aria-label')).toBe(
|
||||
'Restore'
|
||||
);
|
||||
});
|
||||
|
||||
it('applies the restore state returned by toggleMaximizeWindow', async () => {
|
||||
query('window-maximize')?.click();
|
||||
await fixture.whenStable();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(query('window-restore-glyph')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('hides the host while the window is in fullscreen', () => {
|
||||
stateChangeCallback?.({ isMaximized: false, isFullScreen: true });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).classList.contains(
|
||||
'is-hidden'
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
stateChangeCallback?.({ isMaximized: false, isFullScreen: false });
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(
|
||||
(fixture.nativeElement as HTMLElement).classList.contains(
|
||||
'is-hidden'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('unsubscribes from window state changes on destroy', () => {
|
||||
fixture.destroy();
|
||||
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ElectronBridgeWindowState } from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Renderer-drawn window-management buttons (minimize / maximize-restore /
|
||||
* close) for the frameless title bar on Windows and Linux. macOS keeps the
|
||||
* native traffic lights instead.
|
||||
*
|
||||
* Rendered once in the app root so the buttons stay reachable above
|
||||
* full-window content such as the multi-EPG overlay and dialog backdrops —
|
||||
* mirroring how the macOS traffic lights float above everything. CDK
|
||||
* overlays render as popovers in the browser top layer (above any
|
||||
* z-index), so the host is itself a manual popover and re-enters the top
|
||||
* layer whenever another popover opens, keeping the controls topmost.
|
||||
* Hidden while the window is in fullscreen, matching native title-bar
|
||||
* behavior.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-window-controls',
|
||||
templateUrl: './window-controls.component.html',
|
||||
styleUrl: './window-controls.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
popover: 'manual',
|
||||
'[class.is-hidden]': 'isFullScreen()',
|
||||
},
|
||||
})
|
||||
export class WindowControlsComponent implements AfterViewInit {
|
||||
readonly isMaximized = signal(false);
|
||||
readonly isFullScreen = signal(false);
|
||||
|
||||
private readonly host =
|
||||
inject<ElementRef<HTMLElement>>(ElementRef).nativeElement;
|
||||
|
||||
constructor() {
|
||||
const bridge = window.electron;
|
||||
|
||||
void bridge
|
||||
?.getWindowState?.()
|
||||
.then((state) => this.applyState(state))
|
||||
.catch(() => undefined);
|
||||
|
||||
const unsubscribe = bridge?.onWindowStateChange?.((state) =>
|
||||
this.applyState(state)
|
||||
);
|
||||
|
||||
document.addEventListener('toggle', this.onDocumentToggle, true);
|
||||
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
unsubscribe?.();
|
||||
document.removeEventListener('toggle', this.onDocumentToggle, true);
|
||||
});
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.enterTopLayer();
|
||||
}
|
||||
|
||||
onMinimize(): void {
|
||||
void window.electron?.minimizeWindow?.();
|
||||
}
|
||||
|
||||
onToggleMaximize(): void {
|
||||
void window.electron
|
||||
?.toggleMaximizeWindow?.()
|
||||
.then((state) => this.applyState(state))
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
void window.electron?.closeWindow?.();
|
||||
}
|
||||
|
||||
private applyState(state: ElectronBridgeWindowState | undefined): void {
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isMaximized.set(state.isMaximized);
|
||||
this.isFullScreen.set(state.isFullScreen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-enter the top layer after any other popover opens: top-layer
|
||||
* elements paint in insertion order, so hiding and re-showing puts the
|
||||
* controls back above freshly opened CDK overlays (dialogs, multi-EPG).
|
||||
*/
|
||||
private readonly onDocumentToggle = (event: Event): void => {
|
||||
const newState = (event as { newState?: string }).newState;
|
||||
if (event.target === this.host || newState !== 'open') {
|
||||
return;
|
||||
}
|
||||
|
||||
requestAnimationFrame(() => this.enterTopLayer(true));
|
||||
};
|
||||
|
||||
private enterTopLayer(reassert = false): void {
|
||||
if (
|
||||
typeof this.host.showPopover !== 'function' ||
|
||||
!this.host.isConnected
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.host.matches(':popover-open')) {
|
||||
if (!reassert) {
|
||||
return;
|
||||
}
|
||||
this.host.hidePopover();
|
||||
}
|
||||
this.host.showPopover();
|
||||
} catch {
|
||||
// Stays a regular fixed element when the popover API is
|
||||
// unavailable — still above non-top-layer content via z-index.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user