mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)
Settings > General gains "Window on startup" (normal / maximized / fullscreen), Electron only, mirrored into the main-process config by SETTINGS_UPDATE and applied at the next window creation. `--fullscreen` forces one fullscreen launch (consumed by the first window). F11 toggles OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on Windows/Linux where the title bar is hidden — and is skipped while the player owns document.fullscreenElement. attachWindowStateEvents tracks native and HTML fullscreen as two flags, since Electron leaves only the HTML state when the window was already natively fullscreen. macOS ignores the constructor `fullscreen` option on a hidden window, so ready-to-show repeats the request after show(). Toggles are decided by an observe-only, event-fed tracker (native-fullscreen-transitions.ts), never against isFullScreen(). Closes #1455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
078bd07d94
commit
fa9084fca3
57 files changed
+1650
-8
No files matched your search
@@ -80,6 +80,7 @@ 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_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
|
||||
const WINDOW_CLOSE = 'WINDOW:CLOSE';
|
||||
const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
|
||||
const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
|
||||
@@ -414,6 +415,7 @@ const electronApi: ElectronBridgeApi = {
|
||||
},
|
||||
minimizeWindow: () => ipcRenderer.invoke(WINDOW_MINIMIZE),
|
||||
toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE),
|
||||
toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN),
|
||||
closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE),
|
||||
getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE),
|
||||
onWindowStateChange: (
|
||||
|
||||
@@ -201,6 +201,69 @@ describe('window state change pushes', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps isFullScreen=true when the player leaves HTML fullscreen inside a natively fullscreen window', () => {
|
||||
const win = createMockStateWindow();
|
||||
// A fullscreen launch (or F11 before attach) — the window is
|
||||
// natively fullscreen when the events are wired up.
|
||||
win.isFullScreen.mockReturnValue(true);
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
// Electron sees the window is already fullscreen, so entering HTML
|
||||
// fullscreen only emits the html variant …
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
|
||||
// … and leaving it restores ONLY the HTML state: no
|
||||
// 'leave-full-screen' fires and the window stays fullscreen. A
|
||||
// single flag would un-hide the window controls here.
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
|
||||
// F11 afterwards is what actually leaves fullscreen.
|
||||
fireWindowEvent(win, 'leave-full-screen');
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a native fullscreen entered by event across a player HTML fullscreen round-trip', () => {
|
||||
const win = createMockStateWindow();
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
fireWindowEvent(win, 'enter-full-screen');
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('clears isFullScreen when both the native and the HTML state are left', () => {
|
||||
const win = createMockStateWindow();
|
||||
attachWindowStateEvents(win);
|
||||
|
||||
// Windows/Linux HTML fullscreen without prior native fullscreen:
|
||||
// Electron toggles the native window too, so both pairs fire.
|
||||
fireWindowEvent(win, 'enter-full-screen');
|
||||
fireWindowEvent(win, 'enter-html-full-screen');
|
||||
fireWindowEvent(win, 'leave-full-screen');
|
||||
fireWindowEvent(win, 'leave-html-full-screen');
|
||||
|
||||
expect(win.webContents.send).toHaveBeenLastCalledWith(
|
||||
WINDOW_STATE_CHANGED,
|
||||
{ isMaximized: false, isFullScreen: false }
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the fullscreen flag when a maximize event arrives mid-transition', () => {
|
||||
const win = createMockStateWindow();
|
||||
win.isFullScreen.mockReturnValue(true);
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
const mockClearStorageData = jest.fn();
|
||||
const mockIsFrameCopyRuntimeUsable = jest.fn<boolean, []>();
|
||||
const mockIsEmbeddedMpvFeatureEnabled = jest.fn<boolean, []>();
|
||||
const mockHasSwitch = jest.fn<boolean, [string]>();
|
||||
|
||||
jest.mock('electron', () => ({
|
||||
app: {
|
||||
commandLine: {
|
||||
hasSwitch: mockHasSwitch,
|
||||
},
|
||||
getPath: jest.fn(() => '/tmp'),
|
||||
isPackaged: false,
|
||||
isReady: jest.fn(() => false),
|
||||
@@ -31,6 +35,7 @@ jest.mock('./services/store.service', () => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
},
|
||||
STARTUP_WINDOW_MODE: 'startupWindowMode',
|
||||
WINDOW_BOUNDS: 'windowBounds',
|
||||
}));
|
||||
|
||||
@@ -53,6 +58,7 @@ import { app as electronApp, BrowserWindow, screen } from 'electron';
|
||||
import * as path from 'path';
|
||||
import { pathToFileURL } from 'url';
|
||||
import { store } from './services/store.service';
|
||||
import { getPendingFullScreenTarget } from './services/native-fullscreen-transitions';
|
||||
|
||||
type MockMainWindow = {
|
||||
center: jest.Mock<void, []>;
|
||||
@@ -64,8 +70,10 @@ type MockMainWindow = {
|
||||
isMaximized: jest.Mock<boolean, []>;
|
||||
loadFile: jest.Mock<Promise<void>, [string]>;
|
||||
loadURL: jest.Mock<Promise<void>, [string]>;
|
||||
maximize: jest.Mock<void, []>;
|
||||
on: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
once: jest.Mock<void, [string, (...args: unknown[]) => void]>;
|
||||
setFullScreen: jest.Mock<void, [boolean]>;
|
||||
setMenu: jest.Mock<void, [unknown]>;
|
||||
show: jest.Mock<void, []>;
|
||||
webContents: {
|
||||
@@ -83,9 +91,11 @@ function createMockMainWindow(): MockMainWindow {
|
||||
isMaximized: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
loadFile: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
loadURL: jest.fn<Promise<void>, [string]>().mockResolvedValue(),
|
||||
maximize: jest.fn<void, []>(),
|
||||
on: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
once: jest.fn<void, [string, (...args: unknown[]) => void]>(),
|
||||
isDestroyed: jest.fn<boolean, []>().mockReturnValue(false),
|
||||
setFullScreen: jest.fn<void, [boolean]>(),
|
||||
setMenu: jest.fn<void, [unknown]>(),
|
||||
show: jest.fn<void, []>(),
|
||||
webContents: {
|
||||
@@ -97,6 +107,7 @@ function createMockMainWindow(): MockMainWindow {
|
||||
}
|
||||
|
||||
type AppInternals = {
|
||||
launchFullscreenSwitchConsumed: boolean;
|
||||
loadedMainWindow: MockMainWindow | null;
|
||||
mainWindow: MockMainWindow | null;
|
||||
mainWindowListeners: Array<(mainWindow: MockMainWindow) => void>;
|
||||
@@ -119,6 +130,7 @@ describe('Electron app security helpers', () => {
|
||||
mockIsEmbeddedMpvFeatureEnabled.mockReturnValue(false);
|
||||
mockIsFrameCopyRuntimeUsable.mockReturnValue(false);
|
||||
const appInternals = getAppInternals();
|
||||
appInternals.launchFullscreenSwitchConsumed = false;
|
||||
appInternals.loadedMainWindow = null;
|
||||
appInternals.mainWindow = null;
|
||||
appInternals.mainWindowLoadPromise = null;
|
||||
@@ -129,6 +141,7 @@ describe('Electron app security helpers', () => {
|
||||
workAreaSize: { height: 720, width: 1280 },
|
||||
});
|
||||
(store.get as jest.Mock).mockReturnValue(undefined);
|
||||
mockHasSwitch.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('creates an explicitly hardened BrowserWindow webPreferences object', () => {
|
||||
@@ -310,6 +323,170 @@ describe('Electron app security helpers', () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe('startup window mode', () => {
|
||||
function createWindowViaOnReady(): MockMainWindow {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
getAppInternals().onReady();
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
function fireReadyToShow(mainWindow: MockMainWindow): void {
|
||||
const handlers = mainWindow.once.mock.calls
|
||||
.filter(([eventName]) => eventName === 'ready-to-show')
|
||||
.map(([, handler]) => handler);
|
||||
|
||||
expect(handlers).toHaveLength(1);
|
||||
handlers[0]();
|
||||
}
|
||||
|
||||
function storeStartupWindowMode(mode: string): void {
|
||||
(store.get as jest.Mock).mockImplementation((key: string) =>
|
||||
key === 'startupWindowMode' ? mode : undefined
|
||||
);
|
||||
}
|
||||
|
||||
it('creates a plain window when nothing is stored', () => {
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(mainWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('creates the window fullscreen when the stored mode says so', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// Windows/Linux honour the constructor option while the window
|
||||
// is still hidden.
|
||||
mainWindow.isFullScreen.mockReturnValue(true);
|
||||
|
||||
// Still created hidden: fullscreen is entered before the first
|
||||
// paint, and ready-to-show reveals it like any other launch.
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullscreen: true, show: false })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
// Already fullscreen: no second request, which would animate a
|
||||
// toggle on top of the one that took.
|
||||
expect(mainWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repeats the fullscreen request after show() where the constructor option did not take', () => {
|
||||
storeStartupWindowMode('fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
// macOS: an NSWindow only toggles fullscreen once it is on
|
||||
// screen, so a hidden window ignores the constructor option.
|
||||
mainWindow.isFullScreen.mockReturnValue(false);
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.setFullScreen).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.setFullScreen).toHaveBeenCalledWith(true);
|
||||
// Registered with the transition tracker, so an F11 pressed
|
||||
// during the animation is decided against this target and
|
||||
// leaves fullscreen instead of requesting it again.
|
||||
expect(
|
||||
getPendingFullScreenTarget(
|
||||
mainWindow as unknown as Electron.BrowserWindow
|
||||
)
|
||||
).toBe(true);
|
||||
expect(mainWindow.show.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mainWindow.setFullScreen.mock.invocationCallOrder[0]
|
||||
);
|
||||
});
|
||||
|
||||
it('maximizes only once the window is ready to show, before revealing it', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
// maximize() on a hidden window shows it — a blank flash if it
|
||||
// ran here.
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
|
||||
fireReadyToShow(mainWindow);
|
||||
|
||||
expect(mainWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
expect(mainWindow.show).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
mainWindow.maximize.mock.invocationCallOrder[0]
|
||||
).toBeLessThan(mainWindow.show.mock.invocationCallOrder[0]);
|
||||
});
|
||||
|
||||
it('lets --fullscreen override the stored mode for this launch without persisting it', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
mockHasSwitch.mockImplementation((name) => name === 'fullscreen');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
expect(store.set).not.toHaveBeenCalledWith(
|
||||
'startupWindowMode',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('consumes --fullscreen with the first window so a window re-created from the macOS Dock follows the stored setting', () => {
|
||||
storeStartupWindowMode('maximized');
|
||||
mockHasSwitch.mockImplementation((name) => name === 'fullscreen');
|
||||
|
||||
const firstWindow = createWindowViaOnReady();
|
||||
expect(BrowserWindow).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(firstWindow);
|
||||
|
||||
// The user left fullscreen and closed the only window; the
|
||||
// process survived and the Dock brings the window back.
|
||||
const appInternals = getAppInternals();
|
||||
appInternals.mainWindow = null;
|
||||
const secondWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(
|
||||
secondWindow
|
||||
);
|
||||
|
||||
App.ensureMainWindow();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledTimes(2);
|
||||
expect(BrowserWindow).toHaveBeenLastCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(secondWindow);
|
||||
expect(secondWindow.setFullScreen).not.toHaveBeenCalled();
|
||||
expect(secondWindow.maximize).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('treats a hand-edited unknown stored mode as a plain window', () => {
|
||||
storeStartupWindowMode('kiosk');
|
||||
|
||||
const mainWindow = createWindowViaOnReady();
|
||||
|
||||
expect(BrowserWindow).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ fullscreen: true })
|
||||
);
|
||||
fireReadyToShow(mainWindow);
|
||||
expect(mainWindow.maximize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('creates the main window immediately when Electron is already ready', () => {
|
||||
const mainWindow = createMockMainWindow();
|
||||
(BrowserWindow as unknown as jest.Mock).mockReturnValue(mainWindow);
|
||||
|
||||
@@ -12,9 +12,21 @@ import {
|
||||
isWindowTraceEnabled,
|
||||
trace,
|
||||
} from './services/debug-trace';
|
||||
import { store, WINDOW_BOUNDS } from './services/store.service';
|
||||
import {
|
||||
STARTUP_WINDOW_MODE,
|
||||
store,
|
||||
WINDOW_BOUNDS,
|
||||
} from './services/store.service';
|
||||
import { isFrameCopyRuntimeUsable } from './services/embedded-mpv-frame-copy-platform.util';
|
||||
import { isEmbeddedMpvFeatureEnabled } from './services/embedded-mpv-runtime-policy.util';
|
||||
import {
|
||||
FULLSCREEN_LAUNCH_SWITCH,
|
||||
resolveStartupWindowMode,
|
||||
} from './services/startup-window-mode';
|
||||
import {
|
||||
requestFullScreen,
|
||||
trackNativeFullScreen,
|
||||
} from './services/native-fullscreen-transitions';
|
||||
|
||||
const externalBrowserProtocols = new Set(['http:', 'https:']);
|
||||
const trustedDevRendererHosts = new Set([
|
||||
@@ -223,6 +235,13 @@ export default class App {
|
||||
static BrowserWindow;
|
||||
private static loadedMainWindow: Electron.BrowserWindow | null = null;
|
||||
private static mainWindowLoadPromise: Promise<void> | null = null;
|
||||
/**
|
||||
* Whether the `--fullscreen` launch switch has already shaped a window.
|
||||
* See initMainWindow: the switch is consumed by the first window so a
|
||||
* window re-created later in the same process (macOS Dock) follows the
|
||||
* stored setting instead.
|
||||
*/
|
||||
private static launchFullscreenSwitchConsumed = false;
|
||||
private static rendererLoadingEnabled = false;
|
||||
|
||||
private static shouldOpenDevTools() {
|
||||
@@ -384,6 +403,16 @@ export default class App {
|
||||
isFullScreen: win.isFullScreen(),
|
||||
};
|
||||
|
||||
// Native (OS-level) and HTML-element fullscreen are tracked apart
|
||||
// and OR-ed into the pushed flag. Electron remembers when the window
|
||||
// was already natively fullscreen before the player entered HTML
|
||||
// fullscreen and then leaves ONLY the HTML state on exit — no
|
||||
// 'leave-full-screen' fires and the window stays fullscreen. A single
|
||||
// flag cleared by 'leave-html-full-screen' would un-hide the window
|
||||
// controls over a window that is still fullscreen, which a fullscreen
|
||||
// launch or F11 followed by the player's F → Esc makes routine.
|
||||
const fullscreen = { native: state.isFullScreen, html: false };
|
||||
|
||||
const push = (patch: Partial<ElectronBridgeWindowState>) => {
|
||||
Object.assign(state, patch);
|
||||
|
||||
@@ -395,16 +424,30 @@ export default class App {
|
||||
// mutations of the tracked state.
|
||||
win.webContents.send(WINDOW_STATE_CHANGED, { ...state });
|
||||
};
|
||||
const pushFullScreen = () =>
|
||||
push({ isFullScreen: fullscreen.native || fullscreen.html });
|
||||
|
||||
win.on('maximize', () => push({ isMaximized: true }));
|
||||
win.on('unmaximize', () => push({ isMaximized: false }));
|
||||
// The html variants cover HTML-element fullscreen; not every
|
||||
// platform/trigger emits both pairs, and duplicate pushes with the
|
||||
// same payload are harmless.
|
||||
win.on('enter-full-screen', () => push({ isFullScreen: true }));
|
||||
win.on('enter-html-full-screen', () => push({ isFullScreen: true }));
|
||||
win.on('leave-full-screen', () => push({ isFullScreen: false }));
|
||||
win.on('leave-html-full-screen', () => push({ isFullScreen: false }));
|
||||
win.on('enter-full-screen', () => {
|
||||
fullscreen.native = true;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('leave-full-screen', () => {
|
||||
fullscreen.native = false;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('enter-html-full-screen', () => {
|
||||
fullscreen.html = true;
|
||||
pushFullScreen();
|
||||
});
|
||||
win.on('leave-html-full-screen', () => {
|
||||
fullscreen.html = false;
|
||||
pushFullScreen();
|
||||
});
|
||||
}
|
||||
|
||||
private static initMainWindow() {
|
||||
@@ -413,6 +456,17 @@ export default class App {
|
||||
const height = Math.min(720, workAreaSize.height || 720);
|
||||
|
||||
const savedWindowBounds = store.get(WINDOW_BOUNDS);
|
||||
const startupWindowMode = resolveStartupWindowMode({
|
||||
// One-shot: the switch describes the launch, not every window
|
||||
// this process ever opens. On macOS the process outlives its
|
||||
// last window and the Dock re-creates it through this same
|
||||
// path, which must then follow the stored setting only.
|
||||
cliHasFullscreenSwitch:
|
||||
!App.launchFullscreenSwitchConsumed &&
|
||||
app.commandLine.hasSwitch(FULLSCREEN_LAUNCH_SWITCH),
|
||||
storedMode: store.get(STARTUP_WINDOW_MODE),
|
||||
});
|
||||
App.launchFullscreenSwitchConsumed = true;
|
||||
|
||||
// Create the browser window.
|
||||
App.mainWindow = new BrowserWindow({
|
||||
@@ -422,6 +476,12 @@ export default class App {
|
||||
show: false,
|
||||
webPreferences: getMainWindowWebPreferences(),
|
||||
...savedWindowBounds,
|
||||
// Fullscreen is a constructor option: the window is created
|
||||
// hidden and enters fullscreen before its first paint. The saved
|
||||
// bounds stay spread in above — they are the normal bounds the
|
||||
// window returns to, and the close handler keeps persisting
|
||||
// getNormalBounds(), so a fullscreen session never corrupts them.
|
||||
...(startupWindowMode === 'fullscreen' ? { fullscreen: true } : {}),
|
||||
minHeight: 600,
|
||||
minWidth: 900,
|
||||
...App.getPlatformTitleBarOptions(),
|
||||
@@ -429,6 +489,9 @@ export default class App {
|
||||
App.mainWindow.setMenu(null);
|
||||
attachWindowTrace(App.mainWindow);
|
||||
App.attachWindowStateEvents(App.mainWindow);
|
||||
// Seeds the F11 tracker's fullscreen state now, while no transition
|
||||
// can be in flight; from here on it follows the window's events.
|
||||
trackNativeFullScreen(App.mainWindow);
|
||||
App.notifyMainWindowCreated(App.mainWindow);
|
||||
if (!savedWindowBounds) {
|
||||
App.mainWindow.center();
|
||||
@@ -436,7 +499,27 @@ export default class App {
|
||||
|
||||
// if main window is ready to show, close the splash window and show the main window
|
||||
App.mainWindow.once('ready-to-show', () => {
|
||||
// maximize() on a hidden window shows it (Electron docs), so it
|
||||
// has to wait for ready-to-show like show() does — any earlier
|
||||
// and a blank window flashes before the renderer paints.
|
||||
if (startupWindowMode === 'maximized') {
|
||||
App.mainWindow.maximize();
|
||||
}
|
||||
App.mainWindow.show();
|
||||
// macOS ignores the constructor's `fullscreen` while the window
|
||||
// is hidden — an NSWindow can only toggle fullscreen once it is
|
||||
// on screen — so the request is repeated after show() wherever
|
||||
// it has not taken yet. Windows/Linux honoured it at creation
|
||||
// (before the first paint) and are left alone. It goes through
|
||||
// the transition tracker so an F11 pressed during the animation
|
||||
// reads the pending target and leaves fullscreen instead of
|
||||
// asking for it again.
|
||||
if (
|
||||
startupWindowMode === 'fullscreen' &&
|
||||
!App.mainWindow.isFullScreen()
|
||||
) {
|
||||
requestFullScreen(App.mainWindow, true);
|
||||
}
|
||||
});
|
||||
|
||||
// Route target="_blank" / window.open() to the OS default browser
|
||||
|
||||
@@ -6,11 +6,13 @@ type SettingsUpdatePayload = Omit<
|
||||
| 'embeddedMpvFrameCopy'
|
||||
| 'language'
|
||||
| 'mpvPlayerArguments'
|
||||
| 'startupWindowMode'
|
||||
| 'vlcPlayerArguments'
|
||||
> & {
|
||||
embeddedMpvFrameCopy?: boolean | number;
|
||||
language?: string;
|
||||
mpvPlayerArguments?: ExternalPlayerArgumentsSetting;
|
||||
startupWindowMode?: string;
|
||||
vlcPlayerArguments?: ExternalPlayerArgumentsSetting;
|
||||
};
|
||||
type SettingsUpdateHandler = (
|
||||
@@ -23,6 +25,7 @@ const STORE_KEYS = {
|
||||
EMBEDDED_MPV_FRAME_COPY: 'EMBEDDED_MPV_FRAME_COPY',
|
||||
MPV_PLAYER_ARGUMENTS: 'MPV_PLAYER_ARGUMENTS',
|
||||
MPV_REUSE_INSTANCE: 'MPV_REUSE_INSTANCE',
|
||||
STARTUP_WINDOW_MODE: 'STARTUP_WINDOW_MODE',
|
||||
VLC_PLAYER_ARGUMENTS: 'VLC_PLAYER_ARGUMENTS',
|
||||
VLC_REUSE_INSTANCE: 'VLC_REUSE_INSTANCE',
|
||||
} as const;
|
||||
@@ -47,6 +50,7 @@ jest.mock('../services/store.service', () => ({
|
||||
EMBEDDED_MPV_FRAME_COPY: STORE_KEYS.EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS: STORE_KEYS.MPV_PLAYER_ARGUMENTS,
|
||||
MPV_REUSE_INSTANCE: STORE_KEYS.MPV_REUSE_INSTANCE,
|
||||
STARTUP_WINDOW_MODE: STORE_KEYS.STARTUP_WINDOW_MODE,
|
||||
VLC_PLAYER_ARGUMENTS: STORE_KEYS.VLC_PLAYER_ARGUMENTS,
|
||||
VLC_REUSE_INSTANCE: STORE_KEYS.VLC_REUSE_INSTANCE,
|
||||
store: {
|
||||
@@ -136,6 +140,23 @@ describe('SETTINGS_UPDATE', () => {
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('mirrors the startup window mode into the main-process store', () => {
|
||||
settingsUpdateHandler({}, { startupWindowMode: 'fullscreen' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.STARTUP_WINDOW_MODE, 'fullscreen'],
|
||||
]);
|
||||
expect(mockUpdateSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('normalizes an unknown startup window mode to normal instead of storing junk', () => {
|
||||
settingsUpdateHandler({}, { startupWindowMode: 'kiosk' });
|
||||
|
||||
expect(mockStoreSet.mock.calls).toEqual([
|
||||
[STORE_KEYS.STARTUP_WINDOW_MODE, 'normal'],
|
||||
]);
|
||||
});
|
||||
|
||||
it('reconciles an enabled remote-control update with the stored port', () => {
|
||||
mockStoreGet.mockImplementation(
|
||||
(key: string, fallbackValue: unknown): unknown =>
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { ipcMain } from 'electron';
|
||||
import { normalizeExternalPlayerArguments } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
normalizeExternalPlayerArguments,
|
||||
normalizeStartupWindowMode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { redactSensitiveData } from '@iptvnator/shared/logging';
|
||||
import {
|
||||
EMBEDDED_MPV_FRAME_COPY,
|
||||
MPV_PLAYER_ARGUMENTS,
|
||||
MPV_REUSE_INSTANCE,
|
||||
STARTUP_WINDOW_MODE,
|
||||
store,
|
||||
VLC_PLAYER_ARGUMENTS,
|
||||
VLC_REUSE_INSTANCE,
|
||||
@@ -47,6 +51,16 @@ ipcMain.handle('SETTINGS_UPDATE', (_event, arg) => {
|
||||
store.set(EMBEDDED_MPV_FRAME_COPY, !!arg.embeddedMpvFrameCopy);
|
||||
}
|
||||
|
||||
// Read by initMainWindow before any renderer exists, so it applies on
|
||||
// the next launch. Normalized here so a junk value never reaches the
|
||||
// config file.
|
||||
if (arg.startupWindowMode !== undefined) {
|
||||
store.set(
|
||||
STARTUP_WINDOW_MODE,
|
||||
normalizeStartupWindowMode(arg.startupWindowMode)
|
||||
);
|
||||
}
|
||||
|
||||
if (arg.vlcReuseInstance !== undefined) {
|
||||
store.set(VLC_REUSE_INSTANCE, arg.vlcReuseInstance);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { FULLSCREEN_TRANSITION_TIMEOUT_MS } from '../services/native-fullscreen-transitions';
|
||||
|
||||
const mockHandlers = new Map<
|
||||
string,
|
||||
(event: unknown, ...args: unknown[]) => unknown
|
||||
@@ -21,14 +23,25 @@ jest.mock('electron', () => ({
|
||||
}));
|
||||
|
||||
function createFakeWindow(
|
||||
initial: { maximized?: boolean; fullScreen?: boolean } = {}
|
||||
initial: {
|
||||
maximized?: boolean;
|
||||
fullScreen?: boolean;
|
||||
/**
|
||||
* Windows/Linux flip isFullScreen() inside setFullScreen(); macOS
|
||||
* animates and reports the old value until the transition lands,
|
||||
* which the test then signals through `emit`.
|
||||
*/
|
||||
asyncFullScreen?: boolean;
|
||||
} = {}
|
||||
) {
|
||||
const state = {
|
||||
maximized: initial.maximized ?? false,
|
||||
fullScreen: initial.fullScreen ?? false,
|
||||
};
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
return {
|
||||
state,
|
||||
isDestroyed: jest.fn(() => false),
|
||||
isMaximized: jest.fn(() => state.maximized),
|
||||
isFullScreen: jest.fn(() => state.fullScreen),
|
||||
@@ -39,6 +52,25 @@ function createFakeWindow(
|
||||
unmaximize: jest.fn(() => {
|
||||
state.maximized = false;
|
||||
}),
|
||||
setFullScreen: jest.fn((fullScreen: boolean) => {
|
||||
if (!initial.asyncFullScreen) {
|
||||
state.fullScreen = fullScreen;
|
||||
}
|
||||
}),
|
||||
on: jest.fn((event: string, listener: () => void) => {
|
||||
listeners.set(
|
||||
event,
|
||||
(listeners.get(event) ?? new Set()).add(listener)
|
||||
);
|
||||
}),
|
||||
off: jest.fn((event: string, listener: () => void) => {
|
||||
listeners.get(event)?.delete(listener);
|
||||
}),
|
||||
emit(event: string): void {
|
||||
for (const listener of [...(listeners.get(event) ?? [])]) {
|
||||
listener();
|
||||
}
|
||||
},
|
||||
close: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -59,6 +91,7 @@ describe('WindowEvents', () => {
|
||||
'WINDOW:CLOSE',
|
||||
'WINDOW:GET_STATE',
|
||||
'WINDOW:MINIMIZE',
|
||||
'WINDOW:TOGGLE_FULLSCREEN',
|
||||
'WINDOW:TOGGLE_MAXIMIZE',
|
||||
]);
|
||||
});
|
||||
@@ -94,6 +127,271 @@ describe('WindowEvents', () => {
|
||||
expect(result).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
});
|
||||
|
||||
it('enters fullscreen from a windowed state and reports the requested state', () => {
|
||||
const win = createFakeWindow({ maximized: true, fullScreen: false });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!(fakeEvent);
|
||||
|
||||
expect(win.setFullScreen).toHaveBeenCalledWith(true);
|
||||
expect(result).toEqual({ isMaximized: true, isFullScreen: true });
|
||||
});
|
||||
|
||||
it('leaves fullscreen and reports the requested state', () => {
|
||||
const win = createFakeWindow({ fullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
|
||||
const result = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!(fakeEvent);
|
||||
|
||||
expect(win.setFullScreen).toHaveBeenCalledWith(false);
|
||||
expect(result).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
});
|
||||
|
||||
it('keeps toggle parity when a second press arrives before the transition lands', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
// Still animating: the window reports the old state.
|
||||
expect(win.isFullScreen()).toBe(false);
|
||||
|
||||
const second = toggle(fakeEvent);
|
||||
|
||||
// Decided against the pending target, not the stale getter.
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
expect(second).toEqual({ isMaximized: false, isFullScreen: false });
|
||||
|
||||
// The first transition lands on the other state: the exit is still
|
||||
// queued on the platform, so the record stays and nothing is
|
||||
// re-issued — the tracker never requests on its own.
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
win.state.fullScreen = false;
|
||||
win.emit('leave-full-screen');
|
||||
|
||||
// Back to deciding from the tracked state.
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('stops tracking once the transition reports the requested state', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
// Reported the requested state: nothing re-issued.
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(1);
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('settles against the state the event conveys, not a stale getter', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
// Windows: the enter event fires while isFullScreen() still reports
|
||||
// the pre-transition false. Read the getter here and the landed
|
||||
// enter would not match the requested enter, leaving the record in
|
||||
// place to misdirect the next press.
|
||||
expect(win.isFullScreen()).toBe(false);
|
||||
win.emit('enter-full-screen');
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('decides a press that lands between the event and the getter update from the tracked state', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
// The enter event landed and settled the pending target …
|
||||
win.emit('enter-full-screen');
|
||||
// … but the getter has not caught up yet (Windows).
|
||||
expect(win.isFullScreen()).toBe(false);
|
||||
|
||||
toggle(fakeEvent);
|
||||
|
||||
// Decided against the event-fed state: this press is the exit.
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
});
|
||||
|
||||
it('follows fullscreen changes the app did not request', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
// Green button / Ctrl+Cmd+F: only the event tells the tracker.
|
||||
toggle(fakeEvent);
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
win.emit('leave-full-screen');
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('leaves no debt behind when a platform coalesces a burst into one event', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false], [true]]);
|
||||
|
||||
// A single enter is all the platform reports for the whole burst.
|
||||
// It lands on the final target, so the record is done — events
|
||||
// cannot say which request they belong to, and counting them would
|
||||
// leave phantom debt that reverses the user's next action.
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Green button right after: an unrelated action, honoured as is.
|
||||
win.emit('leave-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(3);
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
|
||||
it('self-heals through the timeout if a platform ever drops a queued request', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
const now = jest.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
now.mockReturnValue(1_000);
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
win.emit('enter-full-screen');
|
||||
// The queued exit never happens and nothing is re-issued …
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
// … so once the record expires, the event-fed state governs and
|
||||
// the next press is the exit the user still wants.
|
||||
now.mockReturnValue(1_000 + FULLSCREEN_TRANSITION_TIMEOUT_MS + 1);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('honours a native action opposite to a pending target instead of reversing it', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
|
||||
// Whatever produced this enter — the first press's transition or
|
||||
// the user's own green button — the tracker must not answer it
|
||||
// with a request of its own.
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The exit lands: the record is done, later native actions are
|
||||
// simply followed.
|
||||
win.emit('leave-full-screen');
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('forgets a request that never reports back after the transition timeout', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
const now = jest.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
now.mockReturnValue(1_000);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
|
||||
// No event ever arrived: the window really is still windowed,
|
||||
// so the stale target must not flip this press into an exit.
|
||||
now.mockReturnValue(1_000 + FULLSCREEN_TRANSITION_TIMEOUT_MS + 1);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(true);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not revive an expired target when its transition event arrives late', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
const now = jest.spyOn(Date, 'now');
|
||||
|
||||
try {
|
||||
now.mockReturnValue(1_000);
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false]]);
|
||||
|
||||
// The enter lands long after the timeout, on the other state
|
||||
// than the expired "windowed" target. Re-issuing it here would
|
||||
// resurrect intent the user has long moved past.
|
||||
now.mockReturnValue(1_000 + FULLSCREEN_TRANSITION_TIMEOUT_MS + 1);
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(2);
|
||||
|
||||
// The next press is decided from the live state.
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps deciding against the latest intent through a burst whose events lag behind', () => {
|
||||
const win = createFakeWindow({ asyncFullScreen: true });
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
const toggle = mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!;
|
||||
|
||||
toggle(fakeEvent);
|
||||
toggle(fakeEvent);
|
||||
// The first enter lands while the exit is still queued.
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
|
||||
// A press now is decided against the still-pending exit, so the
|
||||
// window is asked back into fullscreen — the user's third intent.
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen.mock.calls).toEqual([[true], [false], [true]]);
|
||||
|
||||
// The queued exit lands: not the latest target, record kept.
|
||||
win.state.fullScreen = false;
|
||||
win.emit('leave-full-screen');
|
||||
expect(win.setFullScreen).toHaveBeenCalledTimes(3);
|
||||
|
||||
// The third request lands: record cleared, the next press exits.
|
||||
win.state.fullScreen = true;
|
||||
win.emit('enter-full-screen');
|
||||
toggle(fakeEvent);
|
||||
expect(win.setFullScreen).toHaveBeenLastCalledWith(false);
|
||||
});
|
||||
|
||||
it('closes the sender window', () => {
|
||||
const win = createFakeWindow();
|
||||
mockFromWebContents.mockReturnValue(win);
|
||||
@@ -122,6 +420,12 @@ describe('WindowEvents', () => {
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
expect(
|
||||
mockHandlers.get('WINDOW:TOGGLE_FULLSCREEN')!(fakeEvent)
|
||||
).toEqual({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
});
|
||||
expect(mockHandlers.get('WINDOW:GET_STATE')!(fakeEvent)).toEqual({
|
||||
isMaximized: false,
|
||||
isFullScreen: false,
|
||||
|
||||
@@ -10,8 +10,10 @@ import {
|
||||
WINDOW_CLOSE,
|
||||
WINDOW_GET_STATE,
|
||||
WINDOW_MINIMIZE,
|
||||
WINDOW_TOGGLE_FULLSCREEN,
|
||||
WINDOW_TOGGLE_MAXIMIZE,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { toggleFullScreen } from '../services/native-fullscreen-transitions';
|
||||
|
||||
interface WindowState {
|
||||
isMaximized: boolean;
|
||||
@@ -67,6 +69,26 @@ ipcMain.handle(WINDOW_TOGGLE_MAXIMIZE, (event): WindowState => {
|
||||
};
|
||||
});
|
||||
|
||||
// F11. This is the exit path from a fullscreen launch on Windows/Linux,
|
||||
// where the title bar is hidden and the custom controls hide themselves
|
||||
// while fullscreen. The transition tracker decides against the pending
|
||||
// target while a transition (including the startup fullscreen) is still in
|
||||
// flight, so two quick presses stay an enter-then-exit. Like the maximize
|
||||
// toggle it reports the requested state and leaves WINDOW:STATE_CHANGED
|
||||
// authoritative.
|
||||
ipcMain.handle(WINDOW_TOGGLE_FULLSCREEN, (event): WindowState => {
|
||||
const win = getSenderWindow(event);
|
||||
|
||||
if (!win) {
|
||||
return getWindowState(win);
|
||||
}
|
||||
|
||||
return {
|
||||
isMaximized: win.isMaximized(),
|
||||
isFullScreen: toggleFullScreen(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) => {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Native fullscreen state and in-flight transitions, per window.
|
||||
*
|
||||
* `BrowserWindow.isFullScreen()` cannot drive a toggle: setFullScreen()
|
||||
* completes asynchronously (the macOS animation, Linux window managers),
|
||||
* and on Windows the getter can still report the pre-transition value even
|
||||
* while the matching event fires (see attachWindowStateEvents). A toggle
|
||||
* decided against the getter would request the same target twice — two
|
||||
* quick F11 presses would land fullscreen instead of returning to the
|
||||
* window, and F11 during the startup fullscreen animation would ask for
|
||||
* fullscreen again instead of leaving it.
|
||||
*
|
||||
* So the tracker keeps its own notion of the window's fullscreen state: it
|
||||
* is seeded once from the getter when tracking starts — at window creation,
|
||||
* while no transition is in flight — and afterwards fed only by the
|
||||
* `enter-full-screen` / `leave-full-screen` events, which also cover
|
||||
* transitions the app did not request (macOS green button, Ctrl+Cmd+F,
|
||||
* HTML fullscreen on Windows). Every native fullscreen request goes through
|
||||
* `requestFullScreen`, and a toggle is decided against the pending target
|
||||
* if one is in flight, else against that tracked state — never the getter.
|
||||
*
|
||||
* The tracker observes; it never issues a request on its own. The pending
|
||||
* record is the LATEST requested target: an event landing on it clears it,
|
||||
* an event landing on the other state (an earlier request of a burst
|
||||
* landed first; ours is still queued) leaves it in place so the next press
|
||||
* is still decided against the user's latest intent, and a record older
|
||||
* than the transition timeout is ignored outright, since no native
|
||||
* transition takes that long. Events cannot say which request they belong
|
||||
* to, so any automatic "repeat the target" on a mismatch is
|
||||
* indistinguishable from reversing the user's own green-button or
|
||||
* Ctrl+Cmd+F action, and is deliberately not done: should a platform ever
|
||||
* drop a queued request, the record expires within the timeout, the
|
||||
* event-fed state takes over and the next press corrects the window.
|
||||
* Electron queues such requests on macOS and applies them synchronously
|
||||
* elsewhere, so that case is theoretical.
|
||||
*/
|
||||
interface PendingFullScreenTransition {
|
||||
target: boolean;
|
||||
requestedAt: number;
|
||||
}
|
||||
|
||||
interface TrackedFullScreenState {
|
||||
/** Event-fed fullscreen state; seeded from the getter once. */
|
||||
fullScreen: boolean;
|
||||
pending?: PendingFullScreenTransition;
|
||||
}
|
||||
|
||||
/** Longest a native transition is trusted to still be in flight. */
|
||||
export const FULLSCREEN_TRANSITION_TIMEOUT_MS = 2000;
|
||||
|
||||
const trackedWindows = new WeakMap<
|
||||
Electron.BrowserWindow,
|
||||
TrackedFullScreenState
|
||||
>();
|
||||
|
||||
function getFreshPending(
|
||||
state: TrackedFullScreenState,
|
||||
now: number
|
||||
): PendingFullScreenTransition | undefined {
|
||||
const pending = state.pending;
|
||||
|
||||
if (!pending) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (now - pending.requestedAt > FULLSCREEN_TRANSITION_TIMEOUT_MS) {
|
||||
state.pending = undefined;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts following the window's native fullscreen events. Call it right
|
||||
* after the window is created, when no transition can be in flight, so the
|
||||
* seed read is the one time the getter is trusted; later callers get the
|
||||
* existing record.
|
||||
*/
|
||||
export function trackNativeFullScreen(
|
||||
win: Electron.BrowserWindow
|
||||
): TrackedFullScreenState {
|
||||
const existing = trackedWindows.get(win);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const state: TrackedFullScreenState = { fullScreen: win.isFullScreen() };
|
||||
trackedWindows.set(win, state);
|
||||
|
||||
// `landed` is the state the event itself conveys, never a re-read of
|
||||
// the getter.
|
||||
const settle = (landed: boolean) => {
|
||||
state.fullScreen = landed;
|
||||
|
||||
// Through the expiry check so a late event cannot be matched
|
||||
// against a request the user has long moved past.
|
||||
const pending = getFreshPending(state, Date.now());
|
||||
if (pending && landed === pending.target) {
|
||||
state.pending = undefined;
|
||||
}
|
||||
};
|
||||
win.on('enter-full-screen', () => settle(true));
|
||||
win.on('leave-full-screen', () => settle(false));
|
||||
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getPendingFullScreenTarget(
|
||||
win: Electron.BrowserWindow,
|
||||
now: number = Date.now()
|
||||
): boolean | undefined {
|
||||
const state = trackedWindows.get(win);
|
||||
return state ? getFreshPending(state, now)?.target : undefined;
|
||||
}
|
||||
|
||||
export function requestFullScreen(
|
||||
win: Electron.BrowserWindow,
|
||||
target: boolean,
|
||||
now: number = Date.now()
|
||||
): void {
|
||||
const state = trackNativeFullScreen(win);
|
||||
state.pending = { target, requestedAt: now };
|
||||
win.setFullScreen(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the opposite of the pending target, or of the tracked state when
|
||||
* no transition is in flight, and returns the requested state.
|
||||
*/
|
||||
export function toggleFullScreen(
|
||||
win: Electron.BrowserWindow,
|
||||
now: number = Date.now()
|
||||
): boolean {
|
||||
const state = trackNativeFullScreen(win);
|
||||
const target = !(getPendingFullScreenTarget(win, now) ?? state.fullScreen);
|
||||
requestFullScreen(win, target, now);
|
||||
return target;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
FULLSCREEN_LAUNCH_SWITCH,
|
||||
resolveStartupWindowMode,
|
||||
} from './startup-window-mode';
|
||||
|
||||
describe('resolveStartupWindowMode', () => {
|
||||
it('is exposed as the bare switch name Electron parses from argv', () => {
|
||||
// app.commandLine.hasSwitch() takes the name without dashes.
|
||||
expect(FULLSCREEN_LAUNCH_SWITCH).toBe('fullscreen');
|
||||
});
|
||||
|
||||
it('defaults to a normal window on a fresh profile', () => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: false,
|
||||
storedMode: undefined,
|
||||
})
|
||||
).toBe('normal');
|
||||
});
|
||||
|
||||
it.each(['normal', 'maximized', 'fullscreen'] as const)(
|
||||
'honours the stored %s mode',
|
||||
(storedMode) => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: false,
|
||||
storedMode,
|
||||
})
|
||||
).toBe(storedMode);
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['kiosk', '', 1, true, null, {}])(
|
||||
'collapses a hand-edited config value %p to normal',
|
||||
(storedMode) => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: false,
|
||||
storedMode,
|
||||
})
|
||||
).toBe('normal');
|
||||
}
|
||||
);
|
||||
|
||||
it('lets the --fullscreen switch win over any stored mode', () => {
|
||||
expect(
|
||||
resolveStartupWindowMode({
|
||||
cliHasFullscreenSwitch: true,
|
||||
storedMode: 'maximized',
|
||||
})
|
||||
).toBe('fullscreen');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
normalizeStartupWindowMode,
|
||||
StartupWindowMode,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
|
||||
/**
|
||||
* Command-line switch that forces one fullscreen launch:
|
||||
* `iptvnator --fullscreen`. Meant for HTPC autostart scripts. It is read
|
||||
* through Electron's parsed command line, so it can sit anywhere in argv;
|
||||
* the playlist-path extractor already skips every `-`-prefixed argument.
|
||||
*/
|
||||
export const FULLSCREEN_LAUNCH_SWITCH = 'fullscreen';
|
||||
|
||||
/**
|
||||
* Decides how the main window is presented at launch.
|
||||
*
|
||||
* The switch wins over the stored setting but is never persisted — the next
|
||||
* launch without it falls back to whatever Settings say. The stored value is
|
||||
* whatever the SETTINGS_UPDATE mirror wrote (or nothing on a fresh profile)
|
||||
* and is normalized again here, so a hand-edited config file cannot pass an
|
||||
* unknown mode into the window options.
|
||||
*/
|
||||
export function resolveStartupWindowMode(input: {
|
||||
cliHasFullscreenSwitch: boolean;
|
||||
storedMode: unknown;
|
||||
}): StartupWindowMode {
|
||||
if (input.cliHasFullscreenSwitch) {
|
||||
return 'fullscreen';
|
||||
}
|
||||
|
||||
return normalizeStartupWindowMode(input.storedMode);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Conf } from 'electron-conf/main';
|
||||
import { getElectronConfigDirectory } from '@iptvnator/shared/database';
|
||||
import type { StartupWindowMode } from '@iptvnator/shared/interfaces';
|
||||
|
||||
export const WINDOW_BOUNDS = 'WINDOW_BOUNDS';
|
||||
export const MPV_PLAYER_PATH = 'MPV_PLAYER_PATH';
|
||||
@@ -15,6 +16,13 @@ export const VLC_REUSE_INSTANCE = 'VLC_REUSE_INSTANCE';
|
||||
* its preload frame pump, which cannot change after window creation.
|
||||
*/
|
||||
export const EMBEDDED_MPV_FRAME_COPY = 'EMBEDDED_MPV_FRAME_COPY';
|
||||
/**
|
||||
* Window presentation at launch (`normal` / `maximized` / `fullscreen`).
|
||||
* Mirrored here from the renderer's settings by the SETTINGS_UPDATE handler
|
||||
* for the same reason as the frame-copy flag: `initMainWindow` needs it
|
||||
* synchronously, before any renderer exists to ask.
|
||||
*/
|
||||
export const STARTUP_WINDOW_MODE = 'STARTUP_WINDOW_MODE';
|
||||
|
||||
export type StoreType = {
|
||||
[WINDOW_BOUNDS]: Electron.Rectangle;
|
||||
@@ -25,6 +33,7 @@ export type StoreType = {
|
||||
[MPV_REUSE_INSTANCE]: boolean;
|
||||
[VLC_REUSE_INSTANCE]: boolean;
|
||||
[EMBEDDED_MPV_FRAME_COPY]: boolean;
|
||||
[STARTUP_WINDOW_MODE]: StartupWindowMode;
|
||||
};
|
||||
|
||||
// Export singleton store instance
|
||||
|
||||
Reference in new issue
Block a user