Resolves a conflict in src/App/App.js between the new GamepadProvider
(landed via #882 on development) and FullscreenProvider on this branch.
Both wrap parts of the app tree at the same point.
Resolution: nest as <GamepadProvider> > <ShortcutsProvider> >
<FullscreenProvider>. FullscreenProvider stays innermost so it remains
inside ShortcutsProvider — required for the onShortcut('fullscreen', ...)
subscription added in 35b100767. Both ShortcutsModal and GamepadModal
render as siblings inside FullscreenProvider. NavBar conflict
auto-merged cleanly (kept useFullscreen import, added gamepad-nav hook).
Lint and types clean on App.js, HorizontalNavBar.js, FullscreenProvider.tsx.
1. Player.js — Added optional chaining (gamepad?.on/gamepad?.off) to prevent null crash
2. GamepadProvider.tsx — Toast messages now use t('GAMEPAD_CONNECTED') / t('GAMEPAD_DISCONNECTED') via i18next, added keys to en-US.json
3. useVerticalNavGamepadNavigation.tsx — Fixed event.nativeEvent?.spatialNavigationPrevented → event.spatialNavigationPrevented (native DOM events don't have nativeEvent)
4. GamepadProvider.tsx — Changed connectedGamepads from useState to useRef to avoid rAF effect restart cycle, removed it from effect deps, simplified the enabled guard
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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<object>; 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
The C shortcut tracked enabled/disabled via a ref that was never reset
on stream change and was not updated when the user picked tracks via
the menu, so the toggle could become inverted or no-op until pressed
twice. Read selectedSubtitlesTrackId / selectedExtraSubtitlesTrackId
directly instead.
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
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