diff --git a/package.json b/package.json index 53dd6ca65..c40fa32ff 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "stremio", "displayName": "Stremio", - "version": "5.0.0-beta.33", + "version": "5.0.0-beta.36", "author": "Smart Code OOD", "private": true, "license": "gpl-2.0", @@ -17,9 +17,9 @@ "@babel/runtime": "7.26.0", "@sentry/browser": "8.42.0", "@stremio/stremio-colors": "5.2.0", - "@stremio/stremio-core-web": "0.56.3", - "@stremio/stremio-icons": "5.8.0", - "@stremio/stremio-video": "0.0.73", + "@stremio/stremio-core-web": "0.56.4", + "@stremio/stremio-icons": "5.10.0", + "@stremio/stremio-video": "0.0.77", "a-color-picker": "1.2.1", "bowser": "2.11.0", "buffer": "6.0.3", @@ -41,7 +41,7 @@ "react-i18next": "^15.1.3", "react-is": "18.3.1", "spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6", - "stremio-translations": "github:Stremio/stremio-translations#90ea718c18750a0e9cd6824b0ef7c512a41cb90b", + "stremio-translations": "github:Stremio/stremio-translations#176c69f3bdede824f37979a046ee27cef355affc", "url": "0.11.4", "use-long-press": "^3.2.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1b3169409..5b9107a42 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,14 +18,14 @@ importers: specifier: 5.2.0 version: 5.2.0 '@stremio/stremio-core-web': - specifier: 0.56.3 - version: 0.56.3 + specifier: 0.56.4 + version: 0.56.4 '@stremio/stremio-icons': - specifier: 5.8.0 - version: 5.8.0 + specifier: 5.10.0 + version: 5.10.0 '@stremio/stremio-video': - specifier: 0.0.73 - version: 0.0.73 + specifier: 0.0.77 + version: 0.0.77 a-color-picker: specifier: 1.2.1 version: 1.2.1 @@ -90,8 +90,8 @@ importers: specifier: github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6 version: https://codeload.github.com/Stremio/spatial-navigation/tar.gz/64871b1422466f5f45d24ebc8bbd315b2ebab6a6 stremio-translations: - specifier: github:Stremio/stremio-translations#90ea718c18750a0e9cd6824b0ef7c512a41cb90b - version: https://codeload.github.com/Stremio/stremio-translations/tar.gz/90ea718c18750a0e9cd6824b0ef7c512a41cb90b + specifier: github:Stremio/stremio-translations#176c69f3bdede824f37979a046ee27cef355affc + version: https://codeload.github.com/Stremio/stremio-translations/tar.gz/176c69f3bdede824f37979a046ee27cef355affc url: specifier: 0.11.4 version: 0.11.4 @@ -1120,14 +1120,14 @@ packages: '@stremio/stremio-colors@5.2.0': resolution: {integrity: sha512-dYlPgu9W/H7c9s1zmW5tiDnRenaUa4Hg1QCyOg1lhOcgSfM/bVTi5nnqX+IfvGTTUNA0zgzh8hI3o3miwnZxTg==} - '@stremio/stremio-core-web@0.56.3': - resolution: {integrity: sha512-+BBErhza9DACHfzJwuHUnWgUsCCtVutFo1a8r4PVBrzxEkeb6ad/RO+BpStNz8w+BdGDddPctJPyl0FwLtzTWQ==} + '@stremio/stremio-core-web@0.56.4': + resolution: {integrity: sha512-tFAMYgKrJ1bkvHRMpxDykM/844sDjgRPFk6FLhjQiwh01OHIyEgDqGo/NgwFM+CuMR4mW676SDvwNHkK0Xqg3w==} - '@stremio/stremio-icons@5.8.0': - resolution: {integrity: sha512-IVUvQbIWfA4YEHCTed7v/sdQJCJ+OOCf84LTWpkE2W6GLQ+15WHcMEJrVkE1X3ekYJnGg3GjT0KLO6tKSU0P4w==} + '@stremio/stremio-icons@5.10.0': + resolution: {integrity: sha512-Zw/vGC3D2yeQfk8xv/tfMJTDvbCPOI91tBg4XpR2+EgbZSX8Xvm7Vz457PIhFPhTAwdOPHp0VX0M3gzjbt0zOg==} - '@stremio/stremio-video@0.0.73': - resolution: {integrity: sha512-mZwygwq+iM6yq/LB3AOWdxbF7X1wmtLMpUVblt0xkxTjZ/T92mkTtoLT9Znf1rchG0VZik6B+wG/YyQjAgDsCg==} + '@stremio/stremio-video@0.0.77': + resolution: {integrity: sha512-bnKBS5a9R3+M0zx95YpDUiPs1gXcKCsybgdxfZmpWuQaN0RE9bTBAUlIfBSrcEjVhufMOvg+cfXScT+0fBzTTw==} '@stylistic/eslint-plugin-jsx@4.4.1': resolution: {integrity: sha512-83SInq4u7z71vWwGG+6ViOtlOmZ6tSrDkMPhrvdBBTGMLA0gs22WSdhQ4vZP3oJ5Xg4ythvqeUiFSedvVxzhyA==} @@ -4133,9 +4133,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} - stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/90ea718c18750a0e9cd6824b0ef7c512a41cb90b: - resolution: {tarball: https://codeload.github.com/Stremio/stremio-translations/tar.gz/90ea718c18750a0e9cd6824b0ef7c512a41cb90b} - version: 1.48.0 + stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/176c69f3bdede824f37979a046ee27cef355affc: + resolution: {tarball: https://codeload.github.com/Stremio/stremio-translations/tar.gz/176c69f3bdede824f37979a046ee27cef355affc} + version: 1.52.0 string-length@4.0.2: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} @@ -5870,13 +5870,13 @@ snapshots: '@stremio/stremio-colors@5.2.0': {} - '@stremio/stremio-core-web@0.56.3': + '@stremio/stremio-core-web@0.56.4': dependencies: '@babel/runtime': 7.24.1 - '@stremio/stremio-icons@5.8.0': {} + '@stremio/stremio-icons@5.10.0': {} - '@stremio/stremio-video@0.0.73': + '@stremio/stremio-video@0.0.77': dependencies: buffer: 6.0.3 color: 4.2.3 @@ -9378,7 +9378,7 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 - stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/90ea718c18750a0e9cd6824b0ef7c512a41cb90b: {} + stremio-translations@https://codeload.github.com/Stremio/stremio-translations/tar.gz/176c69f3bdede824f37979a046ee27cef355affc: {} string-length@4.0.2: dependencies: diff --git a/src/App/App.js b/src/App/App.js index e41239b7c..1bcc39d0f 100644 --- a/src/App/App.js +++ b/src/App/App.js @@ -4,14 +4,15 @@ require('spatial-navigation-polyfill'); const React = require('react'); const { useTranslation } = require('react-i18next'); const { Router } = require('stremio-router'); -const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services'); +const { Core, Shell, Chromecast, DragAndDrop, KeyboardShortcuts, ServicesProvider, GamepadProvider } = require('stremio/services'); const { NotFound } = require('stremio/routes'); -const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common'); +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'); const { default: UpdaterBanner } = require('./UpdaterBanner'); const { default: ShortcutsModal } = require('./ShortcutsModal'); +const { default: GamepadModal } = require('./GamepadModal'); const ErrorDialog = require('./ErrorDialog'); const withProtectedRoutes = require('./withProtectedRoutes'); const routerViewsConfig = require('./routerViewsConfig'); @@ -22,6 +23,7 @@ const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router)) const App = () => { const { i18n } = useTranslation(); const shell = useShell(); + const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false); const onPathNotMatch = React.useCallback(() => { return NotFound; }, []); @@ -40,12 +42,18 @@ const App = () => { }, []); const [initialized, setInitialized] = React.useState(false); const [shortcutModalOpen,, closeShortcutsModal, toggleShortcutModal] = useBinaryState(false); + const [gamepadModalOpen,, closeGamepadModal, toggleGamepadModal] = useBinaryState(false); const onShortcut = React.useCallback((name) => { - if (name === 'shortcuts') { - toggleShortcutModal(); + switch (name) { + case 'shortcuts': + toggleShortcutModal(); + break; + case 'gamepadGuide': + toggleGamepadModal(); + break; } - }, [toggleShortcutModal]); + }, [toggleShortcutModal, toggleGamepadModal]); React.useEffect(() => { let prevPath = window.location.hash.slice(1); @@ -144,6 +152,9 @@ const App = () => { if (args && args.settings && typeof args.settings.interfaceScale === 'number') { shell.scaleInterface(args.settings.interfaceScale); } + if (args?.settings?.gamepadSupport !== undefined) { + setGamepadSupportEnabled(args.settings.gamepadSupport); + } if (args?.settings?.quitOnClose && shell.windowClosed) { shell.send('quit'); @@ -161,6 +172,9 @@ const App = () => { if (state && state.profile && state.profile.settings && typeof state.profile.settings.interfaceScale === 'number') { shell.scaleInterface(state.profile.settings.interfaceScale); } + if (typeof state.profile.settings.gamepadSupport === 'boolean') { + setGamepadSupportEnabled(state.profile.settings.gamepadSupport); + } if (state?.profile?.settings?.quitOnClose && shell.windowClosed) { shell.send('quit'); @@ -221,20 +235,27 @@ const App = () => { - - { - shortcutModalOpen && - } - - - - - - + + + + { + shortcutModalOpen && + } + { + gamepadModalOpen && + } + + + + + + + + diff --git a/src/App/GamepadModal/GamepadDiagram.tsx b/src/App/GamepadModal/GamepadDiagram.tsx new file mode 100644 index 000000000..20df63418 --- /dev/null +++ b/src/App/GamepadModal/GamepadDiagram.tsx @@ -0,0 +1,264 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useGamepad } from 'stremio/services'; +import type { ControllerType } from 'stremio/services/GamepadContext'; +import styles from './styles.less'; + +type ActiveButton = string | null; + +const CX = 400; +const ARROW = { UP: '↑', DOWN: '↓', LEFT: '←', RIGHT: '→' }; + +type FaceLayout = { + top: { glyph: string; fontSize: number; weight: number }; + right: { glyph: string; fontSize: number; weight: number }; + bottom: { glyph: string; fontSize: number; weight: number }; + left: { glyph: string; fontSize: number; weight: number }; + lb: string; + rb: string; + lt: string; + rt: string; +}; + +const LAYOUTS: Record = { + playstation: { + top: { glyph: '△', fontSize: 12, weight: 400 }, + right: { glyph: '○', fontSize: 12, weight: 400 }, + bottom: { glyph: '✕', fontSize: 12, weight: 400 }, + left: { glyph: '□', fontSize: 12, weight: 400 }, + lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', + }, + xbox: { + top: { glyph: 'Y', fontSize: 11, weight: 700 }, + right: { glyph: 'B', fontSize: 11, weight: 700 }, + bottom: { glyph: 'A', fontSize: 11, weight: 700 }, + left: { glyph: 'X', fontSize: 11, weight: 700 }, + lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT', + }, + generic: { + top: { glyph: '△', fontSize: 12, weight: 400 }, + right: { glyph: '○', fontSize: 12, weight: 400 }, + bottom: { glyph: '✕', fontSize: 12, weight: 400 }, + left: { glyph: '□', fontSize: 12, weight: 400 }, + lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2', + }, +}; + +const GamepadDiagram = () => { + const { t } = useTranslation(); + const gamepad = useGamepad(); + const [active, setActive] = useState(null); + + const layout = LAYOUTS[gamepad?.controllerType ?? 'generic']; + + useEffect(() => { + let timeout: ReturnType; + const flash = (button: string) => () => { + setActive(button); + clearTimeout(timeout); + timeout = setTimeout(() => setActive(null), 400); + }; + + gamepad?.on('buttonA', 'gamepad-diagram', flash('bottom')); + gamepad?.on('buttonB', 'gamepad-diagram', flash('right')); + gamepad?.on('buttonX', 'gamepad-diagram', flash('left')); + gamepad?.on('buttonY', 'gamepad-diagram', flash('top')); + gamepad?.on('buttonLT', 'gamepad-diagram', flash('lb')); + gamepad?.on('buttonRT', 'gamepad-diagram', flash('rb')); + gamepad?.on('analog', 'gamepad-diagram', (dir) => dir && flash('stick-' + dir)()); + gamepad?.on('analogRight', 'gamepad-diagram', (dir) => dir && flash('rstick-' + dir)()); + + return () => { + clearTimeout(timeout); + gamepad?.off('buttonA', 'gamepad-diagram'); + gamepad?.off('buttonB', 'gamepad-diagram'); + gamepad?.off('buttonX', 'gamepad-diagram'); + gamepad?.off('buttonY', 'gamepad-diagram'); + gamepad?.off('buttonLT', 'gamepad-diagram'); + gamepad?.off('buttonRT', 'gamepad-diagram'); + gamepad?.off('analog', 'gamepad-diagram'); + gamepad?.off('analogRight', 'gamepad-diagram'); + }; + }, [gamepad]); + + const glow = (id: string) => active === id ? '#7b5bf5' : undefined; + const glowOp = (id: string) => active === id ? 1 : undefined; + + const SX = 130; + const BX = 120; + const STX = 75; + const BY = 30; + + // Xbox controllers are asymmetric — left stick sits upper-left (where the + // d-pad is on PlayStation) and the d-pad drops to the lower-left. + const isXbox = (gamepad?.controllerType ?? 'generic') === 'xbox'; + const lstickPos = isXbox + ? { cx: CX - BX, cy: 148 + BY } + : { cx: CX - STX, cy: 240 + BY }; + const dpadPos = isXbox + ? { cx: CX - STX, cy: 240 + BY } + : { cx: CX - BX, cy: 149 + BY }; + const navLine = isXbox + ? { x1: CX - BX - 24, y1: 148 + BY } + : { x1: CX - STX - 24, y1: 232 + BY }; + + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + {layout.lt} + + {layout.rt} + + + + + + + + {layout.lb} + + + + {layout.rb} + + + + + {layout.top.glyph} + + + + + {layout.right.glyph} + + + + + {layout.bottom.glyph} + + + + + {layout.left.glyph} + + + + + + + + + + + + + + + + + {ARROW.UP} + {ARROW.DOWN} + {ARROW.LEFT} + {ARROW.RIGHT} + + + + + + + + + + + + + + + + + + + + + + + + + {t('GAMEPAD_ACTION_PREV_TAB')} + {t('GAMEPAD_ACTION_NAVIGATE')} + {t('GAMEPAD_ACTION_GUIDE')} + {t('GAMEPAD_LABEL_PLAY_PAUSE_PLAYER')} + {t('GAMEPAD_ACTION_NEXT_TAB')} + {t('GAMEPAD_ACTION_FULLSCREEN')} + {t('GAMEPAD_ACTION_BACK')} + {t('GAMEPAD_ACTION_SELECT')} + {t('GAMEPAD_LABEL_SEEK_VOL')} + {t('GAMEPAD_LABEL_COMPAT')} + + + ); +}; + +export default GamepadDiagram; diff --git a/src/App/GamepadModal/GamepadModal.tsx b/src/App/GamepadModal/GamepadModal.tsx new file mode 100644 index 000000000..30bee9e5a --- /dev/null +++ b/src/App/GamepadModal/GamepadModal.tsx @@ -0,0 +1,165 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import React, { useEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import Icon from '@stremio/stremio-icons/react'; +import { Button } from 'stremio/components'; +import { useGamepad } from 'stremio/services'; +import type { ControllerType } from 'stremio/services/GamepadContext'; +import GamepadDiagram from './GamepadDiagram'; +import styles from './styles.less'; + +const LEFT = '←'; +const RIGHT = '→'; +const UP = '↑'; +const DOWN = '↓'; + +type FaceLabels = { + bottom: string; + right: string; + left: string; + top: string; + lb: string; + rb: string; + lStick: string; + rStick: string; +}; + +const LABELS: Record = { + playstation: { + bottom: '✕', right: '○', left: '□', top: '△', + lb: 'L1', rb: 'R1', + lStick: 'L stick', rStick: 'R stick', + }, + xbox: { + bottom: 'A', right: 'B', left: 'X', top: 'Y', + lb: 'LB', rb: 'RB', + lStick: 'L stick', rStick: 'R stick', + }, + generic: { + bottom: '✕', right: '○', left: '□', top: '△', + lb: 'L1', rb: 'R1', + lStick: 'L stick', rStick: 'R stick', + }, +}; + +type Props = { + onClose: () => void, +}; + +const GamepadModal = ({ onClose }: Props) => { + const { t } = useTranslation(); + const gamepad = useGamepad(); + + const labels = LABELS[gamepad?.controllerType ?? 'generic']; + + useEffect(() => { + gamepad?.lock('gamepad-'); + const onKeyDown = ({ key }: KeyboardEvent) => { + key === 'Escape' && onClose(); + }; + + document.addEventListener('keydown', onKeyDown); + gamepad?.on('buttonB', 'gamepad-modal', onClose); + return () => { + document.removeEventListener('keydown', onKeyDown); + gamepad?.off('buttonB', 'gamepad-modal'); + gamepad?.unlock(); + }; + }, [gamepad]); + + return createPortal(( +
+
+ +
+
+
+ {t('GAMEPAD_CONTROLS_TITLE')} +
+ + +
+ +
+ + +
+
+
{t('GAMEPAD_SECTION_NAVIGATION')}
+
+ {labels.lStick} + + {t('GAMEPAD_ACTION_NAVIGATE')} +
+
+ {labels.bottom} + + {t('GAMEPAD_ACTION_SELECT')} +
+
+ {labels.right} + + {t('GAMEPAD_ACTION_BACK')} +
+
+ {labels.top} + + {t('GAMEPAD_ACTION_FULLSCREEN')} +
+
+ {labels.left} + + {t('GAMEPAD_ACTION_GUIDE')} +
+
+ {labels.lb} + + {t('GAMEPAD_ACTION_PREV_TAB')} +
+
+ {labels.rb} + + {t('GAMEPAD_ACTION_NEXT_TAB')} +
+
+ +
+
{t('GAMEPAD_SECTION_PLAYER')}
+
+ {labels.left} + + {t('GAMEPAD_ACTION_PLAY_PAUSE')} +
+
+ {labels.rStick} + {LEFT} + {t('GAMEPAD_ACTION_SEEK_BACK')} +
+
+ {labels.rStick} + {RIGHT} + {t('GAMEPAD_ACTION_SEEK_FWD')} +
+
+ {labels.rStick} + {UP} + {t('GAMEPAD_ACTION_VOL_UP')} +
+
+ {labels.rStick} + {DOWN} + {t('GAMEPAD_ACTION_VOL_DOWN')} +
+
+
+
+
+
+ ), document.body); +}; + +export default GamepadModal; diff --git a/src/App/GamepadModal/index.ts b/src/App/GamepadModal/index.ts new file mode 100644 index 000000000..7ea9c5b43 --- /dev/null +++ b/src/App/GamepadModal/index.ts @@ -0,0 +1,4 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import GamepadModal from './GamepadModal'; +export default GamepadModal; diff --git a/src/App/GamepadModal/styles.less b/src/App/GamepadModal/styles.less new file mode 100644 index 000000000..10dfd12ea --- /dev/null +++ b/src/App/GamepadModal/styles.less @@ -0,0 +1,220 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less'; + +.gamepad-modal { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + + .backdrop { + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: @color-background-dark5-40; + cursor: pointer; + } + + .container { + position: relative; + display: flex; + flex-direction: column; + gap: 1rem; + max-height: 90%; + max-width: 72rem; + width: 92%; + border-radius: var(--border-radius); + background-color: var(--modal-background-color); + box-shadow: var(--outer-glow); + overflow: hidden; + + .header { + flex: none; + display: flex; + justify-content: space-between; + align-items: center; + height: 5rem; + padding-left: 2.5rem; + padding-right: 1rem; + + .title { + position: relative; + font-size: 1.5rem; + font-weight: 500; + color: var(--primary-foreground-color); + } + + .close-button { + position: relative; + width: 3rem; + height: 3rem; + padding: 0.5rem; + border-radius: var(--border-radius); + z-index: 2; + + .icon { + display: block; + width: 100%; + height: 100%; + color: var(--primary-foreground-color); + opacity: 0.4; + } + + &:hover, &:focus { + .icon { + opacity: 1; + color: var(--primary-foreground-color); + } + } + + &:focus { + outline-color: var(--primary-foreground-color); + } + } + } + + .content { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 3rem; + padding: 0 3rem; + padding-bottom: 3rem; + flex: 1; + min-height: 0; + overflow-y: auto; + } + } + + @keyframes draw-stroke { + from { stroke-dashoffset: 2000; } + to { stroke-dashoffset: 0; } + } + + @keyframes draw-line { + from { stroke-dashoffset: 800; } + to { stroke-dashoffset: 0; } + } + + @keyframes fade-in { + from { opacity: 0; } + to { opacity: 1; } + } + + .diagram { + flex: none; + width: 100%; + max-width: 48rem; + height: auto; + + .anim-body { + stroke-dasharray: 2000; + stroke-dashoffset: 0; + animation: draw-stroke 1.4s ease-in-out; + } + + .anim-controls { + animation: fade-in 0.6s ease-out 1s both; + } + + .anim-lines { + line { + stroke-dasharray: 800; + stroke-dashoffset: 0; + animation: draw-line 0.8s ease-out 1.6s both; + } + circle { + animation: fade-in 0.3s ease-out 2s both; + } + } + + .anim-labels { + animation: fade-in 0.5s ease-out 2.2s both; + } + } + + .sections { + flex: none; + display: flex; + flex-direction: row; + gap: 5rem; + width: 100%; + max-width: 56rem; + overflow: visible; + } + + .section { + flex: 1; + display: flex; + flex-direction: column; + gap: 1.2rem; + overflow: visible; + } + + .section-title { + font-size: 1rem; + font-weight: 600; + color: var(--primary-foreground-color); + margin-bottom: 0.4rem; + text-transform: uppercase; + letter-spacing: 0.05em; + opacity: 0.7; + } + + .mapping { + display: grid; + grid-template-columns: auto 1.2rem 1fr; + align-items: center; + column-gap: 0.5rem; + } + + .kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 2rem; + height: 1.8rem; + padding: 0 0.5rem; + border-radius: 0.35rem; + background-color: rgba(123, 91, 245, 0.15); + border: 1px solid rgba(123, 91, 245, 0.3); + box-shadow: none; + color: #c4b5fd; + font-size: 0.8rem; + font-weight: 600; + font-family: inherit; + } + + .dir { + color: #8b7faa; + font-size: 1rem; + } + + .action { + color: var(--primary-foreground-color); + font-size: 0.9rem; + opacity: 0.8; + } +} + +@media only screen and (max-width: 640px) { + .gamepad-modal { + .container { + max-width: 95%; + } + + .sections { + flex-direction: column; + gap: 2rem; + } + } +} diff --git a/src/common/Fullscreen/FullscreenContext.ts b/src/common/Fullscreen/FullscreenContext.ts new file mode 100644 index 000000000..1c9599ffb --- /dev/null +++ b/src/common/Fullscreen/FullscreenContext.ts @@ -0,0 +1,16 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import { createContext } from 'react'; + +export type FullscreenContextValue = readonly [ + fullscreen: boolean, + requestFullscreen: () => Promise | void, + exitFullscreen: () => void, + toggleFullscreen: () => void, +]; + +const FullscreenContext = createContext(null); + +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..2300602c5 --- /dev/null +++ b/src/common/Fullscreen/FullscreenProvider.tsx @@ -0,0 +1,109 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +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'; + +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 [settings] = useSettings(); + const escExitFullscreen = settings.escExitFullscreen; + + 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]); + + const toggleFullscreenFromShortcut = useCallback(() => { + if (isTextInputFocused()) return; + toggleFullscreen(); + }, [toggleFullscreen]); + + onShortcut('fullscreen', toggleFullscreenFromShortcut, [toggleFullscreenFromShortcut]); + + useEffect(() => { + const onWindowVisibilityChanged = (state: WindowVisibility) => { + setFullscreen(state.isFullscreen === true); + }; + + const onFullscreenChange = () => { + setFullscreen(document.fullscreenElement === document.documentElement); + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.code === 'Escape' && escExitFullscreen) { + exitFullscreen(); + } + + 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, toggleFullscreen, exitFullscreen, escExitFullscreen]); + + const value = useMemo( + () => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen], + [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen] + ); + + return ( + + {children} + + ); +}; + +export default withCoreSuspender(FullscreenProvider); diff --git a/src/common/Fullscreen/index.ts b/src/common/Fullscreen/index.ts new file mode 100644 index 000000000..db65974ac --- /dev/null +++ b/src/common/Fullscreen/index.ts @@ -0,0 +1,7 @@ +// Copyright (C) 2017-2026 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..5cee0a801 --- /dev/null +++ b/src/common/Fullscreen/useFullscreen.ts @@ -0,0 +1,15 @@ +// Copyright (C) 2017-2026 Smart code 203358507 + +import { useContext } from 'react'; +import FullscreenContext from './FullscreenContext'; + +const useFullscreen = () => { + const value = useContext(FullscreenContext); + if (value === null) { + throw new Error('useFullscreen must be used inside FullscreenProvider'); + } + + return value; +}; + +export default useFullscreen; diff --git a/src/common/Shortcuts/Shortcuts.tsx b/src/common/Shortcuts/Shortcuts.tsx index c9198a857..348471484 100644 --- a/src/common/Shortcuts/Shortcuts.tsx +++ b/src/common/Shortcuts/Shortcuts.tsx @@ -19,10 +19,20 @@ type Props = { onShortcut: (name: ShortcutName) => void, }; +const REPEAT_THROTTLE_MS = 130; + const ShortcutsProvider = ({ children, onShortcut }: Props) => { const listeners = useRef>>(new Map()); + const lastRepeatTime = useRef>(new Map()); + + const onKeyDown = useCallback(({ ctrlKey, shiftKey, code, key, repeat }: KeyboardEvent) => { + if (repeat) { + const now = Date.now(); + const last = lastRepeatTime.current.get(code) ?? 0; + if (now - last < REPEAT_THROTTLE_MS) return; + lastRepeatTime.current.set(code, now); + } - const onKeyDown = useCallback(({ ctrlKey, shiftKey, code, key }: KeyboardEvent) => { SHORTCUTS.forEach(({ name, combos }) => combos.forEach((keys) => { const modifers = (keys.includes('Ctrl') ? ctrlKey : true) && (keys.includes('Shift') ? shiftKey : true); diff --git a/src/common/Shortcuts/shortcuts.json b/src/common/Shortcuts/shortcuts.json index 659c3c373..536b9f7f7 100644 --- a/src/common/Shortcuts/shortcuts.json +++ b/src/common/Shortcuts/shortcuts.json @@ -27,6 +27,11 @@ "name": "shortcuts", "label": "SETTINGS_SHORTCUT_SHORTCUTS", "combos": [["Ctrl", "/"]] + }, + { + "name": "gamepadGuide", + "label": "GAMEPAD_ACTION_GUIDE", + "combos": [["Ctrl", "G"]] } ] }, 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/interfaceLanguages.json b/src/common/interfaceLanguages.json index 18ab79fb6..a89b59f82 100644 --- a/src/common/interfaceLanguages.json +++ b/src/common/interfaceLanguages.json @@ -101,7 +101,7 @@ }, { "name": "Lietuvių", - "codes": ["lt-LT", "ltu"] + "codes": ["lt-LT", "lit"] }, { "name": "македонски јазик", 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/ActionsGroup/ActionsGroup.less b/src/components/ActionsGroup/ActionsGroup.less index 09e903435..b360ebdad 100644 --- a/src/components/ActionsGroup/ActionsGroup.less +++ b/src/components/ActionsGroup/ActionsGroup.less @@ -27,18 +27,27 @@ width: @width; padding: 0 1rem; cursor: pointer; + border-radius: 2rem; + outline: none; .icon { width: calc(@width / 2); height: calc(@height / 2); color: var(--primary-foreground-color); opacity: 0.7; + } - &:hover { + &:hover, &:focus { + .icon { opacity: 1; } } + &:focus-visible { + outline: 2px solid var(--primary-foreground-color); + outline-offset: -2px; + } + &.disabled { pointer-events: none; } diff --git a/src/components/ActionsGroup/ActionsGroup.tsx b/src/components/ActionsGroup/ActionsGroup.tsx index 052f25016..844efd3d0 100644 --- a/src/components/ActionsGroup/ActionsGroup.tsx +++ b/src/components/ActionsGroup/ActionsGroup.tsx @@ -28,6 +28,7 @@ const ActionsGroup = ({ items, className }: Props) => {
{ diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index a5756fc42..e1566c5d5 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -10,6 +10,7 @@ type Props = { style?: object, href?: string, target?: string + download?: string, title?: string, disabled?: boolean, tabIndex?: number, diff --git a/src/components/Chips/Chip/Chip.tsx b/src/components/Chips/Chip/Chip.tsx index cbb2487f7..4cde13bb4 100644 --- a/src/components/Chips/Chip/Chip.tsx +++ b/src/components/Chips/Chip/Chip.tsx @@ -33,7 +33,7 @@ const Chip = memo(({ label, value, active, onSelect }: Props) => { ref={ref} key={value} className={classNames(styles['chip'], { [styles['active']]: active })} - tabIndex={-1} + tabIndex={0} data-value={value} onClick={onClick} > diff --git a/src/components/ContextMenu/ContextMenu.tsx b/src/components/ContextMenu/ContextMenu.tsx index cb2d1f50c..1ac72949a 100644 --- a/src/components/ContextMenu/ContextMenu.tsx +++ b/src/components/ContextMenu/ContextMenu.tsx @@ -7,17 +7,20 @@ const PADDING = 8; type Coordinates = [number, number]; type Size = [number, number]; +type Lock = 'top' | 'right' | 'bottom' | 'left'; type Props = { children: React.ReactNode, on: RefObject[], autoClose: boolean, + lock?: Lock, }; -const ContextMenu = ({ children, on, autoClose }: Props) => { +const ContextMenu = ({ children, on, autoClose, lock }: Props) => { const [active, setActive] = useState(false); const [position, setPosition] = useState([0, 0]); const [containerSize, setContainerSize] = useState([0, 0]); + const [triggerRect, setTriggerRect] = useState(null); const ref = useCallback((element: HTMLDivElement) => { element && setContainerSize([element.offsetWidth, element.offsetHeight]); @@ -26,7 +29,32 @@ const ContextMenu = ({ children, on, autoClose }: Props) => { const style = useMemo(() => { const [viewportWidth, viewportHeight] = [window.innerWidth, window.innerHeight]; const [containerWidth, containerHeight] = containerSize; - const [x, y] = position; + + let x: number; + let y: number; + + if (lock && triggerRect) { + switch (lock) { + case 'top': + x = triggerRect.left; + y = triggerRect.top - containerHeight; + break; + case 'bottom': + x = triggerRect.left; + y = triggerRect.bottom; + break; + case 'left': + x = triggerRect.left - containerWidth; + y = triggerRect.top; + break; + case 'right': + x = triggerRect.right; + y = triggerRect.top; + break; + } + } else { + [x, y] = position; + } const left = Math.max( PADDING, @@ -45,7 +73,7 @@ const ContextMenu = ({ children, on, autoClose }: Props) => { ); return { top, left }; - }, [position, containerSize]); + }, [position, containerSize, lock, triggerRect]); const close = () => { setActive(false); @@ -55,12 +83,17 @@ const ContextMenu = ({ children, on, autoClose }: Props) => { event.stopPropagation(); }; - const onContextMenu = (event: MouseEvent) => { + const onContextMenu = useCallback((event: MouseEvent) => { event.preventDefault(); - setPosition([event.clientX, event.clientY]); + if (lock) { + const target = event.currentTarget as HTMLElement; + setTriggerRect(target.getBoundingClientRect()); + } else { + setPosition([event.clientX, event.clientY]); + } setActive(true); - }; + }, [lock]); const handleKeyDown = useCallback((event: KeyboardEvent) => event.key === 'Escape' && close(), []); @@ -76,7 +109,7 @@ const ContextMenu = ({ children, on, autoClose }: Props) => { on.forEach((ref) => ref.current && ref.current.removeEventListener('contextmenu', onContextMenu)); document.removeEventListener('keydown', handleKeyDown); }; - }, [on]); + }, [on, onContextMenu, handleKeyDown]); return createPortal(( diff --git a/src/components/MainNavBars/MainNavBars.tsx b/src/components/MainNavBars/MainNavBars.tsx index 43d08f5c8..1dae27a16 100644 --- a/src/components/MainNavBars/MainNavBars.tsx +++ b/src/components/MainNavBars/MainNavBars.tsx @@ -3,6 +3,7 @@ import React, { memo } from 'react'; import classnames from 'classnames'; import { VerticalNavBar, HorizontalNavBar } from 'stremio/components/NavBar'; +import { useContentGamepadNavigation, useVerticalNavGamepadNavigation } from 'stremio/services/GamepadNavigation'; import styles from './MainNavBars.less'; const TABS = [ @@ -22,6 +23,13 @@ type Props = { }; const MainNavBars = memo(({ className, route, query, children }: Props) => { + const navRef = React.useRef(null); + const contentRef = React.useRef(null); + + const navRoute = route === 'continue_watching' ? 'library' : (route ?? ''); + useContentGamepadNavigation(contentRef, navRoute); + useVerticalNavGamepadNavigation(navRef, navRoute); + return (
{ navMenu={true} /> -
{children}
+
{children}
); }); diff --git a/src/components/MetaPreview/MetaPreview.js b/src/components/MetaPreview/MetaPreview.js index 5fa7d8ff0..6fba9c098 100644 --- a/src/components/MetaPreview/MetaPreview.js +++ b/src/components/MetaPreview/MetaPreview.js @@ -159,7 +159,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou title={linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label} href={linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).href} target={'_blank'} - {...(compact ? { tabIndex: -1 } : null)} + tabIndex={0} >
{linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label}
@@ -214,7 +214,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou className={styles['action-button']} icon={'trailer'} label={t('TRAILER')} - tabIndex={compact ? -1 : 0} + tabIndex={0} href={trailerHref} tooltip={compact} /> @@ -232,7 +232,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou className={classnames(styles['action-button'], styles['show-button'])} icon={'play'} label={t('SHOW')} - tabIndex={compact ? -1 : 0} + tabIndex={0} href={showHref} /> : @@ -255,7 +255,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou icon={'share'} label={t('CTX_SHARE')} tooltip={true} - tabIndex={compact ? -1 : 0} + tabIndex={0} onClick={openShareModal} /> { diff --git a/src/components/MetaPreview/Ratings/Ratings.tsx b/src/components/MetaPreview/Ratings/Ratings.tsx index 329ee4945..8ba867800 100644 --- a/src/components/MetaPreview/Ratings/Ratings.tsx +++ b/src/components/MetaPreview/Ratings/Ratings.tsx @@ -1,6 +1,7 @@ // Copyright (C) 2017-2025 Smart code 203358507 import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; import useRating from './useRating'; import { ActionsGroup } from 'stremio/components'; @@ -11,17 +12,20 @@ type Props = { }; const Ratings = ({ ratingInfo, className }: Props) => { + const { t } = useTranslation(); const { onLiked, onLoved, liked, loved } = useRating(ratingInfo); const disabled = useMemo(() => ratingInfo?.type !== 'Ready', [ratingInfo]); const items = useMemo(() => [ { icon: liked ? 'thumbs-up' : 'thumbs-up-outline', + label: liked ? t('RATING_UNLIKE') : t('RATING_LIKE'), disabled, onClick: onLiked, }, { icon: loved ? 'heart' : 'heart-outline', + label: loved ? t('RATING_UNLOVE') : t('RATING_LOVE'), disabled, onClick: onLoved, }, diff --git a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js index 65bf30c94..b1644c2b3 100644 --- a/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js +++ b/src/components/NavBar/HorizontalNavBar/HorizontalNavBar.js @@ -5,14 +5,15 @@ 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 { useHorizontalNavGamepadNavigation } = require('stremio/services/GamepadNavigation'); const SearchBar = require('./SearchBar'); const NavMenu = require('./NavMenu'); const styles = require('./styles'); const { t } = require('i18next'); -const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, ...props }) => { +const HorizontalNavBar = React.memo(({ className, route, query, title, backButton, searchBar, fullscreenButton, navMenu, hdrInfo, ...props }) => { const backButtonOnClick = React.useCallback(() => { window.history.back(); }, []); @@ -24,6 +25,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto {children} ), []); + useHorizontalNavGamepadNavigation(route || className, backButton); return (