mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-24 12:32:30 +00:00
Merge branch 'development' into feat/shell-interface-size
This commit is contained in:
commit
40e46178bb
62 changed files with 2636 additions and 519 deletions
10
package.json
10
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.33",
|
||||
"version": "5.0.0-beta.36",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -17,9 +17,9 @@
|
|||
"@babel/runtime": "7.26.0",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "0.56.3",
|
||||
"@stremio/stremio-icons": "5.8.0",
|
||||
"@stremio/stremio-video": "0.0.73",
|
||||
"@stremio/stremio-core-web": "0.56.4",
|
||||
"@stremio/stremio-icons": "5.10.0",
|
||||
"@stremio/stremio-video": "0.0.77",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"buffer": "6.0.3",
|
||||
|
|
@ -41,7 +41,7 @@
|
|||
"react-i18next": "^15.1.3",
|
||||
"react-is": "18.3.1",
|
||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#90ea718c18750a0e9cd6824b0ef7c512a41cb90b",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#176c69f3bdede824f37979a046ee27cef355affc",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
264
src/App/GamepadModal/GamepadDiagram.tsx
Normal file
264
src/App/GamepadModal/GamepadDiagram.tsx
Normal 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;
|
||||
165
src/App/GamepadModal/GamepadModal.tsx
Normal file
165
src/App/GamepadModal/GamepadModal.tsx
Normal 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;
|
||||
4
src/App/GamepadModal/index.ts
Normal file
4
src/App/GamepadModal/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import GamepadModal from './GamepadModal';
|
||||
export default GamepadModal;
|
||||
220
src/App/GamepadModal/styles.less
Normal file
220
src/App/GamepadModal/styles.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
src/common/Fullscreen/FullscreenContext.ts
Normal file
16
src/common/Fullscreen/FullscreenContext.ts
Normal 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;
|
||||
109
src/common/Fullscreen/FullscreenProvider.tsx
Normal file
109
src/common/Fullscreen/FullscreenProvider.tsx
Normal 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);
|
||||
7
src/common/Fullscreen/index.ts
Normal file
7
src/common/Fullscreen/index.ts
Normal 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;
|
||||
15
src/common/Fullscreen/useFullscreen.ts
Normal file
15
src/common/Fullscreen/useFullscreen.ts
Normal 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;
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -27,6 +27,11 @@
|
|||
"name": "shortcuts",
|
||||
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
|
||||
"combos": [["Ctrl", "/"]]
|
||||
},
|
||||
{
|
||||
"name": "gamepadGuide",
|
||||
"label": "GAMEPAD_ACTION_GUIDE",
|
||||
"combos": [["Ctrl", "G"]]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
},
|
||||
{
|
||||
"name": "Lietuvių",
|
||||
"codes": ["lt-LT", "ltu"]
|
||||
"codes": ["lt-LT", "lit"]
|
||||
},
|
||||
{
|
||||
"name": "македонски јазик",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ type Props = {
|
|||
style?: object,
|
||||
href?: string,
|
||||
target?: string
|
||||
download?: string,
|
||||
title?: string,
|
||||
disabled?: boolean,
|
||||
tabIndex?: number,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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'}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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']}>
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
5
src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
Normal file
5
src/routes/Player/SubtitlesMenu/SubtitleVariant/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import SubtitleVariant from './SubtitleVariant';
|
||||
|
||||
export default SubtitleVariant;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
57
src/routes/Player/useMediaSession.ts
Normal file
57
src/routes/Player/useMediaSession.ts
Normal 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
92
src/routes/Player/useSubtitles.d.ts
vendored
Normal 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,
|
||||
};
|
||||
391
src/routes/Player/useSubtitles.ts
Normal file
391
src/routes/Player/useSubtitles.ts
Normal 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;
|
||||
|
|
@ -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
3
src/routes/Player/videoState.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
type VideoState = {
|
||||
paused?: boolean;
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
15
src/services/GamepadContext/GamepadContext.ts
Normal file
15
src/services/GamepadContext/GamepadContext.ts
Normal 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;
|
||||
271
src/services/GamepadContext/GamepadProvider.tsx
Normal file
271
src/services/GamepadContext/GamepadProvider.tsx
Normal 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;
|
||||
11
src/services/GamepadContext/index.tsx
Normal file
11
src/services/GamepadContext/index.tsx
Normal 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
|
||||
};
|
||||
10
src/services/GamepadContext/useGamepad.tsx
Normal file
10
src/services/GamepadContext/useGamepad.tsx
Normal 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;
|
||||
11
src/services/GamepadNavigation/index.tsx
Normal file
11
src/services/GamepadNavigation/index.tsx
Normal 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,
|
||||
};
|
||||
144
src/services/GamepadNavigation/useContentGamepadNavigation.tsx
Normal file
144
src/services/GamepadNavigation/useContentGamepadNavigation.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
1
src/types/models/Ctx.d.ts
vendored
1
src/types/models/Ctx.d.ts
vendored
|
|
@ -25,6 +25,7 @@ type Settings = {
|
|||
interfaceScale: number,
|
||||
quitOnClose: boolean,
|
||||
hideSpoilers: boolean,
|
||||
gamepadSupport: boolean,
|
||||
nextVideoNotificationDuration: number,
|
||||
playInBackground: boolean,
|
||||
playerType: string | null,
|
||||
|
|
|
|||
11
src/types/models/Player.d.ts
vendored
11
src/types/models/Player.d.ts
vendored
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue