diff --git a/apps/electron-backend-e2e/src/window-controls.e2e.ts b/apps/electron-backend-e2e/src/window-controls.e2e.ts
new file mode 100644
index 000000000..5e63f8db1
--- /dev/null
+++ b/apps/electron-backend-e2e/src/window-controls.e2e.ts
@@ -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);
+ }
+ });
+});
diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts
index 1ea1d604f..27b9ece70 100644
--- a/apps/electron-backend/src/app/api/main.preload.ts
+++ b/apps/electron-backend/src/app/api/main.preload.ts
@@ -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) =>
diff --git a/apps/electron-backend/src/app/app.ts b/apps/electron-backend/src/app/app.ts
index db4d12e35..b970513ea 100644
--- a/apps/electron-backend/src/app/app.ts
+++ b/apps/electron-backend/src/app/app.ts
@@ -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();
}
diff --git a/apps/electron-backend/src/app/events/window.events.spec.ts b/apps/electron-backend/src/app/events/window.events.spec.ts
new file mode 100644
index 000000000..c5195ef51
--- /dev/null
+++ b/apps/electron-backend/src/app/events/window.events.spec.ts
@@ -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();
+ });
+});
diff --git a/apps/electron-backend/src/app/events/window.events.ts b/apps/electron-backend/src/app/events/window.events.ts
new file mode 100644
index 000000000..4a4f5a9f2
--- /dev/null
+++ b/apps/electron-backend/src/app/events/window.events.ts
@@ -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));
+});
diff --git a/apps/electron-backend/src/main.ts b/apps/electron-backend/src/main.ts
index 4d616cbde..9ecc6b0d2 100644
--- a/apps/electron-backend/src/main.ts
+++ b/apps/electron-backend/src/main.ts
@@ -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();
diff --git a/apps/web/src/app/app.component.html b/apps/web/src/app/app.component.html
index b11815628..da17c53a0 100644
--- a/apps/web/src/app/app.component.html
+++ b/apps/web/src/app/app.component.html
@@ -1,3 +1,7 @@
+
+@if (usesCustomWindowControls) {
+
+}
diff --git a/apps/web/src/app/app.component.ts b/apps/web/src/app/app.component.ts
index a26f63e50..22a41a3f6 100644
--- a/apps/web/src/app/app.component.ts
+++ b/apps/web/src/app/app.component.ts
@@ -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 &&
diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss
index 6470250f7..799b67185 100644
--- a/apps/web/src/styles.scss
+++ b/apps/web/src/styles.scss
@@ -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 {
diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md
index 05a7642e6..991582789 100644
--- a/docs/architecture/workspace-shell.md
+++ b/docs/architecture/workspace-shell.md
@@ -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.
diff --git a/libs/services/src/lib/runtime-capabilities.service.ts b/libs/services/src/lib/runtime-capabilities.service.ts
index d2f262938..bdc46c198 100644
--- a/libs/services/src/lib/runtime-capabilities.service.ts
+++ b/libs/services/src/lib/runtime-capabilities.service.ts
@@ -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 &&
diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts
index afabf5477..ac21d369a 100644
--- a/libs/shared/interfaces/src/lib/electron-api.interface.ts
+++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts
@@ -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;
platform: string;
+ minimizeWindow: () => Promise;
+ toggleMaximizeWindow: () => Promise;
+ closeWindow: () => Promise;
+ getWindowState: () => Promise;
+ onWindowStateChange: (
+ callback: (state: ElectronBridgeWindowState) => void
+ ) => () => void;
fetchPlaylistByUrl: (url: string, title?: string) => Promise;
updatePlaylistFromFilePath: (
filePath: string,
diff --git a/libs/shared/interfaces/src/lib/ipc-commands.ts b/libs/shared/interfaces/src/lib/ipc-commands.ts
index 4c5ae551f..d309675b7 100644
--- a/libs/shared/interfaces/src/lib/ipc-commands.ts
+++ b/libs/shared/interfaces/src/lib/ipc-commands.ts
@@ -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';
diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts
index 773f06bb8..96a626368 100644
--- a/libs/ui/components/src/index.ts
+++ b/libs/ui/components/src/index.ts
@@ -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';
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.html b/libs/ui/components/src/lib/window-controls/window-controls.component.html
new file mode 100644
index 000000000..a98c9279d
--- /dev/null
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.html
@@ -0,0 +1,55 @@
+
+
+
+
+
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.scss b/libs/ui/components/src/lib/window-controls/window-controls.component.scss
new file mode 100644
index 000000000..22c72625a
--- /dev/null
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.scss
@@ -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;
+ }
+}
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts
new file mode 100644
index 000000000..c9f152259
--- /dev/null
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.spec.ts
@@ -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;
+ 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);
+ });
+});
diff --git a/libs/ui/components/src/lib/window-controls/window-controls.component.ts b/libs/ui/components/src/lib/window-controls/window-controls.component.ts
new file mode 100644
index 000000000..861c06ef1
--- /dev/null
+++ b/libs/ui/components/src/lib/window-controls/window-controls.component.ts
@@ -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).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.
+ }
+ }
+}