mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(shell): startup window mode, --fullscreen switch and F11 toggle (#1514)
Settings > General gains "Window on startup" (normal / maximized / fullscreen), Electron only, mirrored into the main-process config by SETTINGS_UPDATE and applied at the next window creation. `--fullscreen` forces one fullscreen launch (consumed by the first window). F11 toggles OS-level fullscreen through WINDOW:TOGGLE_FULLSCREEN — the exit path on Windows/Linux where the title bar is hidden — and is skipped while the player owns document.fullscreenElement. attachWindowStateEvents tracks native and HTML fullscreen as two flags, since Electron leaves only the HTML state when the window was already natively fullscreen. macOS ignores the constructor `fullscreen` option on a hidden window, so ready-to-show repeats the request after show(). Toggles are decided by an observe-only, event-fed tracker (native-fullscreen-transitions.ts), never against isFullScreen(). Closes #1455 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
078bd07d94
commit
fa9084fca3
57 files changed
+1650
-8
No files matched your search
@@ -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 */
|
||||
|
||||
+87
@@ -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();
|
||||
|
||||
+39
@@ -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';
|
||||
|
||||
Reference in new issue
Block a user