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. + } + } +}