Merge branch 'development' into feat/shell-interface-size

This commit is contained in:
Timothy Z. 2026-05-01 20:06:01 +03:00 committed by GitHub
commit 40e46178bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 2636 additions and 519 deletions

View file

@ -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"
},

View file

@ -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:

View file

@ -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 = () => {
<ToastProvider className={styles['toasts-container']}>
<TooltipProvider className={styles['tooltip-container']}>
<FileDropProvider className={styles['file-drop-container']}>
<ShortcutsProvider onShortcut={onShortcut}>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</ShortcutsProvider>
<GamepadProvider enabled={gamepadSupportEnabled} onGuide={toggleGamepadModal}>
<ShortcutsProvider onShortcut={onShortcut}>
<FullscreenProvider>
{
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
}
{
gamepadModalOpen && <GamepadModal onClose={closeGamepadModal}/>
}
<ServicesToaster />
<DeepLinkHandler />
<SearchParamsHandler />
<UpdaterBanner className={styles['updater-banner-container']} />
<RouterWithProtectedRoutes
className={styles['router']}
viewsConfig={routerViewsConfig}
onPathNotMatch={onPathNotMatch}
/>
</FullscreenProvider>
</ShortcutsProvider>
</GamepadProvider>
</FileDropProvider>
</TooltipProvider>
</ToastProvider>

View file

@ -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<ControllerType, FaceLayout> = {
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<ActiveButton>(null);
const layout = LAYOUTS[gamepad?.controllerType ?? 'generic'];
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;
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 (
<svg className={styles['diagram']} viewBox={'0 0 800 510'} xmlns={'http://www.w3.org/2000/svg'}>
<defs>
<linearGradient id={'bodyGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
<stop offset={'0%'} stopColor={'#2a2545'} />
<stop offset={'100%'} stopColor={'#1a1530'} />
</linearGradient>
<linearGradient id={'triggerGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
<stop offset={'0%'} stopColor={'#1e1a35'} />
<stop offset={'100%'} stopColor={'#16122a'} />
</linearGradient>
<linearGradient id={'bumperGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
<stop offset={'0%'} stopColor={'#3d3660'} />
<stop offset={'100%'} stopColor={'#2a2545'} />
</linearGradient>
<filter id={'glow'} x={'-50%'} y={'-50%'} width={'200%'} height={'200%'}>
<feGaussianBlur stdDeviation={'4'} result={'blur'} />
<feMerge>
<feMergeNode in={'blur'} />
<feMergeNode in={'SourceGraphic'} />
</feMerge>
</filter>
</defs>
<g className={styles['anim-controls']}>
<path
d={`M${CX - SX - 38},68 Q${CX - SX - 40},48 ${CX - SX - 28},42 L${CX - SX + 28},42 Q${CX - SX + 40},48 ${CX - SX + 38},68 Z`}
fill={'url(#triggerGrad)'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.7'}
/>
<text x={CX - SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{layout.lt}</text>
<path
d={`M${CX + SX - 38},68 Q${CX + SX - 40},48 ${CX + SX - 28},42 L${CX + SX + 28},42 Q${CX + SX + 40},48 ${CX + SX + 38},68 Z`}
fill={'url(#triggerGrad)'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.7'}
/>
<text x={CX + SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{layout.rt}</text>
</g>
<path
className={styles['anim-body']}
d={
`M${CX - 178},${105 + BY}
Q${CX - 165},${80 + BY} ${CX - 95},${74 + BY}
L${CX + 95},${74 + BY}
Q${CX + 165},${80 + BY} ${CX + 178},${105 + BY}
L${CX + 195},${135 + BY}
Q${CX + 232},${172 + BY} ${CX + 252},${232 + BY}
Q${CX + 272},${298 + BY} ${CX + 255},${350 + BY}
Q${CX + 238},${390 + BY} ${CX + 203},${400 + BY}
Q${CX + 168},${410 + BY} ${CX + 150},${382 + BY}
L${CX + 113},${320 + BY}
Q${CX + 90},${284 + BY} ${CX},${284 + BY}
Q${CX - 90},${284 + BY} ${CX - 113},${320 + BY}
L${CX - 150},${382 + BY}
Q${CX - 168},${410 + BY} ${CX - 203},${400 + BY}
Q${CX - 238},${390 + BY} ${CX - 255},${350 + BY}
Q${CX - 272},${298 + BY} ${CX - 252},${232 + BY}
Q${CX - 232},${172 + BY} ${CX - 195},${135 + BY}
Z`
}
fill={'url(#bodyGrad)'}
stroke={'#3d3660'}
strokeWidth={'2.5'}
/>
<g className={styles['anim-controls']}>
<rect x={CX - 58} y={96 + BY} rx={'8'} ry={'8'} width={'116'} height={'48'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1.5'} />
<g filter={active === 'lb' ? 'url(#glow)' : undefined}>
<path
d={`M${CX - SX - 40},74 Q${CX - SX - 38},66 ${CX - SX - 30},64 L${CX - SX + 30},64 Q${CX - SX + 38},66 ${CX - SX + 40},74 L${CX - SX + 36},82 Q${CX - SX + 34},85 ${CX - SX + 28},85 L${CX - SX - 28},85 Q${CX - SX - 34},85 ${CX - SX - 36},82 Z`}
fill={'url(#bumperGrad)'} stroke={glow('lb') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('lb') || 0.9}
/>
<text x={CX - SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{layout.lb}</text>
</g>
<g filter={active === 'rb' ? 'url(#glow)' : undefined}>
<path
d={`M${CX + SX - 40},74 Q${CX + SX - 38},66 ${CX + SX - 30},64 L${CX + SX + 30},64 Q${CX + SX + 38},66 ${CX + SX + 40},74 L${CX + SX + 36},82 Q${CX + SX + 34},85 ${CX + SX + 28},85 L${CX + SX - 28},85 Q${CX + SX - 34},85 ${CX + SX - 36},82 Z`}
fill={'url(#bumperGrad)'} stroke={glow('rb') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('rb') || 0.9}
/>
<text x={CX + SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{layout.rb}</text>
</g>
<g filter={active === 'top' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX} cy={118 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('top') || '#5848a0'} strokeWidth={'1.5'} />
<text x={CX + BX} y={123 + BY} textAnchor={'middle'} fill={active === 'top' ? '#fff' : '#a89ecc'} fontSize={layout.top.fontSize} fontWeight={layout.top.weight}>{layout.top.glyph}</text>
</g>
<g filter={active === 'right' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX + 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('right') || '#5848a0'} strokeWidth={'1.5'} />
<text x={CX + BX + 30} y={153 + BY} textAnchor={'middle'} fill={active === 'right' ? '#fff' : '#a89ecc'} fontSize={layout.right.fontSize} fontWeight={layout.right.weight}>{layout.right.glyph}</text>
</g>
<g filter={active === 'bottom' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX} cy={178 + BY} r={'15'} fill={active === 'bottom' ? '#9b7fff' : '#7b5bf5'} stroke={'#9b7fff'} strokeWidth={'1.5'} />
<text x={CX + BX} y={183 + BY} textAnchor={'middle'} fill={'#fff'} fontSize={layout.bottom.fontSize} fontWeight={layout.bottom.weight}>{layout.bottom.glyph}</text>
</g>
<g filter={active === 'left' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX - 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('left') || '#5848a0'} strokeWidth={'1.5'} />
<text x={CX + BX - 30} y={153 + BY} textAnchor={'middle'} fill={active === 'left' ? '#fff' : '#a89ecc'} fontSize={layout.left.fontSize} fontWeight={layout.left.weight}>{layout.left.glyph}</text>
</g>
<rect x={dpadPos.cx - 12} y={dpadPos.cy - 29} rx={'3'} ry={'3'} width={'24'} height={'58'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
<rect x={dpadPos.cx - 29} y={dpadPos.cy - 12} rx={'3'} ry={'3'} width={'58'} height={'24'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
<g filter={active?.startsWith('stick-') ? 'url(#glow)' : undefined}>
<circle cx={lstickPos.cx} cy={lstickPos.cy} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('stick-') ? '#7b5bf5' : '#3d3660'} strokeWidth={'2'} />
<circle cx={lstickPos.cx} cy={lstickPos.cy} r={'17'} fill={'#252040'} stroke={'#4a4075'} strokeWidth={'1.5'} />
<text x={lstickPos.cx} y={lstickPos.cy - 8} textAnchor={'middle'} fill={active === 'stick-up' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-up' ? '700' : '400'}></text>
<text x={lstickPos.cx} y={lstickPos.cy + 13} textAnchor={'middle'} fill={active === 'stick-down' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-down' ? '700' : '400'}></text>
<text x={lstickPos.cx - 11} y={lstickPos.cy + 4} textAnchor={'middle'} fill={active === 'stick-left' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-left' ? '700' : '400'}></text>
<text x={lstickPos.cx + 11} y={lstickPos.cy + 4} textAnchor={'middle'} fill={active === 'stick-right' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-right' ? '700' : '400'}></text>
</g>
<g filter={active?.startsWith('rstick-') ? 'url(#glow)' : undefined}>
<circle cx={CX + STX} cy={240 + BY} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('rstick-') ? '#7b5bf5' : '#3d3660'} strokeWidth={'2'} />
<circle cx={CX + STX} cy={240 + BY} r={'17'} fill={'#252040'} stroke={'#4a4075'} strokeWidth={'1.5'} />
<text x={CX + STX} y={232 + BY} textAnchor={'middle'} fill={active === 'rstick-up' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-up' ? '700' : '400'}>{ARROW.UP}</text>
<text x={CX + STX} y={253 + BY} textAnchor={'middle'} fill={active === 'rstick-down' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-down' ? '700' : '400'}>{ARROW.DOWN}</text>
<text x={CX + STX - 11} y={244 + BY} textAnchor={'middle'} fill={active === 'rstick-left' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-left' ? '700' : '400'}>{ARROW.LEFT}</text>
<text x={CX + STX + 11} y={244 + BY} textAnchor={'middle'} fill={active === 'rstick-right' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-right' ? '700' : '400'}>{ARROW.RIGHT}</text>
</g>
</g>
<g className={styles['anim-lines']}>
<line x1={CX - SX - 40} y1={'74'} x2={'85'} y2={'48'} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'85'} cy={'48'} r={'2'} fill={'#5848a0'} />
<line x1={navLine.x1} y1={navLine.y1} x2={'85'} y2={168} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'85'} cy={168} r={'2'} fill={'#7b5bf5'} />
<line x1={CX + BX - 44} y1={148 + BY} x2={'85'} y2={248} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.35'} />
<circle cx={'85'} cy={248} r={'2'} fill={'#5848a0'} />
<line x1={CX + SX + 40} y1={'74'} x2={'715'} y2={'48'} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={'48'} r={'2'} fill={'#5848a0'} />
<line x1={CX + BX + 13} y1={112 + BY} x2={'715'} y2={108} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={108} r={'2'} fill={'#5848a0'} />
<line x1={CX + BX + 43} y1={142 + BY} x2={'715'} y2={148} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={148} r={'2'} fill={'#5848a0'} />
<line x1={CX + BX + 13} y1={184 + BY} x2={'715'} y2={208} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={208} r={'2'} fill={'#7b5bf5'} />
<line x1={CX + STX + 24} y1={234 + BY} x2={'715'} y2={268} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={268} r={'2'} fill={'#5848a0'} />
</g>
<g className={styles['anim-labels']}>
<text x={'80'} y={'44'} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_PREV_TAB')}</text>
<text x={'80'} y={164} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NAVIGATE')}</text>
<text x={'80'} y={244} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_GUIDE')}</text>
<text x={'80'} y={259} textAnchor={'end'} fill={'#8b7faa'} fontSize={'10'}>{t('GAMEPAD_LABEL_PLAY_PAUSE_PLAYER')}</text>
<text x={'720'} y={'44'} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NEXT_TAB')}</text>
<text x={'720'} y={104} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_FULLSCREEN')}</text>
<text x={'720'} y={144} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_BACK')}</text>
<text x={'720'} y={204} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_SELECT')}</text>
<text x={'720'} y={264} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_LABEL_SEEK_VOL')}</text>
<text x={CX} y={'475'} textAnchor={'middle'} fill={'#5848a0'} fontSize={'11'}>{t('GAMEPAD_LABEL_COMPAT')}</text>
</g>
</svg>
);
};
export default GamepadDiagram;

View file

@ -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<ControllerType, FaceLabels> = {
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((
<div className={styles['gamepad-modal']} data-gamepad-modal>
<div className={styles['backdrop']} onClick={onClose} />
<div className={styles['container']}>
<div className={styles['header']}>
<div className={styles['title']}>
{t('GAMEPAD_CONTROLS_TITLE')}
</div>
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
<div className={styles['content']}>
<GamepadDiagram />
<div className={styles['sections']}>
<div className={styles['section']}>
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_NAVIGATION')}</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.lStick}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_NAVIGATE')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.bottom}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_SELECT')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.right}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_BACK')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.top}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_FULLSCREEN')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.left}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_GUIDE')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.lb}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_PREV_TAB')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rb}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_NEXT_TAB')}</span>
</div>
</div>
<div className={styles['section']}>
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_PLAYER')}</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.left}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_PLAY_PAUSE')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{LEFT}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_BACK')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{RIGHT}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_FWD')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{UP}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_UP')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{DOWN}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_DOWN')}</span>
</div>
</div>
</div>
</div>
</div>
</div>
), document.body);
};
export default GamepadModal;

View file

@ -0,0 +1,4 @@
// Copyright (C) 2017-2026 Smart code 203358507
import GamepadModal from './GamepadModal';
export default GamepadModal;

View file

@ -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;
}
}
}

View file

@ -0,0 +1,16 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { createContext } from 'react';
export type FullscreenContextValue = readonly [
fullscreen: boolean,
requestFullscreen: () => Promise<void> | void,
exitFullscreen: () => void,
toggleFullscreen: () => void,
];
const FullscreenContext = createContext<FullscreenContextValue | null>(null);
FullscreenContext.displayName = 'FullscreenContext';
export default FullscreenContext;

View file

@ -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<boolean>(() => {
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<FullscreenContextValue>(
() => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen],
[fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen]
);
return (
<FullscreenContext.Provider value={value}>
{children}
</FullscreenContext.Provider>
);
};
export default withCoreSuspender(FullscreenProvider);

View file

@ -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;

View file

@ -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;

View file

@ -19,10 +19,20 @@ type Props = {
onShortcut: (name: ShortcutName) => void,
};
const REPEAT_THROTTLE_MS = 130;
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
const listeners = useRef<Map<ShortcutName, Set<ShortcutListener>>>(new Map());
const lastRepeatTime = useRef<Map<string, number>>(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);

View file

@ -27,6 +27,11 @@
"name": "shortcuts",
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
"combos": [["Ctrl", "/"]]
},
{
"name": "gamepadGuide",
"label": "GAMEPAD_ACTION_GUIDE",
"combos": [["Ctrl", "G"]]
}
]
},

