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:
Timothy Z. 2026-05-12 18:58:12 +02:00
commit fe1f13010d
126 changed files with 5866 additions and 3583 deletions

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -1,5 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const ErrorDialog = require('./ErrorDialog');
module.exports = ErrorDialog;

View file

@ -0,0 +1,264 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useGamepad } from 'stremio/services';
import type { ControllerType } from 'stremio/services/GamepadContext';
import styles from './styles.less';
type ActiveButton = string | null;
const CX = 400;
const ARROW = { UP: '↑', DOWN: '↓', LEFT: '←', RIGHT: '→' };
type FaceLayout = {
top: { glyph: string; fontSize: number; weight: number };
right: { glyph: string; fontSize: number; weight: number };
bottom: { glyph: string; fontSize: number; weight: number };
left: { glyph: string; fontSize: number; weight: number };
lb: string;
rb: string;
lt: string;
rt: string;
};
const LAYOUTS: Record<ControllerType, FaceLayout> = {
playstation: {
top: { glyph: '△', fontSize: 12, weight: 400 },
right: { glyph: '○', fontSize: 12, weight: 400 },
bottom: { glyph: '✕', fontSize: 12, weight: 400 },
left: { glyph: '□', fontSize: 12, weight: 400 },
lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2',
},
xbox: {
top: { glyph: 'Y', fontSize: 11, weight: 700 },
right: { glyph: 'B', fontSize: 11, weight: 700 },
bottom: { glyph: 'A', fontSize: 11, weight: 700 },
left: { glyph: 'X', fontSize: 11, weight: 700 },
lb: 'LB', rb: 'RB', lt: 'LT', rt: 'RT',
},
generic: {
top: { glyph: '△', fontSize: 12, weight: 400 },
right: { glyph: '○', fontSize: 12, weight: 400 },
bottom: { glyph: '✕', fontSize: 12, weight: 400 },
left: { glyph: '□', fontSize: 12, weight: 400 },
lb: 'L1', rb: 'R1', lt: 'L2', rt: 'R2',
},
};
const GamepadDiagram = () => {
const { t } = useTranslation();
const gamepad = useGamepad();
const [active, setActive] = useState<ActiveButton>(null);
const layout = LAYOUTS[gamepad?.controllerType ?? 'generic'];
useEffect(() => {
let timeout: ReturnType<typeof setTimeout>;
const flash = (button: string) => () => {
setActive(button);
clearTimeout(timeout);
timeout = setTimeout(() => setActive(null), 400);
};
gamepad?.on('buttonA', 'gamepad-diagram', flash('bottom'));
gamepad?.on('buttonB', 'gamepad-diagram', flash('right'));
gamepad?.on('buttonX', 'gamepad-diagram', flash('left'));
gamepad?.on('buttonY', 'gamepad-diagram', flash('top'));
gamepad?.on('buttonLT', 'gamepad-diagram', flash('lb'));
gamepad?.on('buttonRT', 'gamepad-diagram', flash('rb'));
gamepad?.on('analog', 'gamepad-diagram', (dir) => dir && flash('stick-' + dir)());
gamepad?.on('analogRight', 'gamepad-diagram', (dir) => dir && flash('rstick-' + dir)());
return () => {
clearTimeout(timeout);
gamepad?.off('buttonA', 'gamepad-diagram');
gamepad?.off('buttonB', 'gamepad-diagram');
gamepad?.off('buttonX', 'gamepad-diagram');
gamepad?.off('buttonY', 'gamepad-diagram');
gamepad?.off('buttonLT', 'gamepad-diagram');
gamepad?.off('buttonRT', 'gamepad-diagram');
gamepad?.off('analog', 'gamepad-diagram');
gamepad?.off('analogRight', 'gamepad-diagram');
};
}, [gamepad]);
const glow = (id: string) => active === id ? '#7b5bf5' : undefined;
const glowOp = (id: string) => active === id ? 1 : undefined;
const SX = 130;
const BX = 120;
const STX = 75;
const BY = 30;
// Xbox controllers are asymmetric — left stick sits upper-left (where the
// d-pad is on PlayStation) and the d-pad drops to the lower-left.
const isXbox = (gamepad?.controllerType ?? 'generic') === 'xbox';
const lstickPos = isXbox
? { cx: CX - BX, cy: 148 + BY }
: { cx: CX - STX, cy: 240 + BY };
const dpadPos = isXbox
? { cx: CX - STX, cy: 240 + BY }
: { cx: CX - BX, cy: 149 + BY };
const navLine = isXbox
? { x1: CX - BX - 24, y1: 148 + BY }
: { x1: CX - STX - 24, y1: 232 + BY };
return (
<svg className={styles['diagram']} viewBox={'0 0 800 510'} xmlns={'http://www.w3.org/2000/svg'}>
<defs>
<linearGradient id={'bodyGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
<stop offset={'0%'} stopColor={'#2a2545'} />
<stop offset={'100%'} stopColor={'#1a1530'} />
</linearGradient>
<linearGradient id={'triggerGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
<stop offset={'0%'} stopColor={'#1e1a35'} />
<stop offset={'100%'} stopColor={'#16122a'} />
</linearGradient>
<linearGradient id={'bumperGrad'} x1={'0'} y1={'0'} x2={'0'} y2={'1'}>
<stop offset={'0%'} stopColor={'#3d3660'} />
<stop offset={'100%'} stopColor={'#2a2545'} />
</linearGradient>
<filter id={'glow'} x={'-50%'} y={'-50%'} width={'200%'} height={'200%'}>
<feGaussianBlur stdDeviation={'4'} result={'blur'} />
<feMerge>
<feMergeNode in={'blur'} />
<feMergeNode in={'SourceGraphic'} />
</feMerge>
</filter>
</defs>
<g className={styles['anim-controls']}>
<path
d={`M${CX - SX - 38},68 Q${CX - SX - 40},48 ${CX - SX - 28},42 L${CX - SX + 28},42 Q${CX - SX + 40},48 ${CX - SX + 38},68 Z`}
fill={'url(#triggerGrad)'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.7'}
/>
<text x={CX - SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{layout.lt}</text>
<path
d={`M${CX + SX - 38},68 Q${CX + SX - 40},48 ${CX + SX - 28},42 L${CX + SX + 28},42 Q${CX + SX + 40},48 ${CX + SX + 38},68 Z`}
fill={'url(#triggerGrad)'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.7'}
/>
<text x={CX + SX} y={'58'} textAnchor={'middle'} fill={'#8b7faa'} fontSize={'8'} fontWeight={'500'}>{layout.rt}</text>
</g>
<path
className={styles['anim-body']}
d={
`M${CX - 178},${105 + BY}
Q${CX - 165},${80 + BY} ${CX - 95},${74 + BY}
L${CX + 95},${74 + BY}
Q${CX + 165},${80 + BY} ${CX + 178},${105 + BY}
L${CX + 195},${135 + BY}
Q${CX + 232},${172 + BY} ${CX + 252},${232 + BY}
Q${CX + 272},${298 + BY} ${CX + 255},${350 + BY}
Q${CX + 238},${390 + BY} ${CX + 203},${400 + BY}
Q${CX + 168},${410 + BY} ${CX + 150},${382 + BY}
L${CX + 113},${320 + BY}
Q${CX + 90},${284 + BY} ${CX},${284 + BY}
Q${CX - 90},${284 + BY} ${CX - 113},${320 + BY}
L${CX - 150},${382 + BY}
Q${CX - 168},${410 + BY} ${CX - 203},${400 + BY}
Q${CX - 238},${390 + BY} ${CX - 255},${350 + BY}
Q${CX - 272},${298 + BY} ${CX - 252},${232 + BY}
Q${CX - 232},${172 + BY} ${CX - 195},${135 + BY}
Z`
}
fill={'url(#bodyGrad)'}
stroke={'#3d3660'}
strokeWidth={'2.5'}
/>
<g className={styles['anim-controls']}>
<rect x={CX - 58} y={96 + BY} rx={'8'} ry={'8'} width={'116'} height={'48'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1.5'} />
<g filter={active === 'lb' ? 'url(#glow)' : undefined}>
<path
d={`M${CX - SX - 40},74 Q${CX - SX - 38},66 ${CX - SX - 30},64 L${CX - SX + 30},64 Q${CX - SX + 38},66 ${CX - SX + 40},74 L${CX - SX + 36},82 Q${CX - SX + 34},85 ${CX - SX + 28},85 L${CX - SX - 28},85 Q${CX - SX - 34},85 ${CX - SX - 36},82 Z`}
fill={'url(#bumperGrad)'} stroke={glow('lb') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('lb') || 0.9}
/>
<text x={CX - SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{layout.lb}</text>
</g>
<g filter={active === 'rb' ? 'url(#glow)' : undefined}>
<path
d={`M${CX + SX - 40},74 Q${CX + SX - 38},66 ${CX + SX - 30},64 L${CX + SX + 30},64 Q${CX + SX + 38},66 ${CX + SX + 40},74 L${CX + SX + 36},82 Q${CX + SX + 34},85 ${CX + SX + 28},85 L${CX + SX - 28},85 Q${CX + SX - 34},85 ${CX + SX - 36},82 Z`}
fill={'url(#bumperGrad)'} stroke={glow('rb') || '#5848a0'} strokeWidth={'1.2'} opacity={glowOp('rb') || 0.9}
/>
<text x={CX + SX} y={'78'} textAnchor={'middle'} fill={'#a89ecc'} fontSize={'9'} fontWeight={'600'}>{layout.rb}</text>
</g>
<g filter={active === 'top' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX} cy={118 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('top') || '#5848a0'} strokeWidth={'1.5'} />
<text x={CX + BX} y={123 + BY} textAnchor={'middle'} fill={active === 'top' ? '#fff' : '#a89ecc'} fontSize={layout.top.fontSize} fontWeight={layout.top.weight}>{layout.top.glyph}</text>
</g>
<g filter={active === 'right' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX + 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('right') || '#5848a0'} strokeWidth={'1.5'} />
<text x={CX + BX + 30} y={153 + BY} textAnchor={'middle'} fill={active === 'right' ? '#fff' : '#a89ecc'} fontSize={layout.right.fontSize} fontWeight={layout.right.weight}>{layout.right.glyph}</text>
</g>
<g filter={active === 'bottom' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX} cy={178 + BY} r={'15'} fill={active === 'bottom' ? '#9b7fff' : '#7b5bf5'} stroke={'#9b7fff'} strokeWidth={'1.5'} />
<text x={CX + BX} y={183 + BY} textAnchor={'middle'} fill={'#fff'} fontSize={layout.bottom.fontSize} fontWeight={layout.bottom.weight}>{layout.bottom.glyph}</text>
</g>
<g filter={active === 'left' ? 'url(#glow)' : undefined}>
<circle cx={CX + BX - 30} cy={148 + BY} r={'15'} fill={'#1e1a35'} stroke={glow('left') || '#5848a0'} strokeWidth={'1.5'} />
<text x={CX + BX - 30} y={153 + BY} textAnchor={'middle'} fill={active === 'left' ? '#fff' : '#a89ecc'} fontSize={layout.left.fontSize} fontWeight={layout.left.weight}>{layout.left.glyph}</text>
</g>
<rect x={dpadPos.cx - 12} y={dpadPos.cy - 29} rx={'3'} ry={'3'} width={'24'} height={'58'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
<rect x={dpadPos.cx - 29} y={dpadPos.cy - 12} rx={'3'} ry={'3'} width={'58'} height={'24'} fill={'#1e1a35'} stroke={'#3d3660'} strokeWidth={'1'} opacity={'0.4'} />
<g filter={active?.startsWith('stick-') ? 'url(#glow)' : undefined}>
<circle cx={lstickPos.cx} cy={lstickPos.cy} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('stick-') ? '#7b5bf5' : '#3d3660'} strokeWidth={'2'} />
<circle cx={lstickPos.cx} cy={lstickPos.cy} r={'17'} fill={'#252040'} stroke={'#4a4075'} strokeWidth={'1.5'} />
<text x={lstickPos.cx} y={lstickPos.cy - 8} textAnchor={'middle'} fill={active === 'stick-up' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-up' ? '700' : '400'}></text>
<text x={lstickPos.cx} y={lstickPos.cy + 13} textAnchor={'middle'} fill={active === 'stick-down' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-down' ? '700' : '400'}></text>
<text x={lstickPos.cx - 11} y={lstickPos.cy + 4} textAnchor={'middle'} fill={active === 'stick-left' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-left' ? '700' : '400'}></text>
<text x={lstickPos.cx + 11} y={lstickPos.cy + 4} textAnchor={'middle'} fill={active === 'stick-right' ? '#fff' : '#7b5bf5'} fontSize={'9'} fontWeight={active === 'stick-right' ? '700' : '400'}></text>
</g>
<g filter={active?.startsWith('rstick-') ? 'url(#glow)' : undefined}>
<circle cx={CX + STX} cy={240 + BY} r={'26'} fill={'#1a1530'} stroke={active?.startsWith('rstick-') ? '#7b5bf5' : '#3d3660'} strokeWidth={'2'} />
<circle cx={CX + STX} cy={240 + BY} r={'17'} fill={'#252040'} stroke={'#4a4075'} strokeWidth={'1.5'} />
<text x={CX + STX} y={232 + BY} textAnchor={'middle'} fill={active === 'rstick-up' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-up' ? '700' : '400'}>{ARROW.UP}</text>
<text x={CX + STX} y={253 + BY} textAnchor={'middle'} fill={active === 'rstick-down' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-down' ? '700' : '400'}>{ARROW.DOWN}</text>
<text x={CX + STX - 11} y={244 + BY} textAnchor={'middle'} fill={active === 'rstick-left' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-left' ? '700' : '400'}>{ARROW.LEFT}</text>
<text x={CX + STX + 11} y={244 + BY} textAnchor={'middle'} fill={active === 'rstick-right' ? '#fff' : '#5848a0'} fontSize={'9'} fontWeight={active === 'rstick-right' ? '700' : '400'}>{ARROW.RIGHT}</text>
</g>
</g>
<g className={styles['anim-lines']}>
<line x1={CX - SX - 40} y1={'74'} x2={'85'} y2={'48'} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'85'} cy={'48'} r={'2'} fill={'#5848a0'} />
<line x1={navLine.x1} y1={navLine.y1} x2={'85'} y2={168} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'85'} cy={168} r={'2'} fill={'#7b5bf5'} />
<line x1={CX + BX - 44} y1={148 + BY} x2={'85'} y2={248} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.35'} />
<circle cx={'85'} cy={248} r={'2'} fill={'#5848a0'} />
<line x1={CX + SX + 40} y1={'74'} x2={'715'} y2={'48'} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={'48'} r={'2'} fill={'#5848a0'} />
<line x1={CX + BX + 13} y1={112 + BY} x2={'715'} y2={108} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={108} r={'2'} fill={'#5848a0'} />
<line x1={CX + BX + 43} y1={142 + BY} x2={'715'} y2={148} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={148} r={'2'} fill={'#5848a0'} />
<line x1={CX + BX + 13} y1={184 + BY} x2={'715'} y2={208} stroke={'#7b5bf5'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={208} r={'2'} fill={'#7b5bf5'} />
<line x1={CX + STX + 24} y1={234 + BY} x2={'715'} y2={268} stroke={'#5848a0'} strokeWidth={'1'} opacity={'0.4'} />
<circle cx={'715'} cy={268} r={'2'} fill={'#5848a0'} />
</g>
<g className={styles['anim-labels']}>
<text x={'80'} y={'44'} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_PREV_TAB')}</text>
<text x={'80'} y={164} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NAVIGATE')}</text>
<text x={'80'} y={244} textAnchor={'end'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_GUIDE')}</text>
<text x={'80'} y={259} textAnchor={'end'} fill={'#8b7faa'} fontSize={'10'}>{t('GAMEPAD_LABEL_PLAY_PAUSE_PLAYER')}</text>
<text x={'720'} y={'44'} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_NEXT_TAB')}</text>
<text x={'720'} y={104} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_FULLSCREEN')}</text>
<text x={'720'} y={144} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_BACK')}</text>
<text x={'720'} y={204} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_ACTION_SELECT')}</text>
<text x={'720'} y={264} textAnchor={'start'} fill={'#c4b5fd'} fontSize={'12'} fontWeight={'500'}>{t('GAMEPAD_LABEL_SEEK_VOL')}</text>
<text x={CX} y={'475'} textAnchor={'middle'} fill={'#5848a0'} fontSize={'11'}>{t('GAMEPAD_LABEL_COMPAT')}</text>
</g>
</svg>
);
};
export default GamepadDiagram;

View file

@ -0,0 +1,165 @@
// Copyright (C) 2017-2026 Smart code 203358507
import React, { useEffect } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button } from 'stremio/components';
import { useGamepad } from 'stremio/services';
import type { ControllerType } from 'stremio/services/GamepadContext';
import GamepadDiagram from './GamepadDiagram';
import styles from './styles.less';
const LEFT = '←';
const RIGHT = '→';
const UP = '↑';
const DOWN = '↓';
type FaceLabels = {
bottom: string;
right: string;
left: string;
top: string;
lb: string;
rb: string;
lStick: string;
rStick: string;
};
const LABELS: Record<ControllerType, FaceLabels> = {
playstation: {
bottom: '✕', right: '○', left: '□', top: '△',
lb: 'L1', rb: 'R1',
lStick: 'L stick', rStick: 'R stick',
},
xbox: {
bottom: 'A', right: 'B', left: 'X', top: 'Y',
lb: 'LB', rb: 'RB',
lStick: 'L stick', rStick: 'R stick',
},
generic: {
bottom: '✕', right: '○', left: '□', top: '△',
lb: 'L1', rb: 'R1',
lStick: 'L stick', rStick: 'R stick',
},
};
type Props = {
onClose: () => void,
};
const GamepadModal = ({ onClose }: Props) => {
const { t } = useTranslation();
const gamepad = useGamepad();
const labels = LABELS[gamepad?.controllerType ?? 'generic'];
useEffect(() => {
gamepad?.lock('gamepad-');
const onKeyDown = ({ key }: KeyboardEvent) => {
key === 'Escape' && onClose();
};
document.addEventListener('keydown', onKeyDown);
gamepad?.on('buttonB', 'gamepad-modal', onClose);
return () => {
document.removeEventListener('keydown', onKeyDown);
gamepad?.off('buttonB', 'gamepad-modal');
gamepad?.unlock();
};
}, [gamepad]);
return createPortal((
<div className={styles['gamepad-modal']} data-gamepad-modal>
<div className={styles['backdrop']} onClick={onClose} />
<div className={styles['container']}>
<div className={styles['header']}>
<div className={styles['title']}>
{t('GAMEPAD_CONTROLS_TITLE')}
</div>
<Button className={styles['close-button']} title={t('BUTTON_CLOSE')} onClick={onClose}>
<Icon className={styles['icon']} name={'close'} />
</Button>
</div>
<div className={styles['content']}>
<GamepadDiagram />
<div className={styles['sections']}>
<div className={styles['section']}>
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_NAVIGATION')}</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.lStick}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_NAVIGATE')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.bottom}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_SELECT')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.right}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_BACK')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.top}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_FULLSCREEN')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.left}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_GUIDE')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.lb}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_PREV_TAB')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rb}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_NEXT_TAB')}</span>
</div>
</div>
<div className={styles['section']}>
<div className={styles['section-title']}>{t('GAMEPAD_SECTION_PLAYER')}</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.left}</kbd>
<span className={styles['dir']} />
<span className={styles['action']}>{t('GAMEPAD_ACTION_PLAY_PAUSE')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{LEFT}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_BACK')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{RIGHT}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_SEEK_FWD')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{UP}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_UP')}</span>
</div>
<div className={styles['mapping']}>
<kbd className={styles['kbd']}>{labels.rStick}</kbd>
<span className={styles['dir']}>{DOWN}</span>
<span className={styles['action']}>{t('GAMEPAD_ACTION_VOL_DOWN')}</span>
</div>
</div>
</div>
</div>
</div>
</div>
), document.body);
};
export default GamepadModal;

View file

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

View file

@ -0,0 +1,220 @@
// Copyright (C) 2017-2026 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.gamepad-modal {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
.backdrop {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: @color-background-dark5-40;
cursor: pointer;
}
.container {
position: relative;
display: flex;
flex-direction: column;
gap: 1rem;
max-height: 90%;
max-width: 72rem;
width: 92%;
border-radius: var(--border-radius);
background-color: var(--modal-background-color);
box-shadow: var(--outer-glow);
overflow: hidden;
.header {
flex: none;
display: flex;
justify-content: space-between;
align-items: center;
height: 5rem;
padding-left: 2.5rem;
padding-right: 1rem;
.title {
position: relative;
font-size: 1.5rem;
font-weight: 500;
color: var(--primary-foreground-color);
}
.close-button {
position: relative;
width: 3rem;
height: 3rem;
padding: 0.5rem;
border-radius: var(--border-radius);
z-index: 2;
.icon {
display: block;
width: 100%;
height: 100%;
color: var(--primary-foreground-color);
opacity: 0.4;
}
&:hover, &:focus {
.icon {
opacity: 1;
color: var(--primary-foreground-color);
}
}
&:focus {
outline-color: var(--primary-foreground-color);
}
}
}
.content {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 3rem;
padding: 0 3rem;
padding-bottom: 3rem;
flex: 1;
min-height: 0;
overflow-y: auto;
}
}
@keyframes draw-stroke {
from { stroke-dashoffset: 2000; }
to { stroke-dashoffset: 0; }
}
@keyframes draw-line {
from { stroke-dashoffset: 800; }
to { stroke-dashoffset: 0; }
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
.diagram {
flex: none;
width: 100%;
max-width: 48rem;
height: auto;
.anim-body {
stroke-dasharray: 2000;
stroke-dashoffset: 0;
animation: draw-stroke 1.4s ease-in-out;
}
.anim-controls {
animation: fade-in 0.6s ease-out 1s both;
}
.anim-lines {
line {
stroke-dasharray: 800;
stroke-dashoffset: 0;
animation: draw-line 0.8s ease-out 1.6s both;
}
circle {
animation: fade-in 0.3s ease-out 2s both;
}
}
.anim-labels {
animation: fade-in 0.5s ease-out 2.2s both;
}
}
.sections {
flex: none;
display: flex;
flex-direction: row;
gap: 5rem;
width: 100%;
max-width: 56rem;
overflow: visible;
}
.section {
flex: 1;
display: flex;
flex-direction: column;
gap: 1.2rem;
overflow: visible;
}
.section-title {
font-size: 1rem;
font-weight: 600;
color: var(--primary-foreground-color);
margin-bottom: 0.4rem;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.7;
}
.mapping {
display: grid;
grid-template-columns: auto 1.2rem 1fr;
align-items: center;
column-gap: 0.5rem;
}
.kbd {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2rem;
height: 1.8rem;
padding: 0 0.5rem;
border-radius: 0.35rem;
background-color: rgba(123, 91, 245, 0.15);
border: 1px solid rgba(123, 91, 245, 0.3);
box-shadow: none;
color: #c4b5fd;
font-size: 0.8rem;
font-weight: 600;
font-family: inherit;
}
.dir {
color: #8b7faa;
font-size: 1rem;
}
.action {
color: var(--primary-foreground-color);
font-size: 0.9rem;
opacity: 0.8;
}
}
@media only screen and (max-width: 640px) {
.gamepad-modal {
.container {
max-width: 95%;
}
.sections {
flex-direction: column;
gap: 2rem;
}
}
}

View file

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

View file

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

View file

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

View file

@ -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 = [

View file

@ -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({});

View file

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

View file

@ -6,7 +6,6 @@ const onFileDrop = (types: FileType[], listener: FileDropListener) => {
useEffect(() => {
types.forEach((type) => on(type, listener));
return () => types.forEach((type) => off(type, listener));
}, []);
};

View 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);
}
}

View file

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

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

View 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);

