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:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-03 19:49:41 +02:00
1 parent 078bd07d94
commit fa9084fca3
57 files changed
+1650 -8

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
---
type: feature
area: shell
issues: [1455]
---
Settings → General has a new "Window on startup" option: open the desktop app
at its last size, maximized, or fullscreen — handy on a TV or HTPC. Starting
with `--fullscreen` forces a single fullscreen launch without changing the
setting, and F11 now toggles fullscreen anywhere in the app.
+2 -1
View File
@@ -566,7 +566,7 @@ See `docs/architecture/m3u-playlist-module.md` for complete documentation.
`/workspace/xtreams/:id/downloads/:downloadId` and
`/workspace/stalker/:id/downloads/:downloadId`. Focused download details hide
the workspace context panel.
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
- Settings: `/workspace/settings/:section` — one page per section (`general`, `playback`, `epg`, `dashboard`, `remote-control`, `tmdb`, `backup`, `reset`, `about`); `/workspace/settings` redirects to `general`, unknown or capability-gated sections redirect there too, and `/settings` redirects into the workspace. The `general` section's "Window on startup" select (`Settings.startupWindowMode`: `normal` / `maximized` / `fullscreen`, Electron only, gated on `RuntimeCapabilitiesService.supportsStartupWindowMode`) is mirrored into the main-process config by the `SETTINGS_UPDATE` handler and read synchronously at the next window creation — the renderer's IndexedDB is unreachable then, so it is the same pattern as `embeddedMpvFrameCopy`; `iptvnator --fullscreen` forces one fullscreen launch without persisting it, and F11 (`WINDOW:TOGGLE_FULLSCREEN`, bound in `WorkspaceKeyboardShortcutsService`, skipped while the player owns `document.fullscreenElement`) is the exit path on Windows/Linux, where the title bar is hidden (contract: `docs/architecture/workspace-shell.md`, "Startup window mode"). The shared form lives on the parent `SettingsComponent`, so edits survive section switches; a floating unsaved-changes bar (Save/Discard) replaces the old always-visible footer Save button. Leaving the settings AREA with a dirty form triggers `settingsUnsavedChangesGuard` (canDeactivate) and a save/discard/stay dialog — section switches deliberately bypass it, and a failed save cancels the navigation. Non-router exits are covered too: `SettingsUnloadGuardService` (provided by `SettingsComponent`) arms a `beforeunload` handler while the form is dirty (native leave prompt in the PWA) and arms an Electron main-process close guard (`window-close-guard.service.ts`) for the whole settings mount — mount-long on purpose, since arming on the first edit would race the close it protects against. The guard intercepts window close/app quit before `beforeunload` fires and completes the original intent only after the renderer confirms through the same dialog (a pristine form auto-confirms); Electron reloads are cancelled and re-triggered the same way, a failed save always keeps the window open, and installing an app update suspends the whole guard so the updater's quit passes unchallenged — every install entry point (settings About section and the global update notification panel) must go through the root `AppUpdateInstallService`, which owns that suspend/restore choreography
**Service Architecture** (Factory Pattern):
@@ -700,6 +700,7 @@ This project uses modern Angular signal-based APIs and patterns. **ALWAYS** use
- Bootstraps Electron app and initializes database
- Registers event handlers for IPC communication
- Creates the main window per the startup window mode (`app/app.ts` `initMainWindow`, resolver in `app/services/startup-window-mode.ts`): the electron-conf `STARTUP_WINDOW_MODE` mirror or the one-shot `--fullscreen` switch (consumed by the first window, so a window the macOS Dock re-creates in the same process follows the stored setting); `fullscreen: true` is a constructor option that Windows/Linux honour before the first paint, while macOS ignores it on a hidden window, so `ready-to-show` repeats the request after `show()` only when `isFullScreen()` is still false, through the same tracker the F11 toggle uses (`app/services/native-fullscreen-transitions.ts`: per-window fullscreen state seeded once at creation via `trackNativeFullScreen` and fed only by the enter/leave events afterwards, plus a pending record holding the latest target, cleared when an event lands on it, kept when an event lands on the other state, and ignored after 2 s; the tracker only observes and never issues a request itself, since a "repeat on mismatch" cannot be told apart from reversing the user's own green-button action — a toggle is never decided against `isFullScreen()`, which is stale mid-transition and, on Windows, even during the event), so F11 during the startup animation exits instead of re-requesting; `maximize()` waits for `ready-to-show` too (it would show a hidden window early). `attachWindowStateEvents` tracks native and HTML-element fullscreen as two flags OR-ed into `WINDOW:STATE_CHANGED`, because Electron leaves only the HTML state when the window was already natively fullscreen
- Holds a single-instance lock (`app/services/single-instance.ts`), requested after the `userData` override so E2E runs with their own data dir keep independent locks. A second launch quits and focuses the running window; concurrent instances would otherwise share a Chromium profile whose IndexedDB only one of them can lock, silently breaking renderer-side settings persistence. `IPTVNATOR_ALLOW_MULTIPLE_INSTANCES=1` opts out for local debugging. The guard also forwards that launch's argv and working directory, so `iptvnator playlist.m3u` against a running app opens the playlist instead of being discarded.
**Database**:
+6
View File
@@ -79,6 +79,7 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
| Global | `Ctrl/Cmd+F` | Open global search in the desktop app |
| Global | `Ctrl/Cmd+R` | Open recently viewed in the desktop app |
| Global | `Enter` in workspace search | Submit the current search |
| Global | `F11` | Toggle app window fullscreen in the desktop app |
| Navigation | `Ctrl/Cmd+B` | Toggle the live sidebar |
| Navigation | `0-9` | Select an M3U channel by number |
| Playback | `Space` / `K` | Play or pause playback |
@@ -90,6 +91,11 @@ Press `?` or `Shift+/` in the workspace to open the in-app shortcuts list.
| Dialogs and lists | `Enter` | Run the selected command or open a focused item |
| Dialogs and lists | `Escape` | Close dialogs and dismiss overlays |
The desktop app can also open at its last size, maximized, or fullscreen on
every launch (Settings → General → "Window on startup"), and `iptvnator
--fullscreen` forces a single fullscreen launch for TV or HTPC autostart
scripts without changing that setting.
## Screenshots:
| Dashboard with recently watched content | Live channels with inline player and EPG |
@@ -837,8 +837,129 @@ test.describe('Electron Settings', () => {
}
});
test('@settings @persistence @electron launches fullscreen once the startup window mode is saved and toggles it with F11', async ({
dataDir,
}) => {
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('startup-window-mode-setting')
).toBeVisible();
await selectSettingsOption(
app.mainWindow,
'select-startup-window-mode',
'startup-window-mode-fullscreen'
);
await saveSettings(app.mainWindow);
// The mode is read when the window is created, so saving it
// leaves the running window alone.
expect(await isMainWindowFullScreen(app)).toBe(false);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await expect
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
.toBe(true);
await openSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('select-startup-window-mode')
).toContainText(/Fullscreen/);
// F11 is the way out on Windows/Linux, where the title bar is
// hidden and the custom window controls hide while fullscreen.
await app.mainWindow.keyboard.press('F11');
await expect
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
.toBe(false);
await app.mainWindow.keyboard.press('F11');
await expect
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
.toBe(true);
} finally {
await closeElectronApp(app);
}
});
test('@settings @electron --fullscreen forces one fullscreen launch without persisting it', async ({
dataDir,
}) => {
let app = await launchElectronApp(dataDir, {
appArgs: ['--fullscreen'],
});
try {
await expect
.poll(() => isMainWindowFullScreen(app), { timeout: 10_000 })
.toBe(true);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
// The switch is a one-shot: the next plain launch follows the
// stored setting, which is still the default.
expect(await isMainWindowFullScreen(app)).toBe(false);
await openSettings(app.mainWindow);
await expect(
app.mainWindow.getByTestId('select-startup-window-mode')
).toContainText(/Last window size/);
} finally {
await closeElectronApp(app);
}
});
test('@settings @persistence @electron launches maximized when the startup window mode says so', async ({
dataDir,
}) => {
test.skip(
process.platform === 'linux' && !!process.env['CI'],
'xvfb on Linux CI has no window manager, so maximize never takes effect'
);
let app = await launchElectronApp(dataDir);
try {
await openSettings(app.mainWindow);
await selectSettingsOption(
app.mainWindow,
'select-startup-window-mode',
'startup-window-mode-maximized'
);
await saveSettings(app.mainWindow);
} finally {
app = await restartElectronApp(app, dataDir);
}
try {
await expect
.poll(
() =>
app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isMaximized() : false;
}),
{ timeout: 10_000 }
)
.toBe(true);
expect(await isMainWindowFullScreen(app)).toBe(false);
} finally {
await closeElectronApp(app);
}
});
});
function isMainWindowFullScreen(app: LaunchedElectronApp): Promise<boolean> {
return app.electronApp.evaluate(({ BrowserWindow }) => {
const mainWindow = BrowserWindow.getAllWindows()[0];
return mainWindow ? mainWindow.isFullScreen() : false;
});
}
async function selectSettingsOption(
page: Page,
selectTestId: string,
@@ -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);
+177
View File
@@ -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);
+88 -5
View File
@@ -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
@@ -34,3 +34,39 @@ describe('settings form utils — strip country prefix', () => {
expect(settings.stripCountryPrefix).toBe(false);
});
});
describe('settings form utils — startup window mode', () => {
const formBuilder = new FormBuilder();
it('defaults the form control to a normal window', () => {
const form = createSettingsForm(formBuilder, true);
expect(form.getRawValue().startupWindowMode).toBe('normal');
});
it('carries the chosen mode into the settings object', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({ startupWindowMode: 'fullscreen' });
const settings = createSettingsFromFormValue(form, {} as Settings);
expect(settings.startupWindowMode).toBe('fullscreen');
});
it('collapses a missing or unknown form value to normal', () => {
const form = createSettingsForm(formBuilder, true);
form.patchValue({
startupWindowMode: null as unknown as 'normal',
});
expect(
createSettingsFromFormValue(form, {} as Settings).startupWindowMode
).toBe('normal');
form.patchValue({
startupWindowMode: 'kiosk' as unknown as 'normal',
});
expect(
createSettingsFromFormValue(form, {} as Settings).startupWindowMode
).toBe('normal');
});
});
@@ -12,8 +12,10 @@ import {
Language,
normalizeDashboardRailsSettings,
normalizeExternalPlayerArguments,
normalizeStartupWindowMode,
Settings,
StartupBehavior,
StartupWindowMode,
StreamFormat,
Theme,
VideoPlayer,
@@ -60,6 +62,7 @@ export function createSettingsForm(
DEFAULT_DASHBOARD_RAILS_SETTINGS.tmdbRecommendations,
}),
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal' as StartupWindowMode,
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
@@ -134,6 +137,7 @@ export function createSettingsFromFormValue(
showDashboard: value.showDashboard ?? true,
dashboardRails: normalizeDashboardRailsSettings(value.dashboardRails),
startupBehavior: value.startupBehavior ?? StartupBehavior.FirstView,
startupWindowMode: normalizeStartupWindowMode(value.startupWindowMode),
showExternalPlaybackBar: value.showExternalPlaybackBar ?? true,
stripCountryPrefix: value.stripCountryPrefix ?? false,
theme: value.theme ?? Theme.SystemTheme,
@@ -144,6 +144,42 @@
</div>
</div>
@if (supportsStartupWindowMode()) {
<div class="setting-item" data-test-id="startup-window-mode-setting">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.STARTUP_WINDOW_MODE' | translate }}</h4>
<p>
{{ 'SETTINGS.STARTUP_WINDOW_MODE_DESCRIPTION' | translate }}
</p>
</div>
<div class="setting-item__control">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{
'SETTINGS.STARTUP_WINDOW_MODE' | translate
}}</mat-label>
<mat-select
formControlName="startupWindowMode"
data-test-id="select-startup-window-mode"
>
@for (
option of startupWindowModeOptions();
track option.value
) {
<mat-option
[value]="option.value"
[attr.data-test-id]="
'startup-window-mode-' + option.value
"
>
{{ option.labelKey | translate }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
<div class="setting-item">
<div class="setting-item__meta">
<h4>{{ 'SETTINGS.SHOW_CAPTIONS' | translate }}</h4>
@@ -10,6 +10,7 @@ import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces';
import {
CoverSizeOption,
StartupBehaviorOption,
StartupWindowModeOption,
ThemeOption,
} from './settings.models';
@@ -34,6 +35,10 @@ export class SettingsGeneralSectionComponent {
readonly themeOptions = input.required<ThemeOption[]>();
readonly coverSizeOptions = input.required<CoverSizeOption[]>();
readonly startupBehaviorOptions = input.required<StartupBehaviorOption[]>();
readonly startupWindowModeOptions =
input.required<StartupWindowModeOption[]>();
/** Desktop only: the window-mode select needs the main-process mirror and F11 */
readonly supportsStartupWindowMode = input(false);
readonly selectTheme = output<Theme>();
readonly selectCoverSize = output<CoverSize>();
@@ -11,6 +11,7 @@ import {
SettingsPlayerOption,
SettingsSection,
StartupBehaviorOption,
StartupWindowModeOption,
ThemeOption,
} from './settings.models';
@@ -74,6 +75,21 @@ export const SETTINGS_STARTUP_BEHAVIOR_OPTIONS: StartupBehaviorOption[] = [
},
];
export const SETTINGS_STARTUP_WINDOW_MODE_OPTIONS: StartupWindowModeOption[] = [
{
value: 'normal',
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE_NORMAL',
},
{
value: 'maximized',
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE_MAXIMIZED',
},
{
value: 'fullscreen',
labelKey: 'SETTINGS.STARTUP_WINDOW_MODE_FULLSCREEN',
},
];
export const SETTINGS_OS_PLAYER_OPTIONS: SettingsPlayerOption[] = [
{
id: VideoPlayer.MPV,
@@ -186,6 +186,9 @@ describe('SettingsComponent form', () => {
expect(component.settingsForm.value.startupBehavior).toBe(
StartupBehavior.FirstView
);
expect(component.settingsForm.value.startupWindowMode).toBe(
'normal'
);
});
it('disables dashboard surface controls when the dashboard is off', async () => {
@@ -110,6 +110,8 @@
[themeOptions]="themeOptions"
[coverSizeOptions]="coverSizeOptions"
[startupBehaviorOptions]="startupBehaviorOptions"
[startupWindowModeOptions]="startupWindowModeOptions"
[supportsStartupWindowMode]="supportsStartupWindowMode"
(selectTheme)="form.selectTheme($event)"
(selectCoverSize)="form.selectCoverSize($event)"
/>
@@ -37,6 +37,7 @@ import {
SETTINGS_COVER_SIZE_OPTIONS,
SETTINGS_EPG_VIEW_MODE_OPTIONS,
SETTINGS_STARTUP_BEHAVIOR_OPTIONS,
SETTINGS_STARTUP_WINDOW_MODE_OPTIONS,
SETTINGS_THEME_OPTIONS,
} from './settings-options';
import { SettingsPlaybackSectionComponent } from './settings-playback-section.component';
@@ -140,6 +141,7 @@ export class SettingsComponent
this.runtime.supportsExternalPlayerPathSettings;
readonly supportsVodMultiSource = this.vodSourceDiscovery.isAvailable;
readonly supportsRemoteControl = this.runtime.supportsRemoteControl;
readonly supportsStartupWindowMode = this.runtime.supportsStartupWindowMode;
/** Settings form object */
readonly settingsForm = this.form.form;
@@ -158,6 +160,7 @@ export class SettingsComponent
readonly themeOptions = SETTINGS_THEME_OPTIONS;
readonly coverSizeOptions = SETTINGS_COVER_SIZE_OPTIONS;
readonly startupBehaviorOptions = SETTINGS_STARTUP_BEHAVIOR_OPTIONS;
readonly startupWindowModeOptions = SETTINGS_STARTUP_WINDOW_MODE_OPTIONS;
readonly epgViewModeOptions = SETTINGS_EPG_VIEW_MODE_OPTIONS;
readonly sectionNavItems: SettingsSection[] = buildSettingsSectionNavItems({
@@ -2,6 +2,7 @@ import {
CoverSize,
EpgViewMode,
StartupBehavior,
StartupWindowMode,
Theme,
VideoPlayer,
} from '@iptvnator/shared/interfaces';
@@ -24,6 +25,11 @@ export interface StartupBehaviorOption {
labelKey: string;
}
export interface StartupWindowModeOption {
value: StartupWindowMode;
labelKey: string;
}
export interface CoverSizeOption {
value: CoverSize;
icon: string;
@@ -95,6 +95,7 @@ export const DEFAULT_SETTINGS = {
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "اختر ما إذا كان IPTVnator يفتح أول عرض متاح في مساحة العمل أو يستعيد آخر عرض على مستوى القسم. العرض الأول يعني لوحة التحكم عند تفعيلها، وإلا فالمصادر.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متاح",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "استعادة آخر عرض",
"STARTUP_WINDOW_MODE": "نافذة التطبيق عند البدء",
"STARTUP_WINDOW_MODE_DESCRIPTION": "اختر كيف تُفتح نافذة سطح المكتب: بآخر حجم لها، أو مكبّرة، أو بملء الشاشة. يُطبَّق عند التشغيل التالي. اضغط F11 في أي وقت للدخول إلى وضع ملء الشاشة أو الخروج منه.",
"STARTUP_WINDOW_MODE_NORMAL": "آخر حجم للنافذة",
"STARTUP_WINDOW_MODE_MAXIMIZED": "مكبّرة",
"STARTUP_WINDOW_MODE_FULLSCREEN": "ملء الشاشة",
"SHOW_CAPTIONS_DESCRIPTION": "عرض أو إخفاء الترجمات افتراضيًا",
"SHOW_EXTERNAL_PLAYBACK_BAR": "عرض شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "إبقاء شريط حالة المشغل الخارجي مرئيًا في أسفل مساحة العمل على سطح المكتب.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "فتح البحث العام",
"OPEN_RECENTLY_VIEWED": "فتح العناصر المشاهدة مؤخرًا",
"SUBMIT_SHELL_SEARCH": "إرسال بحث مساحة العمل",
"TOGGLE_WINDOW_FULLSCREEN": "تبديل ملء الشاشة لنافذة التطبيق",
"TOGGLE_SIDEBAR": "تبديل الشريط الجانبي",
"M3U_CHANNEL_NUMBER": "اختيار قناة M3U حسب الرقم",
"PLAY_PAUSE": "تشغيل أو إيقاف تشغيل Embedded MPV مؤقتًا",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "اختار واش IPTVnator يحل أول عرض متوفر في مساحة العمل ولا يسترجع آخر عرض على مستوى القسم. أول عرض كيعني لوحة التحكم إلى كانت مفعلة، إلا فالا فهي Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "أول عرض متوفر",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "استرجع آخر عرض",
"STARTUP_WINDOW_MODE": "النافذة عند البدء",
"STARTUP_WINDOW_MODE_DESCRIPTION": "اختار كيفاش تتحل نافذة سطح المكتب: بآخر حجم ديالها، ولا مكبّرة، ولا بملء الشاشة. كيتطبق فالتشغيل الجاي. ضغط على F11 فأي وقت باش تدخل ولا تخرج من ملء الشاشة.",
"STARTUP_WINDOW_MODE_NORMAL": "آخر حجم ديال النافذة",
"STARTUP_WINDOW_MODE_MAXIMIZED": "مكبّرة",
"STARTUP_WINDOW_MODE_FULLSCREEN": "ملء الشاشة",
"SHOW_CAPTIONS_DESCRIPTION": "وري ولا خفي الترجمة افتراضياً",
"SHOW_EXTERNAL_PLAYBACK_BAR": "وري شريط التشغيل الحالي",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "خلي شريط حالة المشغل الخارجي ظاهر في الأسفل ديال مساحة العمل على سطح المكتب.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "حل البحث العام",
"OPEN_RECENTLY_VIEWED": "حل المتشافين مؤخراً",
"SUBMIT_SHELL_SEARCH": "رسل بحث مساحة العمل",
"TOGGLE_WINDOW_FULLSCREEN": "بدل ملء الشاشة ديال نافذة التطبيق",
"TOGGLE_SIDEBAR": "بدل عرض الشريط الجانبي",
"M3U_CHANNEL_NUMBER": "اختار قناة M3U بالرقم",
"PLAY_PAUSE": "شغل ولا وقف مؤقتاً تشغيل MPV المدمج",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберыце, ці адкрывае IPTVnator першы даступны выгляд працоўнай прасторы, ці аднаўляе ваш апошні выгляд на ўзроўні раздзела. Першы выгляд азначае «Панэль», калі ўключана, інакш «Крыніцы».",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Першы даступны выгляд",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Аднавіць апошні выгляд",
"STARTUP_WINDOW_MODE": "Акно пры запуску",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Выберыце, як адкрываецца акно праграмы: у апошнім памеры, разгорнутым ці на ўвесь экран. Ужываецца пры наступным запуску. Націсніце F11 у любы момант, каб увайсці ў поўнаэкранны рэжым або выйсці з яго.",
"STARTUP_WINDOW_MODE_NORMAL": "Апошні памер акна",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Разгорнутае",
"STARTUP_WINDOW_MODE_FULLSCREEN": "На ўвесь экран",
"SHOW_CAPTIONS_DESCRIPTION": "Паказаць або схаваць субтытры па змаўчанні.",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Паказаць ніжнюю панэль прайгравання",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Пакідаць панэль стану знешняга прайгравальніка бачнай унізе працоўнай прасторы на дэсктопе.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Адкрыць глабальны пошук",
"OPEN_RECENTLY_VIEWED": "Адкрыць нядаўна прагледжанае",
"SUBMIT_SHELL_SEARCH": "Выканаць пошук у працоўнай вобласці",
"TOGGLE_WINDOW_FULLSCREEN": "Пераключыць поўнаэкранны рэжым акна праграмы",
"TOGGLE_SIDEBAR": "Пераключыць бакавую панэль",
"M3U_CHANNEL_NUMBER": "Выбраць M3U-канал па нумары",
"PLAY_PAUSE": "Прайграць або прыпыніць Embedded MPV",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Legen Sie fest, ob IPTVnator die erste verfügbare Workspace-Ansicht oder Ihre zuletzt geöffnete Abschnittsansicht startet. Erste Ansicht bedeutet Dashboard, wenn aktiviert, sonst Quellen.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Erste verfügbare Ansicht",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Letzte Ansicht wiederherstellen",
"STARTUP_WINDOW_MODE": "Fenster beim Start",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Legen Sie fest, wie das Desktop-Fenster geöffnet wird: in der letzten Größe, maximiert oder im Vollbild. Gilt ab dem nächsten Start. Mit F11 können Sie das Vollbild jederzeit aktivieren oder verlassen.",
"STARTUP_WINDOW_MODE_NORMAL": "Letzte Fenstergröße",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximiert",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Vollbild",
"SHOW_CAPTIONS_DESCRIPTION": "Untertitel standardmäßig ein- oder ausblenden",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Now-Playing-Fußleiste anzeigen",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Zeigt die Statusleiste für externe Player unten im Arbeitsbereich auf dem Desktop an.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Globale Suche öffnen",
"OPEN_RECENTLY_VIEWED": "Kürzlich angesehene Elemente öffnen",
"SUBMIT_SHELL_SEARCH": "Arbeitsbereichssuche absenden",
"TOGGLE_WINDOW_FULLSCREEN": "Vollbild für das App-Fenster umschalten",
"TOGGLE_SIDEBAR": "Seitenleiste umschalten",
"M3U_CHANNEL_NUMBER": "M3U-Kanal per Nummer auswählen",
"PLAY_PAUSE": "Eingebettete MPV-Wiedergabe starten oder pausieren",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Επιλέξτε αν το IPTVnator ανοίγει την πρώτη διαθέσιμη προβολή του χώρου εργασίας ή επαναφέρει την τελευταία προβολή σας. Πρώτη προβολή σημαίνει ο πίνακας ελέγχου, όταν είναι ενεργοποιημένος, διαφορετικά οι Πηγές.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Πρώτη διαθέσιμη προβολή",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Επαναφορά τελευταίας προβολής",
"STARTUP_WINDOW_MODE": "Παράθυρο κατά την εκκίνηση",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Επιλέξτε πώς ανοίγει το παράθυρο της εφαρμογής: στο τελευταίο του μέγεθος, μεγιστοποιημένο ή σε πλήρη οθόνη. Ισχύει από την επόμενη εκκίνηση. Πατήστε F11 οποιαδήποτε στιγμή για είσοδο ή έξοδο από την πλήρη οθόνη.",
"STARTUP_WINDOW_MODE_NORMAL": "Τελευταίο μέγεθος παραθύρου",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Μεγιστοποιημένο",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Πλήρης οθόνη",
"SHOW_CAPTIONS_DESCRIPTION": "Εμφάνιση ή απόκρυψη υποτίτλων από προεπιλογή",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Εμφάνιση υποσέλιδου αναπαραγωγής",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Διατηρήστε ορατή τη γραμμή κατάστασης του εξωτερικού προγράμματος αναπαραγωγής στο κάτω μέρος του χώρου εργασίας στον υπολογιστή.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Άνοιγμα καθολικής αναζήτησης",
"OPEN_RECENTLY_VIEWED": "Άνοιγμα πρόσφατων προβολών",
"SUBMIT_SHELL_SEARCH": "Υποβολή αναζήτησης χώρου εργασίας",
"TOGGLE_WINDOW_FULLSCREEN": "Εναλλαγή πλήρους οθόνης παραθύρου εφαρμογής",
"TOGGLE_SIDEBAR": "Εναλλαγή πλαϊνής μπάρας",
"M3U_CHANNEL_NUMBER": "Επιλογή καναλιού M3U με αριθμό",
"PLAY_PAUSE": "Αναπαραγωγή ή παύση ενσωματωμένου MPV",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Choose whether IPTVnator opens the first available workspace view or restores your last section-level view. First view means Dashboard when enabled, otherwise Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "First available view",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restore last view",
"STARTUP_WINDOW_MODE": "Window on startup",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Choose how the desktop window opens: at its last size, maximized, or fullscreen. Applies on the next launch. Press F11 at any time to enter or leave fullscreen.",
"STARTUP_WINDOW_MODE_NORMAL": "Last window size",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximized",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Fullscreen",
"SHOW_CAPTIONS_DESCRIPTION": "Show or hide subtitles by default",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Show now playing footer",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Keep the external-player status bar visible at the bottom of the workspace on desktop.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Open global search",
"OPEN_RECENTLY_VIEWED": "Open recently viewed",
"SUBMIT_SHELL_SEARCH": "Submit workspace search",
"TOGGLE_WINDOW_FULLSCREEN": "Toggle app window fullscreen",
"TOGGLE_SIDEBAR": "Toggle sidebar",
"M3U_CHANNEL_NUMBER": "Select M3U channel by number",
"PLAY_PAUSE": "Play or pause embedded MPV playback",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Elige si IPTVnator abre la primera vista disponible del espacio de trabajo o restaura la última vista a nivel de sección. Primera vista significa Panel cuando está activado, de lo contrario Fuentes.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primera vista disponible",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurar última vista",
"STARTUP_WINDOW_MODE": "Ventana al iniciar",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Elige cómo se abre la ventana de escritorio: con su último tamaño, maximizada o a pantalla completa. Se aplica en el próximo inicio. Pulsa F11 en cualquier momento para entrar o salir de la pantalla completa.",
"STARTUP_WINDOW_MODE_NORMAL": "Último tamaño de ventana",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximizada",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Pantalla completa",
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar u ocultar subtítulos por defecto",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar pie de reproducción actual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantén visible la barra de estado del reproductor externo en la parte inferior del espacio de trabajo en escritorio.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Abrir búsqueda global",
"OPEN_RECENTLY_VIEWED": "Abrir vistos recientemente",
"SUBMIT_SHELL_SEARCH": "Enviar búsqueda del espacio de trabajo",
"TOGGLE_WINDOW_FULLSCREEN": "Alternar pantalla completa de la ventana de la aplicación",
"TOGGLE_SIDEBAR": "Mostrar u ocultar barra lateral",
"M3U_CHANNEL_NUMBER": "Seleccionar canal M3U por número",
"PLAY_PAUSE": "Reproducir o pausar MPV integrado",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Choisissez si IPTVnator ouvre la première vue disponible de l'espace de travail ou restaure votre dernière vue de section. Première vue signifie Tableau de bord lorsqu'il est activé, sinon Sources.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Première vue disponible",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurer la dernière vue",
"STARTUP_WINDOW_MODE": "Fenêtre au démarrage",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Choisissez comment la fenêtre de bureau s'ouvre : à sa dernière taille, agrandie ou en plein écran. S'applique au prochain démarrage. Appuyez sur F11 à tout moment pour passer en plein écran ou le quitter.",
"STARTUP_WINDOW_MODE_NORMAL": "Dernière taille de fenêtre",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Agrandie",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Plein écran",
"SHOW_CAPTIONS_DESCRIPTION": "Afficher ou masquer les sous-titres par défaut",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Afficher le pied de page de lecture en cours",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Garde la barre d'état du lecteur externe visible en bas de l'espace de travail sur ordinateur.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Ouvrir la recherche globale",
"OPEN_RECENTLY_VIEWED": "Ouvrir les éléments récemment consultés",
"SUBMIT_SHELL_SEARCH": "Lancer la recherche dans l’espace de travail",
"TOGGLE_WINDOW_FULLSCREEN": "Activer ou quitter le plein écran de la fenêtre",
"TOGGLE_SIDEBAR": "Afficher ou masquer la barre latérale",
"M3U_CHANNEL_NUMBER": "Sélectionner une chaîne M3U par numéro",
"PLAY_PAUSE": "Lire ou mettre en pause la lecture MPV intégrée",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Válassza ki, hogy induláskor az első elérhető munkaterület-nézet nyíljon meg, vagy az IPTVnator állítsa vissza a legutóbb használt szakasznézetet. Az első nézet bekapcsolt Vezérlőpult esetén a Vezérlőpult, egyébként a Források.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Első elérhető nézet",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Legutóbbi nézet visszaállítása",
"STARTUP_WINDOW_MODE": "Ablak indításkor",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Válassza ki, hogyan nyíljon meg az asztali ablak: a legutóbbi méretben, maximalizálva vagy teljes képernyőn. A következő indítástól érvényes. Az F11 billentyűvel bármikor be- vagy kiléphet a teljes képernyős módból.",
"STARTUP_WINDOW_MODE_NORMAL": "Legutóbbi ablakméret",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximalizált",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Teljes képernyő",
"SHOW_CAPTIONS_DESCRIPTION": "Feliratok alapértelmezett megjelenítése vagy elrejtése",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Aktuális lejátszás sávjának megjelenítése",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Az asztali alkalmazásban maradjon látható a munkaterület alján a külső lejátszó állapotsávja.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Globális keresés megnyitása",
"OPEN_RECENTLY_VIEWED": "Legutóbbi megtekintések megnyitása",
"SUBMIT_SHELL_SEARCH": "Munkaterületi keresés indítása",
"TOGGLE_WINDOW_FULLSCREEN": "Az alkalmazásablak teljes képernyős módjának be- vagy kikapcsolása",
"TOGGLE_SIDEBAR": "Oldalsáv megjelenítése vagy elrejtése",
"M3U_CHANNEL_NUMBER": "M3U-csatorna kiválasztása a számának beírásával",
"PLAY_PAUSE": "A beágyazott MPV lejátszásának indítása vagy szüneteltetése",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Scegli se IPTVnator apre la prima vista dell'area di lavoro disponibile o ripristina l'ultima vista a livello di sezione. La prima vista è la dashboard se attiva, altrimenti Sorgenti.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Prima vista disponibile",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Ripristina ultima vista",
"STARTUP_WINDOW_MODE": "Finestra all'avvio",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Scegli come si apre la finestra dell'app: con l'ultima dimensione, ingrandita o a schermo intero. Si applica al prossimo avvio. Premi F11 in qualsiasi momento per entrare o uscire dallo schermo intero.",
"STARTUP_WINDOW_MODE_NORMAL": "Ultima dimensione della finestra",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Ingrandita",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Schermo intero",
"SHOW_CAPTIONS_DESCRIPTION": "Visualizza/nascondi i sottotitoli per impostazione predefinita",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostra footer In riproduzione",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantieni visibile la barra di stato del lettore esterno in fondo all'area di lavoro su desktop.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Apri ricerca globale",
"OPEN_RECENTLY_VIEWED": "Apri visti di recente",
"SUBMIT_SHELL_SEARCH": "Invia ricerca nell’area di lavoro",
"TOGGLE_WINDOW_FULLSCREEN": "Attiva/disattiva schermo intero della finestra",
"TOGGLE_SIDEBAR": "Mostra/nascondi barra laterale",
"M3U_CHANNEL_NUMBER": "Seleziona canale M3U per numero",
"PLAY_PAUSE": "Riproduci o metti in pausa MPV integrato",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnatorを起動したときに最初に利用可能なワークスペースビューを開くか、最後に表示していたセクションを復元するかを選択します。「最初のビュー」は、ダッシュボードが有効な場合はダッシュボード、無効な場合はソースを意味します。",
"STARTUP_BEHAVIOR_FIRST_VIEW": "最初に利用可能なビュー",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "最後のビューを復元",
"STARTUP_WINDOW_MODE": "起動時のウィンドウ",
"STARTUP_WINDOW_MODE_DESCRIPTION": "デスクトップウィンドウの開き方を選択します:前回のサイズ、最大化、または全画面。次回の起動時に適用されます。F11キーでいつでも全画面表示を切り替えられます。",
"STARTUP_WINDOW_MODE_NORMAL": "前回のウィンドウサイズ",
"STARTUP_WINDOW_MODE_MAXIMIZED": "最大化",
"STARTUP_WINDOW_MODE_FULLSCREEN": "全画面",
"SHOW_CAPTIONS_DESCRIPTION": "デフォルトで字幕を表示/非表示",
"SHOW_EXTERNAL_PLAYBACK_BAR": "再生中フッターを表示",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "デスクトップでワークスペース下部に外部プレーヤーのステータスバーを表示し続けます。",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "グローバル検索を開く",
"OPEN_RECENTLY_VIEWED": "最近視聴した項目を開く",
"SUBMIT_SHELL_SEARCH": "ワークスペース検索を実行",
"TOGGLE_WINDOW_FULLSCREEN": "アプリウィンドウの全画面表示を切り替え",
"TOGGLE_SIDEBAR": "サイドバーを切り替え",
"M3U_CHANNEL_NUMBER": "番号で M3U チャンネルを選択",
"PLAY_PAUSE": "Embedded MPV の再生/一時停止",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator를 처음 시작할 때 첫 번째로 사용 가능한 작업 공간 화면을 열지, 마지막으로 본 섹션 화면을 복원할지 선택합니다. 첫 번째 화면은 활성화된 경우 대시보드, 그렇지 않으면 소스를 의미합니다.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "첫 번째로 사용 가능한 화면",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "마지막 화면 복원",
"STARTUP_WINDOW_MODE": "시작 시 창",
"STARTUP_WINDOW_MODE_DESCRIPTION": "데스크톱 창을 여는 방식을 선택합니다: 마지막 크기, 최대화 또는 전체 화면. 다음 실행부터 적용됩니다. 언제든지 F11 키를 눌러 전체 화면을 켜거나 끌 수 있습니다.",
"STARTUP_WINDOW_MODE_NORMAL": "마지막 창 크기",
"STARTUP_WINDOW_MODE_MAXIMIZED": "최대화",
"STARTUP_WINDOW_MODE_FULLSCREEN": "전체 화면",
"SHOW_CAPTIONS_DESCRIPTION": "기본적으로 자막 표시 또는 숨기기",
"SHOW_EXTERNAL_PLAYBACK_BAR": "지금 재생 중 표시줄 표시",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "데스크톱의 작업 공간 하단에 외부 플레이어 상태 표시줄을 계속 표시합니다.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "전역 검색 열기",
"OPEN_RECENTLY_VIEWED": "최근 시청 항목 열기",
"SUBMIT_SHELL_SEARCH": "워크스페이스 검색 실행",
"TOGGLE_WINDOW_FULLSCREEN": "앱 창 전체 화면 전환",
"TOGGLE_SIDEBAR": "사이드바 전환",
"M3U_CHANNEL_NUMBER": "번호로 M3U 채널 선택",
"PLAY_PAUSE": "Embedded MPV 재생 또는 일시정지",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Kies of IPTVnator de eerste beschikbare werkruimteweergave opent of je laatste sectieweergave herstelt. Eerste weergave betekent het dashboard wanneer ingeschakeld, anders Bronnen.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Eerste beschikbare weergave",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Laatste weergave herstellen",
"STARTUP_WINDOW_MODE": "Venster bij opstarten",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Kies hoe het desktopvenster opent: op de laatste grootte, gemaximaliseerd of op volledig scherm. Geldt vanaf de volgende start. Druk op elk moment op F11 om volledig scherm in of uit te schakelen.",
"STARTUP_WINDOW_MODE_NORMAL": "Laatste venstergrootte",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Gemaximaliseerd",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Volledig scherm",
"SHOW_CAPTIONS_DESCRIPTION": "Standaard ondertiteling weergeven of verbergen",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Voettekst 'nu spelend' tonen",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Houd de statusbalk van de externe speler zichtbaar onderaan de werkruimte op desktop.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Globaal zoeken openen",
"OPEN_RECENTLY_VIEWED": "Recent bekeken openen",
"SUBMIT_SHELL_SEARCH": "Werkruimtezoekopdracht uitvoeren",
"TOGGLE_WINDOW_FULLSCREEN": "Volledig scherm voor het app-venster in- of uitschakelen",
"TOGGLE_SIDEBAR": "Zijbalk in- of uitschakelen",
"M3U_CHANNEL_NUMBER": "M3U-kanaal selecteren op nummer",
"PLAY_PAUSE": "Ingebedde MPV-weergave afspelen of pauzeren",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Wybierz, czy IPTVnator otwiera pierwszy dostępny widok obszaru roboczego, czy przywraca ostatni widok na poziomie sekcji. Pierwszy widok oznacza pulpit, jeśli jest włączony, w przeciwnym razie Źródła.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Pierwszy dostępny widok",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Przywróć ostatni widok",
"STARTUP_WINDOW_MODE": "Okno przy uruchomieniu",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Wybierz, jak otwiera się okno aplikacji: w ostatnim rozmiarze, zmaksymalizowane lub na pełnym ekranie. Obowiązuje od następnego uruchomienia. Naciśnij F11 w dowolnym momencie, aby włączyć lub wyłączyć pełny ekran.",
"STARTUP_WINDOW_MODE_NORMAL": "Ostatni rozmiar okna",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Zmaksymalizowane",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Pełny ekran",
"SHOW_CAPTIONS_DESCRIPTION": "Domyślnie pokaż lub ukryj napisy",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Pokaż stopkę „teraz odtwarzane”",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Pozostaw pasek statusu zewnętrznego odtwarzacza widoczny u dołu obszaru roboczego na komputerze.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Otwórz wyszukiwanie globalne",
"OPEN_RECENTLY_VIEWED": "Otwórz ostatnio oglądane",
"SUBMIT_SHELL_SEARCH": "Uruchom wyszukiwanie w obszarze roboczym",
"TOGGLE_WINDOW_FULLSCREEN": "Przełącz pełny ekran okna aplikacji",
"TOGGLE_SIDEBAR": "Przełącz pasek boczny",
"M3U_CHANNEL_NUMBER": "Wybierz kanał M3U według numeru",
"PLAY_PAUSE": "Odtwórz lub wstrzymaj osadzony MPV",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Escolha se o IPTVnator abre a primeira visualização disponível do espaço de trabalho ou restaura sua última visualização. Primeira visualização significa Dashboard quando ativada, caso contrário, Fontes.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Primeira visualização disponível",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Restaurar última visualização",
"STARTUP_WINDOW_MODE": "Janela ao iniciar",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Escolha como a janela do aplicativo abre: no último tamanho, maximizada ou em tela cheia. Aplica-se na próxima inicialização. Pressione F11 a qualquer momento para entrar ou sair da tela cheia.",
"STARTUP_WINDOW_MODE_NORMAL": "Último tamanho da janela",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Maximizada",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Tela cheia",
"SHOW_CAPTIONS_DESCRIPTION": "Mostrar ou ocultar legendas por padrão",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Mostrar rodapé de reprodução atual",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Mantém a barra de status do reprodutor externo visível na parte inferior do espaço de trabalho no desktop.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Abrir pesquisa global",
"OPEN_RECENTLY_VIEWED": "Abrir assistidos recentemente",
"SUBMIT_SHELL_SEARCH": "Enviar pesquisa da área de trabalho",
"TOGGLE_WINDOW_FULLSCREEN": "Alternar tela cheia da janela do aplicativo",
"TOGGLE_SIDEBAR": "Alternar barra lateral",
"M3U_CHANNEL_NUMBER": "Selecionar canal M3U por número",
"PLAY_PAUSE": "Reproduzir ou pausar reprodução do Embedded MPV",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "Выберите, должен ли IPTVnator открывать первый доступный экран рабочего пространства или восстанавливать последний экран уровня раздела. Первый экран означает Dashboard, если он включён, иначе Источники.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "Первый доступный экран",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Восстановить последний экран",
"STARTUP_WINDOW_MODE": "Окно при запуске",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Выберите, как открывается окно приложения: в последнем размере, развёрнутым или на весь экран. Применяется при следующем запуске. Нажмите F11 в любой момент, чтобы войти в полноэкранный режим или выйти из него.",
"STARTUP_WINDOW_MODE_NORMAL": "Последний размер окна",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Развёрнутое",
"STARTUP_WINDOW_MODE_FULLSCREEN": "На весь экран",
"SHOW_CAPTIONS_DESCRIPTION": "Показать или скрыть субтитры по умолчанию.",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Показывать панель «Сейчас воспроизводится»",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Оставлять панель состояния внешнего проигрывателя видимой внизу рабочей области в настольном приложении.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Открыть глобальный поиск",
"OPEN_RECENTLY_VIEWED": "Открыть недавно просмотренное",
"SUBMIT_SHELL_SEARCH": "Отправить поиск в рабочей области",
"TOGGLE_WINDOW_FULLSCREEN": "Переключить полноэкранный режим окна приложения",
"TOGGLE_SIDEBAR": "Показать или скрыть боковую панель",
"M3U_CHANNEL_NUMBER": "Выбрать канал M3U по номеру",
"PLAY_PAUSE": "Запустить или приостановить воспроизведение во встроенном MPV",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "IPTVnator'un ilk kullanılabilir çalışma alanı görünümünü mü açacağını yoksa bölüm düzeyindeki son görünümünüzü mü geri yükleyeceğini seçin. İlk görünüm, Pano etkinleştirildiğinde Pano, aksi takdirde Kaynaklar anlamına gelir.",
"STARTUP_BEHAVIOR_FIRST_VIEW": "İlk kullanılabilir görünüm",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "Son görünümü geri yükle",
"STARTUP_WINDOW_MODE": "Başlangıçta pencere",
"STARTUP_WINDOW_MODE_DESCRIPTION": "Masaüstü penceresinin nasıl açılacağını seçin: son boyutunda, ekranı kaplamış veya tam ekran. Bir sonraki başlatmada uygulanır. Tam ekrana girmek veya çıkmak için istediğiniz zaman F11 tuşuna basın.",
"STARTUP_WINDOW_MODE_NORMAL": "Son pencere boyutu",
"STARTUP_WINDOW_MODE_MAXIMIZED": "Ekranı kaplamış",
"STARTUP_WINDOW_MODE_FULLSCREEN": "Tam ekran",
"SHOW_CAPTIONS_DESCRIPTION": "Varsayılan olarak altyazıları göster veya gizle",
"SHOW_EXTERNAL_PLAYBACK_BAR": "Şu an oynatılıyor altbilgisini göster",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "Masaüstünde harici oynatıcı durum çubuğunu çalışma alanının altında görünür tutun.",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "Genel aramayı aç",
"OPEN_RECENTLY_VIEWED": "Son görüntülenenleri aç",
"SUBMIT_SHELL_SEARCH": "Çalışma alanı aramasını gönder",
"TOGGLE_WINDOW_FULLSCREEN": "Uygulama penceresi tam ekranını aç/kapat",
"TOGGLE_SIDEBAR": "Kenar çubuğunu aç/kapat",
"M3U_CHANNEL_NUMBER": "M3U kanalını numarayla seç",
"PLAY_PAUSE": "Gömülü MPV oynatmasını oynat veya duraklat",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "选择 IPTVnator 启动时是打开第一个可用的工作区视图,还是恢复您上次的章节级视图。第一个视图在启用时为仪表盘,否则为「源」。",
"STARTUP_BEHAVIOR_FIRST_VIEW": "首个可用视图",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "恢复上次视图",
"STARTUP_WINDOW_MODE": "启动时的窗口",
"STARTUP_WINDOW_MODE_DESCRIPTION": "选择桌面窗口的打开方式:上次的大小、最大化或全屏。下次启动时生效。随时按 F11 进入或退出全屏。",
"STARTUP_WINDOW_MODE_NORMAL": "上次窗口大小",
"STARTUP_WINDOW_MODE_MAXIMIZED": "最大化",
"STARTUP_WINDOW_MODE_FULLSCREEN": "全屏",
"SHOW_CAPTIONS_DESCRIPTION": "默认显示或隐藏字幕",
"SHOW_EXTERNAL_PLAYBACK_BAR": "显示「正在播放」底栏",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面端工作区底部保持外部播放器状态栏可见。",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "打开全局搜索",
"OPEN_RECENTLY_VIEWED": "打开最近观看",
"SUBMIT_SHELL_SEARCH": "提交工作区搜索",
"TOGGLE_WINDOW_FULLSCREEN": "切换应用窗口全屏",
"TOGGLE_SIDEBAR": "切换侧边栏",
"M3U_CHANNEL_NUMBER": "按数字选择 M3U 频道",
"PLAY_PAUSE": "播放或暂停嵌入式 MPV 播放",
+6
View File
@@ -368,6 +368,11 @@
"STARTUP_BEHAVIOR_DESCRIPTION": "選擇 IPTVnator 啟動時要開啟第一個可用的工作區畫面,或還原您上次瀏覽的區段層級畫面。第一個畫面在啟用儀表板時為儀表板,否則為「來源」。",
"STARTUP_BEHAVIOR_FIRST_VIEW": "第一個可用畫面",
"STARTUP_BEHAVIOR_RESTORE_LAST_VIEW": "還原上次畫面",
"STARTUP_WINDOW_MODE": "啟動時的視窗",
"STARTUP_WINDOW_MODE_DESCRIPTION": "選擇桌面視窗的開啟方式:上次的大小、最大化或全螢幕。下次啟動時生效。隨時按 F11 進入或離開全螢幕。",
"STARTUP_WINDOW_MODE_NORMAL": "上次視窗大小",
"STARTUP_WINDOW_MODE_MAXIMIZED": "最大化",
"STARTUP_WINDOW_MODE_FULLSCREEN": "全螢幕",
"SHOW_CAPTIONS_DESCRIPTION": "預設顯示或隱藏字幕",
"SHOW_EXTERNAL_PLAYBACK_BAR": "顯示「正在播放」列",
"SHOW_EXTERNAL_PLAYBACK_BAR_DESCRIPTION": "在桌面工作區底部保留外部播放器狀態列可見。",
@@ -1574,6 +1579,7 @@
"OPEN_GLOBAL_SEARCH": "開啟全域搜尋",
"OPEN_RECENTLY_VIEWED": "開啟最近觀看",
"SUBMIT_SHELL_SEARCH": "提交工作區搜尋",
"TOGGLE_WINDOW_FULLSCREEN": "切換應用程式視窗全螢幕",
"TOGGLE_SIDEBAR": "切換側邊欄",
"M3U_CHANNEL_NUMBER": "按數字選擇 M3U 頻道",
"PLAY_PAUSE": "播放或暫停嵌入式 MPV 播放",
+90
View File
@@ -298,6 +298,96 @@ handlers in `apps/electron-backend/src/app/events/window.events.ts`):
stuck the maximize/restore glyph on the wrong icon. Regression coverage:
`app-window-state.spec.ts` and `window-controls.e2e.ts`.
The pushed `isFullScreen` is the OR of two flags tracked apart: native
(OS-level, fed by `enter/leave-full-screen`) and HTML-element (fed by
the `*-html-*` pair). 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 — so a single flag cleared by
`leave-html-full-screen` would un-hide the controls over a window that
is still fullscreen. A fullscreen launch (below) or F11 followed by the
player's `F` → `Esc` makes that path routine on Windows/Linux.
3. `WINDOW:TOGGLE_FULLSCREEN` toggles OS-level fullscreen (`setFullScreen`)
and, like the maximize toggle, reports the requested state and leaves
the `WINDOW:STATE_CHANGED` push authoritative. Because the transition is
asynchronous — `isFullScreen()` reports the old value until it lands,
and on Windows even while the matching event fires — a toggle must
never be decided against the getter. Every native fullscreen request
goes through the tracker in
`apps/electron-backend/src/app/services/native-fullscreen-transitions.ts`
(the F11 toggle AND the startup fallback below). Like the state pushes
above it keeps its own per-window fullscreen state, seeded from the
getter once at window creation (`trackNativeFullScreen` in
`initMainWindow`, while no transition can be in flight) and fed
afterwards only by the `enter`/`leave-full-screen` events, which also
cover transitions the app did not request. A toggle is decided against
the pending target while a transition is in flight, else against that
tracked state: two quick presses are an enter-then-exit, not two enters,
and F11 during the startup animation leaves fullscreen instead of asking
for it again. The tracker observes and 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 still follows the user's latest intent, and a record older
than `FULLSCREEN_TRANSITION_TIMEOUT_MS` (2 s) is ignored. 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/Ctrl+Cmd+F action and is deliberately not done: should a
platform ever drop a queued request (Electron queues them on macOS and
applies them synchronously elsewhere), the record expires, the event-fed
state takes over and the next press corrects the window. The renderer
binds it to
**F11** in `WorkspaceKeyboardShortcutsService` (deliberately not gated
by `isTypingInInput` — it must work from any focus, because it is the
only exit from a fullscreen launch on Windows/Linux, where the title bar
is hidden and the controls hide themselves) and skips the key while
`document.fullscreenElement` is set, since the player's own `F` / `Esc`
own HTML fullscreen and F11 must not yank OS fullscreen out from under
it. Without a bridge (PWA) F11 is left to the browser.
Startup window mode (`Settings.startupWindowMode`, issue #1455):
1. `normal` (default) / `maximized` / `fullscreen`, chosen in Settings →
General ("Window on startup"). Electron only — the select renders only
when `RuntimeCapabilitiesService.supportsStartupWindowMode` sees both
`updateSettings` and `toggleFullScreenWindow` on the bridge, so the mode
is never offered without its F11 exit.
2. Settings live in the renderer's IndexedDB, which the main process cannot
read at window creation, so the `SETTINGS_UPDATE` handler mirrors the
value into electron-conf (`STARTUP_WINDOW_MODE`, the same pattern as the
frame-copy flag) and `initMainWindow` reads it synchronously. A change
therefore applies on the next launch. Both sides normalize through
`normalizeStartupWindowMode`, so junk never reaches the config file or
the window options.
3. `fullscreen` is the `BrowserWindow` constructor option: on Windows/Linux
the window is created hidden and enters fullscreen before its first
paint. macOS ignores the option while the window is hidden (an NSWindow
only toggles fullscreen once it is on screen), so `ready-to-show` repeats
the request with `setFullScreen(true)` right after `show()` wherever
`isFullScreen()` is still false — never unconditionally, or the
platforms that honoured the option would animate a second toggle. The
saved bounds stay spread into the options — they are the normal bounds
the window returns to, and the close handler keeps persisting
`getNormalBounds()`. `maximized` calls `maximize()` inside
`ready-to-show` right before `show()`, never earlier: `maximize()` on a
hidden window shows it, and a blank window would flash.
4. `iptvnator --fullscreen` (read via `app.commandLine.hasSwitch`, so it can
sit anywhere in argv; the playlist-path extractor already skips every
`-`-prefixed argument) forces `fullscreen` for that launch only and is
never persisted. Resolution lives in
`apps/electron-backend/src/app/services/startup-window-mode.ts`. The
switch is consumed by the first window (`launchFullscreenSwitchConsumed`
in `App`): on macOS the process outlives its last window and the Dock
re-creates it through the same `initMainWindow`, which must then follow
the stored setting only. A second-instance launch carrying the switch is
ignored — the window already exists.
5. Deliberately not offered: kiosk mode (removes the exit path) and
"remember last state" (bounds persistence stores normal bounds only; an
explicit choice is clearer). Regression coverage: `app.spec.ts`
("startup window mode"), `settings.events.spec.ts`,
`window.events.spec.ts`, and the startup-window-mode cases in
`settings.e2e.ts`.
Layout integration:
1. `document.body` gets a `frameless-platform` class (set in
@@ -99,6 +99,18 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [
keys: ['Enter'],
order: 40,
},
{
// OS-level window fullscreen, distinct from the player's `F`
// (HTML-element fullscreen). Desktop only: the PWA leaves F11 to
// the browser.
id: 'toggle-window-fullscreen',
group: 'global',
labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.TOGGLE_WINDOW_FULLSCREEN',
icon: 'fullscreen',
keys: ['F11'],
order: 50,
electronOnly: true,
},
{
id: 'toggle-sidebar',
group: 'navigation',
@@ -23,6 +23,8 @@ describe('keyboard shortcuts registry', () => {
expect(ids).not.toContain('open-global-search');
expect(ids).not.toContain('open-recently-viewed');
expect(ids).not.toContain('close-player-popovers');
// The browser owns F11 in the PWA.
expect(ids).not.toContain('toggle-window-fullscreen');
expect(ids).toContain('open-command-palette');
// Playback shortcuts run in every runtime: the built-in web players
// attach them through the legacy shortcut wiring in the PWA too.
@@ -33,6 +35,21 @@ describe('keyboard shortcuts registry', () => {
expect(ids).toContain('mute-audio');
});
it('lists the F11 window fullscreen toggle in the global group for Electron', () => {
const groups = getKeyboardShortcutGroups({
isMac: false,
isElectron: true,
});
const globalGroup = groups.find((group) => group.id === 'global');
expect(globalGroup?.items.map((item) => item.id)).toContain(
'toggle-window-fullscreen'
);
expect(findChordLabels(groups, 'toggle-window-fullscreen')).toEqual([
['F11'],
]);
});
it('uses platform-specific modifier labels', () => {
const macGroups = getKeyboardShortcutGroups({
isMac: true,
@@ -270,6 +270,27 @@ describe('RuntimeCapabilitiesService', () => {
expect(service.supportsXtreamSqliteDataSource).toBe(false);
});
it('offers the startup window mode only with both the settings mirror and the F11 toggle', () => {
testWindow.electron = undefined;
const service = new RuntimeCapabilitiesService();
expect(service.supportsStartupWindowMode).toBe(false);
// The mirror alone would let a user pick fullscreen with no way out
// on Windows/Linux, where the title bar is hidden.
testWindow.electron = { updateSettings: jest.fn() };
expect(service.supportsStartupWindowMode).toBe(false);
testWindow.electron = { toggleFullScreenWindow: jest.fn() };
expect(service.supportsStartupWindowMode).toBe(false);
testWindow.electron = {
updateSettings: jest.fn(),
toggleFullScreenWindow: jest.fn(),
};
expect(service.supportsStartupWindowMode).toBe(true);
});
it('decouples external player launch support from path-setting support', () => {
testWindow.electron = {
openInMpv: jest.fn(),
@@ -69,6 +69,20 @@ export class RuntimeCapabilitiesService {
);
}
/**
* Desktop window mode at launch (normal / maximized / fullscreen) plus
* the F11 toggle. The mode reaches the main process through
* `updateSettings`, and the toggle is what makes a fullscreen launch
* escapable on Windows/Linux, where the title bar is hidden — so the
* setting is offered only when both halves exist.
*/
get supportsStartupWindowMode(): boolean {
return (
this.hasElectronMethod('updateSettings') &&
this.hasElectronMethod('toggleFullScreenWindow')
);
}
get supportsEpg(): boolean {
return (
this.supportsEpgImport &&
@@ -146,6 +146,36 @@ describe('SettingsStore dashboard rail settings', () => {
expect(store.getSettings().stripCountryPrefix).toBe(false);
});
it('defaults the startup window mode to normal when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().startupWindowMode).toBe('normal');
});
it('restores a persisted fullscreen startup window mode', async () => {
storedSettings = { startupWindowMode: 'fullscreen' };
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().startupWindowMode).toBe('fullscreen');
});
it('normalizes a persisted unknown startup window mode to normal', async () => {
storedSettings = {
startupWindowMode:
'kiosk' as unknown as Settings['startupWindowMode'],
};
const store = injector.get(SettingsStore);
await store.loadSettings();
expect(store.getSettings().startupWindowMode).toBe('normal');
});
it('defaults ambient player mode to false when the stored field is missing', async () => {
storedSettings = {};
const store = injector.get(SettingsStore);
@@ -22,6 +22,7 @@ import {
Theme,
VideoPlayer,
normalizeDashboardRailsSettings,
normalizeStartupWindowMode,
} from '@iptvnator/shared/interfaces';
const DEFAULT_SETTINGS: Settings = {
@@ -37,6 +38,7 @@ const DEFAULT_SETTINGS: Settings = {
showCaptions: false,
showDashboard: true,
startupBehavior: StartupBehavior.FirstView,
startupWindowMode: 'normal',
showExternalPlaybackBar: true,
stripCountryPrefix: false,
theme: Theme.SystemTheme,
@@ -240,6 +242,9 @@ export const SettingsStore = signalStore(
showCaptions: store.showCaptions(),
showDashboard: store.showDashboard(),
startupBehavior: store.startupBehavior(),
startupWindowMode: normalizeStartupWindowMode(
store.startupWindowMode?.()
),
showExternalPlaybackBar:
store.showExternalPlaybackBar?.() ??
DEFAULT_SETTINGS.showExternalPlaybackBar,
@@ -662,6 +662,12 @@ export interface ElectronBridgeApi {
) => () => void;
minimizeWindow: () => Promise<void>;
toggleMaximizeWindow: () => Promise<ElectronBridgeWindowState>;
/**
* Toggles OS-level window fullscreen (F11). Reports the requested state;
* the `onWindowStateChange` push stays authoritative once the window
* manager has acted.
*/
toggleFullScreenWindow: () => Promise<ElectronBridgeWindowState>;
closeWindow: () => Promise<void>;
getWindowState: () => Promise<ElectronBridgeWindowState>;
onWindowStateChange: (
@@ -129,6 +129,10 @@ export const PLAYBACK_SET_KEEP_AWAKE = 'PLAYBACK:SET_KEEP_AWAKE';
// Window controls (custom title bar on Windows/Linux)
export const WINDOW_MINIMIZE = 'WINDOW:MINIMIZE';
export const WINDOW_TOGGLE_MAXIMIZE = 'WINDOW:TOGGLE_MAXIMIZE';
// OS-level window fullscreen toggle (F11). The exit path from a fullscreen
// launch on Windows/Linux, where the title bar is hidden and the custom
// window controls hide themselves while fullscreen.
export const WINDOW_TOGGLE_FULLSCREEN = 'WINDOW:TOGGLE_FULLSCREEN';
export const WINDOW_CLOSE = 'WINDOW:CLOSE';
export const WINDOW_GET_STATE = 'WINDOW:GET_STATE';
export const WINDOW_STATE_CHANGED = 'WINDOW:STATE_CHANGED';
@@ -39,6 +39,34 @@ export enum StartupBehavior {
RestoreLastView = 'restore-last-view',
}
/**
* How the desktop window is presented when the app launches. Electron only.
*
* Persisted like every other setting in the renderer and mirrored into the
* main-process config file by the `SETTINGS_UPDATE` handler, because the
* window is created before the renderer exists — so a change applies on the
* next launch. `--fullscreen` on the command line forces `'fullscreen'` for
* that launch only, and F11 toggles fullscreen at any time.
*/
export type StartupWindowMode = 'normal' | 'maximized' | 'fullscreen';
export const STARTUP_WINDOW_MODES: readonly StartupWindowMode[] = [
'normal',
'maximized',
'fullscreen',
];
/**
* Collapses anything that is not a known mode to `'normal'`. Shared by the
* renderer form and the main-process mirror so junk never reaches the
* config file or the window options.
*/
export function normalizeStartupWindowMode(value: unknown): StartupWindowMode {
return STARTUP_WINDOW_MODES.includes(value as StartupWindowMode)
? (value as StartupWindowMode)
: 'normal';
}
export type CoverSize = 'small' | 'medium' | 'large';
/** Rendering of the live EPG panel under the player. */
@@ -147,6 +175,12 @@ export interface Settings {
showCaptions: boolean;
showDashboard: boolean;
startupBehavior: StartupBehavior;
/**
* Desktop window presentation at launch: normal (last size), maximized,
* or fullscreen. Electron only and applied on the next launch; a missing
* value means `'normal'`. F11 leaves or re-enters fullscreen.
*/
startupWindowMode?: StartupWindowMode;
/** Show the desktop footer bar for external playback status */
showExternalPlaybackBar?: boolean;
/** Strip country/group prefixes like "US | " or "UK - " from channel names */
@@ -92,6 +92,93 @@ describe('WorkspaceKeyboardShortcutsService', () => {
input.remove();
});
describe('F11 window fullscreen toggle', () => {
const testWindow = window as unknown as {
electron?: Record<string, unknown>;
};
let toggleFullScreenWindow: jest.Mock;
function pressF11(
init: KeyboardEventInit = {},
target: EventTarget = document
): KeyboardEvent {
const event = new KeyboardEvent('keydown', {
key: 'F11',
bubbles: true,
cancelable: true,
...init,
});
target.dispatchEvent(event);
return event;
}
function setHtmlFullscreenElement(element: Element | null): void {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
value: element,
});
}
beforeEach(() => {
toggleFullScreenWindow = jest.fn().mockResolvedValue({
isMaximized: false,
isFullScreen: true,
});
testWindow.electron = { toggleFullScreenWindow };
setHtmlFullscreenElement(null);
});
afterEach(() => {
delete testWindow.electron;
setHtmlFullscreenElement(null);
});
it('toggles window fullscreen through the bridge and swallows the key', () => {
const event = pressF11();
expect(toggleFullScreenWindow).toHaveBeenCalledTimes(1);
expect(event.defaultPrevented).toBe(true);
expect(dialog.open).not.toHaveBeenCalled();
});
it('works while typing in an input — it is the exit from a fullscreen launch', () => {
const input = document.createElement('input');
document.body.appendChild(input);
pressF11({}, input);
expect(toggleFullScreenWindow).toHaveBeenCalledTimes(1);
input.remove();
});
it('leaves the key alone while the player owns HTML fullscreen', () => {
setHtmlFullscreenElement(document.createElement('div'));
const event = pressF11();
expect(toggleFullScreenWindow).not.toHaveBeenCalled();
expect(event.defaultPrevented).toBe(false);
});
it('ignores modified and auto-repeated F11 presses', () => {
pressF11({ ctrlKey: true });
pressF11({ metaKey: true });
pressF11({ altKey: true });
pressF11({ shiftKey: true });
pressF11({ repeat: true });
expect(toggleFullScreenWindow).not.toHaveBeenCalled();
});
it('leaves F11 to the browser without a bridge', () => {
delete testWindow.electron;
const event = pressF11();
expect(event.defaultPrevented).toBe(false);
});
});
it('does not open duplicate dialogs while one is active', () => {
service.openShortcutsDialog();
service.openShortcutsDialog();
@@ -78,6 +78,10 @@ export class WorkspaceKeyboardShortcutsService {
}
private handleKeydown(event: KeyboardEvent): void {
if (this.handleWindowFullscreenToggle(event)) {
return;
}
if (
isTypingInInput(event) ||
!isKeyboardShortcutHelpTrigger(event) ||
@@ -90,6 +94,41 @@ export class WorkspaceKeyboardShortcutsService {
this.openShortcutsDialog();
}
/**
* F11 toggles OS-level window fullscreen in the desktop app. It is the
* way out of a fullscreen launch on Windows/Linux, where the title bar is
* hidden and the window controls hide themselves while fullscreen, so it
* is deliberately not gated by `isTypingInInput` — F11 is expected to
* work from any focus. While the player owns HTML-element fullscreen
* (`document.fullscreenElement`) its own F / Esc are in charge and F11
* must not yank OS fullscreen out from under it. Without a bridge (PWA)
* the browser keeps its own F11.
*/
private handleWindowFullscreenToggle(event: KeyboardEvent): boolean {
if (
event.key !== 'F11' ||
event.repeat ||
event.ctrlKey ||
event.metaKey ||
event.altKey ||
event.shiftKey
) {
return false;
}
const bridge = window.electron;
if (
typeof bridge?.toggleFullScreenWindow !== 'function' ||
document.fullscreenElement
) {
return false;
}
event.preventDefault();
void bridge.toggleFullScreenWindow().catch(() => undefined);
return true;
}
private getShortcutPlatform(): 'mac' | 'other' {
if (typeof navigator === 'undefined') {
return 'other';