View file

@ -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,

View file

@ -101,7 +101,7 @@
},
{
"name": "Lietuvių",
"codes": ["lt-LT", "ltu"]
"codes": ["lt-LT", "lit"]
},
{
"name": "македонски јазик",

View file

@ -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;

View file

@ -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;
}

View file

@ -28,6 +28,7 @@ const ActionsGroup = ({ items, className }: Props) => {
<div
key={index}
className={classNames(styles['icon-container'], item.className, { [styles['disabled']]: item.disabled })}
tabIndex={0}
onClick={item.onClick}
>
{

View file

@ -10,6 +10,7 @@ type Props = {
style?: object,
href?: string,
target?: string
download?: string,
title?: string,
disabled?: boolean,
tabIndex?: number,

View file

@ -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}
>

View file

@ -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<HTMLElement>[],
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<Coordinates>([0, 0]);
const [containerSize, setContainerSize] = useState<Size>([0, 0]);
const [triggerRect, setTriggerRect] = useState<DOMRect | null>(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((
<Transition when={active} name={'fade'}>

View file

@ -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 (
<div className={classnames(className, styles['main-nav-bars-container'])}>
<HorizontalNavBar
@ -34,11 +42,12 @@ const MainNavBars = memo(({ className, route, query, children }: Props) => {
navMenu={true}
/>
<VerticalNavBar
ref={navRef}
className={styles['vertical-nav-bar']}
selected={route}
tabs={TABS}
/>
<div className={styles['nav-content-container']}>{children}</div>
<div ref={contentRef} className={styles['nav-content-container']}>{children}</div>
</div>
);
});

View file

@ -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}
>
<div className={styles['label']}>{linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label}</div>
<Icon className={styles['icon']} name={'imdb'} />
@ -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}
/>
{

View file

@ -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,
},

View file

@ -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}
</Button>
), []);
useHorizontalNavGamepadNavigation(route || className, backButton);
return (
<nav {...props} className={classnames(className, styles['horizontal-nav-bar-container'])}>
{
@ -53,6 +55,14 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
null
}
<div className={styles['buttons-container']}>
{
hdrInfo && (hdrInfo.gamma === 'pq' || hdrInfo.gamma === 'hlg') ?
<div className={styles['hdr-indicator']} title={hdrInfo.gamma === 'pq' ? 'HDR10' : 'HLG'}>
<Icon className={styles['icon']} name={'hdr'} />
</div>
:
null
}
{
!isIOSPWA && fullscreenButton ?
<Button className={styles['button-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} tabIndex={-1} onClick={fullscreen ? exitFullscreen : requestFullscreen}>
@ -82,7 +92,10 @@ HorizontalNavBar.propTypes = {
backButton: PropTypes.bool,
searchBar: PropTypes.bool,
fullscreenButton: PropTypes.bool,
navMenu: PropTypes.bool
navMenu: PropTypes.bool,
hdrInfo: PropTypes.shape({
gamma: PropTypes.string,
}),
};
module.exports = HorizontalNavBar;

View file

@ -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');

View file

@ -57,10 +57,30 @@
.buttons-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0.5rem;
overflow: visible;
}
.hdr-indicator {
flex: none;
display: flex;
align-items: center;
justify-content: center;
height: 3.5rem;
padding: 0 0.5rem;
opacity: 0.6;
user-select: none;
.icon {
flex: none;
width: 3rem;
height: 1.5rem;
color: var(--primary-foreground-color);
opacity: 0.6;
}
}
.button-container {
flex: none;
display: flex;

View file

@ -7,10 +7,10 @@ const { useTranslation } = require('react-i18next');
const NavTabButton = require('./NavTabButton');
const styles = require('./styles');
const VerticalNavBar = React.memo(({ className, selected, tabs }) => {
const VerticalNavBar = React.memo(React.forwardRef(({ className, selected, tabs }, ref) => {
const { t } = useTranslation();
return (
<nav className={classnames(className, styles['vertical-nav-bar-container'])}>
<nav ref={ref} className={classnames(className, styles['vertical-nav-bar-container'])}>
{
Array.isArray(tabs) ?
tabs.map((tab, index) => (
@ -30,7 +30,7 @@ const VerticalNavBar = React.memo(({ className, selected, tabs }) => {
}
</nav>
);
});
}));
VerticalNavBar.displayName = 'VerticalNavBar';

View file

@ -5,6 +5,7 @@ const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { useServices } = require('stremio/services');
const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
const { withCoreSuspender } = require('stremio/common');
const { VerticalNavBar, HorizontalNavBar, DelayedRenderer, Image, MetaPreview, ModalDialog } = require('stremio/components');
const StreamsList = require('./StreamsList');
@ -15,6 +16,7 @@ const useMetaExtensionTabs = require('./useMetaExtensionTabs');
const styles = require('./styles');
const MetaDetails = ({ urlParams, queryParams }) => {
const contentRef = React.useRef(null);
const { t } = useTranslation();
const { core } = useServices();
const metaDetails = useMetaDetails(urlParams);
@ -111,6 +113,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
metaDetails.metaItem.content.content.background.length > 0
), [metaPath, metaDetails]);
useContentGamepadNavigation(contentRef, urlParams.path);
return (
<div className={styles['metadetails-container']}>
{
@ -132,7 +135,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
fullscreenButton={true}
navMenu={true}
/>
<div className={styles['metadetails-content']}>
<div ref={contentRef} className={styles['metadetails-content']}>
{
tabs.length > 0 ?
<VerticalNavBar
@ -238,6 +241,7 @@ const MetaDetails = ({ urlParams, queryParams }) => {
MetaDetails.propTypes = {
urlParams: PropTypes.shape({
path: PropTypes.string,
type: PropTypes.string,
id: PropTypes.string,
videoId: PropTypes.string

View file

@ -104,7 +104,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
{
video ?
<React.Fragment>
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={-1} onClick={backButtonOnClick}>
<Button className={classnames(styles['button-container'], styles['back-button-container'])} tabIndex={0} onClick={backButtonOnClick}>
<Icon className={styles['icon']} name={'chevron-back'} />
</Button>
<div className={styles['episode-title']}>

View file

@ -32,7 +32,7 @@
width: 100%;
height: 100%;
object-fit: cover;
object-position: top left;
object-position: right;
opacity: 0.3;
}
}
@ -137,9 +137,16 @@
@media only screen and (max-width: @minimum) {
.metadetails-container {
.background-image-layer {
.background-image {
object-position: center;
}
}
.metadetails-content {
display: block;
overflow-y: auto;
padding-top: calc(var(--top-overlay-size) + var(--safe-area-inset-top));
.spacing {
display: none;
@ -154,4 +161,4 @@
}
}
}
}
}

View file

@ -39,6 +39,9 @@ const ControlBar = React.forwardRef(({
onToggleSpeedMenu,
onToggleSideDrawer,
onToggleOptionsMenu,
videoScale,
videoScaleLabel,
onVideoScaleChanged,
onToggleStatisticsMenu,
onTouchEnd,
...props
@ -176,6 +179,9 @@ const ControlBar = React.forwardRef(({
:
null
}
<Button className={classnames(styles['control-bar-button'], { 'disabled': videoScale === null })} title={videoScaleLabel} tabIndex={-1} onClick={onVideoScaleChanged}>
<Icon className={styles['icon']} name={'scale'} />
</Button>
<Button className={classnames(styles['control-bar-button'], { 'disabled': !stream })} tabIndex={-1} onMouseDown={onOptionsButtonMouseDown} onClick={onToggleOptionsMenu}>
<Icon className={styles['icon']} name={'more-horizontal'} />
</Button>
@ -194,6 +200,9 @@ ControlBar.propTypes = {
volume: PropTypes.number,
muted: PropTypes.bool,
playbackSpeed: PropTypes.number,
videoScale: PropTypes.string,
videoScaleLabel: PropTypes.string,
onVideoScaleChanged: PropTypes.func,
subtitlesTracks: PropTypes.array,
audioTracks: PropTypes.array,
metaItem: PropTypes.object,

View file

@ -14,12 +14,15 @@
.label {
flex: none;
width: 6rem;
width: 5.5rem;
white-space: nowrap;
text-overflow: ellipsis;
direction: rtl;
text-align: center;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--primary-foreground-color);
opacity: 0.9;
}
.slider {
@ -33,7 +36,8 @@
.slider-thumb {
background-color: var(--primary-accent-color);
transition: transform 150ms ease;
&:after {
content: "";
position: absolute;
@ -46,5 +50,9 @@
filter: brightness(130%);
}
}
&:hover .slider-thumb {
transform: translateX(-50%) scale(1.2);
}
}
}

View file

@ -5,20 +5,42 @@
:import('~stremio/components/Slider/styles.less') {
slider-track: track;
slider-track-after: track-after;
slider-thumb: thumb;
}
.volume-slider:not(:global(.disabled)) {
.slider-track {
background-color: var(--overlay-color);
opacity: 1;
}
.slider-track-after {
background-color: var(--primary-foreground-color);
}
.slider-thumb {
background-color: @color-secondaryvariant1-light4;
transition: transform 150ms ease;
&:after {
content: "";
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
border-radius: 100%;
box-shadow: 0 0 0 0.25rem white inset;
}
}
&:hover, &:global(.active) {
.slider-track-after {
background-color: var(--primary-foreground-color);
}
.slider-thumb {
transform: translateX(-50%) scale(1.2);
}
}
}

View file

@ -4,11 +4,11 @@
@import (reference) '~stremio/common/screen-sizes.less';
.control-bar-container {
padding: 0 1.5rem;
padding: 0 2rem;
.seek-bar {
--track-size: 0.5rem;
--thumb-size: 1.3rem;
--track-size: 0.4rem;
--thumb-size: 1.2rem;
height: 2.5rem;
}
@ -17,26 +17,34 @@
display: flex;
flex-direction: row;
align-items: center;
gap: 0.25rem;
.control-bar-button {
flex: none;
width: 4rem;
height: 5rem;
height: 4rem;
display: flex;
justify-content: center;
align-items: center;
border-radius: 0.75rem;
transition: background-color 150ms ease;
&:hover:not(:global(.disabled)) {
background-color: var(--overlay-color);
}
&:global(.disabled) {
.icon {
opacity: 0.5;
opacity: 0.4;
}
}
.icon {
flex: none;
width: 2.5rem;
height: 2.5rem;
width: 2.2rem;
height: 2.2rem;
color: var(--primary-foreground-color);
transition: transform 100ms ease;
}
}
@ -46,7 +54,7 @@
flex: 0 1 10rem;
height: 4rem;
margin: 0 1rem;
margin: 0 0.5rem;
}
.spacing {
@ -60,11 +68,17 @@
display: none;
justify-content: center;
align-items: center;
border-radius: 0.75rem;
transition: background-color 150ms ease;
&:hover {
background-color: var(--overlay-color);
}
.icon {
flex: none;
width: 2.5rem;
height: 2.5rem;
width: 2.2rem;
height: 2.2rem;
color: var(--primary-foreground-color);
}
}
@ -73,6 +87,7 @@
flex: none;
display: flex;
flex-direction: row;
gap: 0.25rem;
}
}
}
@ -89,6 +104,7 @@
position: relative;
padding: 0 0.5rem;
overflow: visible;
gap: 0.15rem;
.volume-slider {
display: none;
@ -104,6 +120,7 @@
bottom: 4.5rem;
padding: 0.5rem;
margin: 0.5rem;
gap: 0.15rem;
max-width: calc(100dvw - 1rem);
border-radius: var(--border-radius);
background-color: var(--modal-background-color);

View file

@ -7,7 +7,13 @@ import styles from './Indicator.less';
type Property = {
label: string,
format: (value: number) => string,
format: (value: number | string) => string,
};
const VIDEO_SCALE_KEYS: Record<string, string> = {
'contain': 'PLAYER_SCALE_FIT',
'cover': 'PLAYER_SCALE_CROP',
'fill': 'PLAYER_SCALE_STRETCH',
};
const PROPERTIES: Record<string, Property> = {
@ -15,9 +21,13 @@ const PROPERTIES: Record<string, Property> = {
label: 'SUBTITLES_DELAY',
format: (value) => `${(value / 1000).toFixed(2)}s`,
},
'videoScale': {
label: 'VIDEO_SCALE',
format: (value) => t(VIDEO_SCALE_KEYS[String(value)] || String(value)),
},
};
type VideoState = Record<string, number>;
type VideoState = Record<string, number | string>;
type Props = {
className: string,
@ -28,6 +38,7 @@ type Props = {
const Indicator = ({ className, videoState, disabled }: Props) => {
const timeout = useRef<NodeJS.Timeout | null>(null);
const prevVideoState = useRef<VideoState>(videoState);
const initialized = useRef<Set<string>>(new Set());
const [shown, show, hide] = useBinaryState(false);
const [current, setCurrent] = useState<string | null>(null);
@ -49,11 +60,15 @@ const Indicator = ({ className, videoState, disabled }: Props) => {
const next = videoState[property];
if (next && next !== prev) {
setCurrent(property);
show();
if (!initialized.current.has(property)) {
initialized.current.add(property);
} else {
setCurrent(property);
show();
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(hide, 1000);
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(hide, 1000);
}
}
}

View file

@ -7,8 +7,9 @@ const debounce = require('lodash.debounce');
const langs = require('langs');
const { useTranslation } = require('react-i18next');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { onFileDrop, useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform, onShortcut } = require('stremio/common');
const { useServices, useGamepad } = require('stremio/services');
const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, useShell, usePlatform, onShortcut } = require('stremio/common');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const BufferingLoader = require('./BufferingLoader');
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
@ -25,17 +26,22 @@ const { default: SideDrawer } = require('./SideDrawer');
const usePlayer = require('./usePlayer');
const useStatistics = require('./useStatistics');
const useVideo = require('./useVideo');
const { default: useSubtitles } = require('./useSubtitles');
const styles = require('./styles');
const Video = require('./Video');
const { default: Indicator } = require('./Indicator/Indicator');
const { default: useMediaSession } = require('./useMediaSession');
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
const findTrackById = (tracks, id) => tracks.find((track) => track.id === id);
const GAMEPAD_HANDLER_ID = 'player';
const Player = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
const services = useServices();
const shell = useShell();
const gamepad = useGamepad();
const forceTranscoding = React.useMemo(() => {
return queryParams.has('forceTranscoding');
}, [queryParams]);
@ -56,6 +62,7 @@ const Player = ({ urlParams, queryParams }) => {
});
const playbackDevices = React.useMemo(() => streamingServer.playbackDevices !== null && streamingServer.playbackDevices.type === 'Ready' ? streamingServer.playbackDevices.content : [], [streamingServer]);
const playerRef = React.useRef(null);
const bufferingRef = React.useRef();
const errorRef = React.useRef();
@ -84,33 +91,43 @@ const Player = ({ urlParams, queryParams }) => {
closeSideDrawer();
}, []);
const {
streamSubtitles,
allSubtitleTracks,
extraSubtitleTracks,
selectedExtraSubtitleTrackId,
subtitlesMenuProps,
} = useSubtitles({
player,
video,
settings,
streamStateChanged,
menusOpen,
closeMenus,
closeSubtitlesMenu,
toggleSubtitlesMenu,
});
const overlayHidden = React.useMemo(() => {
return immersed && !casting && video.state.paused !== null && !video.state.paused && !menusOpen;
}, [immersed, casting, video.state.paused, menusOpen]);
const nextVideoPopupDismissed = React.useRef(false);
const defaultSubtitlesSelected = React.useRef(false);
const subtitlesEnabled = React.useRef(true);
const defaultAudioTrackSelected = React.useRef(false);
const playingOnExternalDevice = React.useRef(false);
const [error, setError] = React.useState(null);
const isNavigating = React.useRef(false);
const VIDEO_SCALES = ['contain', 'cover', 'fill'];
const VIDEO_SCALE_LABELS = { contain: t('PLAYER_SCALE_FIT'), cover: t('PLAYER_SCALE_CROP'), fill: t('PLAYER_SCALE_STRETCH') };
const playbackSpeed = React.useRef(video.state.playbackSpeed || 1);
const pressTimer = React.useRef(null);
const longPress = React.useRef(false);
const controlBarRef = React.useRef(null);
const HOLD_DELAY = 200;
const onImplementationChanged = React.useCallback(() => {
video.setSubtitlesSize(settings.subtitlesSize);
video.setSubtitlesOffset(settings.subtitlesOffset);
video.setSubtitlesTextColor(settings.subtitlesTextColor);
video.setSubtitlesBackgroundColor(settings.subtitlesBackgroundColor);
video.setSubtitlesOutlineColor(settings.subtitlesOutlineColor);
}, [settings]);
const HOLD_DELAY = 400;
const handleNextVideoNavigation = React.useCallback((deepLinks, bingeWatching, ended) => {
if (ended) {
@ -169,33 +186,6 @@ const Player = ({ urlParams, queryParams }) => {
}
}, []);
const onSubtitlesTrackLoaded = React.useCallback(() => {
toast.show({
type: 'success',
title: t('PLAYER_SUBTITLES_LOADED'),
message: t('PLAYER_SUBTITLES_LOADED_EMBEDDED'),
timeout: 3000
});
}, []);
const onExtraSubtitlesTrackLoaded = React.useCallback((track) => {
toast.show({
type: 'success',
title: t('PLAYER_SUBTITLES_LOADED'),
message:
track.exclusive ? t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE') :
track.local ? t('PLAYER_SUBTITLES_LOADED_LOCAL') :
t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }),
timeout: 3000
});
}, []);
const onExtraSubtitlesTrackAdded = React.useCallback((track) => {
if (track.local) {
video.setExtraSubtitlesTrack(track.id);
}
}, []);
const onPlayRequested = React.useCallback(() => {
playingOnExternalDevice.current = false;
video.setPaused(false);
@ -235,19 +225,12 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
const onSubtitlesTrackSelected = React.useCallback((track) => {
video.setSubtitlesTrack(track?.id ?? null);
streamStateChanged({
subtitleTrack: track ? { id: track.id, embedded: true, lang: track.lang } : null,
});
}, [streamStateChanged]);
const onExtraSubtitlesTrackSelected = React.useCallback((track) => {
video.setExtraSubtitlesTrack(track?.id ?? null);
streamStateChanged({
subtitleTrack: track ? { id: track.id, embedded: false, lang: track.lang } : null,
});
}, [streamStateChanged]);
const onVideoScaleChanged = React.useCallback(() => {
const currentScale = video.state.videoScale || 'contain';
const currentIndex = VIDEO_SCALES.indexOf(currentScale);
const nextScale = VIDEO_SCALES[(currentIndex + 1) % VIDEO_SCALES.length];
video.setVideoScale(nextScale);
}, [video.state.videoScale]);
const onAudioTrackSelected = React.useCallback((id) => {
video.setAudioTrack(id);
@ -258,37 +241,6 @@ const Player = ({ urlParams, queryParams }) => {
});
}, [streamStateChanged]);
const onExtraSubtitlesDelayChanged = React.useCallback((delay) => {
video.setSubtitlesDelay(delay);
streamStateChanged({ subtitleDelay: delay });
}, [streamStateChanged]);
const onIncreaseSubtitlesDelay = React.useCallback(() => {
const delay = video.state.extraSubtitlesDelay + 250;
onExtraSubtitlesDelayChanged(delay);
}, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]);
const onDecreaseSubtitlesDelay = React.useCallback(() => {
const delay = video.state.extraSubtitlesDelay - 250;
onExtraSubtitlesDelayChanged(delay);
}, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]);
const onSubtitlesSizeChanged = React.useCallback((size) => {
video.setSubtitlesSize(size);
streamStateChanged({ subtitleSize: size });
}, [streamStateChanged]);
const onUpdateSubtitlesSize = React.useCallback((delta) => {
const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(video.state.subtitlesSize);
const size = CONSTANTS.SUBTITLES_SIZES[Math.max(0, Math.min(CONSTANTS.SUBTITLES_SIZES.length - 1, sizeIndex + delta))];
onSubtitlesSizeChanged(size);
}, [video.state.subtitlesSize, onSubtitlesSizeChanged]);
const onSubtitlesOffsetChanged = React.useCallback((offset) => {
video.setSubtitlesOffset(offset);
streamStateChanged({ subtitleOffset: offset });
}, [streamStateChanged]);
const onDismissNextVideoPopup = React.useCallback(() => {
closeNextVideoPopup();
nextVideoPopupDismissed.current = true;
@ -357,9 +309,78 @@ const Player = ({ urlParams, queryParams }) => {
event.nativeEvent.immersePrevented = true;
}, []);
onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, async (filename, buffer) => {
video.addLocalSubtitles(filename, buffer);
});
const onPlayPause = React.useCallback(() => {
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
}
}, [menusOpen, nextVideoPopupOpen, video.state.paused]);
const onSeekPrev = React.useCallback((event) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
const seekTime = video.state.time - seekDuration;
setSeeking(true);
onSeekRequested(Math.max(seekTime, 0));
}
}, [menusOpen, nextVideoPopupOpen, video.state.time]);
const onSeekNext = React.useCallback((event) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event?.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
}, [menusOpen, nextVideoPopupOpen, video.state.time]);
const onVolumeUp = React.useCallback(() => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
const onVolumeDown = React.useCallback(() => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
const onGamepadSeekAndVol = React.useCallback((axis) => {
switch(axis) {
case 'left': {
onSeekPrev();
break;
}
case 'right': {
onSeekNext();
break;
}
case 'up': {
onVolumeUp();
break;
}
case 'down': {
onVolumeDown();
break;
}
}
}, [onSeekPrev, onSeekNext, onVolumeUp, onVolumeDown]);
useContentGamepadNavigation(playerRef, GAMEPAD_HANDLER_ID);
React.useEffect(() => {
gamepad?.on('buttonX', GAMEPAD_HANDLER_ID, onPlayPause);
gamepad?.on('analogRight', GAMEPAD_HANDLER_ID, onGamepadSeekAndVol);
return () => {
gamepad?.off('buttonX', GAMEPAD_HANDLER_ID);
gamepad?.off('analogRight', GAMEPAD_HANDLER_ID);
};
}, [onPlayPause, onGamepadSeekAndVol]);
React.useEffect(() => {
setError(null);
@ -369,13 +390,7 @@ const Player = ({ urlParams, queryParams }) => {
video.load({
stream: {
...player.stream.content,
subtitles: Array.isArray(player.selected.stream.subtitles) ?
player.selected.stream.subtitles.map((subtitles) => ({
...subtitles,
label: subtitles.label || subtitles.url
}))
:
[]
subtitles: streamSubtitles
},
autoplay: true,
time: player.libraryItem !== null &&
@ -404,16 +419,7 @@ const Player = ({ urlParams, queryParams }) => {
shellTransport: services.shell.active ? services.shell.transport : null,
});
}
}, [streamingServer.baseUrl, player.selected, player.stream, forceTranscoding, casting]);
React.useEffect(() => {
if (video.state.stream !== null) {
const tracks = player.subtitles.map((subtitles) => ({
...subtitles,
label: subtitles.label || subtitles.url
}));
video.addExtraSubtitlesTracks(tracks);
}
}, [player.subtitles, video.state.stream]);
}, [streamingServer.baseUrl, player.selected, player.stream, streamSubtitles, forceTranscoding, casting]);
React.useEffect(() => {
!seeking && timeChanged(video.state.time, video.state.duration, video.state.manifest?.name);
@ -449,39 +455,6 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [player.nextVideo, video.state.time, video.state.duration]);
// Auto subtitles track selection
React.useEffect(() => {
if (!defaultSubtitlesSelected.current) {
if (settings.subtitlesLanguage === null) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
defaultSubtitlesSelected.current = true;
return;
}
const savedTrackId = player.streamState?.subtitleTrack?.id;
const savedLang = player.streamState?.subtitleTrack?.lang;
const subtitlesTrack =
savedTrackId ? findTrackById(video.state.subtitlesTracks, savedTrackId) :
savedLang ? findTrackByLang(video.state.subtitlesTracks, savedLang) :
findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
const extraSubtitlesTrack =
savedTrackId ? findTrackById(video.state.extraSubtitlesTracks, savedTrackId) :
savedLang ? findTrackByLang(video.state.extraSubtitlesTracks, savedLang) :
findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
if (subtitlesTrack && subtitlesTrack.id) {
video.setSubtitlesTrack(subtitlesTrack.id);
defaultSubtitlesSelected.current = true;
} else if (extraSubtitlesTrack && extraSubtitlesTrack.id) {
video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
defaultSubtitlesSelected.current = true;
}
}
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, player.streamState]);
// Auto audio track selection
React.useEffect(() => {
if (!defaultAudioTrackSelected.current) {
@ -497,28 +470,7 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [video.state.audioTracks, player.streamState]);
// Saved subtitles settings
React.useEffect(() => {
if (video.state.stream !== null) {
const delay = player.streamState?.subtitleDelay;
if (typeof delay === 'number') {
video.setSubtitlesDelay(delay);
}
const size = player.streamState?.subtitleSize;
if (typeof size === 'number') {
video.setSubtitlesSize(size);
}
const offset = player.streamState?.subtitleOffset;
if (typeof offset === 'number') {
video.setSubtitlesOffset(offset);
}
}
}, [video.state.stream, player.streamState]);
React.useEffect(() => {
defaultSubtitlesSelected.current = false;
defaultAudioTrackSelected.current = false;
nextVideoPopupDismissed.current = false;
playingOnExternalDevice.current = false;
@ -527,13 +479,6 @@ const Player = ({ urlParams, queryParams }) => {
setTimeout(() => isNavigating.current = false, 1000);
}, [video.state.stream]);
React.useEffect(() => {
if ((!Array.isArray(video.state.subtitlesTracks) || video.state.subtitlesTracks.length === 0) &&
(!Array.isArray(video.state.extraSubtitlesTracks) || video.state.extraSubtitlesTracks.length === 0)) {
closeSubtitlesMenu();
}
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]);
React.useEffect(() => {
if (!Array.isArray(video.state.audioTracks) || video.state.audioTracks.length === 0) {
closeAudioMenu();
@ -589,63 +534,30 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [settings.pauseOnMinimize, shell.windowClosed, shell.windowHidden]);
// Media Session PlaybackState
useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested);
React.useEffect(() => {
if (!navigator.mediaSession) return;
const playbackState = !video.state.paused ? 'playing' : 'paused';
navigator.mediaSession.playbackState = playbackState;
return () => navigator.mediaSession.playbackState = 'none';
}, [video.state.paused]);
// Media Session Metadata
React.useEffect(() => {
if (!navigator.mediaSession) return;
const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content : null;
const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null;
const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})` : null;
const videoTitle = video ? `${video.title}${videoInfo}` : null;
const metaTitle = metaItem ? metaItem.name : null;
const imageUrl = metaItem ? metaItem.logo : null;
const title = videoTitle ?? metaTitle;
const artist = videoTitle ? metaTitle : undefined;
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
if (title) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
artwork,
});
}
}, [player.metaItem, player.selected]);
// Media Session Actions
React.useEffect(() => {
if (!navigator.mediaSession) return;
navigator.mediaSession.setActionHandler('play', onPlayRequested);
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
onShortcut('playPause', () => {
if (video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else if (!pressTimer.current) {
onPauseRequested();
const onMediaKey = (action) => {
switch (action) {
case 'play-pause':
video.state.paused ? onPlayRequested() : onPauseRequested();
break;
case 'next-track':
if (player.nextVideo !== null) {
video.setTime(0);
onNextVideoRequested();
}
break;
case 'previous-track':
if (video.state.time !== null && video.state.time > 5000) {
onSeekRequested(0);
}
break;
}
}
}, [video.state.paused, pressTimer.current, onPlayRequested, onPauseRequested], !menusOpen);
};
shell.on('media-key', onMediaKey);
return () => shell.off('media-key', onMediaKey);
}, [video.state.paused, video.state.time, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]);
onShortcut('seekForward', (combo) => {
if (video.state.time !== null) {
@ -675,38 +587,10 @@ const Player = ({ urlParams, queryParams }) => {
onShortcut('volumeDown', () => {
if (video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume - 5, 200));
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
}, [video.state.volume], !menusOpen);
onShortcut('subtitlesDelay', (combo) => {
combo === 1 ? onIncreaseSubtitlesDelay() : onDecreaseSubtitlesDelay();
}, [onIncreaseSubtitlesDelay, onDecreaseSubtitlesDelay], !menusOpen);
onShortcut('subtitlesSize', (combo) => {
combo === 1 ? onUpdateSubtitlesSize(-1) : onUpdateSubtitlesSize(1);
}, [onUpdateSubtitlesSize, onUpdateSubtitlesSize], !menusOpen);
onShortcut('toggleSubtitles', () => {
const savedTrack = player.streamState?.subtitleTrack;
if (subtitlesEnabled.current) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
} else if (savedTrack?.id) {
savedTrack.embedded ? video.setSubtitlesTrack(savedTrack.id) : video.setExtraSubtitlesTrack(savedTrack.id);
}
subtitlesEnabled.current = !subtitlesEnabled.current;
}, [player.streamState], !menusOpen);
onShortcut('subtitlesMenu', () => {
closeMenus();
if (video.state?.subtitlesTracks?.length > 0 || video.state?.extraSubtitlesTracks?.length > 0) {
toggleSubtitlesMenu();
}
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, toggleSubtitlesMenu]);
onShortcut('audioMenu', () => {
closeMenus();
if (video.state?.audioTracks?.length > 0) {
@ -743,7 +627,7 @@ const Player = ({ urlParams, queryParams }) => {
onShortcut('statisticsMenu', () => {
closeMenus();
const stream = player.selected?.stream;
if (streamingServer?.statistics?.type !== 'Err' && typeof stream === 'string' && typeof stream === 'number') {
if (streamingServer?.statistics?.type !== 'Err' && typeof stream?.infoHash === 'string' && typeof stream?.fileIdx === 'number') {
toggleStatisticsMenu();
}
}, [player.selected, streamingServer.statistics, toggleStatisticsMenu]);
@ -791,7 +675,17 @@ const Player = ({ urlParams, queryParams }) => {
if (e.code === 'Space') {
clearTimeout(pressTimer.current);
pressTimer.current = null;
onPlaybackSpeedChanged(playbackSpeed.current);
if (longPress.current) {
onPlaybackSpeedChanged(playbackSpeed.current);
} else if (!menusOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
}
longPress.current = false;
}
};
@ -830,12 +724,23 @@ const Player = ({ urlParams, queryParams }) => {
}
};
const onBlur = () => {
clearTimeout(pressTimer.current);
pressTimer.current = null;
if (longPress.current) {
onPlaybackSpeedChanged(playbackSpeed.current);
longPress.current = false;
}
setSeeking(false);
};
if (routeFocused) {
window.addEventListener('keyup', onKeyUp);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('wheel', onWheel);
window.addEventListener('mousedown', onMouseDownHold);
window.addEventListener('mouseup', onMouseUp);
window.addEventListener('blur', onBlur);
}
return () => {
window.removeEventListener('keyup', onKeyUp);
@ -843,24 +748,17 @@ const Player = ({ urlParams, queryParams }) => {
window.removeEventListener('wheel', onWheel);
window.removeEventListener('mousedown', onMouseDownHold);
window.removeEventListener('mouseup', onMouseUp);
window.removeEventListener('blur', onBlur);
};
}, [routeFocused, menusOpen, video.state.volume]);
}, [routeFocused, menusOpen, video.state.volume, video.state.paused]);
React.useEffect(() => {
video.events.on('error', onError);
video.events.on('ended', onEnded);
video.events.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded);
video.events.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
video.events.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
video.events.on('implementationChanged', onImplementationChanged);
return () => {
video.events.off('error', onError);
video.events.off('ended', onEnded);
video.events.off('subtitlesTrackLoaded', onSubtitlesTrackLoaded);
video.events.off('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
video.events.off('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
video.events.off('implementationChanged', onImplementationChanged);
};
}, []);
@ -873,7 +771,7 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
return (
<div className={classnames(styles['player-container'], { [styles['overlayHidden']]: overlayHidden })}
<div ref={playerRef} className={classnames(styles['player-container'], { [styles['overlayHidden']]: overlayHidden })}
onMouseDown={onContainerMouseDown}
onMouseMove={onContainerMouseMove}
onMouseOver={onContainerMouseMove}
@ -933,8 +831,8 @@ const Player = ({ urlParams, queryParams }) => {
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player?.selected?.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
extraSubtitlesTracks={extraSubtitleTracks}
selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId}
/>
</ContextMenu>
<HorizontalNavBar
@ -942,6 +840,7 @@ const Player = ({ urlParams, queryParams }) => {
title={player.title !== null ? player.title : ''}
backButton={true}
fullscreenButton={true}
hdrInfo={video.state.hdrInfo}
onMouseMove={onBarMouseMove}
onMouseOver={onBarMouseMove}
/>
@ -964,7 +863,7 @@ const Player = ({ urlParams, queryParams }) => {
volume={video.state.volume}
muted={video.state.muted}
playbackSpeed={video.state.playbackSpeed}
subtitlesTracks={video.state.subtitlesTracks.concat(video.state.extraSubtitlesTracks)}
subtitlesTracks={allSubtitleTracks}
audioTracks={video.state.audioTracks}
metaItem={player.metaItem}
nextVideo={player.nextVideo}
@ -981,6 +880,9 @@ const Player = ({ urlParams, queryParams }) => {
onToggleSubtitlesMenu={toggleSubtitlesMenu}
onToggleAudioMenu={toggleAudioMenu}
onToggleSpeedMenu={toggleSpeedMenu}
videoScale={video.state.videoScale}
videoScaleLabel={VIDEO_SCALE_LABELS[video.state.videoScale || 'contain']}
onVideoScaleChanged={onVideoScaleChanged}
onToggleStatisticsMenu={toggleStatisticsMenu}
onToggleSideDrawer={toggleSideDrawer}
onMouseMove={onBarMouseMove}
@ -1022,24 +924,7 @@ const Player = ({ urlParams, queryParams }) => {
<Transition when={subtitlesMenuOpen} name={'fade'}>
<SubtitlesMenu
className={classnames(styles['layer'], styles['menu-layer'])}
subtitlesLanguage={settings.subtitlesLanguage}
interfaceLanguage={settings.interfaceLanguage}
subtitlesTracks={video.state.subtitlesTracks}
selectedSubtitlesTrackId={video.state.selectedSubtitlesTrackId}
subtitlesOffset={video.state.subtitlesOffset}
subtitlesSize={video.state.subtitlesSize}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
extraSubtitlesOffset={video.state.extraSubtitlesOffset}
extraSubtitlesDelay={video.state.extraSubtitlesDelay}
extraSubtitlesSize={video.state.extraSubtitlesSize}
onSubtitlesTrackSelected={onSubtitlesTrackSelected}
onExtraSubtitlesTrackSelected={onExtraSubtitlesTrackSelected}
onSubtitlesOffsetChanged={onSubtitlesOffsetChanged}
onSubtitlesSizeChanged={onSubtitlesSizeChanged}
onExtraSubtitlesOffsetChanged={onSubtitlesOffsetChanged}
onExtraSubtitlesDelayChanged={onExtraSubtitlesDelayChanged}
onExtraSubtitlesSizeChanged={onSubtitlesSizeChanged}
{...subtitlesMenuProps}
/>
</Transition>
<Transition when={audioMenuOpen} name={'fade'}>
@ -1062,8 +947,8 @@ const Player = ({ urlParams, queryParams }) => {
className={classnames(styles['layer'], styles['menu-layer'])}
stream={player.selected?.stream}
playbackDevices={playbackDevices}
extraSubtitlesTracks={video.state.extraSubtitlesTracks}
selectedExtraSubtitlesTrackId={video.state.selectedExtraSubtitlesTrackId}
extraSubtitlesTracks={extraSubtitleTracks}
selectedExtraSubtitlesTrackId={selectedExtraSubtitleTrackId}
/>
</Transition>
</div>

View file

@ -26,6 +26,17 @@
transition: transform 0.3s ease-in-out;
z-index: 1;
// Safari has a compositing bug where transform animations on a parent with
// scrollable children causes the video player element to shift left during the animation.
// Disable the slide animation on Safari until WebKit resolves this.
@supports (hanging-punctuation: first) and (-webkit-appearance: none) {
&:global(.slide-left-enter),
&:global(.slide-left-active),
&:global(.slide-left-exit) {
transition: none;
}
}
.close-button {
display: none;
position: absolute;

View file

@ -0,0 +1,82 @@
// Copyright (C) 2017-2026 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.variant-option {
display: flex;
flex-direction: row;
align-items: center;
height: 4rem;
padding: 0 1.5rem;
margin-bottom: 0.5rem;
border-radius: var(--border-radius);
&:global(.selected), &:hover {
background-color: var(--overlay-color);
}
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
.variant-label {
flex: 1;
font-size: 1.1rem;
line-height: 1.5rem;
color: var(--primary-foreground-color);
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
.variant-origin {
font-size: 0.9rem;
color: var(--color-placeholder-text);
text-wrap: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
.icon {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
}
}
.context-menu-option {
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
min-width: 16rem;
padding: 1.25rem 1.5rem;
&:hover, &:focus {
background-color: var(--overlay-color);
}
.menu-icon {
flex: none;
width: 1.4rem;
height: 1.4rem;
color: var(--color-placeholder);
}
.context-menu-option-label {
flex: 1;
min-width: 0;
font-size: 1rem;
font-weight: 400;
color: var(--primary-foreground-color);
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}

View file

@ -0,0 +1,136 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, ContextMenu } from 'stremio/components';
import { languages, useToast } from 'stremio/common';
import classNames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
import styles from './SubtitleVariant.less';
type SubtitlesTrack = {
id: string,
addonSubtitleId?: string,
lang: string,
origin: string,
label?: string,
url?: string,
fallbackUrl?: string,
embedded?: boolean,
local?: boolean,
exclusive?: boolean,
};
type Props = {
track: SubtitlesTrack,
selected: boolean,
onSelect: (track: SubtitlesTrack) => void,
};
const hasValidLabel = (label?: string) => label && label.length > 0 && !label.startsWith('http');
const SubtitleVariant = ({ track, selected, onSelect }: Props) => {
const { t } = useTranslation();
const toast = useToast();
const buttonRef = useRef<HTMLElement>(null);
const triggers = useMemo(() => [buttonRef], []);
const downloadUrl = track.fallbackUrl || track.url;
const variantLabel = hasValidLabel(track.label) ? track.label : languages.label(track.lang);
const downloadFileName = hasValidLabel(track.label) ? track.label : `subtitle-${track.lang || 'unknown'}`;
const canCopyUrl = typeof downloadUrl === 'string' && !downloadUrl.startsWith('blob:');
const hoverTitle = hasValidLabel(track.label)
? track.label
: downloadUrl?.split('/').pop()?.split('?')[0] || variantLabel;
const onSelectClick = useCallback(() => {
onSelect(track);
}, [onSelect, track]);
const copyToClipboard = useCallback((value: string, successKey: string, errorKey: string) => {
navigator.clipboard.writeText(value)
.then(() => toast.show({ type: 'success', title: t(successKey), timeout: 4000 }))
.catch(() => toast.show({ type: 'error', title: t(errorKey), timeout: 4000 }));
}, [toast, t]);
const onCopyUrlClick = useCallback(() => {
if (downloadUrl) {
copyToClipboard(downloadUrl, 'PLAYER_COPY_SUBTITLE_URL_SUCCESS', 'PLAYER_COPY_SUBTITLE_URL_ERROR');
}
}, [downloadUrl, copyToClipboard]);
const onCopyIdClick = useCallback(() => {
if (track.addonSubtitleId) {
copyToClipboard(track.addonSubtitleId, 'PLAYER_COPY_SUBTITLE_ID_SUCCESS', 'PLAYER_COPY_SUBTITLE_ID_ERROR');
}
}, [track.addonSubtitleId, copyToClipboard]);
return (
<Button
ref={buttonRef}
title={hoverTitle}
onClick={onSelectClick}
className={classNames(styles['variant-option'], { 'selected': selected })}
>
<div className={styles['info']}>
<div className={styles['variant-label']}>
{variantLabel}
</div>
<div className={styles['variant-origin']}>
{t(track.origin)}
</div>
</div>
{selected ? <div className={styles['icon']} /> : null}
{!track.embedded &&
<ContextMenu on={triggers} autoClose={true} lock={'bottom'}>
{downloadUrl ?
<Button
className={styles['context-menu-option']}
title={t('CTX_DOWNLOAD_SUBTITLE')}
href={downloadUrl}
target={'_blank'}
download={downloadFileName}
>
<Icon className={styles['menu-icon']} name={'download'} />
<div className={styles['context-menu-option-label']}>
{t('CTX_DOWNLOAD_SUBTITLE')}
</div>
</Button>
:
null
}
{canCopyUrl ?
<Button
className={styles['context-menu-option']}
title={t('CTX_COPY_SUBTITLE_URL')}
onClick={onCopyUrlClick}
>
<Icon className={styles['menu-icon']} name={'link'} />
<div className={styles['context-menu-option-label']}>
{t('CTX_COPY_SUBTITLE_URL')}
</div>
</Button>
:
null
}
{track.addonSubtitleId ?
<Button
className={styles['context-menu-option']}
title={t('CTX_COPY_SUBTITLE_ID')}
onClick={onCopyIdClick}
>
<Icon className={styles['menu-icon']} name={'share'} />
<div className={styles['context-menu-option-label']}>
{t('CTX_COPY_SUBTITLE_ID')}
</div>
</Button>
:
null
}
</ContextMenu>
}
</Button>
);
};
export default SubtitleVariant;

View file

@ -0,0 +1,5 @@
// Copyright (C) 2017-2026 Smart code 203358507
import SubtitleVariant from './SubtitleVariant';
export default SubtitleVariant;

View file

@ -9,6 +9,7 @@ const { Button } = require('stremio/components');
const styles = require('./styles');
const { t } = require('i18next');
const { default: Stepper } = require('./Stepper');
const { default: SubtitleVariant } = require('./SubtitleVariant');
const ORIGIN_PRIORITIES = [
'LOCAL',
@ -47,7 +48,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
const userLanguage = languages.toCode(props.subtitlesLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE;
const interfaceLanguage = languages.toCode(props.interfaceLanguage) ?? DEFAULT_SUBTITLES_LANGUAGE;
const priorities = [LOCAL_SUBTITLES_LANGUAGE, userLanguage, interfaceLanguage];
const langs = Object.keys(Object.groupBy(allSubtitles, ({ lang }) => lang)).sort((a, b) => a.localeCompare(b));
const langs = [...new Set(allSubtitles.map(({ lang }) => lang))].sort((a, b) => a.localeCompare(b));
return sortByValues(langs, priorities);
}, [allSubtitles, props.subtitlesLanguage, props.interfaceLanguage]);
@ -102,9 +103,8 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
}
}
}, [allSubtitles, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
const subtitlesTrackOnClick = React.useCallback((event) => {
const track = subtitlesTracksForLanguage.find((t) => t.id === event.currentTarget.dataset.id) ?? null;
if (track?.embedded) {
const subtitlesTrackOnSelect = React.useCallback((track) => {
if (track.embedded) {
if (typeof props.onSubtitlesTrackSelected === 'function') {
props.onSubtitlesTrackSelected(track);
}
@ -113,7 +113,7 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
props.onExtraSubtitlesTrackSelected(track);
}
}
}, [subtitlesTracksForLanguage, props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
}, [props.onSubtitlesTrackSelected, props.onExtraSubtitlesTrackSelected]);
const onSubtitlesDelayChanged = React.useCallback((value) => {
if (typeof props.selectedExtraSubtitlesTrackId === 'string') {
if (props.extraSubtitlesDelay !== null && !isNaN(props.extraSubtitlesDelay)) {
@ -190,24 +190,12 @@ const SubtitlesMenu = React.memo(React.forwardRef((props, ref) => {
subtitlesTracksForLanguage.length > 0 ?
<div className={styles['variants-list']}>
{subtitlesTracksForLanguage.map((track, index) => (
<Button key={index} title={track.label} className={classnames(styles['variant-option'], { 'selected': props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id })} data-id={track.id} data-origin={track.origin} onClick={subtitlesTrackOnClick}>
<div className={styles['info']}>
<div className={styles['variant-label']}>
{
(track.label && track.label.length > 0 && !track.label.startsWith('http')) ? track.label : languages.label(track.lang)
}
</div>
<div className={styles['variant-origin']}>
{ t(track.origin) }
</div>
</div>
{
props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id ?
<div className={styles['icon']} />
:
null
}
</Button>
<SubtitleVariant
key={index}
track={track}
selected={props.selectedSubtitlesTrackId === track.id || props.selectedExtraSubtitlesTrackId === track.id}
onSelect={subtitlesTrackOnSelect}
/>
))}
</div>
:
@ -276,7 +264,11 @@ SubtitlesMenu.propTypes = {
id: PropTypes.string.isRequired,
lang: PropTypes.string.isRequired,
origin: PropTypes.string.isRequired,
label: PropTypes.string.isRequired
label: PropTypes.string,
url: PropTypes.string,
embedded: PropTypes.bool,
local: PropTypes.bool,
exclusive: PropTypes.bool
})),
selectedExtraSubtitlesTrackId: PropTypes.string,
extraSubtitlesOffset: PropTypes.number,

View file

@ -27,7 +27,7 @@
overflow-y: auto;
padding: 0 1rem;
.language-option, .variant-option {
.language-option {
display: flex;
flex-direction: row;
align-items: center;
@ -40,13 +40,10 @@
background-color: var(--overlay-color);
}
.language-label, .variant-label {
.language-label {
flex: 1;
font-size: 1.1rem;
color: var(--primary-foreground-color);
}
.language-label, .variant-label, .variant-origin {
text-wrap: nowrap;
text-overflow: ellipsis;
}
@ -60,26 +57,6 @@
background-color: var(--secondary-accent-color);
}
}
.variant-option {
height: 4rem;
.info {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.25rem;
.variant-label {
line-height: 1.5rem;
}
.variant-origin {
font-size: 0.9rem;
color: var(--color-placeholder-text);
}
}
}
}
}

View file

@ -64,9 +64,11 @@ html:not(.active-slider-within) {
right: 0;
top: 0;
left: 0;
height: 8rem;
z-index: -1;
box-shadow: 0 0 8rem 6rem @color-background-dark5;
content: "";
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.35) 0%, transparent 100%);
pointer-events: none;
}
.nav-bar-button-container {
@ -95,15 +97,18 @@ html:not(.active-slider-within) {
&.control-bar-layer {
top: initial;
overflow: visible;
padding-bottom: 0.5rem;
&::before {
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 10rem;
z-index: -1;
box-shadow: 0 0 8rem 8rem @color-background-dark5;
content: "";
background: linear-gradient(to top, rgba(0, 0, 0, 0.35) 0%, transparent 100%);
pointer-events: none;
}
}
@ -118,8 +123,8 @@ html:not(.active-slider-within) {
top: initial;
left: initial;
right: 4rem;
bottom: 8rem;
max-height: calc(100% - 13.5rem);
bottom: 7.5rem;
max-height: calc(100% - 13rem);
max-width: calc(100% - 4rem);
border-radius: var(--border-radius);
background-color: var(--modal-background-color);

View file

@ -0,0 +1,57 @@
import { useEffect } from 'react';
const useMediaSession = (
videoState: VideoState,
player: Player,
onPlayRequested: () => void,
onPauseRequested: () => void,
onNextVideoRequested: () => void,
) => {
useEffect(() => {
if (!navigator.mediaSession) return;
const playbackState = !videoState.paused ? 'playing' : 'paused';
navigator.mediaSession.playbackState = playbackState;
return () => {
navigator.mediaSession.playbackState = 'none';
};
}, [videoState.paused]);
useEffect(() => {
if (!navigator.mediaSession) return;
const metaItem = player.metaItem && player.metaItem?.type === 'Ready' ? player.metaItem.content as MetaItemPlayer : null;
const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
const video = metaItem?.videos.find(({ id }) => id === videoId);
const videoInfo = video?.season && video?.episode ? ` (${video.season}x${video.episode})` : null;
const videoTitle = video ? `${video.title}${videoInfo}` : null;
const metaTitle = metaItem ? metaItem.name : null;
const imageUrl = metaItem ? metaItem.logo : null;
const title = videoTitle ?? metaTitle;
const artist = (videoTitle && metaTitle) ?? undefined;
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
if (title) {
navigator.mediaSession.metadata = new MediaMetadata({
title,
artist,
artwork,
});
}
}, [player.metaItem, player.selected]);
useEffect(() => {
if (!navigator.mediaSession) return;
navigator.mediaSession.setActionHandler('play', onPlayRequested);
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
};
export default useMediaSession;

92
src/routes/Player/useSubtitles.d.ts vendored Normal file
View file

@ -0,0 +1,92 @@
// Copyright (C) 2017-2026 Smart code 203358507
type SubtitleTrack = {
id: string,
lang: string,
label?: string | null,
origin?: string,
url?: string | null,
fallbackUrl?: string | null,
embedded?: boolean,
local?: boolean,
exclusive?: boolean,
buffer?: ArrayBuffer,
};
type SelectedSubtitleTrack = {
id: string,
embedded: boolean,
};
type VideoSubtitleState = {
stream: unknown | null,
subtitlesTracks: SubtitleTrack[],
selectedSubtitlesTrackId: string | null,
subtitlesOffset: number | null,
subtitlesSize: number | null,
extraSubtitlesTracks: SubtitleTrack[],
selectedExtraSubtitlesTrackId: string | null,
extraSubtitlesOffset: number | null,
extraSubtitlesDelay: number | null,
extraSubtitlesSize: number | null,
};
type VideoEvents = {
on: (event: string, listener: (...args: any[]) => void) => void,
off: (event: string, listener: (...args: any[]) => void) => void,
};
type VideoController = {
events: VideoEvents,
state: VideoSubtitleState,
addExtraSubtitlesTracks: (tracks: SubtitleTrack[]) => void,
addLocalSubtitles: (filename: string, buffer: ArrayBuffer) => void,
setSubtitlesTrack: (id: string | null) => void,
setExtraSubtitlesTrack: (id: string | null) => void,
setSubtitlesDelay: (delay: number) => void,
setSubtitlesSize: (size: number) => void,
setSubtitlesOffset: (offset: number) => void,
setSubtitlesTextColor: (color: string) => void,
setSubtitlesBackgroundColor: (color: string) => void,
setSubtitlesOutlineColor: (color: string) => void,
};
type UseSubtitlesArgs = {
player: Player,
video: VideoController,
settings: Settings,
streamStateChanged: (state: Partial<StreamState>) => void,
menusOpen: boolean,
closeMenus: () => void,
closeSubtitlesMenu: () => void,
toggleSubtitlesMenu: () => void,
};
type SubtitlesMenuProps = {
subtitlesLanguage: string | null,
interfaceLanguage: string,
subtitlesTracks: SubtitleTrack[],
selectedSubtitlesTrackId: string | null,
subtitlesOffset: number | null,
subtitlesSize: number | null,
extraSubtitlesTracks: SubtitleTrack[],
selectedExtraSubtitlesTrackId: string | null,
extraSubtitlesOffset: number | null,
extraSubtitlesDelay: number | null,
extraSubtitlesSize: number | null,
onSubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
onExtraSubtitlesTrackSelected: (track: SubtitleTrack | null) => void,
onSubtitlesOffsetChanged: (offset: number) => void,
onSubtitlesSizeChanged: (size: number) => void,
onExtraSubtitlesOffsetChanged: (offset: number) => void,
onExtraSubtitlesDelayChanged: (delay: number) => void,
onExtraSubtitlesSizeChanged: (size: number) => void,
};
type UseSubtitlesResult = {
streamSubtitles: SubtitleTrack[],
allSubtitleTracks: SubtitleTrack[],
extraSubtitleTracks: SubtitleTrack[],
selectedExtraSubtitleTrackId: string | null,
subtitlesMenuProps: SubtitlesMenuProps,
};

View file

@ -0,0 +1,391 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { CONSTANTS, languages, onFileDrop, onShortcut, useToast } from 'stremio/common';
const withFallbackLabels = (tracks?: SubtitleTrack[] | null): SubtitleTrack[] => {
if (!Array.isArray(tracks)) {
return [];
}
return tracks.map((track) => ({
...track,
label: track.label || track.url || '',
}));
};
const findTrackById = (tracks: SubtitleTrack[], id?: string | null) => {
if (!id) {
return undefined;
}
return tracks.find((track) => track.id === id);
};
const findTrackByLanguage = (tracks: SubtitleTrack[], language?: string | null) => {
if (!language) {
return undefined;
}
const languageCode = languages.toCode(language);
return tracks.find((track) => {
return track.lang === language || languages.toCode(track.lang) === languageCode;
});
};
const useSubtitles = ({
player,
video,
settings,
streamStateChanged,
menusOpen,
closeMenus,
closeSubtitlesMenu,
toggleSubtitlesMenu,
}: UseSubtitlesArgs): UseSubtitlesResult => {
const { t } = useTranslation();
const toast = useToast();
const videoRef = useRef(video);
const settingsRef = useRef(settings);
const defaultTrackSelected = useRef(false);
const lastSelectedTrack = useRef<SelectedSubtitleTrack | null>(null);
videoRef.current = video;
settingsRef.current = settings;
const streamSubtitles = useMemo(() => {
return withFallbackLabels(player.selected?.stream.subtitles);
}, [player.selected]);
const externalSubtitles = useMemo(() => {
return withFallbackLabels(player.subtitles);
}, [player.subtitles]);
const allTracks = useMemo(() => {
return video.state.subtitlesTracks.concat(video.state.extraSubtitlesTracks);
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]);
const hasTracks = allTracks.length > 0;
const applySubtitleStyle = useCallback(() => {
const currentSettings = settingsRef.current;
const currentVideo = videoRef.current;
currentVideo.setSubtitlesSize(currentSettings.subtitlesSize);
currentVideo.setSubtitlesOffset(currentSettings.subtitlesOffset);
currentVideo.setSubtitlesTextColor(currentSettings.subtitlesTextColor);
currentVideo.setSubtitlesBackgroundColor(currentSettings.subtitlesBackgroundColor);
currentVideo.setSubtitlesOutlineColor(currentSettings.subtitlesOutlineColor);
}, []);
const rememberTrack = useCallback((track: SubtitleTrack, embedded: boolean) => {
lastSelectedTrack.current = { id: track.id, embedded };
streamStateChanged({
subtitleTrack: {
id: track.id,
embedded,
lang: track.lang,
},
});
}, [streamStateChanged]);
const disableSubtitles = useCallback(() => {
defaultTrackSelected.current = true;
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
streamStateChanged({ subtitleTrack: null });
}, [streamStateChanged, video]);
const selectEmbeddedTrack = useCallback((track: SubtitleTrack | null) => {
if (!track) {
disableSubtitles();
return;
}
defaultTrackSelected.current = true;
video.setSubtitlesTrack(track.id);
rememberTrack(track, true);
}, [disableSubtitles, rememberTrack, video]);
const selectExtraTrack = useCallback((track: SubtitleTrack | null) => {
if (!track) {
disableSubtitles();
return;
}
defaultTrackSelected.current = true;
video.setExtraSubtitlesTrack(track.id);
rememberTrack(track, false);
}, [disableSubtitles, rememberTrack, video]);
const changeDelay = useCallback((delay: number) => {
video.setSubtitlesDelay(delay);
streamStateChanged({ subtitleDelay: delay });
}, [streamStateChanged, video]);
const increaseDelay = useCallback(() => {
changeDelay((video.state.extraSubtitlesDelay ?? 0) + 250);
}, [changeDelay, video.state.extraSubtitlesDelay]);
const decreaseDelay = useCallback(() => {
changeDelay((video.state.extraSubtitlesDelay ?? 0) - 250);
}, [changeDelay, video.state.extraSubtitlesDelay]);
const changeSize = useCallback((size: number) => {
video.setSubtitlesSize(size);
streamStateChanged({ subtitleSize: size });
}, [streamStateChanged, video]);
const updateSize = useCallback((delta: number) => {
const sizes = CONSTANTS.SUBTITLES_SIZES as number[];
const sizeIndex = sizes.indexOf(video.state.subtitlesSize ?? -1);
const nextIndex = Math.max(0, Math.min(sizes.length - 1, sizeIndex + delta));
changeSize(sizes[nextIndex]);
}, [changeSize, video.state.subtitlesSize]);
const changeOffset = useCallback((offset: number) => {
video.setSubtitlesOffset(offset);
streamStateChanged({ subtitleOffset: offset });
}, [streamStateChanged, video]);
onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, (filename: string, buffer: ArrayBuffer) => {
videoRef.current.addLocalSubtitles(filename, buffer);
});
useEffect(() => {
if (video.state.stream !== null) {
video.addExtraSubtitlesTracks(externalSubtitles);
}
}, [externalSubtitles, video.state.stream]);
useEffect(() => {
if (defaultTrackSelected.current) {
return;
}
if (settings.subtitlesLanguage === null) {
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
defaultTrackSelected.current = true;
return;
}
const savedTrack = player.streamState?.subtitleTrack;
const savedTrackId = savedTrack?.id;
const savedLanguage = savedTrack?.lang;
const savedExternalTrack = Boolean(savedTrackId && savedTrack?.embedded === false);
const embeddedTrack = savedTrackId ?
findTrackById(video.state.subtitlesTracks, savedTrackId)
:
findTrackByLanguage(video.state.subtitlesTracks, savedLanguage ?? settings.subtitlesLanguage);
const extraTrack = savedTrackId ?
findTrackById(video.state.extraSubtitlesTracks, savedTrackId)
:
findTrackByLanguage(video.state.extraSubtitlesTracks, savedLanguage ?? settings.subtitlesLanguage);
if (embeddedTrack?.id) {
if (video.state.selectedSubtitlesTrackId !== embeddedTrack.id ||
video.state.selectedExtraSubtitlesTrackId !== null) {
video.setSubtitlesTrack(embeddedTrack.id);
}
defaultTrackSelected.current = true;
return;
}
if (extraTrack?.id) {
if (video.state.selectedExtraSubtitlesTrackId !== extraTrack.id ||
video.state.selectedSubtitlesTrackId !== null) {
video.setExtraSubtitlesTrack(extraTrack.id);
}
if (savedExternalTrack) {
defaultTrackSelected.current = true;
}
}
}, [
player.streamState,
settings.subtitlesLanguage,
video.state.extraSubtitlesTracks,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
video.state.subtitlesTracks,
]);
useEffect(() => {
if (video.state.stream === null) {
return;
}
const delay = player.streamState?.subtitleDelay;
if (typeof delay === 'number') {
video.setSubtitlesDelay(delay);
}
const size = player.streamState?.subtitleSize;
if (typeof size === 'number') {
video.setSubtitlesSize(size);
}
const offset = player.streamState?.subtitleOffset;
if (typeof offset === 'number') {
video.setSubtitlesOffset(offset);
}
}, [player.streamState, video.state.stream]);
useEffect(() => {
defaultTrackSelected.current = false;
lastSelectedTrack.current = null;
}, [video.state.stream]);
useEffect(() => {
if (!hasTracks) {
closeSubtitlesMenu();
}
}, [closeSubtitlesMenu, hasTracks]);
useEffect(() => {
const onSubtitlesTrackLoaded = () => {
toast.show({
type: 'success',
title: t('PLAYER_SUBTITLES_LOADED'),
message: t('PLAYER_SUBTITLES_LOADED_EMBEDDED'),
timeout: 3000,
});
};
const onExtraSubtitlesTrackLoaded = (track: SubtitleTrack) => {
toast.show({
type: 'success',
title: t('PLAYER_SUBTITLES_LOADED'),
message: track.exclusive ?
t('PLAYER_SUBTITLES_LOADED_EXCLUSIVE')
:
track.local ?
t('PLAYER_SUBTITLES_LOADED_LOCAL')
:
t('PLAYER_SUBTITLES_LOADED_ORIGIN', { origin: track.origin }),
timeout: 3000,
});
};
const onExtraSubtitlesTrackAdded = (track: SubtitleTrack) => {
if (track.local) {
videoRef.current.setExtraSubtitlesTrack(track.id);
}
};
video.events.on('subtitlesTrackLoaded', onSubtitlesTrackLoaded);
video.events.on('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
video.events.on('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
video.events.on('implementationChanged', applySubtitleStyle);
return () => {
video.events.off('subtitlesTrackLoaded', onSubtitlesTrackLoaded);
video.events.off('extraSubtitlesTrackLoaded', onExtraSubtitlesTrackLoaded);
video.events.off('extraSubtitlesTrackAdded', onExtraSubtitlesTrackAdded);
video.events.off('implementationChanged', applySubtitleStyle);
};
}, [applySubtitleStyle, t, toast, video.events]);
onShortcut('subtitlesDelay', (combo) => {
combo === 1 ? increaseDelay() : decreaseDelay();
}, [increaseDelay, decreaseDelay], !menusOpen);
onShortcut('subtitlesSize', (combo) => {
combo === 1 ? updateSize(1) : updateSize(-1);
}, [updateSize], !menusOpen);
onShortcut('toggleSubtitles', () => {
const subtitlesEnabled = video.state.selectedSubtitlesTrackId !== null ||
video.state.selectedExtraSubtitlesTrackId !== null;
if (subtitlesEnabled) {
if (video.state.selectedSubtitlesTrackId) {
lastSelectedTrack.current = {
id: video.state.selectedSubtitlesTrackId,
embedded: true,
};
} else if (video.state.selectedExtraSubtitlesTrackId) {
lastSelectedTrack.current = {
id: video.state.selectedExtraSubtitlesTrackId,
embedded: false,
};
}
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
return;
}
const savedTrack = player.streamState?.subtitleTrack ?? lastSelectedTrack.current;
if (savedTrack?.id) {
savedTrack.embedded ?
video.setSubtitlesTrack(savedTrack.id)
:
video.setExtraSubtitlesTrack(savedTrack.id);
}
}, [
player.streamState,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
], !menusOpen);
onShortcut('subtitlesMenu', () => {
closeMenus();
if (hasTracks) {
toggleSubtitlesMenu();
}
}, [closeMenus, hasTracks, toggleSubtitlesMenu]);
const menuProps = useMemo(() => ({
subtitlesLanguage: settings.subtitlesLanguage,
interfaceLanguage: settings.interfaceLanguage,
subtitlesTracks: video.state.subtitlesTracks,
selectedSubtitlesTrackId: video.state.selectedSubtitlesTrackId,
subtitlesOffset: video.state.subtitlesOffset,
subtitlesSize: video.state.subtitlesSize,
extraSubtitlesTracks: video.state.extraSubtitlesTracks,
selectedExtraSubtitlesTrackId: video.state.selectedExtraSubtitlesTrackId,
extraSubtitlesOffset: video.state.extraSubtitlesOffset,
extraSubtitlesDelay: video.state.extraSubtitlesDelay,
extraSubtitlesSize: video.state.extraSubtitlesSize,
onSubtitlesTrackSelected: selectEmbeddedTrack,
onExtraSubtitlesTrackSelected: selectExtraTrack,
onSubtitlesOffsetChanged: changeOffset,
onSubtitlesSizeChanged: changeSize,
onExtraSubtitlesOffsetChanged: changeOffset,
onExtraSubtitlesDelayChanged: changeDelay,
onExtraSubtitlesSizeChanged: changeSize,
}), [
changeDelay,
changeOffset,
changeSize,
selectEmbeddedTrack,
selectExtraTrack,
settings.interfaceLanguage,
settings.subtitlesLanguage,
video.state.extraSubtitlesDelay,
video.state.extraSubtitlesOffset,
video.state.extraSubtitlesSize,
video.state.extraSubtitlesTracks,
video.state.selectedExtraSubtitlesTrackId,
video.state.selectedSubtitlesTrackId,
video.state.subtitlesOffset,
video.state.subtitlesSize,
video.state.subtitlesTracks,
]);
return {
streamSubtitles,
allSubtitleTracks: allTracks,
extraSubtitleTracks: video.state.extraSubtitlesTracks,
selectedExtraSubtitleTrackId: video.state.selectedExtraSubtitlesTrackId,
subtitlesMenuProps: menuProps,
};
};
export default useSubtitles;

View file

@ -22,6 +22,7 @@ const useVideo = () => {
muted: null,
playbackSpeed: null,
videoParams: null,
hdrInfo: null,
audioTracks: [],
selectedAudioTrackId: null,
subtitlesTracks: [],
@ -142,6 +143,10 @@ const useVideo = () => {
setProp('extraSubtitlesOffset', offset);
};
const setVideoScale = (scale) => {
setProp('videoScale', scale);
};
const setSubtitlesTextColor = (color) => {
setProp('subtitlesTextColor', color);
setProp('extraSubtitlesTextColor', color);
@ -238,6 +243,7 @@ const useVideo = () => {
setSubtitlesBackgroundColor,
setSubtitlesOutlineColor,
setExtraSubtitlesTrack,
setVideoScale,
};
};

3
src/routes/Player/videoState.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
type VideoState = {
paused?: boolean;
};

View file

@ -17,6 +17,7 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
gamepadSupportToggle,
} = useInterfaceOptions(profile);
return (
@ -57,6 +58,12 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
{...hideSpoilersToggle}
/>
</Option>
<Option label={'SETTINGS_GAMEPAD'}>
<Toggle
tabIndex={-1}
{...gamepadSupportToggle}
/>
</Option>
</Section>
);
});

View file

@ -102,12 +102,29 @@ const useInterfaceOptions = (profile: Profile) => {
}
}), [profile.settings]);
const gamepadSupportToggle = useMemo(() => ({
checked: profile.settings.gamepadSupport,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
gamepadSupport: !profile.settings.gamepadSupport
}
}
});
}
}), [profile.settings]);
return {
interfaceLanguageSelect,
interfaceSize,
escExitFullscreenToggle,
quitOnCloseToggle,
hideSpoilersToggle,
gamepadSupportToggle,
};
};

View file

@ -0,0 +1,15 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { createContext } from 'react';
export type ControllerType = 'playstation' | 'xbox' | 'generic';
const GamepadContext = createContext<{
on: (event: string, id: string, callback: (data?: string) => void) => void;
off: (event: string, id: string) => void;
lock: (prefix: string) => void;
unlock: () => void;
controllerType: ControllerType;
} | null>(null);
export default GamepadContext;

View file

@ -0,0 +1,271 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useEffect, useRef, useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import useToast from 'stremio/common/Toast/useToast';
import GamepadContext from './GamepadContext';
import type { ControllerType } from './GamepadContext';
type GamepadEventHandlers = Map<string, Map<string, (data?: string) => void>>;
type GamepadProviderProps = {
enabled: boolean;
onGuide?: () => void;
children: React.ReactNode;
};
const detectControllerType = (gamepad: Gamepad): ControllerType => {
const id = gamepad.id.toLowerCase();
// Sony vendor id 054c — DualShock / DualSense / generic PlayStation
if (/sony|playstation|dualsense|dualshock|054c/.test(id)) return 'playstation';
// Microsoft vendor id 045e — Xbox / XInput
if (/xbox|microsoft|xinput|045e/.test(id)) return 'xbox';
// Browser "Standard Gamepad" mapping mirrors the Xbox layout
if (gamepad.mapping === 'standard') return 'xbox';
return 'generic';
};
const GamepadProvider = ({ enabled, onGuide, children }: GamepadProviderProps) => {
const { t } = useTranslation();
const toast = useToast();
const connectedGamepads = useRef<number>(0);
const lastButtonState = useRef<number[]>([]);
const lastButtonPressedTime = useRef<number>(0);
const axisTimer = useRef<number>(0);
const axisTimerRight = useRef<number>(0);
const eventHandlers = useRef<GamepadEventHandlers>(new Map());
const lockPrefix = useRef<string | null>(null);
const [controllerType, setControllerType] = useState<ControllerType>('generic');
const on = useCallback((event: string, id: string, callback: (data?: string) => void) => {
if (!eventHandlers.current.has(event)) {
eventHandlers.current.set(event, new Map());
}
const handlers = eventHandlers.current.get(event)!;
// Ensure only one handler per component
handlers.set(id, callback);
}, []);
const off = useCallback((event: string, id: string) => {
const handlersMap = eventHandlers.current.get(event);
handlersMap?.delete(id);
if (handlersMap?.size === 0) {
eventHandlers.current.delete(event);
}
}, []);
const lock = useCallback((prefix: string) => {
lockPrefix.current = prefix;
}, []);
const unlock = useCallback(() => {
lockPrefix.current = null;
}, []);
const emit = (event: string, data?: string) => {
if (eventHandlers.current.has(event)) {
const handlersMap = eventHandlers.current.get(event)!;
if (!handlersMap || handlersMap.size === 0) return;
if (lockPrefix.current) {
const matching = Array.from(handlersMap.entries())
.filter(([id]) => id.startsWith(lockPrefix.current!));
if (matching.length > 0) {
matching[matching.length - 1][1](data);
}
return;
}
const latestHandler = Array.from(handlersMap.values()).slice(-1)[0];
if (latestHandler) {
latestHandler(data);
}
}
};
const onGamepadConnected = useCallback((e: GamepadEvent) => {
setControllerType(detectControllerType(e.gamepad));
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: t('GAMEPAD_CONNECTED'),
timeout: 4000,
});
}, [toast, t]);
const onGamepadDisconnected = useCallback(() => {
const remaining = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
setControllerType(remaining.length > 0 ? detectControllerType(remaining[0]) : 'generic');
// @ts-expect-error show() expects no arguments
toast.show({
type: 'info',
title: t('GAMEPAD_DISCONNECTED'),
timeout: 4000,
});
}, [toast, t]);
useEffect(() => {
if (!enabled) return;
if (typeof navigator.getGamepads === 'function') {
const existing = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
if (existing.length > 0) {
setControllerType(detectControllerType(existing[0]));
}
}
window.addEventListener('gamepadconnected', onGamepadConnected);
window.addEventListener('gamepaddisconnected', onGamepadDisconnected);
return () => {
window.removeEventListener('gamepadconnected', onGamepadConnected);
window.removeEventListener('gamepaddisconnected', onGamepadDisconnected);
};
}, [enabled, onGamepadConnected, onGamepadDisconnected]);
useEffect(() => {
if (onGuide) {
on('buttonX', 'guide', onGuide);
}
return () => {
off('buttonX', 'guide');
};
}, [onGuide]);
useEffect(() => {
if (!enabled || typeof navigator.getGamepads !== 'function') return;
let animationFrameId: number;
const updateStatus = () => {
if (document.hasFocus()) {
const currentTime = Date.now();
const controllers = Array.from(navigator.getGamepads()).filter(
(gp) => gp !== null
) as Gamepad[];
connectedGamepads.current = controllers.length;
controllers.forEach((controller, index) => {
const buttonsState = controller.buttons.reduce(
(buttons, button, i) => buttons | (button.pressed ? 1 << i : 0),
0
);
const processButton =
currentTime - lastButtonPressedTime.current > 250;
if (
lastButtonState.current[index] !== buttonsState ||
processButton
) {
lastButtonPressedTime.current = currentTime;
lastButtonState.current[index] = buttonsState;
if (buttonsState & (1 << 0)) emit('buttonA');
if (buttonsState & (1 << 1)) emit('buttonB');
if (buttonsState & (1 << 2)) emit('buttonX');
if (buttonsState & (1 << 3)) emit('buttonY');
if (buttonsState & (1 << 4)) emit('buttonLT');
if (buttonsState & (1 << 5)) emit('buttonRT');
}
const deadZone = 0.05;
const maxSpeed = 100;
let axisHandled = false;
if (controller.axes[0] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'left');
axisHandled = true;
}
}
if (controller.axes[0] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[0]) * 2000)
) {
emit('analog', 'right');
axisHandled = true;
}
}
if (controller.axes[1] < -deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'up');
axisHandled = true;
}
}
if (controller.axes[1] > deadZone) {
if (
currentTime - axisTimer.current >
maxSpeed + (2000 - Math.abs(controller.axes[1]) * 2000)
) {
emit('analog', 'down');
axisHandled = true;
}
}
if (axisHandled) axisTimer.current = currentTime;
let rightAxisHandled = false;
if (controller.axes.length > 2) {
if (controller.axes[2] < -deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[2]) * 2000)) {
emit('analogRight', 'left');
rightAxisHandled = true;
}
}
if (controller.axes[2] > deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[2]) * 2000)) {
emit('analogRight', 'right');
rightAxisHandled = true;
}
}
if (controller.axes[3] < -deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[3]) * 2000)) {
emit('analogRight', 'up');
rightAxisHandled = true;
}
}
if (controller.axes[3] > deadZone) {
if (currentTime - axisTimerRight.current > maxSpeed + (2000 - Math.abs(controller.axes[3]) * 2000)) {
emit('analogRight', 'down');
rightAxisHandled = true;
}
}
}
if (rightAxisHandled) axisTimerRight.current = currentTime;
});
}
animationFrameId = requestAnimationFrame(updateStatus);
};
animationFrameId = requestAnimationFrame(updateStatus);
return () => {
cancelAnimationFrame(animationFrameId);
};
}, [enabled]);
return (
<GamepadContext.Provider value={{ on, off, lock, unlock, controllerType }}>
{children}
</GamepadContext.Provider>
);
};
export default GamepadProvider;

View file

@ -0,0 +1,11 @@
// Copyright (C) 2017-2026 Smart code 203358507
import GamepadProvider from './GamepadProvider';
import useGamepad from './useGamepad';
export type { ControllerType } from './GamepadContext';
export {
GamepadProvider,
useGamepad
};

View file

@ -0,0 +1,10 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useContext } from 'react';
import GamepadContext from './GamepadContext';
const useGamepad = () => {
return useContext(GamepadContext);
};
export default useGamepad;

View file

@ -0,0 +1,11 @@
// Copyright (C) 2017-2026 Smart code 203358507
import useContentGamepadNavigation from './useContentGamepadNavigation';
import useVerticalNavGamepadNavigation from './useVerticalNavGamepadNavigation';
import useHorizontalNavGamepadNavigation from './useHorizontalNavGamepadNavigation';
export {
useContentGamepadNavigation,
useVerticalNavGamepadNavigation,
useHorizontalNavGamepadNavigation,
};

View file

@ -0,0 +1,144 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useEffect, useRef } from 'react';
import { useGamepad } from '../GamepadContext';
const FOCUSABLE = '[tabindex]:not([data-focus-guard])';
const getActiveScope = (fallback: HTMLDivElement | null): HTMLElement | null => {
if (document.querySelector('[data-gamepad-modal]')) return null;
const modals = document.querySelectorAll<HTMLElement>('.modals-container');
for (const modal of modals) {
if (modal.children.length > 0) return modal;
}
const dropdown = fallback?.querySelector<HTMLElement>('[class*="dropdown"][class*="open"]');
if (dropdown) return dropdown;
return fallback;
};
const useContentGamepadNavigation = (
sectionRef: React.RefObject<HTMLDivElement>,
gamepadHandlerId: string
) => {
const gamepad = useGamepad();
const lastFocused = useRef<HTMLDivElement | null>(null);
const wasInOverlay = useRef(false);
useEffect(() => {
const handleGamepadNavigation = (
direction: 'left' | 'right' | 'up' | 'down'
) => {
const scope = getActiveScope(sectionRef.current);
const inOverlay = scope !== sectionRef.current;
if (inOverlay && !wasInOverlay.current) {
const focused = sectionRef.current?.querySelector<HTMLDivElement>(':focus');
if (focused) lastFocused.current = focused;
}
wasInOverlay.current = inOverlay;
const elements = Array.from(
scope?.querySelectorAll<HTMLDivElement>(FOCUSABLE) || []
);
if (elements.length === 0) return;
const activeElement = (scope ?? document)?.querySelector<HTMLDivElement>(':focus');
if (!activeElement) {
elements[0].focus();
return;
}
let closestElement: HTMLDivElement | null = null;
const cur = activeElement.getBoundingClientRect();
const cx = cur.left + cur.width / 2;
const cy = cur.top + cur.height / 2;
let closestDistance = Infinity;
elements.forEach((el) => {
if (el === activeElement) return;
const r = el.getBoundingClientRect();
const ex = r.left + r.width / 2;
const ey = r.top + r.height / 2;
const isCorrectDirection =
(direction === 'left' && ex < cx) ||
(direction === 'right' && ex > cx) ||
(direction === 'up' && ey < cy) ||
(direction === 'down' && ey > cy);
if (!isCorrectDirection) return;
const dx = ex - cx;
const dy = ey - cy;
const isHorizontal = direction === 'left' || direction === 'right';
const primary = isHorizontal ? Math.abs(dx) : Math.abs(dy);
const secondary = isHorizontal ? Math.abs(dy) : Math.abs(dx);
const distance = primary + secondary * 3;
if (distance < closestDistance) {
closestDistance = distance;
closestElement = el;
}
});
if (closestElement) {
closestElement.focus();
}
};
const onSelect = () => {
const scope = getActiveScope(sectionRef.current);
const inOverlay = scope !== sectionRef.current;
if (inOverlay && !wasInOverlay.current) {
const focused = sectionRef.current?.querySelector<HTMLDivElement>(':focus');
if (focused) lastFocused.current = focused;
}
wasInOverlay.current = inOverlay;
const elements = Array.from(
scope?.querySelectorAll<HTMLDivElement>(FOCUSABLE) || []
);
if (elements.length === 0) {
if (lastFocused.current) {
lastFocused.current.focus();
wasInOverlay.current = false;
}
return;
}
const activeElement = (scope ?? document)?.querySelector<HTMLDivElement>(':focus');
if (!activeElement) {
elements[0].focus();
return;
}
const isSelect = Array.from(activeElement.classList).some((cls) => cls.startsWith('select-input'));
if (!isSelect) {
activeElement?.click();
requestAnimationFrame(() => {
const stillInOverlay = getActiveScope(sectionRef.current) !== sectionRef.current;
if (!stillInOverlay && wasInOverlay.current && lastFocused.current) {
lastFocused.current.focus();
wasInOverlay.current = false;
}
});
}
};
gamepad?.on('analog', gamepadHandlerId, handleGamepadNavigation);
gamepad?.on('buttonA', gamepadHandlerId, onSelect);
return () => {
gamepad?.off('analog', gamepadHandlerId);
gamepad?.off('buttonA', gamepadHandlerId);
};
}, [gamepad, gamepadHandlerId, sectionRef]);
};
export default useContentGamepadNavigation;

View file

@ -0,0 +1,24 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useEffect } from 'react';
import { useGamepad } from '../GamepadContext';
import useFullscreen from 'stremio/common/Fullscreen';
const useHorizontalNavGamepadNavigation = (gamepadHandlerId: string, enableGoBack: boolean) => {
const gamepad = useGamepad();
const [fullscreen,,,toggleFullscreen] = useFullscreen();
useEffect(() => {
const goBack = () => enableGoBack && window.history.back();
gamepad?.on('buttonY', gamepadHandlerId, toggleFullscreen as () => void);
gamepad?.on('buttonB', gamepadHandlerId, goBack);
return () => {
gamepad?.off('buttonY', gamepadHandlerId);
gamepad?.off('buttonB', gamepadHandlerId);
};
}, [gamepad, gamepadHandlerId, enableGoBack, fullscreen]);
};
export default useHorizontalNavGamepadNavigation;

View file

@ -0,0 +1,35 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useEffect } from 'react';
import { useGamepad } from '../GamepadContext';
const ROUTES = ['search', 'board', 'discover', 'library', 'calendar', 'addons', 'settings'];
const useVerticalGamepadNavigation = (_sectionRef: React.RefObject<HTMLDivElement>, currentRoute: string) => {
const gamepad = useGamepad();
useEffect(() => {
const navigate = (direction: 'prev' | 'next') => {
const currentIndex = ROUTES.indexOf(currentRoute);
if (currentIndex === -1) return;
let nextIndex = currentIndex;
if (direction === 'next') nextIndex = Math.min(currentIndex + 1, ROUTES.length - 1);
if (direction === 'prev') nextIndex = Math.max(currentIndex - 1, 0);
if (nextIndex !== currentIndex) {
document.dispatchEvent(new KeyboardEvent('keydown', { key: String(nextIndex), code: `Digit${nextIndex}`, bubbles: true }));
}
};
gamepad?.on('buttonLT', currentRoute, () => navigate('prev'));
gamepad?.on('buttonRT', currentRoute, () => navigate('next'));
return () => {
gamepad?.off('buttonLT', currentRoute);
gamepad?.off('buttonRT', currentRoute);
};
}, [gamepad, currentRoute]);
};
export default useVerticalGamepadNavigation;

View file

@ -5,6 +5,7 @@ const Core = require('./Core');
const DragAndDrop = require('./DragAndDrop');
const KeyboardShortcuts = require('./KeyboardShortcuts');
const { ServicesProvider, useServices } = require('./ServicesContext');
const { GamepadProvider, useGamepad } = require('./GamepadContext');
const Shell = require('./Shell');
module.exports = {
@ -14,5 +15,7 @@ module.exports = {
KeyboardShortcuts,
ServicesProvider,
useServices,
Shell
Shell,
GamepadProvider,
useGamepad,
};

View file

@ -25,6 +25,7 @@ type Settings = {
interfaceScale: number,
quitOnClose: boolean,
hideSpoilers: boolean,
gamepadSupport: boolean,
nextVideoNotificationDuration: number,
playInBackground: boolean,
playerType: string | null,

View file

@ -5,7 +5,6 @@ type LibraryItemPlayer = Pick<LibraryItem, '_id'> & {
type VideoPlayer = Video & {
upcoming: boolean,
watched: boolean,
progress: boolean | null,
scheduled: boolean,
deepLinks: VideoDeepLinks,
};
@ -16,13 +15,16 @@ type MetaItemPlayer = MetaItemPreview & {
type SelectedStream = Stream & {
deepLinks: StreamDeepLinks,
subtitles?: Subtitle[],
};
type Subtitle = {
id: string,
lang: string,
origin: string,
url: string,
origin?: string,
url?: string | null,
fallbackUrl?: string | null,
label?: string | null,
};
type SeriesInfo = {
@ -33,6 +35,7 @@ type SeriesInfo = {
type SubtitlesTrackState = {
id: string,
embedded: boolean,
lang?: string,
};
type AudioTrackState = {
@ -40,7 +43,7 @@ type AudioTrackState = {
};
type StreamState = {
subtitleTrack?: SubtitlesTrackState,
subtitleTrack?: SubtitlesTrackState | null,
subtitleDelay?: number,
subtitleSize?: number,
subtitleOffset?: number,