From 60df6860d77af1fb7f6d173e20ea03967c91298d Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:50:14 -0400 Subject: [PATCH 01/12] feat(common): add FullscreenProvider + context module Introduce a single, app-root-owned source of truth for fullscreen state, mirroring the existing provider pattern (ToastProvider, FileDropProvider). The provider centralizes the fullscreenchange / win-visibility-changed / keydown listeners and exposes the same [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen] tuple that consumers already destructure. Not yet wired up - both the legacy src/common/useFullscreen hook and the new module coexist. Subsequent commits mount the provider in App.js and switch consumers over. Made-with: Cursor --- src/common/Fullscreen/FullscreenContext.ts | 20 ++++ src/common/Fullscreen/FullscreenProvider.tsx | 105 +++++++++++++++++++ src/common/Fullscreen/index.ts | 7 ++ src/common/Fullscreen/useFullscreen.ts | 8 ++ 4 files changed, 140 insertions(+) create mode 100644 src/common/Fullscreen/FullscreenContext.ts create mode 100644 src/common/Fullscreen/FullscreenProvider.tsx create mode 100644 src/common/Fullscreen/index.ts create mode 100644 src/common/Fullscreen/useFullscreen.ts diff --git a/src/common/Fullscreen/FullscreenContext.ts b/src/common/Fullscreen/FullscreenContext.ts new file mode 100644 index 000000000..860eb044f --- /dev/null +++ b/src/common/Fullscreen/FullscreenContext.ts @@ -0,0 +1,20 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +import { createContext } from 'react'; + +export type FullscreenContextValue = readonly [ + boolean, + () => Promise | void, + () => void, + () => void, +]; + +const noop = () => { /* no-op */ }; + +const defaultValue: FullscreenContextValue = [false, noop, noop, noop]; + +const FullscreenContext = createContext(defaultValue); + +FullscreenContext.displayName = 'FullscreenContext'; + +export default FullscreenContext; diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx new file mode 100644 index 000000000..b0b721d30 --- /dev/null +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -0,0 +1,105 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import useShell, { type WindowVisibility } from '../useShell'; +import useSettings from '../useSettings'; +import FullscreenContext, { type FullscreenContextValue } from './FullscreenContext'; + +type Props = { + children: React.ReactNode, +}; + +// Single source of truth for fullscreen state. Mounted once at the app root so +// the value survives route remounts (fixes desync where switching tabs while in +// fullscreen would leave the UI thinking we were still windowed). +const FullscreenProvider = ({ children }: Props) => { + const shell = useShell(); + const [settings] = useSettings(); + + const [fullscreen, setFullscreen] = useState(() => { + if (typeof document === 'undefined') return false; + return document.fullscreenElement === document.documentElement; + }); + + const requestFullscreen = useCallback(async () => { + if (shell.active) { + shell.send('win-set-visibility', { fullscreen: true }); + } else { + try { + await document.documentElement.requestFullscreen(); + } catch (err) { + console.error('Error enabling fullscreen', err); + } + } + }, [shell]); + + const exitFullscreen = useCallback(() => { + if (shell.active) { + shell.send('win-set-visibility', { fullscreen: false }); + } else { + if (document.fullscreenElement === document.documentElement) { + document.exitFullscreen(); + } + } + }, [shell]); + + const toggleFullscreen = useCallback(() => { + fullscreen ? exitFullscreen() : requestFullscreen(); + }, [fullscreen, exitFullscreen, requestFullscreen]); + + useEffect(() => { + const onWindowVisibilityChanged = (state: WindowVisibility) => { + setFullscreen(state.isFullscreen === true); + }; + + const onFullscreenChange = () => { + setFullscreen(document.fullscreenElement === document.documentElement); + }; + + const onKeyDown = (event: KeyboardEvent) => { + const activeElement = document.activeElement as HTMLElement; + + const inputFocused = + activeElement && + (activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.tagName === 'SELECT' || + activeElement.isContentEditable); + + if (event.code === 'Escape' && settings.escExitFullscreen) { + exitFullscreen(); + } + + if (event.code === 'KeyF' && !inputFocused) { + toggleFullscreen(); + } + + if (event.code === 'F11' && shell.active) { + toggleFullscreen(); + } + }; + + shell.on('win-visibility-changed', onWindowVisibilityChanged); + document.addEventListener('keydown', onKeyDown); + document.addEventListener('fullscreenchange', onFullscreenChange); + + return () => { + shell.off('win-visibility-changed', onWindowVisibilityChanged); + document.removeEventListener('keydown', onKeyDown); + document.removeEventListener('fullscreenchange', onFullscreenChange); + }; + }, [shell, settings.escExitFullscreen, toggleFullscreen, exitFullscreen]); + + const value = useMemo( + () => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen], + [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen] + ); + + return ( + + {children} + + ); +}; + +export default FullscreenProvider; diff --git a/src/common/Fullscreen/index.ts b/src/common/Fullscreen/index.ts new file mode 100644 index 000000000..cc89f769b --- /dev/null +++ b/src/common/Fullscreen/index.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +import FullscreenProvider from './FullscreenProvider'; +import useFullscreen from './useFullscreen'; + +export { FullscreenProvider, useFullscreen }; +export default useFullscreen; diff --git a/src/common/Fullscreen/useFullscreen.ts b/src/common/Fullscreen/useFullscreen.ts new file mode 100644 index 000000000..29b2f62e5 --- /dev/null +++ b/src/common/Fullscreen/useFullscreen.ts @@ -0,0 +1,8 @@ +// Copyright (C) 2017-2023 Smart code 203358507 + +import { useContext } from 'react'; +import FullscreenContext from './FullscreenContext'; + +const useFullscreen = () => useContext(FullscreenContext); + +export default useFullscreen; From c97a9f156656a0b26efd0cc9b4e2e1eba078a83f Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:50:51 -0400 Subject: [PATCH 02/12] feat(app): mount FullscreenProvider at app root Wrap the router with so a single provider instance spans the whole app lifetime. The legacy useFullscreen hook is still intact and continues to drive consumers; the provider is in place but not yet consumed. Splitting this from the consumer cutover keeps each commit independently buildable. Made-with: Cursor --- src/App/App.js | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/src/App/App.js b/src/App/App.js index 7a1383dc4..ea226895f 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -7,6 +7,7 @@ const { Router } = require('stremio-router'); const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services'); const { NotFound } = require('stremio/routes'); const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common'); +const { FullscreenProvider } = require('stremio/common/Fullscreen'); const ServicesToaster = require('./ServicesToaster'); const DeepLinkHandler = require('./DeepLinkHandler'); const SearchParamsHandler = require('./SearchParamsHandler'); @@ -214,18 +215,20 @@ const App = () => { - { - shortcutModalOpen && - } - - - - - + + { + shortcutModalOpen && + } + + + + + + From b7f7a3d2edab9c4076d2504f362757fc453a071a Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:52:02 -0400 Subject: [PATCH 03/12] fix(fullscreen): consume FullscreenProvider, remove per-instance state useFullscreen is now a thin useContext consumer of FullscreenProvider, so all callers share a single fullscreen state owned by the app root. Why this fixes the desync bug: stremio-router keeps multiple route layers mounted at once, and each top-level route (Board, Discover, Library, Calendar, Addons, Settings, Search) renders its own MainNavBars -> HorizontalNavBar -> useFullscreen. The previous hook held local useState plus its own listeners, so each route had an independent boolean. Entering fullscreen, then navigating to another tab, mounted a fresh hook initialized to false; the icon flipped back to "enter fullscreen" and clicking it re-requested fullscreen on top of the existing one, leaving the UI unresponsive until a route remount happened to coincide with reality. With one provider above the router, state outlives route remounts and listeners are attached exactly once. The hook's return tuple shape ([fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen]) is preserved, so all three call sites (HorizontalNavBar, NavMenuContent, Player) keep working with no API change. Also removes the legacy src/common/useFullscreen.ts and routes its imports through stremio/common/Fullscreen (and the stremio/common barrel for App.js / Player). Note: MainNavBars is still rendered per-route. Lifting it to a single app-level layout above the router is a worthwhile follow-up (eliminates 6+ duplicate mounts) but carries non-trivial CSS / useRouteFocused / stacked-route risk and is out of scope for this PR; tracking separately. Made-with: Cursor --- src/App/App.js | 3 +- src/common/index.js | 3 +- src/common/useFullscreen.ts | 86 ------------------- .../HorizontalNavBar/HorizontalNavBar.js | 2 +- .../NavMenu/NavMenuContent.js | 2 +- 5 files changed, 5 insertions(+), 91 deletions(-) delete mode 100644 src/common/useFullscreen.ts diff --git a/src/App/App.js b/src/App/App.js index ea226895f..c27cc2901 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -6,8 +6,7 @@ const { useTranslation } = require('react-i18next'); const { Router } = require('stremio-router'); const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services'); const { NotFound } = require('stremio/routes'); -const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common'); -const { FullscreenProvider } = require('stremio/common/Fullscreen'); +const { FileDropProvider, FullscreenProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common'); const ServicesToaster = require('./ServicesToaster'); const DeepLinkHandler = require('./DeepLinkHandler'); const SearchParamsHandler = require('./SearchParamsHandler'); diff --git a/src/common/index.js b/src/common/index.js index e608e7e23..1963e8995 100644 --- a/src/common/index.js +++ b/src/common/index.js @@ -1,6 +1,7 @@ // Copyright (C) 2017-2023 Smart code 203358507 const { FileDropProvider, onFileDrop } = require('./FileDrop'); +const { FullscreenProvider, useFullscreen } = require('./Fullscreen'); const { PlatformProvider, usePlatform } = require('./Platform'); const { ToastProvider, useToast } = require('./Toast'); const { TooltipProvider, Tooltip } = require('./Tooltips'); @@ -14,7 +15,6 @@ const languages = require('./languages'); const routesRegexp = require('./routesRegexp'); const useAnimationFrame = require('./useAnimationFrame'); const useBinaryState = require('./useBinaryState'); -const { default: useFullscreen } = require('./useFullscreen'); const { default: useInterval } = require('./useInterval'); const useLiveRef = require('./useLiveRef'); const useModelState = require('./useModelState'); @@ -34,6 +34,7 @@ const { default: useLanguageSorting } = require('./useLanguageSorting'); module.exports = { FileDropProvider, onFileDrop, + FullscreenProvider, PlatformProvider, usePlatform, ShortcutsProvider, diff --git a/src/common/useFullscreen.ts b/src/common/useFullscreen.ts deleted file mode 100644 index 8a1692254..000000000 --- a/src/common/useFullscreen.ts +++ /dev/null @@ -1,86 +0,0 @@ -// Copyright (C) 2017-2023 Smart code 203358507 - -import { useCallback, useEffect, useState } from 'react'; -import useShell, { type WindowVisibility } from './useShell'; -import useSettings from './useSettings'; - -const useFullscreen = () => { - const shell = useShell(); - const [settings] = useSettings(); - - const [fullscreen, setFullscreen] = useState(false); - - const requestFullscreen = useCallback(async () => { - if (shell.active) { - shell.send('win-set-visibility', { fullscreen: true }); - } else { - try { - await document.documentElement.requestFullscreen(); - } catch (err) { - console.error('Error enabling fullscreen', err); - } - } - }, []); - - const exitFullscreen = useCallback(() => { - if (shell.active) { - shell.send('win-set-visibility', { fullscreen: false }); - } else { - if (document.fullscreenElement === document.documentElement) { - document.exitFullscreen(); - } - } - }, []); - - const toggleFullscreen = useCallback(() => { - fullscreen ? exitFullscreen() : requestFullscreen(); - }, [fullscreen]); - - useEffect(() => { - const onWindowVisibilityChanged = (state: WindowVisibility) => { - setFullscreen(state.isFullscreen === true); - }; - - const onFullscreenChange = () => { - setFullscreen(document.fullscreenElement === document.documentElement); - }; - - const onKeyDown = (event: KeyboardEvent) => { - - const activeElement = document.activeElement as HTMLElement; - - const inputFocused = - activeElement && - (activeElement.tagName === 'INPUT' || - activeElement.tagName === 'TEXTAREA' || - activeElement.tagName === 'SELECT' || - activeElement.isContentEditable); - - if (event.code === 'Escape' && settings.escExitFullscreen) { - exitFullscreen(); - } - - if (event.code === 'KeyF' && !inputFocused) { - toggleFullscreen(); - } - - if (event.code === 'F11' && shell.active) { - toggleFullscreen(); - } - }; - - shell.on('win-visibility-changed', onWindowVisibilityChanged); - document.addEventListener('keydown', onKeyDown); - document.addEventListener('fullscreenchange', onFullscreenChange); - - return () => { - shell.off('win-visibility-changed', onWindowVisibilityChanged); - document.removeEventListener('keydown', onKeyDown); - document.removeEventListener('fullscreenchange', onFullscreenChange); - }; - }, [settings.escExitFullscreen, toggleFullscreen]); - - return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen]; -}; - -export default useFullscreen; diff --git a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js index 6fed91c8a..4b0655918 100644 --- a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js +++ b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js @@ -5,7 +5,7 @@ const PropTypes = require('prop-types'); const classnames = require('classnames'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { Button, Image } = require('stremio/components'); -const { default: useFullscreen } = require('stremio/common/useFullscreen'); +const { useFullscreen } = require('stremio/common/Fullscreen'); const usePWA = require('stremio/common/usePWA'); const SearchBar = require('./SearchBar'); const NavMenu = require('./NavMenu'); diff --git a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js index b6309a44c..6615e5b76 100644 --- a/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js +++ b/src/components/NavBar/HorizontalNavBar/NavMenu/NavMenuContent.js @@ -7,7 +7,7 @@ const { useTranslation } = require('react-i18next'); const { default: Icon } = require('@stremio/stremio-icons/react'); const { useServices } = require('stremio/services'); const { Button } = require('stremio/components'); -const { default: useFullscreen } = require('stremio/common/useFullscreen'); +const { useFullscreen } = require('stremio/common/Fullscreen'); const useProfile = require('stremio/common/useProfile'); const usePWA = require('stremio/common/usePWA'); const { default: usePlayUrl } = require('stremio/common/usePlayUrl'); From 2e13a60007fd8b91094ddcc0a0658aba0b1be9b4 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Mon, 27 Apr 2026 01:56:02 -0400 Subject: [PATCH 04/12] fix(fullscreen): read settings via core.transport, not useSettings FullscreenProvider sits above the router, but useSettings() -> useProfile() -> useModelState() requires CoreSuspenderContext which is only provided by withCoreSuspender below the router. Mounting the provider therefore crashed with "Cannot read properties of null (reading 'getState')". Switch the provider to read profile.settings.escExitFullscreen directly from core.transport.getState('ctx') and refresh on the 'NewState' event when 'ctx' changes. core is available via useServices(), whose provider sits at the very top of the tree and is always reachable here. Behavior is preserved: ESC still exits fullscreen iff the user has the escExitFullscreen setting enabled, and updates to that setting from the Settings tab take effect on the next ctx NewState push. Made-with: Cursor --- src/common/Fullscreen/FullscreenProvider.tsx | 50 ++++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index b0b721d30..bbac4ab82 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -1,8 +1,9 @@ // Copyright (C) 2017-2023 Smart code 203358507 -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +// @ts-expect-error JS module without types +import { useServices } from 'stremio/services'; import useShell, { type WindowVisibility } from '../useShell'; -import useSettings from '../useSettings'; import FullscreenContext, { type FullscreenContextValue } from './FullscreenContext'; type Props = { @@ -12,15 +13,24 @@ type Props = { // Single source of truth for fullscreen state. Mounted once at the app root so // the value survives route remounts (fixes desync where switching tabs while in // fullscreen would leave the UI thinking we were still windowed). +// +// We deliberately avoid useSettings()/useProfile() here because those go +// through useModelState -> useCoreSuspender, which is only available beneath +// the router's withCoreSuspender boundary. This provider sits above the +// router (alongside ToastProvider et al.), so we read the single setting we +// need (escExitFullscreen) directly from core.transport, which is provided +// by ServicesProvider higher up the tree. const FullscreenProvider = ({ children }: Props) => { const shell = useShell(); - const [settings] = useSettings(); + const { core } = useServices(); const [fullscreen, setFullscreen] = useState(() => { if (typeof document === 'undefined') return false; return document.fullscreenElement === document.documentElement; }); + const escExitFullscreenRef = useRef(false); + const requestFullscreen = useCallback(async () => { if (shell.active) { shell.send('win-set-visibility', { fullscreen: true }); @@ -47,6 +57,36 @@ const FullscreenProvider = ({ children }: Props) => { fullscreen ? exitFullscreen() : requestFullscreen(); }, [fullscreen, exitFullscreen, requestFullscreen]); + useEffect(() => { + if (!core?.active) return; + + let cancelled = false; + const refreshSettings = async () => { + try { + const ctx = await core.transport.getState('ctx'); + if (!cancelled) { + escExitFullscreenRef.current = !!ctx?.profile?.settings?.escExitFullscreen; + } + } catch (err) { + console.error('FullscreenProvider: failed to read ctx state', err); + } + }; + + const onNewState = (models: string[]) => { + if (Array.isArray(models) && models.indexOf('ctx') !== -1) { + refreshSettings(); + } + }; + + refreshSettings(); + core.transport.on('NewState', onNewState); + + return () => { + cancelled = true; + core.transport.off('NewState', onNewState); + }; + }, [core]); + useEffect(() => { const onWindowVisibilityChanged = (state: WindowVisibility) => { setFullscreen(state.isFullscreen === true); @@ -66,7 +106,7 @@ const FullscreenProvider = ({ children }: Props) => { activeElement.tagName === 'SELECT' || activeElement.isContentEditable); - if (event.code === 'Escape' && settings.escExitFullscreen) { + if (event.code === 'Escape' && escExitFullscreenRef.current) { exitFullscreen(); } @@ -88,7 +128,7 @@ const FullscreenProvider = ({ children }: Props) => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('fullscreenchange', onFullscreenChange); }; - }, [shell, settings.escExitFullscreen, toggleFullscreen, exitFullscreen]); + }, [shell, toggleFullscreen, exitFullscreen]); const value = useMemo( () => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen], From 90e2cbff151b57a94b3fa44ff1d68bc2e0f2658c Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Tue, 28 Apr 2026 00:57:53 -0400 Subject: [PATCH 05/12] chore(fullscreen): drop ts-expect-error, type ctx + NewState properly useServices is already typed via src/services/ServicesContext/useServices.d.ts, so the @ts-expect-error suppression on the import was unnecessary and masked two real type holes that surfaced once it was removed: - core.transport.getState('ctx') returns Promise; cast to the ambient Ctx type so escExitFullscreen is read through a typed path. - CoreTransport.on/off types listeners as () => void, but the 'NewState' event actually emits a string[]. Use a (...args: unknown[]) wrapper + Array.isArray narrowing so the call site stays type-safe without weakening the ambient transport signature. No behavior change. Made-with: Cursor --- src/common/Fullscreen/FullscreenProvider.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index bbac4ab82..938df0b29 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -1,7 +1,6 @@ // Copyright (C) 2017-2023 Smart code 203358507 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -// @ts-expect-error JS module without types import { useServices } from 'stremio/services'; import useShell, { type WindowVisibility } from '../useShell'; import FullscreenContext, { type FullscreenContextValue } from './FullscreenContext'; @@ -63,7 +62,7 @@ const FullscreenProvider = ({ children }: Props) => { let cancelled = false; const refreshSettings = async () => { try { - const ctx = await core.transport.getState('ctx'); + const ctx = await core.transport.getState('ctx') as Ctx | null; if (!cancelled) { escExitFullscreenRef.current = !!ctx?.profile?.settings?.escExitFullscreen; } @@ -72,7 +71,11 @@ const FullscreenProvider = ({ children }: Props) => { } }; - const onNewState = (models: string[]) => { + // CoreTransport.on types the listener as () => void, but the 'NewState' + // event actually emits a string[] of changed model names. Read it via + // a rest-args wrapper to stay compatible with the ambient signature. + const onNewState = (...args: unknown[]) => { + const models = args[0]; if (Array.isArray(models) && models.indexOf('ctx') !== -1) { refreshSettings(); } From d3d35bcb422d2174c045e6a986042ee7941b4f9f Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Tue, 28 Apr 2026 01:26:42 -0400 Subject: [PATCH 06/12] perf(fullscreen): subscribe to SettingsUpdated, not every ctx NewState MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous NewState listener fired on every change to the ctx model — notifications, search history, library sync, streaming-server URL — and each fire triggered a getState('ctx') round-trip to the worker just to re-read escExitFullscreen. Switch to the CoreEvent / SettingsUpdated channel (same pattern App.js uses for interfaceLanguage/quitOnClose), reading the new value straight from the event payload. Initial seed still uses getState('ctx') once on mount. Co-Authored-By: Claude Opus 4.7 --- src/common/Fullscreen/FullscreenProvider.tsx | 46 ++++++++++---------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index 938df0b29..61a0f67c0 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -60,33 +60,35 @@ const FullscreenProvider = ({ children }: Props) => { if (!core?.active) return; let cancelled = false; - const refreshSettings = async () => { - try { - const ctx = await core.transport.getState('ctx') as Ctx | null; - if (!cancelled) { - escExitFullscreenRef.current = !!ctx?.profile?.settings?.escExitFullscreen; - } - } catch (err) { + + // CoreTransport.on types the listener as () => void, but 'CoreEvent' + // actually emits { event, args }. Read it via a rest-args wrapper to + // stay compatible with the ambient signature. + const onCoreEvent = (...listenerArgs: unknown[]) => { + const payload = listenerArgs[0] as + | { event?: string, args?: { settings?: { escExitFullscreen?: boolean } } } + | undefined; + if (payload?.event === 'SettingsUpdated' && + typeof payload.args?.settings?.escExitFullscreen === 'boolean') { + escExitFullscreenRef.current = payload.args.settings.escExitFullscreen; + } + }; + + core.transport.getState('ctx') + .then((ctx) => { + if (cancelled) return; + const settings = (ctx as Ctx | null)?.profile?.settings; + escExitFullscreenRef.current = !!settings?.escExitFullscreen; + }) + .catch((err) => { console.error('FullscreenProvider: failed to read ctx state', err); - } - }; + }); - // CoreTransport.on types the listener as () => void, but the 'NewState' - // event actually emits a string[] of changed model names. Read it via - // a rest-args wrapper to stay compatible with the ambient signature. - const onNewState = (...args: unknown[]) => { - const models = args[0]; - if (Array.isArray(models) && models.indexOf('ctx') !== -1) { - refreshSettings(); - } - }; - - refreshSettings(); - core.transport.on('NewState', onNewState); + core.transport.on('CoreEvent', onCoreEvent); return () => { cancelled = true; - core.transport.off('NewState', onNewState); + core.transport.off('CoreEvent', onCoreEvent); }; }, [core]); From 35b100767fe7151281660930d4d34368cb42153c Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Tue, 28 Apr 2026 09:15:31 -0400 Subject: [PATCH 07/12] refactor(fullscreen): route F key through ShortcutsProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shortcuts.json already declares fullscreen -> F, but the provider was listening for KeyF on its own keydown handler in parallel — both fired on every F press, with the canonical shortcuts dispatch going nowhere. Subscribe via onShortcut('fullscreen', toggleFullscreen, ...) and drop the KeyF branch (plus the now-unused inputFocused check). Escape stays local because its action is gated on the escExitFullscreen profile setting; F11 stays local because it's shell-only and not in shortcuts.json. Co-Authored-By: Claude Opus 4.7 --- src/common/Fullscreen/FullscreenProvider.tsx | 16 +++------------- 1 file changed, 3 insertions(+), 13 deletions(-) diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index 61a0f67c0..b98d64186 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useServices } from 'stremio/services'; +import onShortcut from '../Shortcuts/onShortcut'; import useShell, { type WindowVisibility } from '../useShell'; import FullscreenContext, { type FullscreenContextValue } from './FullscreenContext'; @@ -56,6 +57,8 @@ const FullscreenProvider = ({ children }: Props) => { fullscreen ? exitFullscreen() : requestFullscreen(); }, [fullscreen, exitFullscreen, requestFullscreen]); + onShortcut('fullscreen', toggleFullscreen, [toggleFullscreen]); + useEffect(() => { if (!core?.active) return; @@ -102,23 +105,10 @@ const FullscreenProvider = ({ children }: Props) => { }; const onKeyDown = (event: KeyboardEvent) => { - const activeElement = document.activeElement as HTMLElement; - - const inputFocused = - activeElement && - (activeElement.tagName === 'INPUT' || - activeElement.tagName === 'TEXTAREA' || - activeElement.tagName === 'SELECT' || - activeElement.isContentEditable); - if (event.code === 'Escape' && escExitFullscreenRef.current) { exitFullscreen(); } - if (event.code === 'KeyF' && !inputFocused) { - toggleFullscreen(); - } - if (event.code === 'F11' && shell.active) { toggleFullscreen(); } From 9412bb004c704a83c539ec7fd17159bd1b2e31e7 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Wed, 29 Apr 2026 09:52:42 -0400 Subject: [PATCH 08/12] fix(build): update gamepad nav useFullscreen import path The merge of development into this branch combined two incompatible changes: development's new GamepadNavigation files (#882) imported useFullscreen from the old 'stremio/common/useFullscreen' path, while this branch removed that file in b7f7a3d2e and moved the hook to 'stremio/common/Fullscreen'. Webpack failed to resolve the module on CI even though git auto-merged without conflict markers. Update the single stale import in useHorizontalNavGamepadNavigation.tsx to point at the new module. The new index.ts exports useFullscreen as default, so the import shape is unchanged. Verified locally with `pnpm build` (passes with only pre-existing bundle-size warnings). Co-Authored-By: Claude Opus 4.7 --- .../GamepadNavigation/useHorizontalNavGamepadNavigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx b/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx index 422489e3e..0d65a3fa1 100644 --- a/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx +++ b/src/services/GamepadNavigation/useHorizontalNavGamepadNavigation.tsx @@ -2,7 +2,7 @@ import { useEffect } from 'react'; import { useGamepad } from '../GamepadContext'; -import useFullscreen from 'stremio/common/useFullscreen'; +import useFullscreen from 'stremio/common/Fullscreen'; const useHorizontalNavGamepadNavigation = (gamepadHandlerId: string, enableGoBack: boolean) => { const gamepad = useGamepad(); From dfe0d08a78e750162ae47195329ac1196528536e Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Wed, 29 Apr 2026 17:20:54 -0400 Subject: [PATCH 09/12] chore(fullscreen): drop inline rationale comments, bump copyright to 2026 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review feedback: the explanatory block comments in FullscreenProvider (source-of-truth rationale, CoreTransport typing note) were noise — the same context already lives in the PR description and commit history. Copyright headers on the four new files were carried over from the template at 2023; bumped to 2026. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/common/Fullscreen/FullscreenContext.ts | 4 ++-- src/common/Fullscreen/FullscreenProvider.tsx | 15 +-------------- src/common/Fullscreen/index.ts | 2 +- src/common/Fullscreen/useFullscreen.ts | 2 +- 4 files changed, 5 insertions(+), 18 deletions(-) diff --git a/src/common/Fullscreen/FullscreenContext.ts b/src/common/Fullscreen/FullscreenContext.ts index 860eb044f..117a616e8 100644 --- a/src/common/Fullscreen/FullscreenContext.ts +++ b/src/common/Fullscreen/FullscreenContext.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2023 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { createContext } from 'react'; @@ -9,7 +9,7 @@ export type FullscreenContextValue = readonly [ () => void, ]; -const noop = () => { /* no-op */ }; +const noop = () => undefined; const defaultValue: FullscreenContextValue = [false, noop, noop, noop]; diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index b98d64186..1c899d283 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2023 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useServices } from 'stremio/services'; @@ -10,16 +10,6 @@ type Props = { children: React.ReactNode, }; -// Single source of truth for fullscreen state. Mounted once at the app root so -// the value survives route remounts (fixes desync where switching tabs while in -// fullscreen would leave the UI thinking we were still windowed). -// -// We deliberately avoid useSettings()/useProfile() here because those go -// through useModelState -> useCoreSuspender, which is only available beneath -// the router's withCoreSuspender boundary. This provider sits above the -// router (alongside ToastProvider et al.), so we read the single setting we -// need (escExitFullscreen) directly from core.transport, which is provided -// by ServicesProvider higher up the tree. const FullscreenProvider = ({ children }: Props) => { const shell = useShell(); const { core } = useServices(); @@ -64,9 +54,6 @@ const FullscreenProvider = ({ children }: Props) => { let cancelled = false; - // CoreTransport.on types the listener as () => void, but 'CoreEvent' - // actually emits { event, args }. Read it via a rest-args wrapper to - // stay compatible with the ambient signature. const onCoreEvent = (...listenerArgs: unknown[]) => { const payload = listenerArgs[0] as | { event?: string, args?: { settings?: { escExitFullscreen?: boolean } } } diff --git a/src/common/Fullscreen/index.ts b/src/common/Fullscreen/index.ts index cc89f769b..db65974ac 100644 --- a/src/common/Fullscreen/index.ts +++ b/src/common/Fullscreen/index.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2023 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import FullscreenProvider from './FullscreenProvider'; import useFullscreen from './useFullscreen'; diff --git a/src/common/Fullscreen/useFullscreen.ts b/src/common/Fullscreen/useFullscreen.ts index 29b2f62e5..da27035d2 100644 --- a/src/common/Fullscreen/useFullscreen.ts +++ b/src/common/Fullscreen/useFullscreen.ts @@ -1,4 +1,4 @@ -// Copyright (C) 2017-2023 Smart code 203358507 +// Copyright (C) 2017-2026 Smart code 203358507 import { useContext } from 'react'; import FullscreenContext from './FullscreenContext'; From fa3cd0f5b287d84f3a6f2ab232a7f9ff9b92e135 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:23:15 -0400 Subject: [PATCH 10/12] fix(fullscreen): ignore shortcut in editable fields --- src/common/Fullscreen/FullscreenProvider.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index 1c899d283..77c385013 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -10,6 +10,16 @@ type Props = { children: React.ReactNode, }; +const isTextInputFocused = () => { + const activeElement = document.activeElement; + + return activeElement instanceof HTMLElement && + (activeElement.tagName === 'INPUT' || + activeElement.tagName === 'TEXTAREA' || + activeElement.tagName === 'SELECT' || + activeElement.isContentEditable); +}; + const FullscreenProvider = ({ children }: Props) => { const shell = useShell(); const { core } = useServices(); @@ -47,7 +57,12 @@ const FullscreenProvider = ({ children }: Props) => { fullscreen ? exitFullscreen() : requestFullscreen(); }, [fullscreen, exitFullscreen, requestFullscreen]); - onShortcut('fullscreen', toggleFullscreen, [toggleFullscreen]); + const toggleFullscreenFromShortcut = useCallback(() => { + if (isTextInputFocused()) return; + toggleFullscreen(); + }, [toggleFullscreen]); + + onShortcut('fullscreen', toggleFullscreenFromShortcut, [toggleFullscreenFromShortcut]); useEffect(() => { if (!core?.active) return; From 56860195879e157c4613fda58451e361eb96c7d0 Mon Sep 17 00:00:00 2001 From: AK <144495202+AKnassa@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:29:56 -0400 Subject: [PATCH 11/12] fix(fullscreen): use settings hook for escape behavior Wrap the fullscreen provider with the core suspender so it can read escExitFullscreen through useSettings instead of manually parsing core event payloads. --- src/common/Fullscreen/FullscreenProvider.tsx | 49 ++++---------------- 1 file changed, 8 insertions(+), 41 deletions(-) diff --git a/src/common/Fullscreen/FullscreenProvider.tsx b/src/common/Fullscreen/FullscreenProvider.tsx index 77c385013..2300602c5 100644 --- a/src/common/Fullscreen/FullscreenProvider.tsx +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -1,8 +1,9 @@ // Copyright (C) 2017-2026 Smart code 203358507 -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useServices } from 'stremio/services'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { withCoreSuspender } from '../CoreSuspender'; import onShortcut from '../Shortcuts/onShortcut'; +import useSettings from '../useSettings'; import useShell, { type WindowVisibility } from '../useShell'; import FullscreenContext, { type FullscreenContextValue } from './FullscreenContext'; @@ -22,15 +23,14 @@ const isTextInputFocused = () => { const FullscreenProvider = ({ children }: Props) => { const shell = useShell(); - const { core } = useServices(); + const [settings] = useSettings(); + const escExitFullscreen = settings.escExitFullscreen; const [fullscreen, setFullscreen] = useState(() => { if (typeof document === 'undefined') return false; return document.fullscreenElement === document.documentElement; }); - const escExitFullscreenRef = useRef(false); - const requestFullscreen = useCallback(async () => { if (shell.active) { shell.send('win-set-visibility', { fullscreen: true }); @@ -64,39 +64,6 @@ const FullscreenProvider = ({ children }: Props) => { onShortcut('fullscreen', toggleFullscreenFromShortcut, [toggleFullscreenFromShortcut]); - useEffect(() => { - if (!core?.active) return; - - let cancelled = false; - - const onCoreEvent = (...listenerArgs: unknown[]) => { - const payload = listenerArgs[0] as - | { event?: string, args?: { settings?: { escExitFullscreen?: boolean } } } - | undefined; - if (payload?.event === 'SettingsUpdated' && - typeof payload.args?.settings?.escExitFullscreen === 'boolean') { - escExitFullscreenRef.current = payload.args.settings.escExitFullscreen; - } - }; - - core.transport.getState('ctx') - .then((ctx) => { - if (cancelled) return; - const settings = (ctx as Ctx | null)?.profile?.settings; - escExitFullscreenRef.current = !!settings?.escExitFullscreen; - }) - .catch((err) => { - console.error('FullscreenProvider: failed to read ctx state', err); - }); - - core.transport.on('CoreEvent', onCoreEvent); - - return () => { - cancelled = true; - core.transport.off('CoreEvent', onCoreEvent); - }; - }, [core]); - useEffect(() => { const onWindowVisibilityChanged = (state: WindowVisibility) => { setFullscreen(state.isFullscreen === true); @@ -107,7 +74,7 @@ const FullscreenProvider = ({ children }: Props) => { }; const onKeyDown = (event: KeyboardEvent) => { - if (event.code === 'Escape' && escExitFullscreenRef.current) { + if (event.code === 'Escape' && escExitFullscreen) { exitFullscreen(); } @@ -125,7 +92,7 @@ const FullscreenProvider = ({ children }: Props) => { document.removeEventListener('keydown', onKeyDown); document.removeEventListener('fullscreenchange', onFullscreenChange); }; - }, [shell, toggleFullscreen, exitFullscreen]); + }, [shell, toggleFullscreen, exitFullscreen, escExitFullscreen]); const value = useMemo( () => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen], @@ -139,4 +106,4 @@ const FullscreenProvider = ({ children }: Props) => { ); }; -export default FullscreenProvider; +export default withCoreSuspender(FullscreenProvider); From 42579ce297d49588d56067bcdf97024633d25e2b Mon Sep 17 00:00:00 2001 From: "Timothy Z." Date: Fri, 1 May 2026 18:37:19 +0300 Subject: [PATCH 12/12] cleanup --- src/common/Fullscreen/FullscreenContext.ts | 14 +++++--------- src/common/Fullscreen/useFullscreen.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/common/Fullscreen/FullscreenContext.ts b/src/common/Fullscreen/FullscreenContext.ts index 117a616e8..1c9599ffb 100644 --- a/src/common/Fullscreen/FullscreenContext.ts +++ b/src/common/Fullscreen/FullscreenContext.ts @@ -3,17 +3,13 @@ import { createContext } from 'react'; export type FullscreenContextValue = readonly [ - boolean, - () => Promise | void, - () => void, - () => void, + fullscreen: boolean, + requestFullscreen: () => Promise | void, + exitFullscreen: () => void, + toggleFullscreen: () => void, ]; -const noop = () => undefined; - -const defaultValue: FullscreenContextValue = [false, noop, noop, noop]; - -const FullscreenContext = createContext(defaultValue); +const FullscreenContext = createContext(null); FullscreenContext.displayName = 'FullscreenContext'; diff --git a/src/common/Fullscreen/useFullscreen.ts b/src/common/Fullscreen/useFullscreen.ts index da27035d2..5cee0a801 100644 --- a/src/common/Fullscreen/useFullscreen.ts +++ b/src/common/Fullscreen/useFullscreen.ts @@ -3,6 +3,13 @@ import { useContext } from 'react'; import FullscreenContext from './FullscreenContext'; -const useFullscreen = () => useContext(FullscreenContext); +const useFullscreen = () => { + const value = useContext(FullscreenContext); + if (value === null) { + throw new Error('useFullscreen must be used inside FullscreenProvider'); + } + + return value; +}; export default useFullscreen;