View file

@ -0,0 +1,7 @@
// Copyright (C) 2017-2026 Smart code 203358507
import FullscreenProvider from './FullscreenProvider';
import useFullscreen from './useFullscreen';
export { FullscreenProvider, useFullscreen };
export default useFullscreen;

View file

@ -0,0 +1,15 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useContext } from 'react';
import FullscreenContext from './FullscreenContext';
const useFullscreen = () => {
const value = useContext(FullscreenContext);
if (value === null) {
throw new Error('useFullscreen must be used inside FullscreenProvider');
}
return value;
};
export default useFullscreen;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,86 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
import { useCallback, useEffect, useState } from 'react';
import useShell, { type WindowVisibility } from './useShell';
import useSettings from './useSettings';
const useFullscreen = () => {
const shell = useShell();
const [settings] = useSettings();
const [fullscreen, setFullscreen] = useState(false);
const requestFullscreen = useCallback(async () => {
if (shell.active) {
shell.send('win-set-visibility', { fullscreen: true });
} else {
try {
await document.documentElement.requestFullscreen();
} catch (err) {
console.error('Error enabling fullscreen', err);
}
}
}, []);
const exitFullscreen = useCallback(() => {
if (shell.active) {
shell.send('win-set-visibility', { fullscreen: false });
} else {
if (document.fullscreenElement === document.documentElement) {
document.exitFullscreen();
}
}
}, []);
const toggleFullscreen = useCallback(() => {
fullscreen ? exitFullscreen() : requestFullscreen();
}, [fullscreen]);
useEffect(() => {
const onWindowVisibilityChanged = (state: WindowVisibility) => {
setFullscreen(state.isFullscreen === true);
};
const onFullscreenChange = () => {
setFullscreen(document.fullscreenElement === document.documentElement);
};
const onKeyDown = (event: KeyboardEvent) => {
const activeElement = document.activeElement as HTMLElement;
const inputFocused =
activeElement &&
(activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.tagName === 'SELECT' ||
activeElement.isContentEditable);
if (event.code === 'Escape' && settings.escExitFullscreen) {
exitFullscreen();
}
if (event.code === 'KeyF' && !inputFocused) {
toggleFullscreen();
}
if (event.code === 'F11' && shell.active) {
toggleFullscreen();
}
};
shell.on('win-visibility-changed', onWindowVisibilityChanged);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('fullscreenchange', onFullscreenChange);
return () => {
shell.off('win-visibility-changed', onWindowVisibilityChanged);
document.removeEventListener('keydown', onKeyDown);
document.removeEventListener('fullscreenchange', onFullscreenChange);
};
}, [settings.escExitFullscreen, toggleFullscreen]);
return [fullscreen, requestFullscreen, exitFullscreen, toggleFullscreen];
};
export default useFullscreen;

