mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-05-24 08:32:10 +00:00
Merge branch 'development' of https://github.com/Stremio/stremio-web into feat/discord-rich-presence
# Conflicts: # src/App/App.js # src/routes/Player/Player.js # src/routes/Settings/General/General.tsx # src/services/index.js
This commit is contained in:
commit
fe1f13010d
126 changed files with 5866 additions and 3583 deletions
76
package.json
76
package.json
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "stremio",
|
||||
"displayName": "Stremio",
|
||||
"version": "5.0.0-beta.35",
|
||||
"version": "5.0.0-beta.36",
|
||||
"author": "Smart Code OOD",
|
||||
"private": true,
|
||||
"license": "gpl-2.0",
|
||||
|
|
@ -14,21 +14,21 @@
|
|||
"scan-translations": "pnpx jest ./tests/i18nScan.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.26.0",
|
||||
"@babel/runtime": "7.29.2",
|
||||
"@sentry/browser": "8.42.0",
|
||||
"@stremio/stremio-colors": "5.2.0",
|
||||
"@stremio/stremio-core-web": "0.56.4",
|
||||
"@stremio/stremio-core-web": "0.57.0",
|
||||
"@stremio/stremio-icons": "5.10.0",
|
||||
"@stremio/stremio-video": "0.0.77",
|
||||
"@stremio/stremio-video": "0.0.79",
|
||||
"a-color-picker": "1.2.1",
|
||||
"bowser": "2.11.0",
|
||||
"bowser": "2.14.1",
|
||||
"buffer": "6.0.3",
|
||||
"classnames": "2.5.1",
|
||||
"eventemitter3": "5.0.1",
|
||||
"eventemitter3": "5.0.4",
|
||||
"fast-equals": "^6.0.0",
|
||||
"filter-invalid-dom-props": "3.0.1",
|
||||
"hat": "^0.0.3",
|
||||
"i18next": "^24.0.5",
|
||||
"i18next": "^24.2.3",
|
||||
"langs": "github:Stremio/nodejs-langs",
|
||||
"lodash.debounce": "4.0.8",
|
||||
"lodash.intersection": "4.4.0",
|
||||
|
|
@ -37,49 +37,51 @@
|
|||
"prop-types": "15.8.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-focus-lock": "2.13.2",
|
||||
"react-i18next": "^15.1.3",
|
||||
"react-focus-lock": "2.13.7",
|
||||
"react-i18next": "^15.7.4",
|
||||
"react-is": "18.3.1",
|
||||
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#fcad3f8077db865bd08b0f93d785f4090f19db40",
|
||||
"stremio-translations": "github:Stremio/stremio-translations#f8ef365fcc90b7904a11ad2e3ebb95c0c9b16163",
|
||||
"url": "0.11.4",
|
||||
"use-long-press": "^3.2.0"
|
||||
"use-long-press": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "7.26.0",
|
||||
"@babel/preset-env": "7.26.0",
|
||||
"@babel/preset-react": "7.26.3",
|
||||
"@eslint/js": "^9.16.0",
|
||||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/preset-env": "7.29.3",
|
||||
"@babel/preset-react": "7.28.5",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@stylistic/eslint-plugin-jsx": "^4.4.1",
|
||||
"@types/hat": "^0.0.4",
|
||||
"@types/lodash.throttle": "^4.1.9",
|
||||
"@types/react": "^18.3.13",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"babel-loader": "9.2.1",
|
||||
"@types/magnet-uri": "^5.1.5",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/react": "^18.3.28",
|
||||
"@types/react-dom": "^18.3.7",
|
||||
"babel-loader": "10.1.1",
|
||||
"copy-webpack-plugin": "12.0.2",
|
||||
"css-loader": "6.11.0",
|
||||
"cssnano": "7.0.6",
|
||||
"cssnano-preset-advanced": "7.0.6",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-plugin-react": "^7.37.2",
|
||||
"globals": "^15.13.0",
|
||||
"html-webpack-plugin": "5.6.3",
|
||||
"cssnano": "7.1.9",
|
||||
"cssnano-preset-advanced": "7.0.16",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^15.15.0",
|
||||
"html-webpack-plugin": "5.6.7",
|
||||
"jest": "29.7.0",
|
||||
"less": "4.2.1",
|
||||
"less-loader": "12.2.0",
|
||||
"mini-css-extract-plugin": "2.9.2",
|
||||
"postcss-loader": "8.1.1",
|
||||
"less": "4.6.4",
|
||||
"less-loader": "12.3.2",
|
||||
"mini-css-extract-plugin": "2.10.2",
|
||||
"postcss-loader": "8.2.1",
|
||||
"readdirp": "4.0.2",
|
||||
"recast": "0.23.11",
|
||||
"terser-webpack-plugin": "5.3.10",
|
||||
"terser-webpack-plugin": "5.5.0",
|
||||
"thread-loader": "^4.0.4",
|
||||
"ts-loader": "^9.5.1",
|
||||
"typescript": "^5.7.2",
|
||||
"typescript-eslint": "^8.17.0",
|
||||
"webpack": "5.97.0",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "^5.1.0",
|
||||
"workbox-webpack-plugin": "^7.3.0"
|
||||
"ts-loader": "^9.5.7",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"webpack": "5.106.2",
|
||||
"webpack-cli": "7.0.2",
|
||||
"webpack-dev-server": "^5.2.3",
|
||||
"workbox-webpack-plugin": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
5176
pnpm-lock.yaml
5176
pnpm-lock.yaml
File diff suppressed because it is too large
Load diff
219
src/App/App.js
219
src/App/App.js
|
|
@ -3,60 +3,82 @@
|
|||
require('spatial-navigation-polyfill');
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { Router } = require('stremio-router');
|
||||
const { Core, Shell, Chromecast, Discord, DragAndDrop, KeyboardShortcuts, ServicesProvider } = require('stremio/services');
|
||||
const { Shell, Chromecast, Discord, ServicesProvider, GamepadProvider } = require('stremio/services');
|
||||
const { NotFound } = require('stremio/routes');
|
||||
const { FileDropProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, DiscordProvider, CONSTANTS, withCoreSuspender, useShell, useBinaryState } = require('stremio/common');
|
||||
const { FullscreenProvider, PlatformProvider, ToastProvider, TooltipProvider, ShortcutsProvider, DiscordProvider, CONSTANTS, useShell, useBinaryState, useProfile, withCoreSuspender, onFileDrop } = 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 ErrorDialog = require('./ErrorDialog');
|
||||
const { default: GamepadModal } = require('./GamepadModal');
|
||||
const withProtectedRoutes = require('./withProtectedRoutes');
|
||||
const routerViewsConfig = require('./routerViewsConfig');
|
||||
const styles = require('./styles');
|
||||
|
||||
const RouterWithProtectedRoutes = withCoreSuspender(withProtectedRoutes(Router));
|
||||
const RouterWithProtectedRoutes = withProtectedRoutes(Router);
|
||||
|
||||
const App = () => {
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
const { i18n } = useTranslation();
|
||||
const shell = useShell();
|
||||
const [gamepadSupportEnabled, setGamepadSupportEnabled] = React.useState(false);
|
||||
const onPathNotMatch = React.useCallback(() => {
|
||||
return NotFound;
|
||||
}, []);
|
||||
const services = React.useMemo(() => {
|
||||
const core = new Core({
|
||||
appVersion: process.env.VERSION,
|
||||
shellVersion: null
|
||||
});
|
||||
return {
|
||||
core,
|
||||
shell: new Shell(),
|
||||
discord: new Discord(),
|
||||
chromecast: new Chromecast(),
|
||||
keyboardShortcuts: new KeyboardShortcuts(),
|
||||
dragAndDrop: new DragAndDrop({ core })
|
||||
};
|
||||
}, []);
|
||||
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();
|
||||
const onShortcut = React.useCallback((name, combo, key) => {
|
||||
switch (name) {
|
||||
case 'shortcuts':
|
||||
toggleShortcutModal();
|
||||
break;
|
||||
case 'gamepadGuide':
|
||||
toggleGamepadModal();
|
||||
break;
|
||||
case 'navigateSearch':
|
||||
window.location = '#/search';
|
||||
break;
|
||||
case 'navigateTabs': {
|
||||
const routes = ['', 'discover', 'library', 'calendar', 'addons', 'settings'];
|
||||
const index = key - 1;
|
||||
if (index in routes) window.location = `#/${routes[index]}`;
|
||||
break;
|
||||
}
|
||||
case 'navigateHistory':
|
||||
combo === 0 ? window.history.back() : window.history.forward();
|
||||
break;
|
||||
}
|
||||
}, [toggleShortcutModal]);
|
||||
}, [toggleShortcutModal, toggleGamepadModal]);
|
||||
|
||||
onFileDrop(['application/x-bittorrent'], (file, buffer) => {
|
||||
core.transport.dispatch({
|
||||
action: 'StreamingServer',
|
||||
args: {
|
||||
action: 'CreateTorrent',
|
||||
args: Array.from(new Uint8Array(buffer))
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
let prevPath = window.location.hash.slice(1);
|
||||
const onLocationHashChange = () => {
|
||||
if (services.core.active) {
|
||||
services.core.transport.analytics({
|
||||
event: 'LocationPathChanged',
|
||||
args: { prevPath }
|
||||
});
|
||||
}
|
||||
core.transport.analytics({
|
||||
event: 'LocationPathChanged',
|
||||
args: { prevPath }
|
||||
});
|
||||
prevPath = window.location.hash.slice(1);
|
||||
};
|
||||
window.addEventListener('hashchange', onLocationHashChange);
|
||||
|
|
@ -64,19 +86,8 @@ const App = () => {
|
|||
window.removeEventListener('hashchange', onLocationHashChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onCoreStateChanged = () => {
|
||||
setInitialized(
|
||||
(services.core.active || services.core.error instanceof Error) &&
|
||||
(services.shell.active || services.shell.error instanceof Error)
|
||||
);
|
||||
};
|
||||
const onShellStateChanged = () => {
|
||||
setInitialized(
|
||||
(services.core.active || services.core.error instanceof Error) &&
|
||||
(services.shell.active || services.shell.error instanceof Error)
|
||||
);
|
||||
};
|
||||
const onChromecastStateChange = () => {
|
||||
if (services.chromecast.active) {
|
||||
services.chromecast.transport.setOptions({
|
||||
|
|
@ -88,25 +99,15 @@ const App = () => {
|
|||
});
|
||||
}
|
||||
};
|
||||
services.core.on('stateChanged', onCoreStateChanged);
|
||||
services.shell.on('stateChanged', onShellStateChanged);
|
||||
services.chromecast.on('stateChanged', onChromecastStateChange);
|
||||
services.core.start();
|
||||
services.shell.start();
|
||||
services.chromecast.start();
|
||||
services.keyboardShortcuts.start();
|
||||
services.dragAndDrop.start();
|
||||
services.discord.init(services.shell);
|
||||
|
||||
window.services = services;
|
||||
return () => {
|
||||
services.core.stop();
|
||||
services.shell.stop();
|
||||
services.chromecast.stop();
|
||||
services.keyboardShortcuts.stop();
|
||||
services.dragAndDrop.stop();
|
||||
services.core.off('stateChanged', onCoreStateChanged);
|
||||
services.shell.off('stateChanged', onShellStateChanged);
|
||||
services.chromecast.off('stateChanged', onChromecastStateChange);
|
||||
};
|
||||
}, []);
|
||||
|
|
@ -137,111 +138,89 @@ const App = () => {
|
|||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onCoreEvent = ({ event, args }) => {
|
||||
switch (event) {
|
||||
case 'SettingsUpdated': {
|
||||
if (args && args.settings && typeof args.settings.interfaceLanguage === 'string') {
|
||||
i18n.changeLanguage(args.settings.interfaceLanguage);
|
||||
}
|
||||
if (typeof profile.settings?.interfaceLanguage === 'string') {
|
||||
i18n.changeLanguage(profile.settings.interfaceLanguage);
|
||||
}
|
||||
|
||||
if (args?.settings?.quitOnClose && shell.windowClosed) {
|
||||
shell.send('quit');
|
||||
}
|
||||
if (typeof profile.settings?.gamepadSupport === 'boolean') {
|
||||
setGamepadSupportEnabled(profile.settings.gamepadSupport);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onCtxState = (state) => {
|
||||
if (state && state.profile && state.profile.settings && typeof state.profile.settings.interfaceLanguage === 'string') {
|
||||
i18n.changeLanguage(state.profile.settings.interfaceLanguage);
|
||||
}
|
||||
if (profile.settings?.quitOnClose && shell.windowClosed) {
|
||||
shell.send('quit');
|
||||
}
|
||||
}, [profile.settings, shell.windowClosed]);
|
||||
|
||||
if (state?.profile?.settings?.quitOnClose && shell.windowClosed) {
|
||||
shell.send('quit');
|
||||
}
|
||||
};
|
||||
React.useEffect(() => {
|
||||
const onWindowFocus = () => {
|
||||
services.core.transport.dispatch({
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'PullAddonsFromAPI'
|
||||
}
|
||||
});
|
||||
services.core.transport.dispatch({
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'PullUserFromAPI',
|
||||
args: {}
|
||||
}
|
||||
});
|
||||
services.core.transport.dispatch({
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'SyncLibraryWithAPI'
|
||||
}
|
||||
});
|
||||
services.core.transport.dispatch({
|
||||
core.transport.dispatch({
|
||||
action: 'Ctx',
|
||||
args: {
|
||||
action: 'PullNotifications'
|
||||
}
|
||||
});
|
||||
};
|
||||
if (services.core.active) {
|
||||
onWindowFocus();
|
||||
window.addEventListener('focus', onWindowFocus);
|
||||
services.core.transport.on('CoreEvent', onCoreEvent);
|
||||
services.core.transport
|
||||
.getState('ctx')
|
||||
.then(onCtxState)
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
onWindowFocus();
|
||||
window.addEventListener('focus', onWindowFocus);
|
||||
|
||||
return () => {
|
||||
if (services.core.active) {
|
||||
window.removeEventListener('focus', onWindowFocus);
|
||||
services.core.transport.off('CoreEvent', onCoreEvent);
|
||||
}
|
||||
window.removeEventListener('focus', onWindowFocus);
|
||||
};
|
||||
}, [initialized, shell.windowClosed]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<React.StrictMode>
|
||||
<ServicesProvider services={services}>
|
||||
{
|
||||
initialized ?
|
||||
services.core.error instanceof Error ?
|
||||
<ErrorDialog className={styles['error-container']} />
|
||||
:
|
||||
<PlatformProvider>
|
||||
<ToastProvider className={styles['toasts-container']}>
|
||||
<TooltipProvider className={styles['tooltip-container']}>
|
||||
<FileDropProvider className={styles['file-drop-container']}>
|
||||
<ShortcutsProvider onShortcut={onShortcut}>
|
||||
<DiscordProvider>
|
||||
{
|
||||
shortcutModalOpen && <ShortcutsModal onClose={closeShortcutsModal}/>
|
||||
}
|
||||
<ServicesToaster />
|
||||
<DeepLinkHandler />
|
||||
<SearchParamsHandler />
|
||||
<UpdaterBanner className={styles['updater-banner-container']} />
|
||||
<RouterWithProtectedRoutes
|
||||
className={styles['router']}
|
||||
viewsConfig={routerViewsConfig}
|
||||
onPathNotMatch={onPathNotMatch}
|
||||
/>
|
||||
</DiscordProvider>
|
||||
</ShortcutsProvider>
|
||||
</FileDropProvider>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</PlatformProvider>
|
||||
:
|
||||
<div className={styles['loader-container']} />
|
||||
}
|
||||
</ServicesProvider>
|
||||
</React.StrictMode>
|
||||
<ServicesProvider services={services}>
|
||||
<PlatformProvider>
|
||||
<ToastProvider className={styles['toasts-container']}>
|
||||
<TooltipProvider className={styles['tooltip-container']}>
|
||||
<GamepadProvider enabled={gamepadSupportEnabled} onGuide={toggleGamepadModal}>
|
||||
<ShortcutsProvider onShortcut={onShortcut}>
|
||||
<FullscreenProvider>
|
||||
<DiscordProvider>
|
||||
{
|
||||
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}
|
||||
/>
|
||||
</DiscordProvider>
|
||||
</FullscreenProvider>
|
||||
</ShortcutsProvider>
|
||||
</GamepadProvider>
|
||||
</TooltipProvider>
|
||||
</ToastProvider>
|
||||
</PlatformProvider>
|
||||
</ServicesProvider>
|
||||
);
|
||||
};
|
||||
|
||||
module.exports = App;
|
||||
module.exports = withCoreSuspender(App);
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const ErrorDialog = require('./ErrorDialog');
|
||||
|
||||
module.exports = ErrorDialog;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
const React = require('react');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { withCoreSuspender, useProfile, useToast } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const SearchParamsHandler = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
const toast = useToast();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,39 +1,17 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useToast } = require('stremio/common');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useToast, useFileDrop } = require('stremio/common');
|
||||
|
||||
const ServicesToaster = () => {
|
||||
const { core, dragAndDrop } = useServices();
|
||||
const core = useCore();
|
||||
const toast = useToast();
|
||||
const filedrop = useFileDrop();
|
||||
|
||||
React.useEffect(() => {
|
||||
const onCoreEvent = ({ event, args }) => {
|
||||
switch (event) {
|
||||
case 'Error': {
|
||||
if (args.source.event === 'UserPulledFromAPI' && args.source.args.uid === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (args.source.event === 'LibrarySyncWithAPIPlanned' && args.source.args.uid === null) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (args.error.type === 'Other' && args.error.code === 3 && args.source.event === 'AddonInstalled' && args.source.args.transport_url.startsWith('https://www.strem.io/trakt/addon')) {
|
||||
break;
|
||||
}
|
||||
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: args.source.event,
|
||||
message: args.error.message,
|
||||
timeout: 4000,
|
||||
dataset: {
|
||||
type: 'CoreEvent'
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
const onCoreEvent = (name, data) => {
|
||||
switch (name) {
|
||||
case 'TorrentParsed': {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
|
|
@ -53,26 +31,45 @@ const ServicesToaster = () => {
|
|||
case 'PlayingOnDevice': {
|
||||
toast.show({
|
||||
type: 'success',
|
||||
title: `Stream opened in ${args.device}`,
|
||||
title: `Stream opened in ${data.device}`,
|
||||
timeout: 4000
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onDragAndDropError = (error) => {
|
||||
const onCoreError = (source, error) => {
|
||||
if (source.event === 'UserPulledFromAPI' && source.args.uid === null) return;
|
||||
if (source.event === 'LibrarySyncWithAPIPlanned' && source.args.uid === null) return;
|
||||
if (error.type === 'Other' && error.code === 3 && source.event === 'AddonInstalled' && source.args.transport_url.startsWith('https://www.strem.io/trakt/addon')) return;
|
||||
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: error.message,
|
||||
message: error.file?.name,
|
||||
timeout: 4000
|
||||
title: source.event,
|
||||
message: error.message,
|
||||
timeout: 4000,
|
||||
dataset: {
|
||||
type: 'CoreEvent'
|
||||
}
|
||||
});
|
||||
};
|
||||
core.transport.on('CoreEvent', onCoreEvent);
|
||||
dragAndDrop.on('error', onDragAndDropError);
|
||||
const onFileDrop = (file, buffer, supported) => {
|
||||
if (!supported) {
|
||||
toast.show({
|
||||
type: 'error',
|
||||
title: 'Unsupported file',
|
||||
message: file.name,
|
||||
timeout: 4000
|
||||
});
|
||||
}
|
||||
};
|
||||
core.on('event', onCoreEvent);
|
||||
core.on('error', onCoreError);
|
||||
filedrop.on('*', onFileDrop);
|
||||
return () => {
|
||||
core.transport.off('CoreEvent', onCoreEvent);
|
||||
dragAndDrop.off('error', onDragAndDropError);
|
||||
core.off('event', onCoreEvent);
|
||||
core.off('error', onCoreError);
|
||||
filedrop.off('*', onFileDrop);
|
||||
};
|
||||
}, []);
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -213,22 +213,6 @@ html {
|
|||
transition: opacity 0.1s ease-out;
|
||||
}
|
||||
|
||||
.file-drop-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 1rem;
|
||||
border: 0.5rem dashed transparent;
|
||||
pointer-events: none;
|
||||
transition: border-color 0.25s ease-out;
|
||||
|
||||
&:global(.active) {
|
||||
border-color: var(--primary-accent-color);
|
||||
}
|
||||
}
|
||||
|
||||
.updater-banner-container {
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
|
|
@ -241,11 +225,6 @@ html {
|
|||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loader-container, .error-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ const ICON_FOR_TYPE = new Map([
|
|||
const MIME_SIGNATURES = {
|
||||
'application/x-subrip': ['310D0A', '310A'],
|
||||
'text/vtt': ['574542565454'],
|
||||
'application/x-bittorrent': ['64'],
|
||||
};
|
||||
|
||||
const SUPPORTED_LOCAL_SUBTITLES = [
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
|
||||
const CoreSuspenderContext = React.createContext(null);
|
||||
|
||||
|
|
@ -40,7 +40,7 @@ const useCoreSuspender = () => {
|
|||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
const withCoreSuspender = (Component, Fallback = () => { }) => {
|
||||
return function withCoreSuspender(props) {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const parentSuspender = useCoreSuspender();
|
||||
const [render, setRender] = React.useState(parentSuspender === null);
|
||||
const statesRef = React.useRef({});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { isFileType } from './utils';
|
||||
import { isFileType, isFileTypeSupported } from './utils';
|
||||
import styles from './styles.less';
|
||||
|
||||
export type FileType = string;
|
||||
export type FileDropListener = (filename: string, buffer: ArrayBuffer) => void;
|
||||
export type FileDropListener = (file: File, buffer: ArrayBuffer, supported: boolean) => void;
|
||||
|
||||
type FileDropContext = {
|
||||
on: (type: FileType, listener: FileDropListener) => void,
|
||||
|
|
@ -13,12 +14,11 @@ type FileDropContext = {
|
|||
const FileDropContext = createContext({} as FileDropContext);
|
||||
|
||||
type Props = {
|
||||
className: string,
|
||||
children: JSX.Element,
|
||||
children: React.ReactNode,
|
||||
};
|
||||
|
||||
const FileDropProvider = ({ className, children }: Props) => {
|
||||
const [listeners, setListeners] = useState<[FileType, FileDropListener][]>([]);
|
||||
const FileDropProvider = ({ children }: Props) => {
|
||||
const listeners = useRef<[FileType, FileDropListener][]>([]);
|
||||
const [active, setActive] = useState(false);
|
||||
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
|
|
@ -30,38 +30,38 @@ const FileDropProvider = ({ className, children }: Props) => {
|
|||
setActive(false);
|
||||
};
|
||||
|
||||
const onDrop = useCallback((event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const { dataTransfer } = event;
|
||||
|
||||
if (dataTransfer && dataTransfer?.files.length > 0) {
|
||||
const file = dataTransfer.files[0];
|
||||
|
||||
file
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
listeners
|
||||
.filter(([type]) => file.type ? type === file.type : isFileType(buffer, type))
|
||||
.forEach(([, listener]) => listener(file.name, buffer));
|
||||
});
|
||||
}
|
||||
|
||||
setActive(false);
|
||||
}, [listeners]);
|
||||
|
||||
const on = (type: FileType, listener: FileDropListener) => {
|
||||
setListeners((listeners) => {
|
||||
return [...listeners, [type, listener]];
|
||||
});
|
||||
listeners.current = [...listeners.current, [type, listener]];
|
||||
};
|
||||
|
||||
const off = (type: FileType, listener: FileDropListener) => {
|
||||
setListeners((listeners) => {
|
||||
return listeners.filter(([key, value]) => key !== type && value !== listener);
|
||||
});
|
||||
listeners.current = listeners.current.filter(([key, value]) => key !== type && value !== listener);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onDrop = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
const { dataTransfer } = event;
|
||||
|
||||
if (dataTransfer && dataTransfer?.files.length > 0) {
|
||||
const file = dataTransfer.files[0];
|
||||
|
||||
file
|
||||
.arrayBuffer()
|
||||
.then((buffer) => {
|
||||
listeners.current
|
||||
.filter(([type]) => type === '*')
|
||||
.forEach(([, listener]) => listener(file, buffer, isFileTypeSupported(buffer)));
|
||||
listeners.current
|
||||
.filter(([type]) => type !== '*' && (file.type ? type === file.type : isFileType(buffer, type)))
|
||||
.forEach(([, listener]) => listener(file, buffer, true));
|
||||
})
|
||||
.catch(console.error);
|
||||
}
|
||||
|
||||
setActive(false);
|
||||
};
|
||||
|
||||
window.addEventListener('dragover', onDragOver);
|
||||
window.addEventListener('dragleave', onDragLeave);
|
||||
window.addEventListener('drop', onDrop);
|
||||
|
|
@ -71,12 +71,12 @@ const FileDropProvider = ({ className, children }: Props) => {
|
|||
window.removeEventListener('dragleave', onDragLeave);
|
||||
window.removeEventListener('drop', onDrop);
|
||||
};
|
||||
}, [onDrop]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<FileDropContext.Provider value={{ on, off }}>
|
||||
{ children }
|
||||
<div className={classNames(className, { 'active': active })} />
|
||||
<div className={classNames(styles['file-drop-container'], { 'active': active })} />
|
||||
</FileDropContext.Provider>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ const onFileDrop = (types: FileType[], listener: FileDropListener) => {
|
|||
|
||||
useEffect(() => {
|
||||
types.forEach((type) => on(type, listener));
|
||||
|
||||
return () => types.forEach((type) => off(type, listener));
|
||||
}, []);
|
||||
};
|
||||
|
|
|
|||
15
src/common/FileDrop/styles.less
Normal file
15
src/common/FileDrop/styles.less
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.file-drop-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
border-radius: 1rem;
|
||||
border: 0.5rem dashed transparent;
|
||||
pointer-events: none;
|
||||
transition: border-color 0.25s ease-out;
|
||||
|
||||
&:global(.active) {
|
||||
border-color: var(--primary-accent-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -14,6 +14,11 @@ const isFileType = (buffer: ArrayBuffer, type: string) => {
|
|||
});
|
||||
};
|
||||
|
||||
const isFileTypeSupported = (buffer: ArrayBuffer) => {
|
||||
return Object.keys(SIGNATURES).some((type) => isFileType(buffer, type));
|
||||
};
|
||||
|
||||
export {
|
||||
isFileType,
|
||||
isFileTypeSupported,
|
||||
};
|
||||
|
|
|
|||
18
src/common/Fullscreen/FullscreenContext.ts
Normal file
18
src/common/Fullscreen/FullscreenContext.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// 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,
|
||||
supported: boolean,
|
||||
setVideoElement: (el: HTMLVideoElement | null) => void,
|
||||
];
|
||||
|
||||
const FullscreenContext = createContext<FullscreenContextValue | null>(null);
|
||||
|
||||
FullscreenContext.displayName = 'FullscreenContext';
|
||||
|
||||
export default FullscreenContext;
|
||||
119
src/common/Fullscreen/FullscreenProvider.tsx
Normal file
119
src/common/Fullscreen/FullscreenProvider.tsx
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
// Copyright (C) 2017-2026 Smart code 203358507
|
||||
|
||||
import React, { useCallback, useEffect, useMemo, useRef, 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 hasWebkitFullscreen = typeof HTMLVideoElement !== 'undefined' &&
|
||||
typeof HTMLVideoElement.prototype.webkitEnterFullscreen === 'function';
|
||||
|
||||
const FullscreenProvider = ({ children }: Props) => {
|
||||
const shell = useShell();
|
||||
const [settings] = useSettings();
|
||||
const escExitFullscreen = settings.escExitFullscreen;
|
||||
|
||||
const videoElementRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [hasVideoElement, setHasVideoElement] = useState(false);
|
||||
|
||||
const [fullscreen, setFullscreen] = useState<boolean>(() => {
|
||||
if (typeof document === 'undefined') return false;
|
||||
return document.fullscreenElement === document.documentElement;
|
||||
});
|
||||
|
||||
const setVideoElement = useCallback((el: HTMLVideoElement | null) => {
|
||||
videoElementRef.current = el;
|
||||
setHasVideoElement(el !== null);
|
||||
}, []);
|
||||
|
||||
const supported = shell.active || document.fullscreenEnabled === true || (hasVideoElement && hasWebkitFullscreen);
|
||||
|
||||
const requestFullscreen = useCallback(async () => {
|
||||
if (shell.active) {
|
||||
shell.send('win-set-visibility', { fullscreen: true });
|
||||
} else if (document.fullscreenEnabled) {
|
||||
try {
|
||||
await document.documentElement.requestFullscreen();
|
||||
} catch (err) {
|
||||
console.error('Error enabling fullscreen', err);
|
||||
}
|
||||
} else if (videoElementRef.current && hasWebkitFullscreen) {
|
||||
(videoElementRef.current as any).webkitEnterFullscreen();
|
||||
}
|
||||
}, [shell]);
|
||||
|
||||
const exitFullscreen = useCallback(() => {
|
||||
if (shell.active) {
|
||||
shell.send('win-set-visibility', { fullscreen: false });
|
||||
} else if (document.fullscreenElement === document.documentElement) {
|
||||
document.exitFullscreen();
|
||||
} else if (videoElementRef.current && (videoElementRef.current as any).webkitDisplayingFullscreen) {
|
||||
(videoElementRef.current as any).webkitExitFullscreen();
|
||||
}
|
||||
}, [shell]);
|
||||
|
||||
const toggleFullscreen = useCallback(() => {
|
||||
fullscreen ? exitFullscreen() : requestFullscreen();
|
||||
}, [fullscreen, exitFullscreen, requestFullscreen]);
|
||||
|
||||
onShortcut('fullscreen', toggleFullscreen, [toggleFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoElementRef.current;
|
||||
|
||||
const onWindowVisibilityChanged = (state: WindowVisibility) => {
|
||||
setFullscreen(state.isFullscreen === true);
|
||||
};
|
||||
|
||||
const onFullscreenChange = () => {
|
||||
setFullscreen(document.fullscreenElement === document.documentElement);
|
||||
};
|
||||
|
||||
const onWebkitFullscreenChange = () => {
|
||||
setFullscreen((videoElement as any)?.webkitDisplayingFullscreen === true);
|
||||
};
|
||||
|
||||
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);
|
||||
videoElement?.addEventListener('webkitbeginfullscreen', onWebkitFullscreenChange);
|
||||
videoElement?.addEventListener('webkitendfullscreen', onWebkitFullscreenChange);
|
||||
|
||||
return () => {
|
||||
shell.off('win-visibility-changed', onWindowVisibilityChanged);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
document.removeEventListener('fullscreenchange', onFullscreenChange);
|
||||
videoElement?.removeEventListener('webkitbeginfullscreen', onWebkitFullscreenChange);
|
||||
videoElement?.removeEventListener('webkitendfullscreen', onWebkitFullscreenChange);
|
||||
};
|
||||
}, [shell, toggleFullscreen, exitFullscreen, escExitFullscreen, hasVideoElement]);
|
||||
|
||||
const value = useMemo<FullscreenContextValue>(
|
||||
() => [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported, setVideoElement],
|
||||
[fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen, supported, setVideoElement]
|
||||
);
|
||||
|
||||
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;
|
||||
|
|
@ -16,22 +16,44 @@ const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext)
|
|||
|
||||
type Props = {
|
||||
children: JSX.Element,
|
||||
onShortcut: (name: ShortcutName) => void,
|
||||
onShortcut: (name: ShortcutName, combo: number, key: string) => void,
|
||||
};
|
||||
|
||||
const REPEAT_THROTTLE_MS = 130;
|
||||
|
||||
const isInputFocused = () => {
|
||||
const inputElements = ['INPUT', 'TEXTAREA', 'SELECT'];
|
||||
const activeElement = document.activeElement;
|
||||
|
||||
return activeElement instanceof HTMLElement &&
|
||||
(inputElements.includes(activeElement.tagName) || activeElement.isContentEditable);
|
||||
};
|
||||
|
||||
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, altKey, metaKey, code, key, repeat }: KeyboardEvent) => {
|
||||
if (isInputFocused()) return;
|
||||
|
||||
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);
|
||||
const modifers = (keys.includes('Ctrl') === ctrlKey)
|
||||
&& (keys.includes('Shift') === shiftKey)
|
||||
&& !altKey
|
||||
&& !metaKey;
|
||||
|
||||
if (modifers && (keys.includes(code) || keys.includes(key.toUpperCase()))) {
|
||||
const combo = combos.indexOf(keys);
|
||||
listeners.current.get(name)?.forEach((listener) => listener(combo));
|
||||
|
||||
onShortcut(name as ShortcutName);
|
||||
onShortcut(name as ShortcutName, combo, key);
|
||||
}
|
||||
}));
|
||||
}, [onShortcut]);
|
||||
|
|
|
|||
|
|
@ -13,20 +13,25 @@
|
|||
"label": "SETTINGS_SHORTCUT_GO_TO_SEARCH",
|
||||
"combos": [["0"]]
|
||||
},
|
||||
{
|
||||
"name": "navigateHistory",
|
||||
"label": "SETTINGS_SHORTCUT_NAVIGATE_HISTORY",
|
||||
"combos": [["Backspace"], ["Ctrl", "Backspace"]]
|
||||
},
|
||||
{
|
||||
"name": "fullscreen",
|
||||
"label": "SETTINGS_SHORTCUT_FULLSCREEN",
|
||||
"combos": [["F"]]
|
||||
},
|
||||
{
|
||||
"name": "exit",
|
||||
"label": "SETTINGS_SHORTCUT_EXIT_BACK",
|
||||
"combos": [["Escape"]]
|
||||
},
|
||||
{
|
||||
"name": "shortcuts",
|
||||
"label": "SETTINGS_SHORTCUT_SHORTCUTS",
|
||||
"combos": [["Ctrl", "/"]]
|
||||
},
|
||||
{
|
||||
"name": "gamepadGuide",
|
||||
"label": "GAMEPAD_ACTION_GUIDE",
|
||||
"combos": [["Ctrl", "G"]]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
@ -50,14 +55,9 @@
|
|||
"combos": [["ArrowLeft"], ["Shift", "ArrowLeft"]]
|
||||
},
|
||||
{
|
||||
"name": "volumeUp",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME_UP",
|
||||
"combos": [["ArrowUp"]]
|
||||
},
|
||||
{
|
||||
"name": "volumeDown",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME_DOWN",
|
||||
"combos": [["ArrowDown"]]
|
||||
"name": "volume",
|
||||
"label": "SETTINGS_SHORTCUT_VOLUME",
|
||||
"combos": [["ArrowUp"], ["ArrowDown"]]
|
||||
},
|
||||
{
|
||||
"name": "mute",
|
||||
|
|
@ -75,14 +75,9 @@
|
|||
"combos": [["G"], ["H"]]
|
||||
},
|
||||
{
|
||||
"name": "speedDown",
|
||||
"label": "SETTINGS_SHORTCUT_DECREASE_PLAYBACK_SPEED",
|
||||
"combos": [["["]]
|
||||
},
|
||||
{
|
||||
"name": "speedUp",
|
||||
"label": "SETTINGS_SHORTCUT_INCREASE_PLAYBACK_SPEED",
|
||||
"combos": [["]"]]
|
||||
"name": "speed",
|
||||
"label": "SETTINGS_SHORTCUT_PLAYBACK_SPEED",
|
||||
"combos": [["["], ["]"]]
|
||||
},
|
||||
{
|
||||
"name": "toggleSubtitles",
|
||||
|
|
@ -118,6 +113,11 @@
|
|||
"name": "playNext",
|
||||
"label": "SETTINGS_SHORTCUT_PLAY_NEXT",
|
||||
"combos": [["Shift", "N"]]
|
||||
},
|
||||
{
|
||||
"name": "exit",
|
||||
"label": "SETTINGS_SHORTCUT_EXIT_BACK",
|
||||
"combos": [["Escape"]]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const { FileDropProvider, onFileDrop } = require('./FileDrop');
|
||||
const { FileDropProvider, useFileDrop, onFileDrop } = require('./FileDrop');
|
||||
const { FullscreenProvider, useFullscreen } = require('./Fullscreen');
|
||||
const { PlatformProvider, usePlatform } = require('./Platform');
|
||||
const { ToastProvider, useToast } = require('./Toast');
|
||||
const { TooltipProvider, Tooltip } = require('./Tooltips');
|
||||
|
|
@ -15,7 +16,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,7 +34,9 @@ const { default: useLanguageSorting } = require('./useLanguageSorting');
|
|||
|
||||
module.exports = {
|
||||
FileDropProvider,
|
||||
useFileDrop,
|
||||
onFileDrop,
|
||||
FullscreenProvider,
|
||||
PlatformProvider,
|
||||
usePlatform,
|
||||
ShortcutsProvider,
|
||||
|
|
|
|||
|
|
@ -101,7 +101,7 @@
|
|||
},
|
||||
{
|
||||
"name": "Lietuvių",
|
||||
"codes": ["lt-LT", "ltu"]
|
||||
"codes": ["lt-LT", "lit"]
|
||||
},
|
||||
{
|
||||
"name": "македонски јазик",
|
||||
|
|
|
|||
|
|
@ -1,57 +1,57 @@
|
|||
{
|
||||
"abk": "аҧсуа бызшәа",
|
||||
"abk": "Аҧсуа бызшәа",
|
||||
"aar": "Afaraf",
|
||||
"afr": "Afrikaans",
|
||||
"aka": "Akan",
|
||||
"sqi": "gjuha shqipe",
|
||||
"sqi": "Gjuha shqipe",
|
||||
"amh": "አማርኛ",
|
||||
"ara": "العربية",
|
||||
"arg": "aragonés",
|
||||
"arg": "Aragonés",
|
||||
"hye": "Հայերեն",
|
||||
"asm": "অসমীয়া",
|
||||
"ava": "авар мацӀ",
|
||||
"ave": "avesta",
|
||||
"aym": "aymar aru",
|
||||
"aze": "azərbaycan dili",
|
||||
"bam": "bamanankan",
|
||||
"bak": "башҡорт теле",
|
||||
"eus": "euskara",
|
||||
"bel": "беларуская мова",
|
||||
"ava": "Авар мацӀ",
|
||||
"ave": "Avesta",
|
||||
"aym": "Aymar aru",
|
||||
"aze": "Azərbaycan dili",
|
||||
"bam": "Bamanankan",
|
||||
"bak": "Башҡорт теле",
|
||||
"eus": "Euskara",
|
||||
"bel": "Беларуская мова",
|
||||
"ben": "বাংলা",
|
||||
"bih": "भोजपुरी",
|
||||
"bis": "Bislama",
|
||||
"bos": "bosanski jezik",
|
||||
"bre": "brezhoneg",
|
||||
"bul": "български език",
|
||||
"bos": "Bosanski jezik",
|
||||
"bre": "Brezhoneg",
|
||||
"bul": "Български език",
|
||||
"mya": "ဗမာစာ",
|
||||
"cat": "català",
|
||||
"cat": "Català",
|
||||
"cha": "Chamoru",
|
||||
"che": "нохчийн мотт",
|
||||
"nya": "chiCheŵa",
|
||||
"che": "Нохчийн мотт",
|
||||
"nya": "ChiCheŵa",
|
||||
"zho": "中文 (Zhōngwén)",
|
||||
"chv": "чӑваш чӗлхи",
|
||||
"chv": "Чӑваш чӗлхи",
|
||||
"cor": "Kernewek",
|
||||
"cos": "corsu",
|
||||
"cos": "Corsu",
|
||||
"cre": "ᓀᐦᐃᔭᐍᐏᐣ",
|
||||
"hrv": "hrvatski jezik",
|
||||
"ces": "čeština",
|
||||
"dan": "dansk",
|
||||
"hrv": "Hrvatski jezik",
|
||||
"ces": "Čeština",
|
||||
"dan": "Dansk",
|
||||
"div": "ދިވެހި",
|
||||
"nld": "Nederlands",
|
||||
"dzo": "རྫོང་ཁ",
|
||||
"eng": "English",
|
||||
"epo": "Esperanto",
|
||||
"est": "eesti",
|
||||
"est": "Eesti",
|
||||
"ewe": "Eʋegbe",
|
||||
"fao": "føroyskt",
|
||||
"fij": "vosa Vakaviti",
|
||||
"fin": "suomi",
|
||||
"fre": "français",
|
||||
"fao": "Føroyskt",
|
||||
"fij": "Vosa Vakaviti",
|
||||
"fin": "Suomi",
|
||||
"fre": "Français",
|
||||
"ful": "Fulfulde",
|
||||
"glg": "galego",
|
||||
"glg": "Galego",
|
||||
"kat": "ქართული",
|
||||
"ger": "Deutsch",
|
||||
"ell": "ελληνικά",
|
||||
"ell": "Ελληνικά",
|
||||
"grn": "Avañe'ẽ",
|
||||
"guj": "ગુજરાતી",
|
||||
"hat": "Kreyòl ayisyen",
|
||||
|
|
@ -60,7 +60,7 @@
|
|||
"her": "Otjiherero",
|
||||
"hin": "हिन्दी",
|
||||
"hmo": "Hiri Motu",
|
||||
"hun": "magyar",
|
||||
"hun": "Magyar",
|
||||
"ina": "Interlingua",
|
||||
"ind": "Bahasa Indonesia",
|
||||
"ile": "Interlingue",
|
||||
|
|
@ -69,119 +69,119 @@
|
|||
"ipk": "Iñupiaq",
|
||||
"ido": "Ido",
|
||||
"isl": "Íslenska",
|
||||
"ita": "italiano",
|
||||
"ita": "Italiano",
|
||||
"iku": "ᐃᓄᒃᑎᑐᑦ",
|
||||
"jpn": "日本語 (にほんご)",
|
||||
"jav": "basa Jawa",
|
||||
"kal": "kalaallisut",
|
||||
"jav": "Basa Jawa",
|
||||
"kal": "Kalaallisut",
|
||||
"kan": "ಕನ್ನಡ",
|
||||
"kau": "Kanuri",
|
||||
"kas": "कश्मीरी",
|
||||
"kaz": "қазақ тілі",
|
||||
"kaz": "Қазақ тілі",
|
||||
"khm": "ខ្មែរ",
|
||||
"kik": "Gĩkũyũ",
|
||||
"kin": "Ikinyarwanda",
|
||||
"kir": "Кыргызча",
|
||||
"kom": "коми кыв",
|
||||
"kom": "Коми кыв",
|
||||
"kon": "KiKongo",
|
||||
"kor": "한국어 (韓國語)",
|
||||
"kur": "Kurdî",
|
||||
"kua": "Kuanyama",
|
||||
"lat": "latine",
|
||||
"lat": "Latine",
|
||||
"ltz": "Lëtzebuergesch",
|
||||
"lug": "Luganda",
|
||||
"lim": "Limburgs",
|
||||
"lin": "Lingála",
|
||||
"lao": "ພາສາລາວ",
|
||||
"lit": "lietuvių kalba",
|
||||
"lit": "Lietuvių kalba",
|
||||
"lub": "Tshiluba",
|
||||
"lav": "latviešu valoda",
|
||||
"lav": "Latviešu valoda",
|
||||
"glv": "Gaelg",
|
||||
"mkd": "македонски јазик",
|
||||
"mlg": "fiteny malagasy",
|
||||
"msa": "bahasa Melayu",
|
||||
"mkd": "Македонски јазик",
|
||||
"mlg": "Fiteny malagasy",
|
||||
"msa": "Bahasa Melayu",
|
||||
"mal": "മലയാളം",
|
||||
"mlt": "Malti",
|
||||
"mri": "te reo Māori",
|
||||
"mri": "Te reo Māori",
|
||||
"mar": "मराठी",
|
||||
"mah": "Kajin M̧ajeļ",
|
||||
"mon": "монгол",
|
||||
"mon": "Монгол",
|
||||
"nau": "Ekakairũ Naoero",
|
||||
"nav": "Diné bizaad",
|
||||
"nob": "Norsk bokmål",
|
||||
"nde": "isiNdebele",
|
||||
"nde": "IsiNdebele",
|
||||
"nep": "नेपाली",
|
||||
"ndo": "Owambo",
|
||||
"nno": "Norsk nynorsk",
|
||||
"nor": "Norsk",
|
||||
"iii": "ꆈꌠ꒿ Nuosuhxop",
|
||||
"nbl": "isiNdebele",
|
||||
"oci": "occitan",
|
||||
"nbl": "IsiNdebele",
|
||||
"oci": "Occitan",
|
||||
"oji": "ᐊᓂᔑᓈᐯᒧᐎᓐ",
|
||||
"chu": "ѩзыкъ словѣньскъ",
|
||||
"chu": "Ѩзыкъ словѣньскъ",
|
||||
"orm": "Afaan Oromoo",
|
||||
"ori": "ଓଡ଼ିଆ",
|
||||
"oss": "ирон æвзаг",
|
||||
"oss": "Ирон æвзаг",
|
||||
"pan": "ਪੰਜਾਬੀ",
|
||||
"pli": "पाऴि",
|
||||
"fas": "فارسی",
|
||||
"pol": "język polski",
|
||||
"pol": "Język polski",
|
||||
"pus": "پښتو",
|
||||
"por": "português",
|
||||
"pob": "português Brazil",
|
||||
"por": "Português",
|
||||
"pob": "Português Brazil",
|
||||
"que": "Runa Simi",
|
||||
"roh": "rumantsch grischun",
|
||||
"roh": "Rumantsch grischun",
|
||||
"run": "Ikirundi",
|
||||
"ron": "limba română",
|
||||
"rus": "русский язык",
|
||||
"ron": "Limba română",
|
||||
"rus": "Русский язык",
|
||||
"san": "संस्कृतम्",
|
||||
"srd": "sardu",
|
||||
"srd": "Sardu",
|
||||
"snd": "सिन्धी",
|
||||
"sme": "Davvisámegiella",
|
||||
"smo": "gagana fa'a Samoa",
|
||||
"sag": "yângâ tî sängö",
|
||||
"srp": "српски језик",
|
||||
"smo": "Gagana fa'a Samoa",
|
||||
"sag": "Yângâ tî sängö",
|
||||
"srp": "Српски језик",
|
||||
"gla": "Gàidhlig",
|
||||
"sna": "chiShona",
|
||||
"sna": "ChiShona",
|
||||
"sin": "සිංහල",
|
||||
"slk": "slovenčina",
|
||||
"slv": "slovenski jezik",
|
||||
"slk": "Slovenčina",
|
||||
"slv": "Slovenski jezik",
|
||||
"som": "Soomaaliga",
|
||||
"sot": "Sesotho",
|
||||
"spa": "español",
|
||||
"spa": "Español",
|
||||
"sun": "Basa Sunda",
|
||||
"swa": "Kiswahili",
|
||||
"ssw": "SiSwati",
|
||||
"swe": "Svenska",
|
||||
"tam": "தமிழ்",
|
||||
"tel": "తెలుగు",
|
||||
"tgk": "тоҷикӣ",
|
||||
"tgk": "Тоҷикӣ",
|
||||
"tha": "ไทย",
|
||||
"tir": "ትግርኛ",
|
||||
"bod": "བོད་ཡིག",
|
||||
"tuk": "Türkmen",
|
||||
"tgl": "Wikang Tagalog",
|
||||
"tsn": "Setswana",
|
||||
"ton": "faka Tonga",
|
||||
"ton": "Faka Tonga",
|
||||
"tur": "Türkçe",
|
||||
"tso": "Xitsonga",
|
||||
"tat": "татар теле",
|
||||
"tat": "Татар теле",
|
||||
"twi": "Twi",
|
||||
"tah": "Reo Tahiti",
|
||||
"uig": "Uyƣurqə",
|
||||
"ukr": "українська мова",
|
||||
"ukr": "Українська мова",
|
||||
"urd": "اردو",
|
||||
"uzb": "O'zbek",
|
||||
"ven": "Tshivenḓa",
|
||||
"vie": "Tiếng Việt",
|
||||
"vol": "Volapük",
|
||||
"wln": "walon",
|
||||
"wln": "Walon",
|
||||
"cym": "Cymraeg",
|
||||
"wol": "Wollof",
|
||||
"fry": "Frysk",
|
||||
"xho": "isiXhosa",
|
||||
"xho": "IsiXhosa",
|
||||
"yid": "ייִדיש",
|
||||
"yor": "Yorùbá",
|
||||
"zha": "Saɯ cueŋƅ",
|
||||
"zul": "isiZulu"
|
||||
}
|
||||
"zul": "IsiZulu"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -4,12 +4,12 @@ const React = require('react');
|
|||
const throttle = require('lodash.throttle');
|
||||
const { deepEqual } = require('fast-equals');
|
||||
const intersection = require('lodash.intersection');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const useModelState = ({ action, ...args }) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const routeFocused = useRouteFocused();
|
||||
const mountedRef = React.useRef(false);
|
||||
const [model, timeout, map, deps] = React.useMemo(() => {
|
||||
|
|
@ -25,24 +25,21 @@ const useModelState = ({ action, ...args }) => {
|
|||
},
|
||||
undefined,
|
||||
() => {
|
||||
if (typeof map === 'function') {
|
||||
return map(getState(model));
|
||||
} else {
|
||||
return getState(model);
|
||||
}
|
||||
const state = getState(model);
|
||||
return typeof map === 'function' ? map(state) : state;
|
||||
}
|
||||
);
|
||||
React.useInsertionEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (action) {
|
||||
core.transport.dispatch(action, model);
|
||||
}
|
||||
}, [action]);
|
||||
React.useInsertionEffect(() => {
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
core.transport.dispatch({ action: 'Unload' }, model);
|
||||
};
|
||||
}, []);
|
||||
React.useInsertionEffect(() => {
|
||||
React.useEffect(() => {
|
||||
const onNewState = async (models) => {
|
||||
if (models.indexOf(model) === -1 && (!Array.isArray(deps) || intersection(deps, models).length === 0)) {
|
||||
return;
|
||||
|
|
@ -57,17 +54,17 @@ const useModelState = ({ action, ...args }) => {
|
|||
};
|
||||
const onNewStateThrottled = throttle(onNewState, timeout);
|
||||
if (routeFocused) {
|
||||
core.transport.on('NewState', onNewStateThrottled);
|
||||
core.on('state', onNewStateThrottled);
|
||||
if (mountedRef.current) {
|
||||
onNewState([model]);
|
||||
}
|
||||
}
|
||||
return () => {
|
||||
onNewStateThrottled.cancel();
|
||||
core.transport.off('NewState', onNewStateThrottled);
|
||||
core.off('state', onNewStateThrottled);
|
||||
};
|
||||
}, [routeFocused]);
|
||||
React.useInsertionEffect(() => {
|
||||
React.useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
}, []);
|
||||
return state;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback } from 'react';
|
||||
import magnet from 'magnet-uri';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { useCore } from 'stremio/core';
|
||||
import useToast from 'stremio/common/Toast/useToast';
|
||||
import useTorrent from 'stremio/common/useTorrent';
|
||||
import useStreamingServer from 'stremio/common/useStreamingServer';
|
||||
|
|
@ -8,7 +8,7 @@ import useStreamingServer from 'stremio/common/useStreamingServer';
|
|||
const HTTP_REGEX = /^https?:\/\/.+/i;
|
||||
|
||||
const usePlayUrl = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const toast = useToast();
|
||||
const { createTorrentFromMagnet } = useTorrent();
|
||||
const streamingServer = useStreamingServer();
|
||||
|
|
@ -24,7 +24,11 @@ const usePlayUrl = () => {
|
|||
timeout: 3000
|
||||
});
|
||||
try {
|
||||
const encoded = await core.transport.encodeStream({ url: trimmed });
|
||||
const encoded = await core.transport.encodeStream({
|
||||
name: '',
|
||||
description: '',
|
||||
url: trimmed,
|
||||
});
|
||||
if (typeof encoded === 'string') {
|
||||
window.location.hash = `#/player/${encodeURIComponent(encoded)}`;
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useServices } from 'stremio/services';
|
||||
import useProfile from './useProfile';
|
||||
import { useCore } from 'stremio/core';
|
||||
|
||||
const useSettings = (): [Settings, (settings: Settings) => void] => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
|
||||
const updateSettings = useCallback((settings: Settings) => {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,10 @@ export type WindowState = {
|
|||
state: number;
|
||||
};
|
||||
|
||||
export type MediaStatus = {
|
||||
paused: boolean;
|
||||
};
|
||||
|
||||
const createId = () => Math.floor(Math.random() * 9999) + 1;
|
||||
|
||||
const useShell = () => {
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@
|
|||
|
||||
const React = require('react');
|
||||
const magnet = require('magnet-uri');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const useToast = require('stremio/common/Toast/useToast');
|
||||
const useStreamingServer = require('stremio/common/useStreamingServer');
|
||||
|
||||
const CREATE_TORRENT_TIMEOUT = 20000;
|
||||
|
||||
const useTorrent = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const streamingServer = useStreamingServer();
|
||||
const toast = useToast();
|
||||
const createTorrentTimeout = React.useRef(null);
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const { useCore } = require('stremio/core');
|
||||
const ModalDialog = require('stremio/components/ModalDialog');
|
||||
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
|
||||
const { usePlatform } = require('stremio/common/Platform');
|
||||
const { useServices } = require('stremio/services');
|
||||
const AddonDetailsWithRemoteAndLocalAddon = withRemoteAndLocalAddon(require('./AddonDetails'));
|
||||
const useAddonDetails = require('./useAddonDetails');
|
||||
const styles = require('./styles');
|
||||
|
|
@ -45,7 +45,7 @@ function withRemoteAndLocalAddon(AddonDetails) {
|
|||
|
||||
const AddonDetailsModal = ({ transportUrl, onCloseRequest }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const platform = usePlatform();
|
||||
const addonDetails = useAddonDetails(transportUrl);
|
||||
const modalButtons = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@
|
|||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const LibItem = require('stremio/components/LibItem');
|
||||
|
||||
const ContinueWatchingItem = ({ _id, notifications, ...props }) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const onDismissClick = React.useCallback((event) => {
|
||||
event.preventDefault();
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const { useCore } = require('stremio/core');
|
||||
const useModelState = require('stremio/common/useModelState');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const map = (ctx) => ({
|
||||
...ctx.events,
|
||||
});
|
||||
|
||||
const useEvents = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const pullEvents = () => {
|
||||
core.transport.dispatch({
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const PropTypes = require('prop-types');
|
||||
const { useCore } = require('stremio/core');
|
||||
const MetaItem = require('stremio/components/MetaItem');
|
||||
const { t } = require('i18next');
|
||||
|
||||
const LibItem = ({ _id, removable, notifications, watched, ...props }) => {
|
||||
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const newVideos = React.useMemo(() => {
|
||||
const count = notifications.items?.[_id]?.length ?? 0;
|
||||
|
|
|
|||
|
|
@ -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,10 +1,10 @@
|
|||
// Copyright (C) 2017-2025 Smart code 203358507
|
||||
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { useCore } from 'stremio/core';
|
||||
|
||||
const useRating = (ratingInfo?: Loadable<RatingInfo>) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const setRating = useCallback((status: Rating) => {
|
||||
core.transport.dispatch({
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ 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 usePWA = require('stremio/common/usePWA');
|
||||
const { useFullscreen } = require('stremio/common/Fullscreen');
|
||||
const { useHorizontalNavGamepadNavigation } = require('stremio/services/GamepadNavigation');
|
||||
const SearchBar = require('./SearchBar');
|
||||
const NavMenu = require('./NavMenu');
|
||||
const styles = require('./styles');
|
||||
|
|
@ -16,14 +16,14 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
|
|||
const backButtonOnClick = React.useCallback(() => {
|
||||
window.history.back();
|
||||
}, []);
|
||||
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
|
||||
const [isIOSPWA] = usePWA();
|
||||
const [fullscreen, requestFullscreen, exitFullscreen, , supported] = useFullscreen();
|
||||
const renderNavMenuLabel = React.useCallback(({ ref, className, onClick, children, }) => (
|
||||
<Button ref={ref} className={classnames(className, styles['button-container'], styles['menu-button-container'])} tabIndex={-1} onClick={onClick}>
|
||||
<Icon className={styles['icon']} name={'person-outline'} />
|
||||
{children}
|
||||
</Button>
|
||||
), []);
|
||||
useHorizontalNavGamepadNavigation(route || className, backButton);
|
||||
return (
|
||||
<nav {...props} className={classnames(className, styles['horizontal-nav-bar-container'])}>
|
||||
{
|
||||
|
|
@ -62,7 +62,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
|
|||
null
|
||||
}
|
||||
{
|
||||
!isIOSPWA && fullscreenButton ?
|
||||
supported && fullscreenButton ?
|
||||
<Button className={styles['button-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} tabIndex={-1} onClick={fullscreen ? exitFullscreen : requestFullscreen}>
|
||||
<Icon className={styles['icon']} name={fullscreen ? 'minimize' : 'maximize'} />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
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');
|
||||
|
|
@ -18,13 +18,13 @@ const styles = require('./styles');
|
|||
|
||||
const NavMenuContent = ({ onClick }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
const streamingServer = useStreamingServer();
|
||||
const { handlePlayUrl } = usePlayUrl();
|
||||
const toast = useToast();
|
||||
const [fullscreen, requestFullscreen, exitFullscreen] = useFullscreen();
|
||||
const [isIOSPWA, isAndroidPWA] = usePWA();
|
||||
const [fullscreen, requestFullscreen, exitFullscreen, , supported] = useFullscreen();
|
||||
const [, isAndroidPWA] = usePWA();
|
||||
const streamingServerWarningDismissed = React.useMemo(() => {
|
||||
return streamingServer.settings !== null && streamingServer.settings.type === 'Ready' || (
|
||||
!isNaN(profile.settings.streamingServerWarningDismissed.getTime()) &&
|
||||
|
|
@ -79,7 +79,7 @@ const NavMenuContent = ({ onClick }) => {
|
|||
</div>
|
||||
</div>
|
||||
{
|
||||
!isIOSPWA && !isAndroidPWA ?
|
||||
supported && !isAndroidPWA ?
|
||||
<div className={styles['nav-menu-section']}>
|
||||
<Button className={styles['nav-menu-option-container']} title={fullscreen ? t('EXIT_FULLSCREEN') : t('ENTER_FULLSCREEN')} onClick={fullscreen ? exitFullscreen : requestFullscreen}>
|
||||
<Icon className={styles['icon']} name={fullscreen ? 'minimize' : 'maximize'} />
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const useModelState = require('stremio/common/useModelState');
|
||||
|
||||
const useLocalSearch = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const action = React.useMemo(() => ({
|
||||
action: 'Load',
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useCore } = require('stremio/core');
|
||||
const useModelState = require('stremio/common/useModelState');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const useSearchHistory = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const { searchHistory: items } = useModelState({ model: 'ctx' });
|
||||
|
||||
const clear = React.useCallback(() => {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const classnames = require('classnames');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { Button } = require('stremio/components');
|
||||
const { default: TextInput } = require('stremio/components/TextInput');
|
||||
const useToast = require('stremio/common/Toast/useToast');
|
||||
|
|
@ -14,7 +14,7 @@ const styles = require('./styles');
|
|||
|
||||
const SharePrompt = ({ className, url }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const toast = useToast();
|
||||
const inputRef = React.useRef(null);
|
||||
const routeFocused = useRouteFocused();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
.combos {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
row-gap: 1rem;
|
||||
justify-content: end;
|
||||
overflow: visible;
|
||||
|
||||
.combo {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ const Keys = ({ keys }: Props) => {
|
|||
'Space': t('SETTINGS_SHORTCUT_SPACE'),
|
||||
'Ctrl': t('SETTINGS_SHORTCUT_CTRL'),
|
||||
'Escape': t('SETTINGS_SHORTCUT_ESC'),
|
||||
'Backspace': t('SETTINGS_SHORTCUT_BACKSPACE'),
|
||||
'ArrowUp': '↑',
|
||||
'ArrowDown': '↓',
|
||||
'ArrowLeft': '←',
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.shortcuts-group {
|
||||
flex: 1 1 0;
|
||||
position: relative;
|
||||
width: 30rem;
|
||||
width: 35rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2rem;
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
.shortcut {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
overflow: visible;
|
||||
|
|
@ -35,7 +35,6 @@
|
|||
position: relative;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
|||
15
src/core/CoreContext.ts
Normal file
15
src/core/CoreContext.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { createContext } from 'react';
|
||||
|
||||
interface CoreContext {
|
||||
transport: CoreTransport;
|
||||
on(name: 'state', listener: CoreStateListener): void;
|
||||
on(name: 'event', listener: CoreEventListener): void;
|
||||
on(name: 'error', listener: CoreErrorListener): void;
|
||||
off(name: 'state', listener: CoreStateListener): void;
|
||||
off(name: 'event', listener: CoreEventListener): void;
|
||||
off(name: 'error', listener: CoreErrorListener): void;
|
||||
}
|
||||
|
||||
const CoreContext = createContext<CoreContext>({} as CoreContext);
|
||||
|
||||
export default CoreContext;
|
||||
99
src/core/CoreProvider.tsx
Normal file
99
src/core/CoreProvider.tsx
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import CoreContext from './CoreContext';
|
||||
import createTransport from './createTransport';
|
||||
import Error from './Error';
|
||||
|
||||
type Props = {
|
||||
appInfo: object,
|
||||
children: React.ReactNode,
|
||||
};
|
||||
|
||||
const Core = (props: Props) => {
|
||||
const transport = createTransport();
|
||||
const [initialized, setInitialized] = useState(false);
|
||||
const [error, setError] = useState<Error | null>();
|
||||
|
||||
const stateListeners = useRef<CoreStateListener[]>([]);
|
||||
const eventListeners = useRef<CoreEventListener[]>([]);
|
||||
const errorListeners = useRef<CoreErrorListener[]>([]);
|
||||
|
||||
const on = (name: CoreListenerType, listener: CoreListener) => {
|
||||
if (name === 'state') stateListeners.current = [...stateListeners.current, listener as CoreStateListener];
|
||||
if (name === 'event') eventListeners.current = [...eventListeners.current, listener as CoreEventListener];
|
||||
if (name === 'error') errorListeners.current = [...errorListeners.current, listener as CoreErrorListener];
|
||||
};
|
||||
|
||||
const off = (name: CoreListenerType, listener: CoreListener) => {
|
||||
if (name === 'state') stateListeners.current = stateListeners.current.filter((l) => l !== listener);
|
||||
if (name === 'event') eventListeners.current = eventListeners.current.filter((l) => l !== listener);
|
||||
if (name === 'error') errorListeners.current = errorListeners.current.filter((l) => l !== listener);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onCoreEvent = ({ name, args }: NewStateEvent | CoreEventEvent) => {
|
||||
switch (name) {
|
||||
case 'NewState':
|
||||
stateListeners.current.forEach((listener) => listener(args));
|
||||
break;
|
||||
|
||||
case 'CoreEvent': {
|
||||
switch (args.event) {
|
||||
case 'Error': {
|
||||
const { source, error } = args.args;
|
||||
errorListeners.current.forEach((listener) => listener(
|
||||
source,
|
||||
error,
|
||||
));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
eventListeners.current.forEach((listener) => listener(
|
||||
args.event,
|
||||
args.args,
|
||||
));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if (!window.core) {
|
||||
transport
|
||||
.init(props.appInfo)
|
||||
.then(() => {
|
||||
window.core = transport;
|
||||
window.onCoreEvent = onCoreEvent;
|
||||
setInitialized(true);
|
||||
setError(null);
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
console.error('Failed to initialize core:', e);
|
||||
setInitialized(false);
|
||||
setError(e);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
stateListeners.current = [];
|
||||
eventListeners.current = [];
|
||||
errorListeners.current = [];
|
||||
setInitialized(false);
|
||||
setError(null);
|
||||
window.onCoreEvent = null;
|
||||
window.core = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<CoreContext.Provider value={{ transport, on, off }}>
|
||||
{ error && !initialized && <Error /> }
|
||||
{ initialized && !error && props.children }
|
||||
</CoreContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default Core;
|
||||
|
|
@ -1,25 +1,27 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { Image, Button } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import Image from 'stremio/components/Image';
|
||||
import Button from 'stremio/components/Button';
|
||||
import styles from './styles.less';
|
||||
|
||||
const ErrorDialog = ({ className }) => {
|
||||
const Error = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [dataCleared, setDataCleared] = React.useState(false);
|
||||
|
||||
const reload = React.useCallback(() => {
|
||||
window.location.reload();
|
||||
}, []);
|
||||
|
||||
const clearData = React.useCallback(() => {
|
||||
window.localStorage.clear();
|
||||
setDataCleared(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={classnames(className, styles['error-container'])}>
|
||||
<div className={styles['error-container']}>
|
||||
<Image
|
||||
className={styles['error-image']}
|
||||
src={require('/assets/images/empty.png')}
|
||||
|
|
@ -44,10 +46,4 @@ const ErrorDialog = ({ className }) => {
|
|||
);
|
||||
};
|
||||
|
||||
ErrorDialog.displayName = 'ErrorDialog';
|
||||
|
||||
ErrorDialog.propTypes = {
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
module.exports = ErrorDialog;
|
||||
export default Error;
|
||||
4
src/core/Error/index.ts
Normal file
4
src/core/Error/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
import Error from './Error';
|
||||
export default Error;
|
||||
|
|
@ -3,6 +3,9 @@
|
|||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
|
||||
.error-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
41
src/core/createTransport.ts
Normal file
41
src/core/createTransport.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Bridge from '@stremio/stremio-core-web/bridge';
|
||||
|
||||
const worker = new Worker(`${process.env.COMMIT_HASH}/scripts/worker.js`);
|
||||
const bridge = new Bridge(window, worker);
|
||||
|
||||
const createTransport = (): CoreTransport => {
|
||||
const init = async (args: object): Promise<void> => {
|
||||
return bridge.call(['init'], [args]);
|
||||
};
|
||||
|
||||
const getState = (model: string): Promise<object> => {
|
||||
return bridge.call(['getState'], [model]);
|
||||
};
|
||||
|
||||
const dispatch = (action: DispatchAction, model?: string): Promise<void> => {
|
||||
return bridge.call(['dispatch'], [action, model, location.hash]);
|
||||
};
|
||||
|
||||
const encodeStream = (stream: Stream): Promise<string> => {
|
||||
return bridge.call(['encodeStream'], [stream]);
|
||||
};
|
||||
|
||||
const decodeStream = (stream: string): Promise<Stream> => {
|
||||
return bridge.call(['decodeStream'], [stream]);
|
||||
};
|
||||
|
||||
const analytics = (event: object): Promise<void> => {
|
||||
return bridge.call(['analytics'], [event, location.hash]);
|
||||
};
|
||||
|
||||
return {
|
||||
init,
|
||||
getState,
|
||||
dispatch,
|
||||
encodeStream,
|
||||
decodeStream,
|
||||
analytics,
|
||||
};
|
||||
};
|
||||
|
||||
export default createTransport;
|
||||
8
src/core/global.d.ts
vendored
Normal file
8
src/core/global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
interface Window {
|
||||
core: CoreTransport | null | undefined,
|
||||
onCoreEvent: ((event: NewStateEvent | CoreEventEvent) => void) | null;
|
||||
}
|
||||
|
||||
interface Bridge {
|
||||
call(action: string[], args: any[]): Promise<any>,
|
||||
}
|
||||
7
src/core/index.ts
Normal file
7
src/core/index.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import CoreProvider from './CoreProvider';
|
||||
import useCore from './useCore';
|
||||
|
||||
export {
|
||||
CoreProvider,
|
||||
useCore,
|
||||
};
|
||||
54
src/core/types.d.ts
vendored
Normal file
54
src/core/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
type DispatchAction = {
|
||||
action: string,
|
||||
args?: {
|
||||
model?: string,
|
||||
action?: string,
|
||||
args?: any,
|
||||
}
|
||||
};
|
||||
|
||||
type CoreTransport = {
|
||||
init: (args: object) => Promise<void>,
|
||||
getState: (model: string) => Promise<object>,
|
||||
dispatch: (action: DispatchAction, model?: string) => Promise<void>,
|
||||
encodeStream: (stream: Stream) => Promise<string>,
|
||||
decodeStream: (stream: string) => Promise<Stream>,
|
||||
analytics: (event: object) => Promise<void>,
|
||||
};
|
||||
|
||||
type CoreStateListener = (models: string[]) => void;
|
||||
type CoreEventListener = (name: string, data: object) => void;
|
||||
type CoreErrorListener = (source: CoreEvent, error: CoreEventError) => void;
|
||||
|
||||
type CoreListener = CoreStateListener | CoreEventListener | CoreErrorListener;
|
||||
type CoreListenerType = 'state' | 'event' | 'error';
|
||||
|
||||
type NewStateEvent = {
|
||||
name: 'NewState',
|
||||
args: string[],
|
||||
};
|
||||
|
||||
type CoreEvent = {
|
||||
event: 'UserPulledFromAPI' | 'UserLibraryMissing' | 'UserAuthenticated' | 'UserAddonsLocked' |
|
||||
'LibraryItemsPulledFromAPI' | 'LibraryItemsPushedToStorage' | 'LibrarySyncWithAPIPlanned',
|
||||
args: object,
|
||||
};
|
||||
|
||||
type CoreEventError = {
|
||||
code: number,
|
||||
type: string,
|
||||
message: string,
|
||||
};
|
||||
|
||||
type CoreError = {
|
||||
event: 'Error',
|
||||
args: {
|
||||
source: CoreEvent,
|
||||
error: CoreEventError,
|
||||
},
|
||||
};
|
||||
|
||||
type CoreEventEvent = {
|
||||
name: 'CoreEvent',
|
||||
args: CoreEvent | CoreError,
|
||||
};
|
||||
5
src/core/useCore.ts
Normal file
5
src/core/useCore.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { useContext } from 'react';
|
||||
import CoreContext from './CoreContext';
|
||||
|
||||
const useCore = () => useContext(CoreContext);
|
||||
export default useCore;
|
||||
15
src/index.js
15
src/index.js
|
|
@ -17,6 +17,8 @@ const i18n = require('i18next');
|
|||
const { initReactI18next } = require('react-i18next');
|
||||
const stremioTranslations = require('stremio-translations');
|
||||
const App = require('./App');
|
||||
const { CoreProvider } = require('./core');
|
||||
const { FileDropProvider } = require('./common');
|
||||
|
||||
const translations = Object.fromEntries(Object.entries(stremioTranslations()).map(([key, value]) => [key, {
|
||||
translation: value
|
||||
|
|
@ -33,8 +35,19 @@ i18n
|
|||
}
|
||||
});
|
||||
|
||||
const appInfo = {
|
||||
appVersion: process.env.VERSION,
|
||||
shellVersion: null
|
||||
};
|
||||
|
||||
const root = ReactDOM.createRoot(document.getElementById('app'));
|
||||
root.render(<App />);
|
||||
root.render(
|
||||
<CoreProvider appInfo={appInfo}>
|
||||
<FileDropProvider>
|
||||
<App />
|
||||
</FileDropProvider>
|
||||
</CoreProvider>
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && process.env.SERVICE_WORKER_DISABLED !== 'true' && process.env.SERVICE_WORKER_DISABLED !== true && 'serviceWorker' in navigator) {
|
||||
window.addEventListener('load', () => {
|
||||
|
|
|
|||
2
src/modules.d.ts
vendored
2
src/modules.d.ts
vendored
|
|
@ -1,3 +1,5 @@
|
|||
declare module '@stremio/stremio-core-web/bridge';
|
||||
|
||||
declare module '*.less' {
|
||||
const resource: Record<string, string>;
|
||||
export = resource;
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { usePlatform, useBinaryState, withCoreSuspender } = require('stremio/common');
|
||||
const { AddonDetailsModal, Button, Image, MainNavBars, ModalDialog, SearchBar, SharePrompt, TextInput, MultiselectMenu } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const useToast = require('stremio/common/Toast/useToast');
|
||||
const Addon = require('./Addon');
|
||||
const useInstalledAddons = require('./useInstalledAddons');
|
||||
|
|
@ -20,7 +20,7 @@ const { AddonPlaceholder } = require('./AddonPlaceholder');
|
|||
const Addons = ({ urlParams, queryParams }) => {
|
||||
const { t } = useTranslation();
|
||||
const platform = usePlatform();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const toast = useToast();
|
||||
const installedAddons = useInstalledAddons(urlParams);
|
||||
const remoteAddons = useRemoteAddons(urlParams);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import classnames from 'classnames';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { Button } from 'stremio/components';
|
||||
import { useCore } from 'stremio/core';
|
||||
import useProfile from 'stremio/common/useProfile';
|
||||
import { withCoreSuspender } from 'stremio/common/CoreSuspender';
|
||||
import styles from './StreamingServerWarning.less';
|
||||
|
|
@ -15,7 +15,7 @@ type Props = {
|
|||
|
||||
const StreamingServerWarning = ({ className }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
|
||||
const createDismissalDate = (months: number, years = 0): Date => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const useBoard = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const action = React.useMemo(() => ({
|
||||
action: 'Load',
|
||||
args: {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ const { useTranslation } = require('react-i18next');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { CONSTANTS, useBinaryState, useOnScrollToBottom, withCoreSuspender } = require('stremio/common');
|
||||
const { AddonDetailsModal, Button, DelayedRenderer, Image, MainNavBars, MetaItem, MetaPreview, ModalDialog, MultiselectMenu } = require('stremio/components');
|
||||
const useDiscover = require('./useDiscover');
|
||||
|
|
@ -16,7 +16,7 @@ const SCROLL_TO_BOTTOM_THRESHOLD = 400;
|
|||
|
||||
const Discover = ({ urlParams, queryParams }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const [discover, loadNextPage] = useDiscover(urlParams, queryParams);
|
||||
const [selectInputs, hasNextPage] = useSelectableInputs(discover);
|
||||
const [inputsModalOpen, openInputsModal, closeInputsModal] = useBinaryState(false);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
const React = require('react');
|
||||
const UrlUtils = require('url');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const map = (discover) => ({
|
||||
|
|
@ -23,7 +23,7 @@ const map = (discover) => ({
|
|||
});
|
||||
|
||||
const useDiscover = (urlParams, queryParams) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const loadNextPage = React.useCallback(() => {
|
||||
core.transport.dispatch({
|
||||
action: 'CatalogWithFilters',
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Modal, useRouteFocused } = require('stremio-router');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useBinaryState } = require('stremio/common');
|
||||
const { Button, Image, Checkbox } = require('stremio/components');
|
||||
const CredentialsTextInput = require('./CredentialsTextInput');
|
||||
|
|
@ -20,7 +20,7 @@ const SIGNUP_FORM = 'signup';
|
|||
const LOGIN_FORM = 'login';
|
||||
|
||||
const Intro = ({ queryParams }) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const { t } = useTranslation();
|
||||
const routeFocused = useRouteFocused();
|
||||
const [startFacebookLogin, stopFacebookLogin] = useFacebookLogin();
|
||||
|
|
@ -268,27 +268,24 @@ const Intro = ({ queryParams }) => {
|
|||
}
|
||||
}, [state.form, routeFocused]);
|
||||
React.useEffect(() => {
|
||||
const onCoreEvent = ({ event, args }) => {
|
||||
switch (event) {
|
||||
case 'UserAuthenticated': {
|
||||
closeLoaderModal();
|
||||
if (routeFocused) {
|
||||
window.location = '#/';
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Error': {
|
||||
if (args.source.event === 'UserAuthenticated') {
|
||||
closeLoaderModal();
|
||||
}
|
||||
|
||||
break;
|
||||
const onCoreEvent = (name) => {
|
||||
if (name === 'UserAuthenticated') {
|
||||
closeLoaderModal();
|
||||
if (routeFocused) {
|
||||
window.location = '#/';
|
||||
}
|
||||
}
|
||||
};
|
||||
core.transport.on('CoreEvent', onCoreEvent);
|
||||
const onCoreError = (source) => {
|
||||
if (source.event === 'UserAuthenticated') {
|
||||
closeLoaderModal();
|
||||
}
|
||||
};
|
||||
core.on('event', onCoreEvent);
|
||||
core.on('error', onCoreError);
|
||||
return () => {
|
||||
core.transport.off('CoreEvent', onCoreEvent);
|
||||
core.off('event', onCoreEvent);
|
||||
core.off('error', onCoreError);
|
||||
};
|
||||
}, [routeFocused]);
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const useLibrary = (model, urlParams, queryParams) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const loadNextPage = React.useCallback(() => {
|
||||
core.transport.dispatch({
|
||||
action: 'LibraryWithFilters',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ const React = require('react');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
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,8 +16,9 @@ const useMetaExtensionTabs = require('./useMetaExtensionTabs');
|
|||
const styles = require('./styles');
|
||||
|
||||
const MetaDetails = ({ urlParams, queryParams }) => {
|
||||
const contentRef = React.useRef(null);
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const metaDetails = useMetaDetails(urlParams);
|
||||
const [season, setSeason] = useSeason(urlParams, queryParams);
|
||||
const [tabs, metaExtension, clearMetaExtension] = useMetaExtensionTabs(metaDetails.metaExtensions);
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ const PropTypes = require('prop-types');
|
|||
const classnames = require('classnames');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { t } = require('i18next');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useProfile, usePlatform, useToast, useBinaryState } = require('stremio/common');
|
||||
const { Button, Image, Popup } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useRouteFocused } = require('stremio-router');
|
||||
const StreamPlaceholder = require('./StreamPlaceholder');
|
||||
const styles = require('./styles');
|
||||
|
|
@ -16,7 +16,7 @@ const Stream = ({ className, videoId, videoReleased, addonName, name, descriptio
|
|||
const profile = useProfile();
|
||||
const toast = useToast();
|
||||
const platform = usePlatform();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const routeFocused = useRouteFocused();
|
||||
|
||||
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ const classnames = require('classnames');
|
|||
const { useTranslation } = require('react-i18next');
|
||||
const { default: Icon } = require('@stremio/stremio-icons/react');
|
||||
const { Button, Image, MultiselectMenu } = require('stremio/components');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const Stream = require('./Stream');
|
||||
const styles = require('./styles');
|
||||
const { usePlatform, useProfile } = require('stremio/common');
|
||||
|
|
@ -16,7 +16,7 @@ const ALL_ADDONS_KEY = 'ALL';
|
|||
|
||||
const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const platform = usePlatform();
|
||||
const profile = useProfile();
|
||||
const streamsContainerRef = React.useRef(null);
|
||||
|
|
@ -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']}>
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { t } = require('i18next');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useProfile } = require('stremio/common');
|
||||
const { Image, SearchBar, Toggle, Video } = require('stremio/components');
|
||||
const SeasonsBar = require('./SeasonsBar');
|
||||
|
|
@ -12,7 +12,7 @@ const { default: EpisodePicker } = require('../EpisodePicker');
|
|||
const styles = require('./styles');
|
||||
|
||||
const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect, selectedVideoId, toggleNotifications }) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
|
||||
const showNotificationsToggle = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -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 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ const React = require('react');
|
|||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useTranslation } = require('react-i18next');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { usePlatform, useToast } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
const Option = require('./Option');
|
||||
const styles = require('./styles');
|
||||
|
||||
const OptionsMenu = React.memo(React.forwardRef(({ className, stream, playbackDevices, extraSubtitlesTracks, selectedExtraSubtitlesTrackId }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
const [streamingUrl, downloadUrl, magnetUrl] = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,10 @@ 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, useDiscord } = require('stremio/common');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useServices, useGamepad } = require('stremio/services');
|
||||
const { useContentGamepadNavigation } = require('stremio/services/GamepadNavigation');
|
||||
const { useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, useShell, usePlatform, onShortcut, useDiscord } = require('stremio/common');
|
||||
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
|
||||
const BufferingLoader = require('./BufferingLoader');
|
||||
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
|
||||
|
|
@ -25,6 +27,7 @@ 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');
|
||||
|
|
@ -33,10 +36,14 @@ 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 core = useCore();
|
||||
const shell = useShell();
|
||||
const gamepad = useGamepad();
|
||||
const forceTranscoding = React.useMemo(() => {
|
||||
return queryParams.has('forceTranscoding');
|
||||
}, [queryParams]);
|
||||
|
|
@ -58,12 +65,19 @@ 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();
|
||||
|
||||
const [immersed, setImmersed] = React.useState(true);
|
||||
const setImmersedDebounced = React.useCallback(debounce(setImmersed, 3000), []);
|
||||
const [, , , toggleFullscreen] = useFullscreen();
|
||||
const [fullscreen, , , toggleFullscreen, , setVideoElement] = useFullscreen();
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = video.containerRef.current?.querySelector('video');
|
||||
setVideoElement(el || null);
|
||||
return () => setVideoElement(null);
|
||||
}, [video.state.manifest]);
|
||||
|
||||
const [optionsMenuOpen, , closeOptionsMenu, toggleOptionsMenu] = useBinaryState(false);
|
||||
const [subtitlesMenuOpen, , closeSubtitlesMenu, toggleSubtitlesMenu] = useBinaryState(false);
|
||||
|
|
@ -86,13 +100,28 @@ 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);
|
||||
|
|
@ -107,15 +136,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
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) {
|
||||
|
|
@ -174,33 +195,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);
|
||||
|
|
@ -247,20 +241,6 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
video.setVideoScale(nextScale);
|
||||
}, [video.state.videoScale]);
|
||||
|
||||
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 onAudioTrackSelected = React.useCallback((id) => {
|
||||
video.setAudioTrack(id);
|
||||
streamStateChanged({
|
||||
|
|
@ -270,37 +250,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;
|
||||
|
|
@ -369,9 +318,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);
|
||||
|
|
@ -381,13 +399,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 &&
|
||||
|
|
@ -416,16 +428,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);
|
||||
|
|
@ -461,46 +464,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 savedIsExternal = savedTrackId && player.streamState?.subtitleTrack?.embedded === false;
|
||||
|
||||
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) {
|
||||
if (video.state.selectedSubtitlesTrackId !== subtitlesTrack.id) {
|
||||
video.setSubtitlesTrack(subtitlesTrack.id);
|
||||
}
|
||||
defaultSubtitlesSelected.current = true;
|
||||
} else if (extraSubtitlesTrack && extraSubtitlesTrack.id) {
|
||||
if (video.state.selectedExtraSubtitlesTrackId !== extraSubtitlesTrack.id) {
|
||||
video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
|
||||
}
|
||||
if (savedIsExternal) {
|
||||
defaultSubtitlesSelected.current = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, video.state.selectedSubtitlesTrackId, video.state.selectedExtraSubtitlesTrackId, player.streamState]);
|
||||
|
||||
// Auto audio track selection
|
||||
React.useEffect(() => {
|
||||
if (!defaultAudioTrackSelected.current) {
|
||||
|
|
@ -516,28 +479,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;
|
||||
|
|
@ -546,13 +488,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();
|
||||
|
|
@ -580,19 +515,19 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
);
|
||||
}
|
||||
};
|
||||
const onCoreEvent = ({ event }) => {
|
||||
if (event === 'PlayingOnDevice') {
|
||||
const onCoreEvent = (name) => {
|
||||
if (name === 'PlayingOnDevice') {
|
||||
playingOnExternalDevice.current = true;
|
||||
onPauseRequested();
|
||||
}
|
||||
};
|
||||
services.chromecast.on('stateChanged', onChromecastServiceStateChange);
|
||||
services.core.transport.on('CoreEvent', onCoreEvent);
|
||||
core.on('event', onCoreEvent);
|
||||
onChromecastServiceStateChange();
|
||||
return () => {
|
||||
toast.removeFilter(toastFilter);
|
||||
services.chromecast.off('stateChanged', onChromecastServiceStateChange);
|
||||
services.core.transport.off('CoreEvent', onCoreEvent);
|
||||
core.off('event', onCoreEvent);
|
||||
if (services.chromecast.active) {
|
||||
services.chromecast.transport.off(
|
||||
cast.framework.CastContextEventType.CAST_STATE_CHANGED,
|
||||
|
|
@ -630,13 +565,21 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
};
|
||||
}, [discord.setActivity]);
|
||||
|
||||
useMediaSession(video.state, player, onPlayRequested, onPauseRequested, onNextVideoRequested);
|
||||
useMediaSession(video.state, player, fullscreen, onPlayRequested, onPauseRequested, onNextVideoRequested);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onMediaKey = (action) => {
|
||||
switch (action) {
|
||||
case 'play-pause':
|
||||
video.state.paused ? onPlayRequested() : onPauseRequested();
|
||||
if (video.state.paused !== null) {
|
||||
video.state.paused ? onPlayRequested() : onPauseRequested();
|
||||
}
|
||||
break;
|
||||
case 'play':
|
||||
onPlayRequested();
|
||||
break;
|
||||
case 'pause':
|
||||
onPauseRequested();
|
||||
break;
|
||||
case 'next-track':
|
||||
if (player.nextVideo !== null) {
|
||||
|
|
@ -644,16 +587,11 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
onNextVideoRequested();
|
||||
}
|
||||
break;
|
||||
case 'previous-track':
|
||||
if (video.state.time !== null && video.state.time > 5000) {
|
||||
onSeekRequested(0);
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
shell.on('media-key', onMediaKey);
|
||||
return () => shell.off('media-key', onMediaKey);
|
||||
}, [video.state.paused, video.state.time, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested, onSeekRequested]);
|
||||
}, [video.state.paused, player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
|
||||
|
||||
onShortcut('seekForward', (combo) => {
|
||||
if (video.state.time !== null) {
|
||||
|
|
@ -675,46 +613,13 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
video.state.muted === true ? onUnmuteRequested() : onMuteRequested();
|
||||
}, [video.state.muted], !menusOpen);
|
||||
|
||||
onShortcut('volumeUp', () => {
|
||||
onShortcut('volume', (combo) => {
|
||||
if (video.state.volume !== null) {
|
||||
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
|
||||
const volume = combo === 0 ? Math.min(video.state.volume + 5, 200) : Math.max(video.state.volume - 5, 0);
|
||||
onVolumeChangeRequested(volume);
|
||||
}
|
||||
}, [video.state.volume], !menusOpen);
|
||||
|
||||
onShortcut('volumeDown', () => {
|
||||
if (video.state.volume !== null) {
|
||||
onVolumeChangeRequested(Math.min(video.state.volume - 5, 200));
|
||||
}
|
||||
}, [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) {
|
||||
|
|
@ -736,15 +641,10 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
}
|
||||
}, [video.state.playbackSpeed, toggleSpeedMenu]);
|
||||
|
||||
onShortcut('speedUp', () => {
|
||||
onShortcut('speed', (combo) => {
|
||||
if (video.state.playbackSpeed !== null) {
|
||||
onPlaybackSpeedChanged(Math.min(video.state.playbackSpeed + 0.25, 2));
|
||||
}
|
||||
}, [video.state.playbackSpeed, onPlaybackSpeedChanged], !menusOpen);
|
||||
|
||||
onShortcut('speedDown', () => {
|
||||
if (video.state.playbackSpeed !== null) {
|
||||
onPlaybackSpeedChanged(Math.max(video.state.playbackSpeed - 0.25, 0.25));
|
||||
const speed = combo === 0 ? Math.max(video.state.playbackSpeed - 0.25, 0.25) : Math.min(video.state.playbackSpeed + 0.25, 2);
|
||||
onPlaybackSpeedChanged(speed);
|
||||
}
|
||||
}, [video.state.playbackSpeed, onPlaybackSpeedChanged], !menusOpen);
|
||||
|
||||
|
|
@ -779,7 +679,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
|
||||
const onKeyDown = (e) => {
|
||||
if (e.code !== 'Space' || e.repeat) return;
|
||||
if (menusOpen) return;
|
||||
if (menusOpen || e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
longPress.current = false;
|
||||
|
||||
|
|
@ -791,6 +691,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
|
||||
const onKeyUp = (e) => {
|
||||
if (e.code !== 'Space' && e.code !== 'ArrowRight' && e.code !== 'ArrowLeft') return;
|
||||
if (e.ctrlKey || e.metaKey || e.altKey) return;
|
||||
|
||||
if (e.code === 'ArrowRight' || e.code === 'ArrowLeft') {
|
||||
setSeeking(false);
|
||||
|
|
@ -879,18 +780,10 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
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);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
|
@ -903,7 +796,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}
|
||||
|
|
@ -963,8 +856,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
|
||||
|
|
@ -995,7 +888,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}
|
||||
|
|
@ -1056,24 +949,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'}>
|
||||
|
|
@ -1096,8 +972,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>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,11 @@
|
|||
action-buttons-container: action-buttons-container;
|
||||
}
|
||||
|
||||
:import('~stremio/components/MultiselectMenu/Dropdown/Dropdown.less') {
|
||||
dropdown: dropdown;
|
||||
open: open;
|
||||
}
|
||||
|
||||
@padding: 1rem;
|
||||
|
||||
.side-drawer {
|
||||
|
|
@ -26,6 +31,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;
|
||||
|
|
@ -58,6 +74,7 @@
|
|||
|
||||
.info {
|
||||
padding: @padding;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
|
||||
.side-drawer-meta-preview {
|
||||
|
|
@ -78,8 +95,11 @@
|
|||
flex: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
|
||||
.videos {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
|
|
@ -98,6 +118,14 @@
|
|||
@media @phone-landscape {
|
||||
.side-drawer {
|
||||
max-width: 50dvw;
|
||||
|
||||
.info {
|
||||
max-height: 40dvh;
|
||||
}
|
||||
|
||||
.dropdown.open {
|
||||
max-height: calc(3rem * 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { useMemo, useCallback, useState, forwardRef, memo } from 'react';
|
||||
import React, { useMemo, useCallback, useState, useRef, forwardRef, memo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Icon from '@stremio/stremio-icons/react';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { useCore } from 'stremio/core';
|
||||
import { CONSTANTS } from 'stremio/common';
|
||||
import { MetaPreview, Video } from 'stremio/components';
|
||||
import SeasonsBar from 'stremio/routes/MetaDetails/VideosList/SeasonsBar';
|
||||
|
|
@ -19,9 +19,10 @@ type Props = {
|
|||
};
|
||||
|
||||
const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, className, closeSideDrawer, selected, ...props }: Props, ref) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const [season, setSeason] = useState<number>(seriesInfo?.season);
|
||||
const [selectedVideoId, setSelectedVideoId] = useState<string | null>(null);
|
||||
const videosRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const metaItem = useMemo(() => {
|
||||
return seriesInfo ?
|
||||
|
|
@ -47,8 +48,9 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
|||
.sort((a, b) => (a || Number.MAX_SAFE_INTEGER) - (b || Number.MAX_SAFE_INTEGER));
|
||||
}, [props.metaItem.videos]);
|
||||
|
||||
const seasonOnSelect = useCallback((event: { value: string }) => {
|
||||
setSeason(parseInt(event.value));
|
||||
const seasonOnSelect = useCallback((event: { value: string | number }) => {
|
||||
setSeason(parseInt(String(event.value), 10));
|
||||
videosRef.current?.scrollTo({ top: 0, left: 0 });
|
||||
}, []);
|
||||
|
||||
const seasonWatched = React.useMemo(() => {
|
||||
|
|
@ -109,7 +111,7 @@ const SideDrawer = memo(forwardRef<HTMLDivElement, Props>(({ seriesInfo, classNa
|
|||
seasons={seasons}
|
||||
onSelect={seasonOnSelect}
|
||||
/>
|
||||
<div className={styles['videos']}>
|
||||
<div ref={videosRef} className={styles['videos']}>
|
||||
{videos.map((video, index) => (
|
||||
<Video
|
||||
key={index}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@
|
|||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const classnames = require('classnames');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { Video } = require('stremio/components');
|
||||
const styles = require('./styles');
|
||||
|
||||
const VideosMenu = ({ className, metaItem, seriesInfo }) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const onMouseDown = React.useCallback((event) => {
|
||||
event.nativeEvent.videosMenuClosePrevented = true;
|
||||
|
|
|
|||
|
|
@ -55,6 +55,8 @@ html:not(.active-slider-within) {
|
|||
}
|
||||
|
||||
&.nav-bar-layer {
|
||||
left: var(--safe-area-inset-left);
|
||||
right: var(--safe-area-inset-right);
|
||||
bottom: initial;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
|
|
@ -64,8 +66,11 @@ html:not(.active-slider-within) {
|
|||
right: 0;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 8rem;
|
||||
z-index: -1;
|
||||
content: "";
|
||||
background: linear-gradient(to bottom, rgba(0, 0, 0, 0.35) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nav-bar-button-container {
|
||||
|
|
@ -92,16 +97,22 @@ html:not(.active-slider-within) {
|
|||
}
|
||||
|
||||
&.control-bar-layer {
|
||||
left: var(--safe-area-inset-left);
|
||||
right: var(--safe-area-inset-right);
|
||||
top: initial;
|
||||
overflow: visible;
|
||||
padding-bottom: calc(0.5rem + var(--safe-area-inset-bottom));
|
||||
|
||||
&::before {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 10rem;
|
||||
z-index: -1;
|
||||
content: "";
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.35) 0%, transparent 100%);
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,8 +127,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);
|
||||
|
|
|
|||
|
|
@ -1,26 +1,48 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useShell } from 'stremio/common';
|
||||
import { MediaStatus } from 'stremio/common/useShell';
|
||||
|
||||
const useMediaSession = (
|
||||
videoState: VideoState,
|
||||
player: Player,
|
||||
fullscreen: boolean,
|
||||
onPlayRequested: () => void,
|
||||
onPauseRequested: () => void,
|
||||
onNextVideoRequested: () => void,
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (!navigator.mediaSession) return;
|
||||
const shell = useShell();
|
||||
|
||||
const playbackState = !videoState.paused ? 'playing' : 'paused';
|
||||
navigator.mediaSession.playbackState = playbackState;
|
||||
useEffect(() => {
|
||||
if (!('audioSession' in navigator)) return;
|
||||
const audioSession = (navigator as any).audioSession;
|
||||
audioSession.type = fullscreen ? 'ambient' : 'playback';
|
||||
return () => {
|
||||
audioSession.type = 'playback';
|
||||
};
|
||||
}, [fullscreen]);
|
||||
|
||||
// Playback state
|
||||
useEffect(() => {
|
||||
if (navigator.mediaSession) {
|
||||
const playbackState = videoState.paused === null ? 'none' : videoState.paused ? 'paused' : 'playing';
|
||||
navigator.mediaSession.playbackState = playbackState;
|
||||
}
|
||||
|
||||
if (shell.active) {
|
||||
shell.send('media.status', {
|
||||
paused: !!videoState.paused,
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
navigator.mediaSession.playbackState = 'none';
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.playbackState = 'none';
|
||||
}
|
||||
};
|
||||
}, [videoState.paused]);
|
||||
|
||||
// Metadata
|
||||
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);
|
||||
|
|
@ -35,22 +57,48 @@ const useMediaSession = (
|
|||
const artwork = imageUrl ? [{ src: imageUrl }] : undefined;
|
||||
|
||||
if (title) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title,
|
||||
artist,
|
||||
artwork,
|
||||
});
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title,
|
||||
artist,
|
||||
artwork,
|
||||
});
|
||||
}
|
||||
|
||||
if (shell.active) {
|
||||
shell.send('media.metadata', {
|
||||
title,
|
||||
artist,
|
||||
artUrl: imageUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [player.metaItem, player.selected]);
|
||||
|
||||
// Callbacks
|
||||
useEffect(() => {
|
||||
if (!navigator.mediaSession) return;
|
||||
|
||||
navigator.mediaSession.setActionHandler('play', onPlayRequested);
|
||||
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
|
||||
if (navigator.mediaSession) {
|
||||
navigator.mediaSession.setActionHandler('play', onPlayRequested);
|
||||
navigator.mediaSession.setActionHandler('pause', onPauseRequested);
|
||||
}
|
||||
|
||||
const nexVideoCallback = player.nextVideo ? onNextVideoRequested : null;
|
||||
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
|
||||
if (navigator.mediaSession && nexVideoCallback) {
|
||||
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
|
||||
}
|
||||
|
||||
const onMediaStatus = ({ paused }: MediaStatus) => {
|
||||
paused ? onPauseRequested() : onPlayRequested();
|
||||
};
|
||||
|
||||
shell.on('media.status', onMediaStatus);
|
||||
|
||||
return () => {
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||
shell.on('media.status', onMediaStatus);
|
||||
};
|
||||
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useModelState, useCoreSuspender } = require('stremio/common');
|
||||
|
||||
const map = (player) => ({
|
||||
|
|
@ -33,7 +33,7 @@ const map = (player) => ({
|
|||
});
|
||||
|
||||
const usePlayer = (urlParams) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const { decodeStream } = useCoreSuspender();
|
||||
const stream = decodeStream(urlParams.stream);
|
||||
const action = React.useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
|
||||
const useStatistics = (player, streamingServer) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const stream = React.useMemo(() => {
|
||||
if (player.stream?.type === 'Ready') {
|
||||
|
|
|
|||
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, (file: File, buffer: ArrayBuffer) => {
|
||||
videoRef.current.addLocalSubtitles(file.name, 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;
|
||||
|
|
@ -40,6 +40,7 @@ const useVideo = () => {
|
|||
extraSubtitlesTextColor: null,
|
||||
extraSubtitlesBackgroundColor: null,
|
||||
extraSubtitlesOutlineColor: null,
|
||||
fullscreen: null,
|
||||
});
|
||||
|
||||
const dispatch = (action, options) => {
|
||||
|
|
@ -147,6 +148,10 @@ const useVideo = () => {
|
|||
setProp('videoScale', scale);
|
||||
};
|
||||
|
||||
const setFullscreen = (state) => {
|
||||
setProp('fullscreen', state);
|
||||
};
|
||||
|
||||
const setSubtitlesTextColor = (color) => {
|
||||
setProp('subtitlesTextColor', color);
|
||||
setProp('extraSubtitlesTextColor', color);
|
||||
|
|
@ -244,6 +249,7 @@ const useVideo = () => {
|
|||
setSubtitlesOutlineColor,
|
||||
setExtraSubtitlesTrack,
|
||||
setVideoScale,
|
||||
setFullscreen,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useModelState } = require('stremio/common');
|
||||
const { useServices } = require('stremio/services');
|
||||
|
||||
const useSearch = (queryParams) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
// TODO: refactor this to be in stremio-core-web
|
||||
// React.useEffect(() => {
|
||||
// let timerId = setTimeout(emitSearchEvent, 500);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCore } from 'stremio/core';
|
||||
import { Button } from 'stremio/components';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { usePlatform, useToast, useDiscord } from 'stremio/common';
|
||||
import { Section, Option, Link } from '../components';
|
||||
import User from './User';
|
||||
|
|
@ -14,7 +14,7 @@ type Props = {
|
|||
|
||||
const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
const discord = useDiscord();
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { useCore } from 'stremio/core';
|
||||
import { Link } from '../../components';
|
||||
import styles from './User.less';
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ type Props = {
|
|||
|
||||
const User = ({ profile }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const avatar = useMemo(() => (
|
||||
!profile.auth ?
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Copyright (C) 2017-2023 Smart code 203358507
|
||||
|
||||
const React = require('react');
|
||||
const { useServices } = require('stremio/services');
|
||||
const { useCore } = require('stremio/core');
|
||||
const { useModelState } = require('stremio/common');
|
||||
|
||||
const map = (dataExport) => ({
|
||||
|
|
@ -13,7 +13,7 @@ const map = (dataExport) => ({
|
|||
});
|
||||
|
||||
const useDataExport = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const loadDataExport = React.useCallback(() => {
|
||||
core.transport.dispatch({
|
||||
action: 'Load',
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
|
|||
quitOnCloseToggle,
|
||||
escExitFullscreenToggle,
|
||||
hideSpoilersToggle,
|
||||
gamepadSupportToggle,
|
||||
} = useInterfaceOptions(profile);
|
||||
|
||||
return (
|
||||
|
|
@ -50,6 +51,12 @@ const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) =>
|
|||
{...hideSpoilersToggle}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_GAMEPAD'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...gamepadSupportToggle}
|
||||
/>
|
||||
</Option>
|
||||
</Section>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useCore } from 'stremio/core';
|
||||
import { interfaceLanguages, useLanguageSorting } from 'stremio/common';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
||||
const useInterfaceOptions = (profile: Profile) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
|
||||
const interfaceLanguageOptions = useMemo(() =>
|
||||
interfaceLanguages.map(({ name, codes }) => ({
|
||||
|
|
@ -81,11 +81,28 @@ 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,
|
||||
escExitFullscreenToggle,
|
||||
quitOnCloseToggle,
|
||||
hideSpoilersToggle,
|
||||
gamepadSupportToggle,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCore } from 'stremio/core';
|
||||
import { CONSTANTS, languageNames, useLanguageSorting, usePlatform } from 'stremio/common';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
||||
const LANGUAGES_NAMES: Record<string, string> = languageNames;
|
||||
|
||||
const usePlayerOptions = (profile: Profile) => {
|
||||
const { t } = useTranslation();
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const platform = usePlatform();
|
||||
|
||||
const languageOptions = useMemo(() => Object.keys(LANGUAGES_NAMES).map((code) => ({
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCore } from 'stremio/core';
|
||||
import { useModelState, useToast } from 'stremio/common';
|
||||
import useProfile from 'stremio/common/useProfile';
|
||||
import { useServices } from 'stremio/services';
|
||||
|
||||
const useStreamingServerUrls = () => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const profile = useProfile();
|
||||
const toast = useToast();
|
||||
const ctx = useModelState({ model: 'ctx' });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { deepEqual } from 'fast-equals';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { useCore } from 'stremio/core';
|
||||
|
||||
const CACHE_SIZES = [0, 2147483648, 5368709120, 10737418240, null];
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ const TORRENT_PROFILES: Record<string, TorrentProfile> = {
|
|||
};
|
||||
|
||||
const useStreamingOptions = (streamingServer: StreamingServer) => {
|
||||
const { core } = useServices();
|
||||
const core = useCore();
|
||||
const { t } = useTranslation();
|
||||
// TODO combine those useMemo in one
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@
|
|||
|
||||
.label {
|
||||
line-height: 1.5rem;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
color: var(--primary-foreground-color);
|
||||
}
|
||||
}
|
||||
|
|
@ -75,4 +77,4 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue