diff --git a/.changes/shell-zoom-level-persistence.md b/.changes/shell-zoom-level-persistence.md index b85a3451c..68047d495 100644 --- a/.changes/shell-zoom-level-persistence.md +++ b/.changes/shell-zoom-level-persistence.md @@ -4,6 +4,8 @@ area: shell issues: [1109] --- -The app now remembers your zoom level (Cmd and +/- on macOS): it holds when -you switch between sections or resize the window, and comes back after a -restart, so a larger font size no longer resets to the default. +The app now remembers your zoom level (Cmd/Ctrl and +/-, Cmd/Ctrl+0 to +reset): it holds when you switch between sections or resize the window, and +comes back after a restart, so a larger font size no longer resets to the +default. Windows and Linux gain the zoom shortcuts, which had only worked +through the macOS menu. diff --git a/CLAUDE.md b/CLAUDE.md index 0168e6922..73effccb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -769,7 +769,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 -- Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). Contract: `docs/architecture/workspace-shell.md`, "Zoom level" +- Persists the app zoom level (issue #1109): the preload restores it with `webFrame.setZoomLevel` (temporary, frame-bound zoom; level answered over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC, applied at `DOMContentLoaded` and acknowledged with `WINDOW:ZOOM_LEVEL_APPLIED` — any earlier `webFrame.setZoomLevel` leaves a hidden Linux/Windows window without `ready-to-show`), never `webContents.setZoomLevel` — under `file://` Chromium keys zoom by the full URL, so the app's path routing would reset it on the next resize after a section change, and dev mode (`http://localhost`) never shows that. `app/services/window-zoom-level.ts` writes the live level to electron-conf `ZOOM_LEVEL` on close, `before-quit` and before every cross-document navigation (a reload drops the temporary level). The zoom shortcuts (Cmd/Ctrl and +/−/0, numpad included) are a renderer key binding in `WorkspaceKeyboardShortcutsService` — the Windows/Linux window has no menu (`setMenu(null)`) — calling the synchronous preload-local bridge method `adjustZoomLevel`, which steps the same frame-bound level (`stepZoomLevel` in `libs/shared/interfaces`: 0.5 per press like Electron's `zoomIn`/`zoomOut` roles, clamped to levels −4…6); on macOS the renderer's `preventDefault()` keeps the menu role from stepping a second time. Contract: `docs/architecture/workspace-shell.md`, "Zoom level" - 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**: diff --git a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts index e8a27c64e..5b0acf8ff 100644 --- a/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts +++ b/apps/electron-backend-e2e/src/window-zoom-level.e2e.ts @@ -25,12 +25,23 @@ async function renderedZoomFactor(app: LaunchedElectronApp): Promise { return contentWidth / innerWidth; } -/** What the macOS menu roles (Cmd +/−) do: `webContents.zoomLevel += 1`. */ -async function zoomInFromMenu(app: LaunchedElectronApp): Promise { - await app.electronApp.evaluate(({ BrowserWindow }) => { - const [win] = BrowserWindow.getAllWindows(); - win.webContents.setZoomLevel(win.webContents.getZoomLevel() + 1); - }); +/** + * The app zoom shortcuts as the renderer receives them: Cmd on macOS, Ctrl + * elsewhere. Dispatched through CDP they reach the renderer's keydown + * binding directly (`WorkspaceKeyboardShortcutsService`), which steps the + * frame-bound level through the preload bridge; the macOS application menu + * is not in this path. + */ +const ZOOM_MODIFIER = process.platform === 'darwin' ? 'Meta' : 'Control'; + +async function pressZoomShortcut( + app: LaunchedElectronApp, + key: 'Equal' | 'Minus' | 'Digit0' | 'NumpadAdd' | 'NumpadSubtract', + times = 1 +): Promise { + for (let index = 0; index < times; index += 1) { + await app.mainWindow.keyboard.press(`${ZOOM_MODIFIER}+${key}`); + } } /** @@ -58,11 +69,13 @@ async function resizeWindowBy( }, delta); } -// Zoom level 1 is a 1.2 factor. The packaged renderer runs under file:// with -// path routing, where Chromium keys zoom by full URL: without frame-bound -// (temporary) zoom the level "holds" only until the next resize after a -// section change, and a restart brings back the default (issue #1109). +// Zoom level 1 is a 1.2 factor; each shortcut press steps the level by 0.5 +// (Electron's zoomIn/zoomOut role step). The packaged renderer runs under +// file:// with path routing, where Chromium keys zoom by full URL: without +// frame-bound (temporary) zoom the level "holds" only until the next resize +// after a section change, and a restart brings back the default (issue #1109). const ZOOMED_FACTOR = 1.2; +const HALF_STEP_FACTOR = Math.sqrt(ZOOMED_FACTOR); test('@electron @window keeps the zoom level across sections, resizes and a restart', async ({ dataDir, @@ -72,7 +85,23 @@ test('@electron @window keeps the zoom level across sections, resizes and a rest try { expect(await renderedZoomFactor(app)).toBeCloseTo(1, 1); - await zoomInFromMenu(app); + // In three times, out once (main keys and numpad): level 1. + await pressZoomShortcut(app, 'Equal', 2); + await pressZoomShortcut(app, 'NumpadAdd'); + await expect + .poll(() => renderedZoomFactor(app)) + .toBeCloseTo(ZOOMED_FACTOR * HALF_STEP_FACTOR, 1); + await pressZoomShortcut(app, 'Minus'); + await expect + .poll(() => renderedZoomFactor(app)) + .toBeCloseTo(ZOOMED_FACTOR, 1); + + // Reset returns to level 0, then zoom back in (three in, one out on + // the numpad) for the persistence checks below. + await pressZoomShortcut(app, 'Digit0'); + await expect.poll(() => renderedZoomFactor(app)).toBeCloseTo(1, 1); + await pressZoomShortcut(app, 'Equal', 3); + await pressZoomShortcut(app, 'NumpadSubtract'); await expect .poll(() => renderedZoomFactor(app)) .toBeCloseTo(ZOOMED_FACTOR, 1); diff --git a/apps/electron-backend/src/app/api/main.preload.spec.ts b/apps/electron-backend/src/app/api/main.preload.spec.ts index 992b32a75..b634cbbff 100644 --- a/apps/electron-backend/src/app/api/main.preload.spec.ts +++ b/apps/electron-backend/src/app/api/main.preload.spec.ts @@ -124,6 +124,24 @@ describe('main preload DB IPC contract', () => { ); }); + it('steps the zoom shortcuts through webFrame without any IPC', () => { + // Same frame-bound temporary zoom as the restore: a main-process + // setZoomLevel would write the per-URL entry the app's file:// + // routing resets (issue #1109). + mockWebFrame.getZoomLevel.mockReturnValue(1); + mockIpcRenderer.invoke.mockClear(); + mockIpcRenderer.send.mockClear(); + + expect(mockExposedApi?.adjustZoomLevel('in')).toBe(1.5); + expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(1.5); + + expect(mockExposedApi?.adjustZoomLevel('reset')).toBe(0); + expect(mockWebFrame.setZoomLevel).toHaveBeenCalledWith(0); + + expect(mockIpcRenderer.invoke).not.toHaveBeenCalled(); + expect(mockIpcRenderer.send).not.toHaveBeenCalled(); + }); + it('covers every worker-backed DB operation exposed by the preload bridge', () => { const workerChannels = dbPreloadCases .map((contractCase) => contractCase.channel) diff --git a/apps/electron-backend/src/app/api/main.preload.ts b/apps/electron-backend/src/app/api/main.preload.ts index 6427003b5..adb8fefb9 100644 --- a/apps/electron-backend/src/app/api/main.preload.ts +++ b/apps/electron-backend/src/app/api/main.preload.ts @@ -21,7 +21,10 @@ import { createPreloadPerformanceCapture, toPreloadPerformanceTargetMethod, } from './preload-performance-capture'; -import { applyPersistedZoomLevel } from './preload-zoom-level'; +import { + adjustFrameZoomLevel, + applyPersistedZoomLevel, +} from './preload-zoom-level'; import { createXtreamPreloadPerformanceCapture, isXtreamPreloadPerformanceCaptureEnabled, @@ -277,6 +280,11 @@ function wrapElectronApi(api: T): T { ) as T; } +const frameZoomPorts = { + getZoomLevel: () => webFrame.getZoomLevel(), + setZoomLevel: (level: number) => webFrame.setZoomLevel(level), +}; + const electronApi: ElectronBridgeApi = { // Remote control channel change listener onChannelChange: ( @@ -424,6 +432,9 @@ const electronApi: ElectronBridgeApi = { toggleMaximizeWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_MAXIMIZE), toggleFullScreenWindow: () => ipcRenderer.invoke(WINDOW_TOGGLE_FULLSCREEN), closeWindow: () => ipcRenderer.invoke(WINDOW_CLOSE), + // Zoom shortcuts: same frame-bound webFrame route as the restore below; + // synchronous, no IPC — persistence reads the level back in main. + adjustZoomLevel: (action) => adjustFrameZoomLevel(frameZoomPorts, action), getWindowState: () => ipcRenderer.invoke(WINDOW_GET_STATE), onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void @@ -1161,8 +1172,7 @@ const electronApi: ElectronBridgeApi = { // preload-zoom-level.ts for the Linux/Windows ready-to-show trap. applyPersistedZoomLevel({ requestPersistedZoomLevel: () => ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL), - getZoomLevel: () => webFrame.getZoomLevel(), - setZoomLevel: (level) => webFrame.setZoomLevel(level), + ...frameZoomPorts, whenDocumentParsed: (apply) => { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', apply, { once: true }); diff --git a/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts b/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts index 3ec787df7..cc72ffa0c 100644 --- a/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts +++ b/apps/electron-backend/src/app/api/preload-zoom-level.spec.ts @@ -1,4 +1,7 @@ -import { applyPersistedZoomLevel } from './preload-zoom-level'; +import { + adjustFrameZoomLevel, + applyPersistedZoomLevel, +} from './preload-zoom-level'; function createPorts(saved: unknown, current = 0) { const parsed: Array<() => void> = []; @@ -70,3 +73,46 @@ describe('applyPersistedZoomLevel', () => { expect(ports.notifyApplied).not.toHaveBeenCalled(); }); }); + +describe('adjustFrameZoomLevel', () => { + function createFrame(current: number) { + return { + getZoomLevel: jest.fn(() => current), + setZoomLevel: jest.fn(), + }; + } + + it('steps the frame level through webFrame and reports the applied level', () => { + const frame = createFrame(0); + + expect(adjustFrameZoomLevel(frame, 'in')).toBe(0.5); + expect(frame.setZoomLevel).toHaveBeenCalledWith(0.5); + + const zoomed = createFrame(0.5); + expect(adjustFrameZoomLevel(zoomed, 'out')).toBe(0); + expect(zoomed.setZoomLevel).toHaveBeenCalledWith(0); + }); + + it('resets to level 0', () => { + const frame = createFrame(2.5); + + expect(adjustFrameZoomLevel(frame, 'reset')).toBe(0); + expect(frame.setZoomLevel).toHaveBeenCalledWith(0); + }); + + it('does not write when the level is already at the limit', () => { + const frame = createFrame(6); + + expect(adjustFrameZoomLevel(frame, 'in')).toBe(6); + expect(frame.setZoomLevel).not.toHaveBeenCalled(); + }); + + it('reports the unchanged level when webFrame refuses the write', () => { + const frame = createFrame(1); + frame.setZoomLevel.mockImplementation(() => { + throw new Error('frame gone'); + }); + + expect(adjustFrameZoomLevel(frame, 'in')).toBe(1); + }); +}); diff --git a/apps/electron-backend/src/app/api/preload-zoom-level.ts b/apps/electron-backend/src/app/api/preload-zoom-level.ts index f81511312..b716d9120 100644 --- a/apps/electron-backend/src/app/api/preload-zoom-level.ts +++ b/apps/electron-backend/src/app/api/preload-zoom-level.ts @@ -20,6 +20,11 @@ * the call is harmless, and it still lands before the first Angular paint. */ +import { + stepZoomLevel, + type ZoomLevelAction, +} from '@iptvnator/shared/interfaces/zoom-level'; + export interface PreloadZoomLevelPorts { /** `ipcRenderer.sendSync(WINDOW_GET_ZOOM_LEVEL)` — the stored level or null. */ requestPersistedZoomLevel(): unknown; @@ -61,3 +66,30 @@ export function applyPersistedZoomLevel( return null; } } + +/** + * Bridge half of the zoom shortcuts (`window.electron.adjustZoomLevel`): + * steps the frame's temporary level in place. Same `webFrame` route as the + * restore above, so the result is URL-independent and `getZoomLevel()` in the + * main process reports it for the close/quit/reload persistence. Returns the + * level now applied; a refused write leaves the frame as it was and returns + * its current level, so the caller never learns a level that is not on + * screen. + */ +export function adjustFrameZoomLevel( + ports: Pick, + action: ZoomLevelAction +): number { + const current = ports.getZoomLevel(); + const next = stepZoomLevel(current, action); + + if (next !== current) { + try { + ports.setZoomLevel(next); + } catch { + return current; + } + } + + return next; +} diff --git a/apps/web/src/assets/i18n/ar.json b/apps/web/src/assets/i18n/ar.json index a31140ff7..e924a04c5 100644 --- a/apps/web/src/assets/i18n/ar.json +++ b/apps/web/src/assets/i18n/ar.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "فتح العناصر المشاهدة مؤخرًا", "SUBMIT_SHELL_SEARCH": "إرسال بحث مساحة العمل", "TOGGLE_WINDOW_FULLSCREEN": "تبديل ملء الشاشة لنافذة التطبيق", + "ZOOM_IN_OUT": "تكبير التطبيق أو تصغيره", + "RESET_ZOOM": "إعادة ضبط تكبير التطبيق", "TOGGLE_SIDEBAR": "تبديل الشريط الجانبي", "M3U_CHANNEL_NUMBER": "اختيار قناة M3U حسب الرقم", "PLAY_PAUSE": "تشغيل أو إيقاف تشغيل Embedded MPV مؤقتًا", diff --git a/apps/web/src/assets/i18n/ary.json b/apps/web/src/assets/i18n/ary.json index ed44a6ec3..4faf3bf5e 100644 --- a/apps/web/src/assets/i18n/ary.json +++ b/apps/web/src/assets/i18n/ary.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "حل المتشافين مؤخراً", "SUBMIT_SHELL_SEARCH": "رسل بحث مساحة العمل", "TOGGLE_WINDOW_FULLSCREEN": "بدل ملء الشاشة ديال نافذة التطبيق", + "ZOOM_IN_OUT": "كبر ولا صغر التطبيق", + "RESET_ZOOM": "رجع تكبير التطبيق للأصل", "TOGGLE_SIDEBAR": "بدل عرض الشريط الجانبي", "M3U_CHANNEL_NUMBER": "اختار قناة M3U بالرقم", "PLAY_PAUSE": "شغل ولا وقف مؤقتاً تشغيل MPV المدمج", diff --git a/apps/web/src/assets/i18n/by.json b/apps/web/src/assets/i18n/by.json index f3405d246..84ff88fa7 100644 --- a/apps/web/src/assets/i18n/by.json +++ b/apps/web/src/assets/i18n/by.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Адкрыць нядаўна прагледжанае", "SUBMIT_SHELL_SEARCH": "Выканаць пошук у працоўнай вобласці", "TOGGLE_WINDOW_FULLSCREEN": "Пераключыць поўнаэкранны рэжым акна праграмы", + "ZOOM_IN_OUT": "Павялічыць або паменшыць маштаб праграмы", + "RESET_ZOOM": "Скінуць маштаб праграмы", "TOGGLE_SIDEBAR": "Пераключыць бакавую панэль", "M3U_CHANNEL_NUMBER": "Выбраць M3U-канал па нумары", "PLAY_PAUSE": "Прайграць або прыпыніць Embedded MPV", diff --git a/apps/web/src/assets/i18n/de.json b/apps/web/src/assets/i18n/de.json index 49d141305..773cf630c 100644 --- a/apps/web/src/assets/i18n/de.json +++ b/apps/web/src/assets/i18n/de.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Kürzlich angesehene Elemente öffnen", "SUBMIT_SHELL_SEARCH": "Arbeitsbereichssuche absenden", "TOGGLE_WINDOW_FULLSCREEN": "Vollbild für das App-Fenster umschalten", + "ZOOM_IN_OUT": "App vergrößern oder verkleinern", + "RESET_ZOOM": "App-Zoom zurücksetzen", "TOGGLE_SIDEBAR": "Seitenleiste umschalten", "M3U_CHANNEL_NUMBER": "M3U-Kanal per Nummer auswählen", "PLAY_PAUSE": "Eingebettete MPV-Wiedergabe starten oder pausieren", diff --git a/apps/web/src/assets/i18n/el.json b/apps/web/src/assets/i18n/el.json index 1ede1b581..02540dc4a 100644 --- a/apps/web/src/assets/i18n/el.json +++ b/apps/web/src/assets/i18n/el.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Άνοιγμα πρόσφατων προβολών", "SUBMIT_SHELL_SEARCH": "Υποβολή αναζήτησης χώρου εργασίας", "TOGGLE_WINDOW_FULLSCREEN": "Εναλλαγή πλήρους οθόνης παραθύρου εφαρμογής", + "ZOOM_IN_OUT": "Μεγέθυνση ή σμίκρυνση της εφαρμογής", + "RESET_ZOOM": "Επαναφορά ζουμ εφαρμογής", "TOGGLE_SIDEBAR": "Εναλλαγή πλαϊνής μπάρας", "M3U_CHANNEL_NUMBER": "Επιλογή καναλιού M3U με αριθμό", "PLAY_PAUSE": "Αναπαραγωγή ή παύση ενσωματωμένου MPV", diff --git a/apps/web/src/assets/i18n/en.json b/apps/web/src/assets/i18n/en.json index 33bad79c4..b5ff5a57b 100644 --- a/apps/web/src/assets/i18n/en.json +++ b/apps/web/src/assets/i18n/en.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Open recently viewed", "SUBMIT_SHELL_SEARCH": "Submit workspace search", "TOGGLE_WINDOW_FULLSCREEN": "Toggle app window fullscreen", + "ZOOM_IN_OUT": "Zoom the app in or out", + "RESET_ZOOM": "Reset app zoom", "TOGGLE_SIDEBAR": "Toggle sidebar", "M3U_CHANNEL_NUMBER": "Select M3U channel by number", "PLAY_PAUSE": "Play or pause embedded MPV playback", diff --git a/apps/web/src/assets/i18n/es.json b/apps/web/src/assets/i18n/es.json index d8ab65866..43d6a11e9 100644 --- a/apps/web/src/assets/i18n/es.json +++ b/apps/web/src/assets/i18n/es.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Acercar o alejar la aplicación", + "RESET_ZOOM": "Restablecer el zoom 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", diff --git a/apps/web/src/assets/i18n/fr.json b/apps/web/src/assets/i18n/fr.json index 33031f37f..059cf29cc 100644 --- a/apps/web/src/assets/i18n/fr.json +++ b/apps/web/src/assets/i18n/fr.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Zoom avant ou arrière sur l'application", + "RESET_ZOOM": "Réinitialiser le zoom de l'application", "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", diff --git a/apps/web/src/assets/i18n/hu.json b/apps/web/src/assets/i18n/hu.json index a98066e29..d6124ddd3 100644 --- a/apps/web/src/assets/i18n/hu.json +++ b/apps/web/src/assets/i18n/hu.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Az alkalmazás nagyítása vagy kicsinyítése", + "RESET_ZOOM": "Az alkalmazás nagyításának visszaállítá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", diff --git a/apps/web/src/assets/i18n/it.json b/apps/web/src/assets/i18n/it.json index e1948ab9a..976571c3e 100644 --- a/apps/web/src/assets/i18n/it.json +++ b/apps/web/src/assets/i18n/it.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Ingrandisci o riduci l'applicazione", + "RESET_ZOOM": "Reimposta lo zoom dell'applicazione", "TOGGLE_SIDEBAR": "Mostra/nascondi barra laterale", "M3U_CHANNEL_NUMBER": "Seleziona canale M3U per numero", "PLAY_PAUSE": "Riproduci o metti in pausa MPV integrato", diff --git a/apps/web/src/assets/i18n/ja.json b/apps/web/src/assets/i18n/ja.json index e096d3631..e0b73686c 100644 --- a/apps/web/src/assets/i18n/ja.json +++ b/apps/web/src/assets/i18n/ja.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "最近視聴した項目を開く", "SUBMIT_SHELL_SEARCH": "ワークスペース検索を実行", "TOGGLE_WINDOW_FULLSCREEN": "アプリウィンドウの全画面表示を切り替え", + "ZOOM_IN_OUT": "アプリを拡大または縮小", + "RESET_ZOOM": "アプリのズームをリセット", "TOGGLE_SIDEBAR": "サイドバーを切り替え", "M3U_CHANNEL_NUMBER": "番号で M3U チャンネルを選択", "PLAY_PAUSE": "Embedded MPV の再生/一時停止", diff --git a/apps/web/src/assets/i18n/ko.json b/apps/web/src/assets/i18n/ko.json index 0730dc3c5..d17299be3 100644 --- a/apps/web/src/assets/i18n/ko.json +++ b/apps/web/src/assets/i18n/ko.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "최근 시청 항목 열기", "SUBMIT_SHELL_SEARCH": "워크스페이스 검색 실행", "TOGGLE_WINDOW_FULLSCREEN": "앱 창 전체 화면 전환", + "ZOOM_IN_OUT": "앱 확대 또는 축소", + "RESET_ZOOM": "앱 확대/축소 초기화", "TOGGLE_SIDEBAR": "사이드바 전환", "M3U_CHANNEL_NUMBER": "번호로 M3U 채널 선택", "PLAY_PAUSE": "Embedded MPV 재생 또는 일시정지", diff --git a/apps/web/src/assets/i18n/nl.json b/apps/web/src/assets/i18n/nl.json index 7c3150fa2..d710f6689 100644 --- a/apps/web/src/assets/i18n/nl.json +++ b/apps/web/src/assets/i18n/nl.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Recent bekeken openen", "SUBMIT_SHELL_SEARCH": "Werkruimtezoekopdracht uitvoeren", "TOGGLE_WINDOW_FULLSCREEN": "Volledig scherm voor het app-venster in- of uitschakelen", + "ZOOM_IN_OUT": "App in- of uitzoomen", + "RESET_ZOOM": "App-zoom herstellen", "TOGGLE_SIDEBAR": "Zijbalk in- of uitschakelen", "M3U_CHANNEL_NUMBER": "M3U-kanaal selecteren op nummer", "PLAY_PAUSE": "Ingebedde MPV-weergave afspelen of pauzeren", diff --git a/apps/web/src/assets/i18n/pl.json b/apps/web/src/assets/i18n/pl.json index afab20651..ece0dbe5b 100644 --- a/apps/web/src/assets/i18n/pl.json +++ b/apps/web/src/assets/i18n/pl.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Powiększ lub pomniejsz aplikację", + "RESET_ZOOM": "Zresetuj powiększenie aplikacji", "TOGGLE_SIDEBAR": "Przełącz pasek boczny", "M3U_CHANNEL_NUMBER": "Wybierz kanał M3U według numeru", "PLAY_PAUSE": "Odtwórz lub wstrzymaj osadzony MPV", diff --git a/apps/web/src/assets/i18n/pt.json b/apps/web/src/assets/i18n/pt.json index 4f43ec9c3..e869de97f 100644 --- a/apps/web/src/assets/i18n/pt.json +++ b/apps/web/src/assets/i18n/pt.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Aumentar ou diminuir o zoom do aplicativo", + "RESET_ZOOM": "Redefinir o zoom 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", diff --git a/apps/web/src/assets/i18n/ru.json b/apps/web/src/assets/i18n/ru.json index a3219897f..0448478c4 100644 --- a/apps/web/src/assets/i18n/ru.json +++ b/apps/web/src/assets/i18n/ru.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "Открыть недавно просмотренное", "SUBMIT_SHELL_SEARCH": "Отправить поиск в рабочей области", "TOGGLE_WINDOW_FULLSCREEN": "Переключить полноэкранный режим окна приложения", + "ZOOM_IN_OUT": "Увеличить или уменьшить масштаб приложения", + "RESET_ZOOM": "Сбросить масштаб приложения", "TOGGLE_SIDEBAR": "Показать или скрыть боковую панель", "M3U_CHANNEL_NUMBER": "Выбрать канал M3U по номеру", "PLAY_PAUSE": "Запустить или приостановить воспроизведение во встроенном MPV", diff --git a/apps/web/src/assets/i18n/tr.json b/apps/web/src/assets/i18n/tr.json index 9557658ea..e0d4e21fe 100644 --- a/apps/web/src/assets/i18n/tr.json +++ b/apps/web/src/assets/i18n/tr.json @@ -1741,6 +1741,8 @@ "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", + "ZOOM_IN_OUT": "Uygulamayı yakınlaştır veya uzaklaştır", + "RESET_ZOOM": "Uygulama yakınlaştırmasını sıfırla", "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", diff --git a/apps/web/src/assets/i18n/zh.json b/apps/web/src/assets/i18n/zh.json index fb9a1af3a..0b6834f23 100644 --- a/apps/web/src/assets/i18n/zh.json +++ b/apps/web/src/assets/i18n/zh.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "打开最近观看", "SUBMIT_SHELL_SEARCH": "提交工作区搜索", "TOGGLE_WINDOW_FULLSCREEN": "切换应用窗口全屏", + "ZOOM_IN_OUT": "放大或缩小应用", + "RESET_ZOOM": "重置应用缩放", "TOGGLE_SIDEBAR": "切换侧边栏", "M3U_CHANNEL_NUMBER": "按数字选择 M3U 频道", "PLAY_PAUSE": "播放或暂停嵌入式 MPV 播放", diff --git a/apps/web/src/assets/i18n/zhtw.json b/apps/web/src/assets/i18n/zhtw.json index 241071599..6fd3b0e06 100644 --- a/apps/web/src/assets/i18n/zhtw.json +++ b/apps/web/src/assets/i18n/zhtw.json @@ -1741,6 +1741,8 @@ "OPEN_RECENTLY_VIEWED": "開啟最近觀看", "SUBMIT_SHELL_SEARCH": "提交工作區搜尋", "TOGGLE_WINDOW_FULLSCREEN": "切換應用程式視窗全螢幕", + "ZOOM_IN_OUT": "放大或縮小應用程式", + "RESET_ZOOM": "重設應用程式縮放", "TOGGLE_SIDEBAR": "切換側邊欄", "M3U_CHANNEL_NUMBER": "按數字選擇 M3U 頻道", "PLAY_PAUSE": "播放或暫停嵌入式 MPV 播放", diff --git a/docs/architecture/nx-workspace-boundaries.md b/docs/architecture/nx-workspace-boundaries.md index 0ca059aa1..2e7a0c2d7 100644 --- a/docs/architecture/nx-workspace-boundaries.md +++ b/docs/architecture/nx-workspace-boundaries.md @@ -210,7 +210,12 @@ the lazy workspace shell feature into the initial bundle. Use scoped aliases from `tsconfig.base.json` and expose public imports through a library's `src/index.ts`. Do not introduce legacy bare aliases such as `services`, `components`, `shared-interfaces`, or `database`, and avoid deep -imports unless a sub-entrypoint is explicitly configured. +imports unless a sub-entrypoint is explicitly configured. The configured +sub-entrypoints are `@iptvnator/shared/interfaces/ipc-commands` and +`@iptvnator/shared/interfaces/zoom-level`, both for the Electron preload: the +`@iptvnator/shared/interfaces` barrel pulls in `ngx-indexed-db`, which the +preload bundle must not carry, so the preload only type-imports the barrel +and value-imports those two dependency-free modules directly. For a buildable library that has a local `package.json`, its `name` must match the scoped alias. Nx uses that package name when rewriting buildable dependency diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 731e5cb6f..aa8cbaee6 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -407,7 +407,7 @@ Startup window mode (`Settings.startupWindowMode`, issue #1455): `window.events.spec.ts`, and the startup-window-mode cases in `settings.e2e.ts`. -Zoom level (Cmd/Ctrl and +/−, issue #1109): +Zoom level (Cmd/Ctrl and +/−/0, issue #1109): 1. The packaged renderer runs under `file://` with path routing. Chromium keys per-host zoom by the FULL URL when a URL has no host, so every @@ -424,8 +424,8 @@ Zoom level (Cmd/Ctrl and +/−, issue #1109): stored level over the synchronous `WINDOW:GET_ZOOM_LEVEL` IPC at preload start and applies it with `webFrame.setZoomLevel`, which installs a TEMPORARY, frame-bound zoom level. It survives in-page navigation and - resizes, the macOS menu roles (`zoomIn`/`zoomOut`/`resetZoom`) increment - it, and `getZoomLevel()` reports it regardless of the route. + resizes, the zoom shortcuts (point 4) step it through the same call, and + `getZoomLevel()` reports it regardless of the route. `webContents.setZoomLevel` from the main process would write the per-URL entry and re-create the bug. When nothing is stored the preload re-applies the current level for the same reason: entering temporary mode makes the @@ -453,13 +453,44 @@ Zoom level (Cmd/Ctrl and +/−, issue #1109): document's preload has already read whatever was stored. That navigation also releases ownership until the next preload answers, so a close mid-reload cannot save the per-URL default over the user's level. -4. Windows/Linux have no zoom shortcuts today: the window calls - `setMenu(null)` and nothing handles `zoom-changed`, so only macOS' default - application menu zooms. Persistence is platform-neutral and would pick - up any future shortcut that goes through `webContents.setZoomLevel`. - Regression coverage lives in `window-zoom-level.e2e.ts`, which measures - the rendered factor (content width ÷ `window.innerWidth`) across a - section change, a resize, a reload and a restart. +4. The shortcuts are a renderer key binding, not a native menu: the + Windows/Linux window calls `setMenu(null)`, so no accelerator could reach + it there. `WorkspaceKeyboardShortcutsService` (`libs/workspace/shell`) + listens on the document like it does for F11 and resolves the chord with + `resolveZoomShortcutAction` (`libs/portal/shared/util`): Cmd on macOS, + Ctrl elsewhere, never Alt; `+`/`=` (so `Ctrl+=` and `Ctrl+Shift+=` both + zoom in), `-`/`_`, `0`, and the numpad `+`/`-`/`0` (by `code`, since a + NumLock-off `0` reports `Insert`). Keys are matched by `event.key`, so + non-US layouts zoom with their own `+`/`-` keys. Like F11 it is not gated + by the typing-target check — browsers zoom from any focus — and a key + another handler already `preventDefault`ed is left alone. The binding + calls the synchronous, preload-local `window.electron.adjustZoomLevel` + (`adjustFrameZoomLevel` in `api/preload-zoom-level.ts`), which steps the + frame's temporary level through the same `webFrame.setZoomLevel` as the + restore and returns the level applied — never a main-process + `webContents.setZoomLevel`, which would re-create the per-URL bug. The + step and limits live in `libs/shared/interfaces/src/lib/zoom-level.util.ts` + (`stepZoomLevel`): 0.5 per press, Electron's own `zoomIn`/`zoomOut` role + step (≈10 %), clamped to levels −4…6 (≈48 %…299 %, inside Chromium's + 25–500 %), off-grid levels snapping to the next grid point in the pressed + direction; `Ctrl/Cmd+0` returns to level 0. Persistence needs nothing + extra: the main process reads the live level back (point 3). On macOS the + default application menu still carries the `zoomIn`/`zoomOut`/`resetZoom` + roles, but Chromium hands a key equivalent to the web contents first and + Electron performs the menu equivalent only in + `WebContents::PlatformHandleKeyboardEvent` + (`shell/browser/api/electron_api_web_contents_mac.mm`, Electron 43.3.0), + the unhandled-keyboard-event hook — a `preventDefault`ed keydown never + gets there, so the binding keeps one press at one step. CDP-dispatched + keys (the E2E) never reach the menu at all. + Without a bridge (PWA) the browser keeps its own zoom, and the help + dialog lists the chords as Electron-only. Regression coverage: + `window-zoom-level.e2e.ts` presses the real shortcuts (in, out, numpad, + reset) and measures the rendered factor (content width ÷ + `window.innerWidth`) across a section change, a resize, a reload and a + restart; key resolution and the bridge step are unit-tested in + `keyboard-shortcuts.spec.ts`, `workspace-keyboard-shortcuts.service.spec.ts` + and `preload-zoom-level.spec.ts`. Layout integration: diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts index 5a1bab2cc..972d85fd6 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcut-definitions.ts @@ -111,6 +111,26 @@ export const APP_KEYBOARD_SHORTCUTS: readonly KeyboardShortcutDefinition[] = [ order: 50, electronOnly: true, }, + { + // Frame-bound app zoom (issue #1109); the PWA leaves these keys to + // the browser's own zoom. + id: 'zoom-in-out', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.ZOOM_IN_OUT', + icon: 'zoom_in', + keys: [commandChord('Plus'), commandChord('Minus')], + order: 60, + electronOnly: true, + }, + { + id: 'reset-zoom', + group: 'global', + labelKey: 'WORKSPACE.SHORTCUTS.ITEMS.RESET_ZOOM', + icon: 'youtube_searched_for', + keys: [commandChord('0')], + order: 70, + electronOnly: true, + }, { id: 'toggle-sidebar', group: 'navigation', diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts index d3e99c003..f5b2fc3bb 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.spec.ts @@ -2,6 +2,7 @@ import { APP_KEYBOARD_SHORTCUTS, getKeyboardShortcutGroups, isKeyboardShortcutHelpTrigger, + resolveZoomShortcutAction, } from './keyboard-shortcuts'; describe('keyboard shortcuts registry', () => { @@ -25,6 +26,9 @@ describe('keyboard shortcuts registry', () => { expect(ids).not.toContain('close-player-popovers'); // The browser owns F11 in the PWA. expect(ids).not.toContain('toggle-window-fullscreen'); + // ...and its own zoom keys. + expect(ids).not.toContain('zoom-in-out'); + expect(ids).not.toContain('reset-zoom'); 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. @@ -50,6 +54,125 @@ describe('keyboard shortcuts registry', () => { ]); }); + it('lists the zoom shortcuts in the global group for Electron with platform modifiers', () => { + const linuxGroups = getKeyboardShortcutGroups({ + isMac: false, + isElectron: true, + }); + const macGroups = getKeyboardShortcutGroups({ + isMac: true, + isElectron: true, + }); + + expect( + linuxGroups + .find((group) => group.id === 'global') + ?.items.map((item) => item.id) + ).toEqual(expect.arrayContaining(['zoom-in-out', 'reset-zoom'])); + expect(findChordLabels(linuxGroups, 'zoom-in-out')).toEqual([ + ['Ctrl', '+'], + ['Ctrl', '−'], + ]); + expect(findChordLabels(linuxGroups, 'reset-zoom')).toEqual([ + ['Ctrl', '0'], + ]); + expect(findChordLabels(macGroups, 'zoom-in-out')).toEqual([ + ['Cmd', '+'], + ['Cmd', '−'], + ]); + }); + + describe('resolveZoomShortcutAction', () => { + const press = (init: KeyboardEventInit) => + new KeyboardEvent('keydown', init); + const other = { isMac: false }; + const mac = { isMac: true }; + + it('maps Ctrl and +/=/-/_/0 on Windows and Linux', () => { + expect( + resolveZoomShortcutAction(press({ key: '=', ctrlKey: true }), other) + ).toBe('in'); + // Ctrl+Shift+= reports the shifted character. + expect( + resolveZoomShortcutAction( + press({ key: '+', ctrlKey: true, shiftKey: true }), + other + ) + ).toBe('in'); + expect( + resolveZoomShortcutAction(press({ key: '-', ctrlKey: true }), other) + ).toBe('out'); + expect( + resolveZoomShortcutAction( + press({ key: '_', ctrlKey: true, shiftKey: true }), + other + ) + ).toBe('out'); + expect( + resolveZoomShortcutAction(press({ key: '0', ctrlKey: true }), other) + ).toBe('reset'); + }); + + it('accepts the numpad keys, including NumLock-off 0', () => { + expect( + resolveZoomShortcutAction( + press({ key: '+', code: 'NumpadAdd', ctrlKey: true }), + other + ) + ).toBe('in'); + expect( + resolveZoomShortcutAction( + press({ key: '-', code: 'NumpadSubtract', ctrlKey: true }), + other + ) + ).toBe('out'); + expect( + resolveZoomShortcutAction( + press({ key: 'Insert', code: 'Numpad0', ctrlKey: true }), + other + ) + ).toBe('reset'); + }); + + it('uses Cmd on macOS and Ctrl elsewhere, never Alt', () => { + expect( + resolveZoomShortcutAction(press({ key: '=', metaKey: true }), mac) + ).toBe('in'); + expect( + resolveZoomShortcutAction(press({ key: '=', ctrlKey: true }), mac) + ).toBeNull(); + expect( + resolveZoomShortcutAction(press({ key: '=', metaKey: true }), other) + ).toBeNull(); + expect( + resolveZoomShortcutAction( + press({ key: '=', ctrlKey: true, altKey: true }), + other + ) + ).toBeNull(); + expect( + resolveZoomShortcutAction( + press({ key: '=', ctrlKey: true, metaKey: true }), + other + ) + ).toBeNull(); + }); + + it('ignores unmodified keys and unrelated combinations', () => { + expect(resolveZoomShortcutAction(press({ key: '=' }), other)).toBeNull(); + expect( + resolveZoomShortcutAction(press({ key: 'k', ctrlKey: true }), other) + ).toBeNull(); + // Digit0 with Shift is ")" on US layouts — not a reset. + expect( + resolveZoomShortcutAction( + press({ key: ')', code: 'Digit0', ctrlKey: true, shiftKey: true }), + other + ) + ).toBeNull(); + }); + }); + it('uses platform-specific modifier labels', () => { const macGroups = getKeyboardShortcutGroups({ isMac: true, diff --git a/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts index 7a2ebbd8f..4e271074c 100644 --- a/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts +++ b/libs/portal/shared/util/src/lib/keyboard-shortcuts.ts @@ -1,3 +1,4 @@ +import type { ZoomLevelAction } from '@iptvnator/shared/interfaces'; import { APP_KEYBOARD_SHORTCUTS, KEYBOARD_SHORTCUT_GROUPS, @@ -43,6 +44,8 @@ const KEY_LABELS = new Map([ ['ArrowUp', '↑'], ['ArrowDown', '↓'], ['Escape', 'Esc'], + ['Plus', '+'], + ['Minus', '−'], ]); const KEY_ARIA_LABELS = new Map([ @@ -88,6 +91,49 @@ export function isKeyboardShortcutHelpTrigger(event: KeyboardEvent): boolean { return event.key === '?' || (event.key === '/' && event.shiftKey); } +/** + * The app zoom shortcut `event` carries, or null. Follows the browser + * convention on each platform — Cmd on macOS, Ctrl elsewhere, Alt never: + * `+`/`=` (so `Ctrl+=` and `Ctrl+Shift+=` both zoom in), `-`/`_`, `0`, and + * the numpad `+`/`-`/`0` keys (matched by `code`, since NumLock-off `0` + * reports `Insert`). Keys are matched by `event.key` so non-US layouts zoom + * with their own `+`/`-` keys rather than with whatever sits on `Equal`. + */ +export function resolveZoomShortcutAction( + event: KeyboardEvent, + options: { isMac: boolean } +): ZoomLevelAction | null { + const commandModifier = options.isMac + ? event.metaKey && !event.ctrlKey + : event.ctrlKey && !event.metaKey; + + if (!commandModifier || event.altKey) { + return null; + } + + switch (event.key) { + case '+': + case '=': + return 'in'; + case '-': + case '_': + return 'out'; + case '0': + return 'reset'; + } + + switch (event.code) { + case 'NumpadAdd': + return 'in'; + case 'NumpadSubtract': + return 'out'; + case 'Numpad0': + return 'reset'; + } + + return null; +} + function resolveShortcutChord( key: KeyboardShortcutChord, isMac: boolean diff --git a/libs/shared/interfaces/src/index.ts b/libs/shared/interfaces/src/index.ts index 470e6ed23..f902d8bf9 100644 --- a/libs/shared/interfaces/src/index.ts +++ b/libs/shared/interfaces/src/index.ts @@ -7,6 +7,7 @@ export * from './lib/dev-logger.util'; export * from './lib/download-metadata.interface'; export * from './lib/embedded-mpv-session.interface'; export * from './lib/subtitle-style.util'; +export * from './lib/zoom-level.util'; export * from './lib/electron-api.interface'; export * from './lib/epg-channel-metadata.model'; export * from './lib/epg-channel-with-programs.interface'; diff --git a/libs/shared/interfaces/src/lib/electron-api.interface.ts b/libs/shared/interfaces/src/lib/electron-api.interface.ts index 8a4a29912..6091b597b 100644 --- a/libs/shared/interfaces/src/lib/electron-api.interface.ts +++ b/libs/shared/interfaces/src/lib/electron-api.interface.ts @@ -1,6 +1,7 @@ import type { AppUpdateChannel } from './app-update-channel.util'; import type { SourceProbeContext, SourceHealthResult } from './source-health'; import type { XtreamConnectionFailure } from './xtream-connection-test'; +import type { ZoomLevelAction } from './zoom-level.util'; import type { CatchupDownloadMetadata, DownloadRecoveryResult, @@ -718,6 +719,16 @@ export interface ElectronBridgeApi { */ toggleFullScreenWindow: () => Promise; closeWindow: () => Promise; + /** + * Zoom shortcuts (Cmd/Ctrl and +/−/0). Synchronous and preload-local: + * steps the FRAME-BOUND temporary zoom level through `webFrame` (never a + * main-process `webContents.setZoomLevel`, whose per-URL entry the app's + * `file://` path routing resets — issue #1109), clamped to + * `ZOOM_LEVEL_MIN..ZOOM_LEVEL_MAX`, and returns the level now applied. + * Persistence needs nothing from the caller: the main process reads the + * live level back on close, quit and reload. + */ + adjustZoomLevel: (action: ZoomLevelAction) => number; getWindowState: () => Promise; onWindowStateChange: ( callback: (state: ElectronBridgeWindowState) => void diff --git a/libs/shared/interfaces/src/lib/zoom-level.util.spec.ts b/libs/shared/interfaces/src/lib/zoom-level.util.spec.ts new file mode 100644 index 000000000..a565d4861 --- /dev/null +++ b/libs/shared/interfaces/src/lib/zoom-level.util.spec.ts @@ -0,0 +1,48 @@ +import { + clampZoomLevel, + stepZoomLevel, + ZOOM_LEVEL_MAX, + ZOOM_LEVEL_MIN, + ZOOM_LEVEL_STEP, +} from './zoom-level.util'; + +describe('zoom level shortcuts contract', () => { + it('steps by the Electron menu-role step in both directions', () => { + expect(stepZoomLevel(0, 'in')).toBe(ZOOM_LEVEL_STEP); + expect(stepZoomLevel(0, 'out')).toBe(-ZOOM_LEVEL_STEP); + expect(stepZoomLevel(1, 'in')).toBe(1.5); + expect(stepZoomLevel(-1.5, 'out')).toBe(-2); + }); + + it('resets to the default level from anywhere', () => { + expect(stepZoomLevel(3, 'reset')).toBe(0); + expect(stepZoomLevel(-2.5, 'reset')).toBe(0); + expect(stepZoomLevel(Number.NaN, 'reset')).toBe(0); + }); + + it('clamps at the app limits, not at Chromium limits', () => { + expect(stepZoomLevel(ZOOM_LEVEL_MAX, 'in')).toBe(ZOOM_LEVEL_MAX); + expect(stepZoomLevel(ZOOM_LEVEL_MIN, 'out')).toBe(ZOOM_LEVEL_MIN); + expect(stepZoomLevel(ZOOM_LEVEL_MAX + 3, 'in')).toBe(ZOOM_LEVEL_MAX); + expect(stepZoomLevel(ZOOM_LEVEL_MAX + 3, 'out')).toBe( + ZOOM_LEVEL_MAX - ZOOM_LEVEL_STEP + ); + expect(clampZoomLevel(ZOOM_LEVEL_MIN - 1)).toBe(ZOOM_LEVEL_MIN); + }); + + it('snaps an off-grid level to the next grid point in the requested direction', () => { + // A level the macOS menu roles or an older store left between steps. + expect(stepZoomLevel(0.3, 'in')).toBe(0.5); + expect(stepZoomLevel(0.3, 'out')).toBe(0); + expect(stepZoomLevel(-0.7, 'out')).toBe(-1); + expect(stepZoomLevel(-0.7, 'in')).toBe(-0.5); + }); + + it('treats a non-finite level as the default', () => { + expect(stepZoomLevel(Number.NaN, 'in')).toBe(ZOOM_LEVEL_STEP); + expect(stepZoomLevel(Number.POSITIVE_INFINITY, 'out')).toBe( + -ZOOM_LEVEL_STEP + ); + expect(clampZoomLevel(Number.NaN)).toBe(0); + }); +}); diff --git a/libs/shared/interfaces/src/lib/zoom-level.util.ts b/libs/shared/interfaces/src/lib/zoom-level.util.ts new file mode 100644 index 000000000..6846e86e8 --- /dev/null +++ b/libs/shared/interfaces/src/lib/zoom-level.util.ts @@ -0,0 +1,59 @@ +/** + * App zoom shortcut contract (Cmd/Ctrl and +/−/0), shared by the renderer + * key binding (`WorkspaceKeyboardShortcutsService`) and the preload, which + * applies the result through `webFrame.setZoomLevel` (issue #1109: the level + * must stay a frame-bound temporary zoom; see + * `docs/architecture/workspace-shell.md`, "Zoom level"). + * + * A Chromium zoom level is the exponent of a 1.2 factor: level 1 renders at + * 120 %, level −1 at ~83 %. The step matches Electron's `zoomIn`/`zoomOut` + * menu roles (0.5, about 10 % per press), so the level the macOS menu used + * to produce and the level the shortcuts produce are the same series. + */ + +export type ZoomLevelAction = 'in' | 'out' | 'reset'; + +export const ZOOM_LEVEL_DEFAULT = 0; +/** Electron's `zoomIn`/`zoomOut` role step. */ +export const ZOOM_LEVEL_STEP = 0.5; +/** ≈48 % — below this the workspace chrome is unreadable. */ +export const ZOOM_LEVEL_MIN = -4; +/** ≈299 % — well inside Chromium's own 25–500 % limits. */ +export const ZOOM_LEVEL_MAX = 6; + +export function clampZoomLevel(level: number): number { + if (!Number.isFinite(level)) { + return ZOOM_LEVEL_DEFAULT; + } + + return Math.min(ZOOM_LEVEL_MAX, Math.max(ZOOM_LEVEL_MIN, level)); +} + +/** + * The level `action` produces from `current`. Steps are snapped to the step + * grid so a level restored from an older store (or set by the macOS menu + * before the shortcuts existed) does not carry a fractional offset forever. + * A non-finite `current` is treated as the default. + */ +export function stepZoomLevel( + current: number, + action: ZoomLevelAction +): number { + if (action === 'reset') { + return ZOOM_LEVEL_DEFAULT; + } + + const base = clampZoomLevel(current); + const direction = action === 'in' ? 1 : -1; + const steps = base / ZOOM_LEVEL_STEP; + // A level between grid points steps to the next grid point in the + // requested direction instead of skipping past it. + const nextSteps = Number.isInteger(steps) + ? steps + direction + : direction > 0 + ? Math.ceil(steps) + : Math.floor(steps); + const next = nextSteps * ZOOM_LEVEL_STEP; + + return clampZoomLevel(next); +} diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts index 371c43616..5aaeaba65 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.spec.ts @@ -179,6 +179,116 @@ describe('WorkspaceKeyboardShortcutsService', () => { }); }); + describe('zoom shortcuts', () => { + const testWindow = window as unknown as { + electron?: Record; + }; + let adjustZoomLevel: jest.Mock; + + function press( + init: KeyboardEventInit, + target: EventTarget = document + ): KeyboardEvent { + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + ...init, + }); + target.dispatchEvent(event); + return event; + } + + beforeEach(() => { + adjustZoomLevel = jest.fn().mockReturnValue(0.5); + testWindow.electron = { adjustZoomLevel, platform: 'linux' }; + }); + + afterEach(() => { + delete testWindow.electron; + }); + + it('zooms in, out and resets through the bridge with Ctrl on Linux/Windows', () => { + const zoomIn = press({ key: '=', ctrlKey: true }); + const zoomOut = press({ key: '-', ctrlKey: true }); + const reset = press({ key: '0', ctrlKey: true }); + + expect(adjustZoomLevel.mock.calls).toEqual([ + ['in'], + ['out'], + ['reset'], + ]); + expect(zoomIn.defaultPrevented).toBe(true); + expect(zoomOut.defaultPrevented).toBe(true); + expect(reset.defaultPrevented).toBe(true); + expect(dialog.open).not.toHaveBeenCalled(); + }); + + it('takes the numpad keys and Ctrl+Shift+=', () => { + press({ key: '+', code: 'NumpadAdd', ctrlKey: true }); + press({ key: '+', ctrlKey: true, shiftKey: true }); + + expect(adjustZoomLevel.mock.calls).toEqual([['in'], ['in']]); + }); + + it('follows the bridge platform: Cmd on macOS, and swallows the key so the menu role cannot step twice', () => { + testWindow.electron = { adjustZoomLevel, platform: 'darwin' }; + + const ctrl = press({ key: '=', ctrlKey: true }); + const cmd = press({ key: '=', metaKey: true }); + + expect(adjustZoomLevel.mock.calls).toEqual([['in']]); + expect(ctrl.defaultPrevented).toBe(false); + expect(cmd.defaultPrevented).toBe(true); + }); + + it('works while typing in an input, like the browser zoom it replaces', () => { + const input = document.createElement('input'); + document.body.appendChild(input); + + press({ key: '=', ctrlKey: true }, input); + + expect(adjustZoomLevel).toHaveBeenCalledWith('in'); + input.remove(); + }); + + it('leaves a key another handler already consumed alone', () => { + const event = new KeyboardEvent('keydown', { + key: '=', + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + event.preventDefault(); + document.dispatchEvent(event); + + expect(adjustZoomLevel).not.toHaveBeenCalled(); + }); + + it('ignores unrelated Ctrl combinations and unmodified keys', () => { + press({ key: 'k', ctrlKey: true }); + press({ key: '=' }); + press({ key: '=', ctrlKey: true, altKey: true }); + + expect(adjustZoomLevel).not.toHaveBeenCalled(); + }); + + it('leaves zoom to the browser without a bridge', () => { + delete testWindow.electron; + + const event = press({ key: '=', ctrlKey: true }); + + expect(event.defaultPrevented).toBe(false); + }); + + it('swallows a bridge failure', () => { + adjustZoomLevel.mockImplementation(() => { + throw new Error('frame gone'); + }); + + expect(() => press({ key: '=', ctrlKey: true })).not.toThrow(); + }); + }); + it('does not open duplicate dialogs while one is active', () => { service.openShortcutsDialog(); service.openShortcutsDialog(); diff --git a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts index 31007a6df..1fe63f29e 100644 --- a/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts +++ b/libs/workspace/shell/feature/src/lib/workspace-keyboard-shortcuts/workspace-keyboard-shortcuts.service.ts @@ -5,6 +5,7 @@ import { getKeyboardShortcutGroups, isKeyboardShortcutHelpTrigger, isTypingInInput, + resolveZoomShortcutAction, } from '@iptvnator/portal/shared/util'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; import { @@ -78,7 +79,10 @@ export class WorkspaceKeyboardShortcutsService { } private handleKeydown(event: KeyboardEvent): void { - if (this.handleWindowFullscreenToggle(event)) { + if ( + this.handleWindowFullscreenToggle(event) || + this.handleZoomShortcut(event) + ) { return; } @@ -129,6 +133,44 @@ export class WorkspaceKeyboardShortcutsService { return true; } + /** + * Cmd/Ctrl and +/−/0 zoom the app (issue #1109). Like F11 this is not + * gated by `isTypingInInput`: browsers zoom from any focus, and the + * combination is not one a text field consumes. The bridge steps the + * frame-bound zoom level itself, so no native menu is needed — Windows + * and Linux run without one. On macOS the renderer sees the key before + * the application menu's `zoomIn`/`zoomOut`/`resetZoom` roles do, and + * `preventDefault()` stops the role from stepping a second time, so one + * press is one step on every platform. A key another handler already + * consumed is left alone; without a bridge (PWA) the browser keeps its + * own zoom. + */ + private handleZoomShortcut(event: KeyboardEvent): boolean { + if (event.defaultPrevented) { + return false; + } + + const bridge = window.electron; + if (typeof bridge?.adjustZoomLevel !== 'function') { + return false; + } + + const action = resolveZoomShortcutAction(event, { + isMac: bridge.platform === 'darwin', + }); + if (!action) { + return false; + } + + event.preventDefault(); + try { + bridge.adjustZoomLevel(action); + } catch { + // The frame refused the write; the level on screen is unchanged. + } + return true; + } + private getShortcutPlatform(): 'mac' | 'other' { if (typeof navigator === 'undefined') { return 'other'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 79394d16e..e3c20cee1 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -93,6 +93,9 @@ "@iptvnator/shared/interfaces/ipc-commands": [ "libs/shared/interfaces/src/lib/ipc-commands.ts" ], + "@iptvnator/shared/interfaces/zoom-level": [ + "libs/shared/interfaces/src/lib/zoom-level.util.ts" + ], "@iptvnator/ui/shared-portals": [ "libs/ui/shared-portals/src/index.ts" ],