View file

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

View file

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

View file

@ -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) => {

View file

@ -27,6 +27,10 @@ export type WindowState = {
state: number;
};
export type MediaStatus = {
paused: boolean;
};
const createId = () => Math.floor(Math.random() * 9999) + 1;
const useShell = () => {

View file

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

View file

@ -27,18 +27,27 @@
width: @width;
padding: 0 1rem;
cursor: pointer;
border-radius: 2rem;
outline: none;
.icon {
width: calc(@width / 2);
height: calc(@height / 2);
color: var(--primary-foreground-color);
opacity: 0.7;
}
&:hover {
&:hover, &:focus {
.icon {
opacity: 1;
}
}
&:focus-visible {
outline: 2px solid var(--primary-foreground-color);
outline-offset: -2px;
}
&.disabled {
pointer-events: none;
}

View file

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

View file

@ -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(() => {

View file

@ -33,7 +33,7 @@ const Chip = memo(({ label, value, active, onSelect }: Props) => {
ref={ref}
key={value}
className={classNames(styles['chip'], { [styles['active']]: active })}
tabIndex={-1}
tabIndex={0}
data-value={value}
onClick={onClick}
>

View file

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

View file

@ -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({

View file

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

View file

@ -3,6 +3,7 @@
import React, { memo } from 'react';
import classnames from 'classnames';
import { VerticalNavBar, HorizontalNavBar } from 'stremio/components/NavBar';
import { useContentGamepadNavigation, useVerticalNavGamepadNavigation } from 'stremio/services/GamepadNavigation';
import styles from './MainNavBars.less';
const TABS = [
@ -22,6 +23,13 @@ type Props = {
};
const MainNavBars = memo(({ className, route, query, children }: Props) => {
const navRef = React.useRef(null);
const contentRef = React.useRef(null);
const navRoute = route === 'continue_watching' ? 'library' : (route ?? '');
useContentGamepadNavigation(contentRef, navRoute);
useVerticalNavGamepadNavigation(navRef, navRoute);
return (
<div className={classnames(className, styles['main-nav-bars-container'])}>
<HorizontalNavBar
@ -34,11 +42,12 @@ const MainNavBars = memo(({ className, route, query, children }: Props) => {
navMenu={true}
/>
<VerticalNavBar
ref={navRef}
className={styles['vertical-nav-bar']}
selected={route}
tabs={TABS}
/>
<div className={styles['nav-content-container']}>{children}</div>
<div ref={contentRef} className={styles['nav-content-container']}>{children}</div>
</div>
);
});

View file

@ -159,7 +159,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
title={linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label}
href={linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).href}
target={'_blank'}
{...(compact ? { tabIndex: -1 } : null)}
tabIndex={0}
>
<div className={styles['label']}>{linksGroups.get(CONSTANTS.IMDB_LINK_CATEGORY).label}</div>
<Icon className={styles['icon']} name={'imdb'} />
@ -214,7 +214,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
className={styles['action-button']}
icon={'trailer'}
label={t('TRAILER')}
tabIndex={compact ? -1 : 0}
tabIndex={0}
href={trailerHref}
tooltip={compact}
/>
@ -232,7 +232,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
className={classnames(styles['action-button'], styles['show-button'])}
icon={'play'}
label={t('SHOW')}
tabIndex={compact ? -1 : 0}
tabIndex={0}
href={showHref}
/>
:
@ -255,7 +255,7 @@ const MetaPreview = React.forwardRef(({ className, compact, name, logo, backgrou
icon={'share'}
label={t('CTX_SHARE')}
tooltip={true}
tabIndex={compact ? -1 : 0}
tabIndex={0}
onClick={openShareModal}
/>
{

View file

@ -1,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({

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

@ -1,6 +1,9 @@
.combos {
position: relative;
display: flex;
flex-wrap: wrap;
row-gap: 1rem;
justify-content: end;
overflow: visible;
.combo {

View file

@ -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': '←',

View file

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

View file

@ -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
View file

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

View file

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

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
import { useContext } from 'react';
import CoreContext from './CoreContext';
const useCore = () => useContext(CoreContext);
export default useCore;

View file

@ -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
View file

@ -1,3 +1,5 @@
declare module '@stremio/stremio-core-web/bridge';
declare module '*.less' {
const resource: Record<string, string>;
export = resource;

View file

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

View file

@ -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 => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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(() => {

View file

@ -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
View file

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

View file

@ -0,0 +1,391 @@
// Copyright (C) 2017-2026 Smart code 203358507
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { CONSTANTS, languages, onFileDrop, onShortcut, useToast } from 'stremio/common';
const withFallbackLabels = (tracks?: SubtitleTrack[] | null): SubtitleTrack[] => {
if (!Array.isArray(tracks)) {
return [];
}
return tracks.map((track) => ({
...track,
label: track.label || track.url || '',
}));
};
const findTrackById = (tracks: SubtitleTrack[], id?: string | null) => {
if (!id) {
return undefined;
}
return tracks.find((track) => track.id === id);
};
const findTrackByLanguage = (tracks: SubtitleTrack[], language?: string | null) => {
if (!language) {
return undefined;
}
const languageCode = languages.toCode(language);
return tracks.find((track) => {
return track.lang === language || languages.toCode(track.lang) === languageCode;
});
};
const useSubtitles = ({
player,
video,
settings,
streamStateChanged,
menusOpen,
closeMenus,
closeSubtitlesMenu,
toggleSubtitlesMenu,
}: UseSubtitlesArgs): UseSubtitlesResult => {
const { t } = useTranslation();
const toast = useToast();
const videoRef = useRef(video);
const settingsRef = useRef(settings);
const defaultTrackSelected = useRef(false);
const lastSelectedTrack = useRef<SelectedSubtitleTrack | null>(null);
videoRef.current = video;
settingsRef.current = settings;
const streamSubtitles = useMemo(() => {
return withFallbackLabels(player.selected?.stream.subtitles);
}, [player.selected]);
const externalSubtitles = useMemo(() => {
return withFallbackLabels(player.subtitles);
}, [player.subtitles]);
const allTracks = useMemo(() => {
return video.state.subtitlesTracks.concat(video.state.extraSubtitlesTracks);
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]);
const hasTracks = allTracks.length > 0;
const applySubtitleStyle = useCallback(() => {
const currentSettings = settingsRef.current;
const currentVideo = videoRef.current;
currentVideo.setSubtitlesSize(currentSettings.subtitlesSize);
currentVideo.setSubtitlesOffset(currentSettings.subtitlesOffset);
currentVideo.setSubtitlesTextColor(currentSettings.subtitlesTextColor);
currentVideo.setSubtitlesBackgroundColor(currentSettings.subtitlesBackgroundColor);
currentVideo.setSubtitlesOutlineColor(currentSettings.subtitlesOutlineColor);
}, []);
const rememberTrack = useCallback((track: SubtitleTrack, embedded: boolean) => {
lastSelectedTrack.current = { id: track.id, embedded };
streamStateChanged({
subtitleTrack: {
id: track.id,
embedded,
lang: track.lang,
},
});
}, [streamStateChanged]);
const disableSubtitles = useCallback(() => {
defaultTrackSelected.current = true;
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
streamStateChanged({ subtitleTrack: null });
}, [streamStateChanged, video]);
const selectEmbeddedTrack = useCallback((track: SubtitleTrack | null) => {
if (!track) {
disableSubtitles();
return;
}
defaultTrackSelected.current = true;
video.setSubtitlesTrack(track.id);
rememberTrack(track, true);
}, [disableSubtitles, rememberTrack, video]);
const selectExtraTrack = useCallback((track: SubtitleTrack | null) => {
if (!track) {
disableSubtitles();
return;
}
defaultTrackSelected.current = true;
video.setExtraSubtitlesTrack(track.id);
rememberTrack(track, false);
}, [disableSubtitles, rememberTrack, video]);
const changeDelay = useCallback((delay: number) => {
video.setSubtitlesDelay(delay);
streamStateChanged({ subtitleDelay: delay });
}, [streamStateChanged, video]);
const increaseDelay = useCallback(() => {
changeDelay((video.state.extraSubtitlesDelay ?? 0) + 250);
}, [changeDelay, video.state.extraSubtitlesDelay]);
const decreaseDelay = useCallback(() => {
changeDelay((video.state.extraSubtitlesDelay ?? 0) - 250);
}, [changeDelay, video.state.extraSubtitlesDelay]);
const changeSize = useCallback((size: number) => {
video.setSubtitlesSize(size);
streamStateChanged({ subtitleSize: size });
}, [streamStateChanged, video]);
const updateSize = useCallback((delta: number) => {
const sizes = CONSTANTS.SUBTITLES_SIZES as number[];
const sizeIndex = sizes.indexOf(video.state.subtitlesSize ?? -1);
const nextIndex = Math.max(0, Math.min(sizes.length - 1, sizeIndex + delta));
changeSize(sizes[nextIndex]);
}, [changeSize, video.state.subtitlesSize]);
const changeOffset = useCallback((offset: number) => {
video.setSubtitlesOffset(offset);
streamStateChanged({ subtitleOffset: offset });
}, [streamStateChanged, video]);
onFileDrop(CONSTANTS.SUPPORTED_LOCAL_SUBTITLES, (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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) => ({

View file

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

View file

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

View file

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