Merge branch 'development' into feat/dispatching-addon-install-action-can-throw-exeception

This commit is contained in:
Timothy Z. 2026-01-22 12:15:09 +02:00 committed by GitHub
commit a5265bacf9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
79 changed files with 641 additions and 1376 deletions

View file

@ -41,15 +41,15 @@ docker run -p 8080:8080 stremio-web
### Board
![Board](/screenshots/board.png)
![Board](/assets/screenshots/board.png)
### Discover
![Discover](/screenshots/discover.png)
![Discover](/assets/screenshots/discover.png)
### Meta Details
![Meta Details](/screenshots/metadetails.png)
![Meta Details](/assets/screenshots/metadetails.png)
## License

View file

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View file

Before

Width:  |  Height:  |  Size: 652 KiB

After

Width:  |  Height:  |  Size: 652 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

Before

Width:  |  Height:  |  Size: 2.1 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View file

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 202 KiB

After

Width:  |  Height:  |  Size: 202 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

View file

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 61 KiB

View file

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 159 KiB

View file

Before

Width:  |  Height:  |  Size: 1.4 MiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View file

Before

Width:  |  Height:  |  Size: 1.6 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

59
manifest.json Normal file
View file

@ -0,0 +1,59 @@
{
"name": "Stremio Web",
"short_name": "Stremio",
"description": "Freedom To Stream",
"background_color": "#161523",
"theme_color": "#2a2843",
"orientation": "any",
"display": "standalone",
"display_override": ["standalone"],
"scope": "./",
"start_url": "./",
"icons": [
{
"src": "favicons/icon_256x256.ico",
"sizes": "256x256",
"type": "image/vnd.microsoft.icon"
},
{
"src": "images/maskable_icon_512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "images/maskable_icon_196x196.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "images/icon_512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "images/icon_196x196.png",
"sizes": "196x196",
"type": "image/png",
"purpose": "any"
}
],
"screenshots": [
{
"src": "screenshots/board_wide.webp",
"sizes": "1440x900",
"type": "image/webp",
"form_factor": "wide",
"label": "Homescreen of Stremio"
},
{
"src": "screenshots/board_narrow.webp",
"sizes": "414x896",
"type": "image/webp",
"form_factor": "narrow",
"label": "Homescreen of Stremio"
}
]
}

View file

@ -17,21 +17,21 @@
"@babel/runtime": "7.26.0",
"@sentry/browser": "8.42.0",
"@stremio/stremio-colors": "5.2.0",
"@stremio/stremio-core-web": "https://stremio.github.io/stremio-core/stremio-core-web/feat/stremio-core-web-bridge-panicking/dev/stremio-stremio-core-web-0.51.1.tgz",
"@stremio/stremio-core-web": "0.52.0",
"@stremio/stremio-icons": "5.8.0",
"@stremio/stremio-video": "0.0.64",
"@stremio/stremio-video": "0.0.70",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
"buffer": "6.0.3",
"classnames": "2.5.1",
"eventemitter3": "5.0.1",
"fast-equals": "^6.0.0",
"filter-invalid-dom-props": "3.0.1",
"hat": "^0.0.3",
"i18next": "^24.0.5",
"langs": "github:Stremio/nodejs-langs",
"lodash.debounce": "4.0.8",
"lodash.intersection": "4.4.0",
"lodash.isequal": "4.5.0",
"lodash.throttle": "4.1.1",
"magnet-uri": "6.2.0",
"prop-types": "15.8.1",
@ -41,7 +41,7 @@
"react-i18next": "^15.1.3",
"react-is": "18.3.1",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#01aaa201e419782b26b9f2cbe4430795021426e5",
"stremio-translations": "github:Stremio/stremio-translations#7c0c337f32163aa13158bb90cd6133da43feafef",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -53,12 +53,10 @@
"@stylistic/eslint-plugin": "^5.4.0",
"@stylistic/eslint-plugin-jsx": "^4.4.1",
"@types/hat": "^0.0.4",
"@types/lodash.isequal": "^4.5.8",
"@types/lodash.throttle": "^4.1.9",
"@types/react": "^18.3.13",
"@types/react-dom": "^18.3.1",
"babel-loader": "9.2.1",
"clean-webpack-plugin": "4.0.0",
"copy-webpack-plugin": "12.0.2",
"css-loader": "6.11.0",
"cssnano": "7.0.6",
@ -82,7 +80,6 @@
"webpack": "5.97.0",
"webpack-cli": "5.1.4",
"webpack-dev-server": "^5.1.0",
"webpack-pwa-manifest": "^4.3.0",
"workbox-webpack-plugin": "^7.3.0"
}
}

File diff suppressed because it is too large Load diff

View file

@ -22,7 +22,7 @@ const ErrorDialog = ({ className }) => {
<div className={classnames(className, styles['error-container'])}>
<Image
className={styles['error-image']}
src={require('/images/empty.png')}
src={require('/assets/images/empty.png')}
alt={' '}
/>
<div className={styles['error-message']}>

View file

@ -1,7 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const isEqual = require('lodash.isequal');
const { deepEqual } = require('fast-equals');
const { withCoreSuspender, useProfile, useToast } = require('stremio/common');
const { useServices } = require('stremio/services');
@ -18,7 +18,7 @@ const SearchParamsHandler = () => {
setSearchParams((previousSearchParams) => {
const currentSearchParams = Object.fromEntries(searchParams.entries());
return isEqual(previousSearchParams, currentSearchParams) ? previousSearchParams : currentSearchParams;
return deepEqual(previousSearchParams, currentSearchParams) ? previousSearchParams : currentSearchParams;
});
};

View file

@ -5,7 +5,7 @@
@font-face {
font-family: 'PlusJakartaSans';
src: url('/fonts/PlusJakartaSans.ttf') format('truetype');
src: url('/assets/fonts/PlusJakartaSans.ttf') format('truetype');
}
:global {
@ -48,7 +48,7 @@
--color-x: #000000;
--color-reddit: #FF4500;
--color-imdb: #f5c518;
--color-trakt: #ED2224;
--color-trakt: rgb(255, 255, 255);
--color-placeholder: #60606080;
--color-placeholder-text: @color-surface-50;
--color-placeholder-background: @color-surface-dark5-20;

View file

@ -1,13 +1,15 @@
import React, { createContext, useCallback, useContext, useEffect } from 'react';
import React, { createContext, useCallback, useContext, useEffect, useRef } from 'react';
import shortcuts from './shortcuts.json';
const SHORTCUTS = shortcuts.map(({ shortcuts }) => shortcuts).flat();
export type ShortcutName = string;
export type ShortcutListener = () => void;
export type ShortcutListener = (combo: number) => void;
interface ShortcutsContext {
grouped: ShortcutGroup[],
on: (name: ShortcutName, listener: ShortcutListener) => void,
off: (name: ShortcutName, listener: ShortcutListener) => void,
}
const ShortcutsContext = createContext<ShortcutsContext>({} as ShortcutsContext);
@ -18,27 +20,38 @@ type Props = {
};
const ShortcutsProvider = ({ children, onShortcut }: Props) => {
const onKeyDown = useCallback(({ ctrlKey, shiftKey, key }: KeyboardEvent) => {
const listeners = useRef<Map<ShortcutName, Set<ShortcutListener>>>(new Map());
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);
if (modifers && keys.includes(key.toUpperCase())) {
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]);
const on = (name: ShortcutName, listener: ShortcutListener) => {
!listeners.current.has(name) && listeners.current.set(name, new Set());
listeners.current.get(name)!.add(listener);
};
const off = (name: ShortcutName, listener: ShortcutListener) => {
listeners.current.get(name)?.delete(listener);
};
useEffect(() => {
document.addEventListener('keydown', onKeyDown);
return () => {
document.removeEventListener('keydown', onKeyDown);
};
return () => document.removeEventListener('keydown', onKeyDown);
}, [onKeyDown]);
return (
<ShortcutsContext.Provider value={{ grouped: shortcuts }}>
<ShortcutsContext.Provider value={{ grouped: shortcuts, on, off }}>
{children}
</ShortcutsContext.Provider>
);
@ -50,5 +63,5 @@ const useShortcuts = () => {
export {
ShortcutsProvider,
useShortcuts
useShortcuts,
};

View file

@ -1,5 +1,8 @@
import { ShortcutsProvider, useShortcuts } from './Shortcuts';
import onShortcut from './onShortcut';
export {
ShortcutsProvider,
useShortcuts,
onShortcut,
};

View file

@ -0,0 +1,15 @@
import { DependencyList, useCallback, useEffect } from 'react';
import { ShortcutListener, ShortcutName, useShortcuts } from './Shortcuts';
const onShortcut = (name: ShortcutName, listener: ShortcutListener, deps: DependencyList) => {
const shortcuts = useShortcuts();
const listenerCallback = useCallback(listener, deps);
useEffect(() => {
shortcuts.on(name, listenerCallback);
return () => shortcuts.off(name, listenerCallback);
}, [listenerCallback]);
};
export default onShortcut;

View file

@ -59,6 +59,11 @@
"label": "SETTINGS_SHORTCUT_VOLUME_DOWN",
"combos": [["ArrowDown"]]
},
{
"name": "mute",
"label": "SETTINGS_SHORTCUT_MUTE",
"combos": [["M"]]
},
{
"name": "subtitlesSize",
"label": "SETTINGS_SHORTCUT_SUBTITLES_SIZE",
@ -83,6 +88,16 @@
"name": "infoMenu",
"label": "SETTINGS_SHORTCUT_MENU_INFO",
"combos": [["I"]]
},
{
"name": "speedMenu",
"label": "SETTINGS_SHORTCUT_MENU_PLAYBACK_SPEED",
"combos": [["R"]]
},
{
"name": "statisticsMenu",
"label": "SETTINGS_SHORTCUT_MENU_STATISTICS",
"combos": [["D"]]
}
]
}

View file

@ -27,7 +27,7 @@
&.error {
.icon-container {
.icon {
color: var(--color-trakt);
color: var(--danger-accent-color);
}
}
}

View file

@ -4,7 +4,7 @@ const { FileDropProvider, onFileDrop } = require('./FileDrop');
const { PlatformProvider, usePlatform } = require('./Platform');
const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips');
const { ShortcutsProvider, useShortcuts } = require('./Shortcuts');
const { ShortcutsProvider, useShortcuts, onShortcut } = require('./Shortcuts');
const comparatorWithPriorities = require('./comparatorWithPriorities');
const CONSTANTS = require('./CONSTANTS');
const { withCoreSuspender, useCoreSuspender } = require('./CoreSuspender');
@ -38,6 +38,7 @@ module.exports = {
usePlatform,
ShortcutsProvider,
useShortcuts,
onShortcut,
ToastProvider,
useToast,
TooltipProvider,

View file

@ -3,6 +3,10 @@
"name": "العربية",
"codes": ["ar-AR", "ara"]
},
{
"name": "Беларуская",
"codes": ["be-BY", "bel"]
},
{
"name": "български език",
"codes": ["bg-BG", "bul"]
@ -13,7 +17,7 @@
},
{
"name": "català",
"codes": ["ca-CA", "cat"]
"codes": ["ca-ES", "cat"]
},
{
"name": "čeština",
@ -43,6 +47,10 @@
"name": "español",
"codes": ["es-ES", "spa"]
},
{
"name": "Eesti",
"codes": ["et-EE", "est"]
},
{
"name": "euskara",
"codes": ["eu-ES", "eus"]
@ -111,6 +119,10 @@
"name": "Norsk nynorsk",
"codes": ["nn-NO", "nno"]
},
{
"name": "ਪੰਜਾਬੀ",
"codes": ["pa-IN", "pan"]
},
{
"name": "język polski",
"codes": ["pl-PL", "pol"]
@ -151,6 +163,10 @@
"name": "తెలుగు",
"codes": ["te-IN", "tel"]
},
{
"name": "தமிழ்",
"codes": ["tl-TM", "tam"]
},
{
"name": "Türkçe",
"codes": ["tr-TR", "tur"]
@ -159,6 +175,10 @@
"name": "українська мова",
"codes": ["uk-UA", "ukr"]
},
{
"name": "اُرْدُو",
"codes": ["ur-PK", "urd"]
},
{
"name": "Tiếng Việt",
"codes": ["vi-VN", "vie"]

View file

@ -2,7 +2,7 @@
const React = require('react');
const throttle = require('lodash.throttle');
const isEqual = require('lodash.isequal');
const { deepEqual } = require('fast-equals');
const intersection = require('lodash.intersection');
const { useCoreSuspender } = require('stremio/common/CoreSuspender');
const { useRouteFocused } = require('stremio-router');
@ -19,7 +19,7 @@ const useModelState = ({ action, ...args }) => {
const [state, setState] = React.useReducer(
(prevState, nextState) => {
return Object.keys(prevState).reduce((result, key) => {
result[key] = isEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
result[key] = deepEqual(prevState[key], nextState[key]) ? prevState[key] : nextState[key];
return result;
}, {});
},

View file

@ -70,7 +70,7 @@
}
&.error {
border-color: var(--color-trakt);
border-color: var(--danger-accent-color);
}
&.checked {

View file

@ -17,6 +17,7 @@
border-radius: 2rem;
height: @height;
width: fit-content;
backdrop-filter: blur(5px);
.icon-container {
display: flex;

View file

@ -35,7 +35,7 @@ const HorizontalNavBar = React.memo(({ className, route, query, title, backButto
<div className={styles['logo-container']}>
<Image
className={styles['logo']}
src={require('/images/stremio_symbol.png')}
src={require('/assets/images/stremio_symbol.png')}
alt={' '}
/>
</div>

View file

@ -52,12 +52,12 @@ const NavMenuContent = ({ onClick }) => {
className={styles['avatar-container']}
style={{
backgroundImage: profile.auth === null ?
`url('${require('/images/anonymous.png')}')`
`url('${require('/assets/images/anonymous.png')}')`
:
profile.auth.user.avatar ?
`url('${profile.auth.user.avatar}')`
:
`url('${require('/images/default_avatar.png')}')`
`url('${require('/assets/images/default_avatar.png')}')`
}}
/>
<div className={styles['user-info-details']}>

View file

@ -52,7 +52,7 @@
}
&.error {
border-color: var(--color-trakt);
border-color: var(--danger-accent-color);
}
&.selected {

View file

@ -19,6 +19,7 @@
padding: 0.5rem;
margin-bottom: 0.5rem;
border-radius: var(--border-radius);
border: 0.15rem solid transparent;
@supports (scroll-margin: 1.25rem) {
scroll-margin: 1.25rem;

View file

@ -7,6 +7,7 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-title" content="Stremio">
<link rel="icon" type="image/x-icon" href="<%= htmlWebpackPlugin.options.faviconsPath %>/favicon.ico">
<link rel="manifest" href="manifest.json" />
<title>Stremio - Freedom to Stream</title>
<%= htmlWebpackPlugin.tags.headTags %>
</head>

View file

@ -5,7 +5,7 @@ const ReactIs = require('react-is');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const UrlUtils = require('url');
const isEqual = require('lodash.isequal');
const { deepEqual } = require('fast-equals');
const { RouteFocusedProvider } = require('../RouteFocusedContext');
const Route = require('../Route');
const routeConfigForPath = require('./routeConfigForPath');
@ -54,11 +54,11 @@ const Router = ({ className, onPathNotMatch, onRouteChange, ...props }) => {
return {
key: `${routeViewIndex}${routeIndex}`,
component: routeConfig.component,
urlParams: view !== null && isEqual(view.urlParams, urlParams) ?
urlParams: view !== null && deepEqual(view.urlParams, urlParams) ?
view.urlParams
:
urlParams,
queryParams: view !== null && isEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
queryParams: view !== null && deepEqual(Array.from(view.queryParams.entries()), Array.from(queryParams.entries())) ?
view.queryParams
:
queryParams

View file

@ -17,7 +17,7 @@ const Placeholder = () => {
<div className={styles['image-container']}>
<Image
className={styles['image']}
src={require('/images/calendar_placeholder.png')}
src={require('/assets/images/calendar_placeholder.png')}
alt={' '}
/>
</div>

View file

@ -133,14 +133,14 @@ const Discover = ({ urlParams, queryParams }) => {
discover.catalog === null ?
<DelayedRenderer delay={500}>
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{t('NO_CATALOG_SELECTED')}</div>
</div>
</DelayedRenderer>
:
discover.catalog.content.type === 'Err' ?
<div className={styles['message-container']}>
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{discover.catalog.content.content}</div>
</div>
:

View file

@ -296,7 +296,7 @@ const Intro = ({ queryParams }) => {
<div className={styles['background-container']} />
<div className={styles['heading-container']}>
<div className={styles['logo-container']}>
<Image className={styles['logo']} src={require('/images/logo.png')} alt={' '} />
<Image className={styles['logo']} src={require('/assets/images/logo.png')} alt={' '} />
</div>
<div className={styles['title-container']}>
{t('WEBSITE_SLOGAN_NEW_NEW')}

View file

@ -19,7 +19,7 @@
bottom: -1rem;
left: -1rem;
right: -1rem;
background: url('/images/background_1.svg'), url('/images/background_2.svg');
background: url('/assets/images/background_1.svg'), url('/assets/images/background_2.svg');
background-color: var(--primary-background-color);
background-position: bottom left, top right;
background-size: 53%, 54%;

View file

@ -85,7 +85,7 @@ const Library = ({ model, urlParams, queryParams }) => {
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
src={require('/assets/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_NOT_LOADED') : t('BOARD_CONTINUE_WATCHING_NOT_LOADED')}</div>
@ -96,7 +96,7 @@ const Library = ({ model, urlParams, queryParams }) => {
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
src={require('/assets/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>{model === 'library' ? t('LIBRARY_EMPTY') : t('BOARD_CONTINUE_WATCHING_EMPTY')}</div>

View file

@ -17,7 +17,7 @@ const Placeholder = () => {
<div className={styles['image-container']}>
<Image
className={styles['image']}
src={require('/images/library_placeholder.png')}
src={require('/assets/images/library_placeholder.png')}
alt={' '}
/>
</div>

View file

@ -130,20 +130,20 @@ const MetaDetails = ({ urlParams, queryParams }) => {
metaPath === null ?
<DelayedRenderer delay={500}>
<div className={styles['meta-message-container']}>
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{t('ERR_NO_META_SELECTED')}</div>
</div>
</DelayedRenderer>
:
metaDetails.metaItem === null ?
<div className={styles['meta-message-container']}>
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{t('ERR_NO_ADDONS_FOR_META')}</div>
</div>
:
metaDetails.metaItem.content.type === 'Err' ?
<div className={styles['meta-message-container']}>
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['message-label']}>{t('ERR_NO_META_FOUND')}</div>
</div>
:

View file

@ -132,7 +132,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
<SeasonEpisodePicker className={styles['search']} onSubmit={handleEpisodePicker} />
: null
}
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['label']}>{t('ERR_NO_ADDONS_FOR_STREAMS')}</div>
</div>
:
@ -148,7 +148,7 @@ const StreamsList = ({ className, video, type, onEpisodeSearch, ...props }) => {
<div className={styles['label']}>{t('UPCOMING')}...</div>
: null
}
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['label']}>{t('NO_STREAM')}</div>
{
showInstallAddonsButton ?

View file

@ -124,7 +124,7 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
metaItem.content.type === 'Err' || videosForSeason.length === 0 ?
<div className={styles['message-container']}>
<EpisodePicker className={styles['episode-picker']} onSubmit={onSeasonSearch} />
<Image className={styles['image']} src={require('/images/empty.png')} alt={' '} />
<Image className={styles['image']} src={require('/assets/images/empty.png')} alt={' '} />
<div className={styles['label']}>{t('ERR_NO_VIDEOS_FOR_META')}</div>
</div>
:

View file

@ -19,7 +19,7 @@ const NotFound = () => {
<div className={styles['not-found-content']}>
<Image
className={styles['not-found-image']}
src={require('/images/empty.png')}
src={require('/assets/images/empty.png')}
alt={' '}
/>
<div className={styles['not-found-label']}>{t('PAGE_NOT_FOUND')}</div>

View file

@ -7,6 +7,7 @@
align-self: stretch;
display: flex;
flex-direction: column;
max-height: 25rem;
width: 16rem;
.header {

View file

@ -13,7 +13,7 @@ const BufferingLoader = React.forwardRef(({ className, logo }, ref) => {
className={styles['buffering-loader']}
src={logo}
alt={' '}
fallbackSrc={require('/images/stremio_symbol.png')}
fallbackSrc={require('/assets/images/stremio_symbol.png')}
/>
</div>
);

View file

@ -8,7 +8,7 @@ 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 } = require('stremio/common');
const { onFileDrop, useSettings, useProfile, useFullscreen, useBinaryState, useToast, useStreamingServer, withCoreSuspender, CONSTANTS, useShell, usePlatform, onShortcut } = require('stremio/common');
const { HorizontalNavBar, Transition, ContextMenu } = require('stremio/components');
const BufferingLoader = require('./BufferingLoader');
const VolumeChangeIndicator = require('./VolumeChangeIndicator');
@ -29,6 +29,9 @@ const styles = require('./styles');
const Video = require('./Video');
const { default: Indicator } = require('./Indicator/Indicator');
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 Player = ({ urlParams, queryParams }) => {
const { t } = useTranslation();
const services = useServices();
@ -37,8 +40,8 @@ const Player = ({ urlParams, queryParams }) => {
return queryParams.has('forceTranscoding');
}, [queryParams]);
const profile = useProfile();
const [player, videoParamsChanged, timeChanged, seek, pausedChanged, ended, nextVideo] = usePlayer(urlParams);
const [settings, updateSettings] = useSettings();
const [player, videoParamsChanged, streamStateChanged, timeChanged, seek, pausedChanged, ended, nextVideo] = usePlayer(urlParams);
const [settings] = useSettings();
const streamingServer = useStreamingServer();
const statistics = useStatistics(player, streamingServer);
const video = useVideo();
@ -93,17 +96,12 @@ const Player = ({ urlParams, queryParams }) => {
const isNavigating = React.useRef(false);
const onImplementationChanged = React.useCallback(() => {
video.setProp('subtitlesSize', settings.subtitlesSize);
video.setProp('subtitlesOffset', settings.subtitlesOffset);
video.setProp('subtitlesTextColor', settings.subtitlesTextColor);
video.setProp('subtitlesBackgroundColor', settings.subtitlesBackgroundColor);
video.setProp('subtitlesOutlineColor', settings.subtitlesOutlineColor);
video.setProp('extraSubtitlesSize', settings.subtitlesSize);
video.setProp('extraSubtitlesOffset', settings.subtitlesOffset);
video.setProp('extraSubtitlesTextColor', settings.subtitlesTextColor);
video.setProp('extraSubtitlesBackgroundColor', settings.subtitlesBackgroundColor);
video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor);
}, [settings.subtitlesSize, settings.subtitlesOffset, settings.subtitlesTextColor, settings.subtitlesBackgroundColor, settings.subtitlesOutlineColor]);
video.setSubtitlesSize(settings.subtitlesSize);
video.setSubtitlesOffset(settings.subtitlesOffset);
video.setSubtitlesTextColor(settings.subtitlesTextColor);
video.setSubtitlesBackgroundColor(settings.subtitlesBackgroundColor);
video.setSubtitlesOutlineColor(settings.subtitlesOutlineColor);
}, [settings]);
const handleNextVideoNavigation = React.useCallback((deepLinks, bingeWatching, ended) => {
if (ended) {
@ -190,53 +188,71 @@ const Player = ({ urlParams, queryParams }) => {
}, []);
const onPlayRequested = React.useCallback(() => {
video.setProp('paused', false);
video.setPaused(false);
setSeeking(false);
}, []);
const onPlayRequestedDebounced = React.useCallback(debounce(onPlayRequested, 200), []);
const onPauseRequested = React.useCallback(() => {
video.setProp('paused', true);
video.setPaused(true);
}, []);
const onPauseRequestedDebounced = React.useCallback(debounce(onPauseRequested, 200), []);
const onMuteRequested = React.useCallback(() => {
video.setProp('muted', true);
video.setMuted(true);
}, []);
const onUnmuteRequested = React.useCallback(() => {
video.setProp('muted', false);
video.setMuted(false);
}, []);
const onVolumeChangeRequested = React.useCallback((volume) => {
video.setProp('volume', volume);
video.setVolume(volume);
}, []);
const onSeekRequested = React.useCallback((time) => {
video.setProp('time', time);
video.setTime(time);
seek(time, video.state.duration, video.state.manifest?.name);
}, [video.state.duration, video.state.manifest]);
const onPlaybackSpeedChanged = React.useCallback((rate) => {
video.setProp('playbackSpeed', rate);
video.setPlaybackSpeed(rate);
}, []);
const onSubtitlesTrackSelected = React.useCallback((id) => {
video.setSubtitlesTrack(id);
}, []);
streamStateChanged({
subtitleTrack: {
id,
embedded: true,
},
});
}, [streamStateChanged]);
const onExtraSubtitlesTrackSelected = React.useCallback((id) => {
video.setExtraSubtitlesTrack(id);
}, []);
streamStateChanged({
subtitleTrack: {
id,
embedded: false,
},
});
}, [streamStateChanged]);
const onAudioTrackSelected = React.useCallback((id) => {
video.setProp('selectedAudioTrackId', id);
}, []);
video.setAudioTrack(id);
streamStateChanged({
audioTrack: {
id,
},
});
}, [streamStateChanged]);
const onExtraSubtitlesDelayChanged = React.useCallback((delay) => {
video.setProp('extraSubtitlesDelay', delay);
}, []);
video.setSubtitlesDelay(delay);
streamStateChanged({ subtitleDelay: delay });
}, [streamStateChanged]);
const onIncreaseSubtitlesDelay = React.useCallback(() => {
const delay = video.state.extraSubtitlesDelay + 250;
@ -249,8 +265,9 @@ const Player = ({ urlParams, queryParams }) => {
}, [video.state.extraSubtitlesDelay, onExtraSubtitlesDelayChanged]);
const onSubtitlesSizeChanged = React.useCallback((size) => {
updateSettings({ subtitlesSize: size });
}, [updateSettings]);
video.setSubtitlesSize(size);
streamStateChanged({ subtitleSize: size });
}, [streamStateChanged]);
const onUpdateSubtitlesSize = React.useCallback((delta) => {
const sizeIndex = CONSTANTS.SUBTITLES_SIZES.indexOf(video.state.subtitlesSize);
@ -259,8 +276,9 @@ const Player = ({ urlParams, queryParams }) => {
}, [video.state.subtitlesSize, onSubtitlesSizeChanged]);
const onSubtitlesOffsetChanged = React.useCallback((offset) => {
updateSettings({ subtitlesOffset: offset });
}, [updateSettings]);
video.setSubtitlesOffset(offset);
streamStateChanged({ subtitleOffset: offset });
}, [streamStateChanged]);
const onDismissNextVideoPopup = React.useCallback(() => {
closeNextVideoPopup();
@ -361,6 +379,7 @@ const Player = ({ urlParams, queryParams }) => {
forceTranscoding: forceTranscoding || casting,
maxAudioChannels: settings.surroundSound ? 32 : 2,
hardwareDecoding: settings.hardwareDecoding,
assSubtitlesStyling: settings.assSubtitlesStyling,
videoMode: settings.videoMode,
platform: platform.name,
streamingServerURL: streamingServer.baseUrl ?
@ -387,31 +406,6 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [player.subtitles, video.state.stream]);
React.useEffect(() => {
video.setProp('subtitlesSize', settings.subtitlesSize);
video.setProp('extraSubtitlesSize', settings.subtitlesSize);
}, [settings.subtitlesSize]);
React.useEffect(() => {
video.setProp('subtitlesOffset', settings.subtitlesOffset);
video.setProp('extraSubtitlesOffset', settings.subtitlesOffset);
}, [settings.subtitlesOffset]);
React.useEffect(() => {
video.setProp('subtitlesTextColor', settings.subtitlesTextColor);
video.setProp('extraSubtitlesTextColor', settings.subtitlesTextColor);
}, [settings.subtitlesTextColor]);
React.useEffect(() => {
video.setProp('subtitlesBackgroundColor', settings.subtitlesBackgroundColor);
video.setProp('extraSubtitlesBackgroundColor', settings.subtitlesBackgroundColor);
}, [settings.subtitlesBackgroundColor]);
React.useEffect(() => {
video.setProp('subtitlesOutlineColor', settings.subtitlesOutlineColor);
video.setProp('extraSubtitlesOutlineColor', settings.subtitlesOutlineColor);
}, [settings.subtitlesOutlineColor]);
React.useEffect(() => {
!seeking && timeChanged(video.state.time, video.state.duration, video.state.manifest?.name);
}, [video.state.time, video.state.duration, video.state.manifest, seeking]);
@ -444,41 +438,69 @@ const Player = ({ urlParams, queryParams }) => {
}
}, [player.nextVideo, video.state.time, video.state.duration]);
// Auto subtitles track selection
React.useEffect(() => {
if (!defaultSubtitlesSelected.current) {
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
if (settings.subtitlesLanguage === null) {
onSubtitlesTrackSelected(null);
onExtraSubtitlesTrackSelected(null);
video.setSubtitlesTrack(null);
video.setExtraSubtitlesTrack(null);
defaultSubtitlesSelected.current = true;
return;
}
const subtitlesTrack = findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
const extraSubtitlesTrack = findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
const savedTrackId = player.streamState?.subtitleTrack?.id;
const subtitlesTrack = savedTrackId ?
findTrackById(video.state.subtitlesTracks, savedTrackId) :
findTrackByLang(video.state.subtitlesTracks, settings.subtitlesLanguage);
const extraSubtitlesTrack = savedTrackId ?
findTrackById(video.state.extraSubtitlesTracks, savedTrackId) :
findTrackByLang(video.state.extraSubtitlesTracks, settings.subtitlesLanguage);
if (subtitlesTrack && subtitlesTrack.id) {
onSubtitlesTrackSelected(subtitlesTrack.id);
video.setSubtitlesTrack(subtitlesTrack.id);
defaultSubtitlesSelected.current = true;
} else if (extraSubtitlesTrack && extraSubtitlesTrack.id) {
onExtraSubtitlesTrackSelected(extraSubtitlesTrack.id);
video.setExtraSubtitlesTrack(extraSubtitlesTrack.id);
defaultSubtitlesSelected.current = true;
}
}
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks]);
}, [video.state.subtitlesTracks, video.state.extraSubtitlesTracks, player.streamState]);
// Auto audio track selection
React.useEffect(() => {
if (!defaultAudioTrackSelected.current) {
const findTrackByLang = (tracks, lang) => tracks.find((track) => track.lang === lang || langs.where('1', track.lang)?.[2] === lang);
const audioTrack = findTrackByLang(video.state.audioTracks, settings.audioLanguage);
const savedTrackId = player.streamState?.audioTrack?.id;
const audioTrack = savedTrackId ?
findTrackById(video.state.audioTracks, savedTrackId) :
findTrackByLang(video.state.audioTracks, settings.audioLanguage);
if (audioTrack && audioTrack.id) {
onAudioTrackSelected(audioTrack.id);
video.setAudioTrack(audioTrack.id);
defaultAudioTrackSelected.current = true;
}
}
}, [video.state.audioTracks]);
}, [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;
@ -568,7 +590,7 @@ const Player = ({ urlParams, queryParams }) => {
const videoId = player.selected ? player.selected?.streamRequest?.path?.id : null;
const video = metaItem ? metaItem.videos.find(({ id }) => id === videoId) : null;
const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})`: null;
const videoInfo = video && video.season && video.episode ? ` (${video.season}x${video.episode})` : null;
const videoTitle = video ? `${video.title}${videoInfo}` : null;
const metaTitle = metaItem ? metaItem.name : null;
const imageUrl = metaItem ? metaItem.logo : null;
@ -597,117 +619,99 @@ const Player = ({ urlParams, queryParams }) => {
navigator.mediaSession.setActionHandler('nexttrack', nexVideoCallback);
}, [player.nextVideo, onPlayRequested, onPauseRequested, onNextVideoRequested]);
React.useLayoutEffect(() => {
const onKeyDown = (event) => {
switch (event.code) {
case 'Space': {
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
}
break;
}
case 'ArrowRight': {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
break;
}
case 'ArrowLeft': {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = event.shiftKey ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time - seekDuration);
}
break;
}
case 'ArrowUp': {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
break;
}
case 'ArrowDown': {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.max(video.state.volume - 5, 0));
}
break;
}
case 'KeyS': {
closeMenus();
if ((Array.isArray(video.state.subtitlesTracks) && video.state.subtitlesTracks.length > 0) ||
(Array.isArray(video.state.extraSubtitlesTracks) && video.state.extraSubtitlesTracks.length > 0)) {
toggleSubtitlesMenu();
}
break;
}
case 'KeyA': {
closeMenus();
if (Array.isArray(video.state.audioTracks) && video.state.audioTracks.length > 0) {
toggleAudioMenu();
}
break;
}
case 'KeyI': {
closeMenus();
if (player.metaItem !== null && player.metaItem.type === 'Ready') {
toggleSideDrawer();
}
break;
}
case 'KeyR': {
closeMenus();
if (video.state.playbackSpeed !== null) {
toggleSpeedMenu();
}
break;
}
case 'KeyD': {
closeMenus();
if (streamingServer.statistics !== null && streamingServer.statistics.type !== 'Err' && player.selected && typeof player.selected.stream.infoHash === 'string' && typeof player.selected.stream.fileIdx === 'number') {
toggleStatisticsMenu();
}
break;
}
case 'KeyG': {
onDecreaseSubtitlesDelay();
break;
}
case 'KeyH': {
onIncreaseSubtitlesDelay();
break;
}
case 'Minus': {
onUpdateSubtitlesSize(-1);
break;
}
case 'Equal': {
onUpdateSubtitlesSize(1);
break;
}
case 'Escape': {
closeMenus();
!settings.escExitFullscreen && window.history.back();
break;
}
onShortcut('playPause', () => {
if (!menusOpen && !nextVideoPopupOpen && video.state.paused !== null) {
if (video.state.paused) {
onPlayRequested();
setSeeking(false);
} else {
onPauseRequested();
}
};
}
}, [menusOpen, nextVideoPopupOpen, video.state.paused, onPlayRequested, onPauseRequested]);
onShortcut('seekForward', (combo) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time + seekDuration);
}
}, [menusOpen, nextVideoPopupOpen, video.state.time, onSeekRequested]);
onShortcut('seekBackward', (combo) => {
if (!menusOpen && !nextVideoPopupOpen && video.state.time !== null) {
const seekDuration = combo === 1 ? settings.seekShortTimeDuration : settings.seekTimeDuration;
setSeeking(true);
onSeekRequested(video.state.time - seekDuration);
}
}, [menusOpen, nextVideoPopupOpen, video.state.time, onSeekRequested]);
onShortcut('mute', () => {
video.state.muted === true ? onUnmuteRequested() : onMuteRequested();
}, [video.state.muted]);
onShortcut('volumeUp', () => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume + 5, 200));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
onShortcut('volumeDown', () => {
if (!menusOpen && !nextVideoPopupOpen && video.state.volume !== null) {
onVolumeChangeRequested(Math.min(video.state.volume - 5, 200));
}
}, [menusOpen, nextVideoPopupOpen, video.state.volume]);
onShortcut('subtitlesDelay', (combo) => {
combo === 1 ? onIncreaseSubtitlesDelay() : onDecreaseSubtitlesDelay();
}, [onIncreaseSubtitlesDelay, onDecreaseSubtitlesDelay]);
onShortcut('subtitlesSize', (combo) => {
combo === 1 ? onUpdateSubtitlesSize(-1) : onUpdateSubtitlesSize(1);
}, [onUpdateSubtitlesSize, onUpdateSubtitlesSize]);
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) {
toggleAudioMenu();
}
}, [video.state.audioTracks, toggleAudioMenu]);
onShortcut('infoMenu', () => {
closeMenus();
if (player.metaItem?.type === 'Ready') {
toggleSideDrawer();
}
}, [player.metaItem, toggleSideDrawer]);
onShortcut('speedMenu', () => {
closeMenus();
if (video.state.playbackSpeed !== null) {
toggleSpeedMenu();
}
}, [video.state.playbackSpeed, toggleSpeedMenu]);
onShortcut('statisticsMenu', () => {
closeMenus();
const stream = player.selected?.stream;
if (streamingServer?.statistics?.type !== 'Err' && typeof stream === 'string' && typeof stream === 'number') {
toggleStatisticsMenu();
}
}, [player.selected, streamingServer.statistics, toggleStatisticsMenu]);
onShortcut('exit', () => {
closeMenus();
!settings.escExitFullscreen && window.history.back();
}, [settings.escExitFullscreen]);
React.useLayoutEffect(() => {
const onKeyUp = (event) => {
if (event.code === 'ArrowRight' || event.code === 'ArrowLeft') {
setSeeking(false);
@ -725,39 +729,14 @@ const Player = ({ urlParams, queryParams }) => {
}
};
if (routeFocused) {
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('wheel', onWheel);
}
return () => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('wheel', onWheel);
};
}, [
player.metaItem,
player.selected,
streamingServer.statistics,
settings.seekTimeDuration,
settings.seekShortTimeDuration,
settings.escExitFullscreen,
routeFocused,
menusOpen,
nextVideoPopupOpen,
video.state.paused,
video.state.time,
video.state.volume,
video.state.audioTracks,
video.state.subtitlesTracks,
video.state.extraSubtitlesTracks,
video.state.playbackSpeed,
toggleSubtitlesMenu,
toggleStatisticsMenu,
toggleSideDrawer,
onDecreaseSubtitlesDelay,
onIncreaseSubtitlesDelay,
onUpdateSubtitlesSize,
]);
}, [routeFocused, menusOpen, video.state.volume]);
React.useEffect(() => {
video.events.on('error', onError);

View file

@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import Icon from '@stremio/stremio-icons/react';
@ -37,6 +37,18 @@ const Stepper = ({ className, label, value, unit, step, min, max, disabled, onCh
timeout.cancel();
};
const decreaseDisabled = useMemo(() => {
return disabled || typeof value !== 'number' || (typeof min === 'number' && value <= min);
}, [disabled, min, value]);
const increaseDisabled = useMemo(() => {
return disabled || typeof value !== 'number' || (typeof max === 'number' && value >= max);
}, [disabled, max, value]);
const valueLabel = useMemo(() => {
return (disabled || typeof value !== 'number') ? '--' : `${value}${unit}`;
}, [disabled, value, unit]);
const updateValue = useCallback((delta: number) => {
onChange(clamp(localValue.current + delta, min, max));
}, [onChange]);
@ -72,7 +84,7 @@ const Stepper = ({ className, label, value, unit, step, min, max, disabled, onCh
</div>
<div className={styles['content']}>
<Button
className={classNames(styles['button'], { 'disabled': disabled })}
className={classNames(styles['button'], { 'disabled': decreaseDisabled })}
onMouseDown={onDecrementMouseDown}
onMouseUp={onDecrementMouseUp}
onMouseLeave={cancel}
@ -80,10 +92,10 @@ const Stepper = ({ className, label, value, unit, step, min, max, disabled, onCh
<Icon className={styles['icon']} name={'remove'} />
</Button>
<div className={styles['value']}>
{ disabled ? '--' : `${value}${unit}` }
{ valueLabel }
</div>
<Button
className={classNames(styles['button'], { 'disabled': disabled })}
className={classNames(styles['button'], { 'disabled': increaseDisabled })}
onMouseDown={onIncrementMouseDown}
onMouseUp={onIncrementMouseUp}
onMouseLeave={cancel}

View file

@ -86,6 +86,9 @@ const usePlayer = (urlParams) => {
};
}
}, [urlParams]);
const player = useModelState({ model: 'player', action, map });
const videoParamsChanged = React.useCallback((videoParams) => {
core.transport.dispatch({
action: 'Player',
@ -153,8 +156,22 @@ const usePlayer = (urlParams) => {
}, 'player');
}, []);
const player = useModelState({ model: 'player', action, map });
return [player, videoParamsChanged, timeChanged, seek, pausedChanged, ended, nextVideo];
const streamStateChanged = React.useCallback((partialStreamState) => {
return core.transport.dispatch({
action: 'Player',
args: {
action: 'StreamStateChanged',
args: {
state: {
...player.streamState,
...partialStreamState,
},
},
},
}, 'player');
}, [player.streamState]);
return [player, videoParamsChanged, streamStateChanged, timeChanged, seek, pausedChanged, ended, nextVideo];
};
module.exports = usePlayer;

View file

@ -94,6 +94,30 @@ const useVideo = () => {
dispatch({ type: 'setProp', propName: name, propValue: value });
};
const setPaused = (state) => {
setProp('paused', state);
};
const setVolume = (volume) => {
setProp('volume', volume);
};
const setMuted = (state) => {
setProp('muted', state);
};
const setTime = (time) => {
setProp('time', time);
};
const setPlaybackSpeed = (rate) => {
setProp('playbackSpeed', rate);
};
const setAudioTrack = (id) => {
setProp('selectedAudioTrackId', id);
};
const setSubtitlesTrack = (id) => {
setProp('selectedSubtitlesTrackId', id);
setProp('selectedExtraSubtitlesTrackId', null);
@ -104,6 +128,35 @@ const useVideo = () => {
setProp('selectedExtraSubtitlesTrackId', id);
};
const setSubtitlesDelay = (delay) => {
setProp('extraSubtitlesDelay', delay);
};
const setSubtitlesSize = (size) => {
setProp('subtitlesSize', size);
setProp('extraSubtitlesSize', size);
};
const setSubtitlesOffset = (offset) => {
setProp('subtitlesOffset', offset);
setProp('extraSubtitlesOffset', offset);
};
const setSubtitlesTextColor = (color) => {
setProp('subtitlesTextColor', color);
setProp('extraSubtitlesTextColor', color);
};
const setSubtitlesBackgroundColor = (color) => {
setProp('subtitlesBackgroundColor', color);
setProp('extraSubtitlesBackgroundColor', color);
};
const setSubtitlesOutlineColor = (color) => {
setProp('subtitlesOutlineColor', color);
setProp('extraSubtitlesOutlineColor', color);
};
const onError = (error) => {
events.emit('error', error);
};
@ -171,8 +224,19 @@ const useVideo = () => {
unload,
addExtraSubtitlesTracks,
addLocalSubtitles,
setProp,
setPaused,
setVolume,
setMuted,
setTime,
setPlaybackSpeed,
setAudioTrack,
setSubtitlesTrack,
setSubtitlesDelay,
setSubtitlesSize,
setSubtitlesOffset,
setSubtitlesTextColor,
setSubtitlesBackgroundColor,
setSubtitlesOutlineColor,
setExtraSubtitlesTrack,
};
};

View file

@ -78,7 +78,7 @@ const Search = ({ queryParams }) => {
<div className={styles['message-container']}>
<Image
className={styles['image']}
src={require('/images/empty.png')}
src={require('/assets/images/empty.png')}
alt={' '}
/>
<div className={styles['message-label']}>{ t.string('STREMIO_TV_SEARCH_NO_ADDONS') }</div>

View file

@ -1,13 +1,12 @@
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, MultiselectMenu, Toggle } from 'stremio/components';
import { Button } from 'stremio/components';
import { useServices } from 'stremio/services';
import { usePlatform, useToast } from 'stremio/common';
import { Section, Option, Link } from '../components';
import User from './User';
import useDataExport from './useDataExport';
import styles from './General.less';
import useGeneralOptions from './useGeneralOptions';
type Props = {
profile: Profile,
@ -15,18 +14,11 @@ type Props = {
const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
const { t } = useTranslation();
const { core, shell } = useServices();
const { core } = useServices();
const platform = usePlatform();
const toast = useToast();
const [dataExport, loadDataExport] = useDataExport();
const {
interfaceLanguageSelect,
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
} = useGeneralOptions(profile);
const [traktAuthStarted, setTraktAuthStarted] = useState(false);
const isTraktAuthenticated = useMemo(() => {
@ -143,39 +135,6 @@ const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
</Button>
</Option>
</Section>
<Section>
<Option label={'SETTINGS_UI_LANGUAGE'}>
<MultiselectMenu
className={'multiselect'}
{...interfaceLanguageSelect}
/>
</Option>
{
shell.active &&
<Option label={'SETTINGS_QUIT_ON_CLOSE'}>
<Toggle
tabIndex={-1}
{...quitOnCloseToggle}
/>
</Option>
}
{
shell.active &&
<Option label={'SETTINGS_FULLSCREEN_EXIT'}>
<Toggle
tabIndex={-1}
{...escExitFullscreenToggle}
/>
</Option>
}
<Option label={'SETTINGS_BLUR_UNWATCHED_IMAGE'}>
<Toggle
tabIndex={-1}
{...hideSpoilersToggle}
/>
</Option>
</Section>
</>;
});

View file

@ -14,12 +14,12 @@ const User = ({ profile }: Props) => {
const avatar = useMemo(() => (
!profile.auth ?
`url('${require('/images/anonymous.png')}')`
`url('${require('/assets/images/anonymous.png')}')`
:
profile.auth.user.avatar ?
`url('${profile.auth.user.avatar}')`
:
`url('${require('/images/default_avatar.png')}')`
`url('${require('/assets/images/default_avatar.png')}')`
), [profile.auth]);
const onLogout = useCallback(() => {

View file

@ -0,0 +1,57 @@
import React, { forwardRef } from 'react';
import { useServices } from 'stremio/services';
import { MultiselectMenu, Toggle } from 'stremio/components';
import { Section, Option } from '../components';
import useInterfaceOptions from './useInterfaceOptions';
type Props = {
profile: Profile,
};
const Interface = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
const { shell } = useServices();
const {
interfaceLanguageSelect,
quitOnCloseToggle,
escExitFullscreenToggle,
hideSpoilersToggle,
} = useInterfaceOptions(profile);
return (
<Section ref={ref} label={'INTERFACE'}>
<Option label={'SETTINGS_UI_LANGUAGE'}>
<MultiselectMenu
className={'multiselect'}
{...interfaceLanguageSelect}
/>
</Option>
{
shell.active &&
<Option label={'SETTINGS_QUIT_ON_CLOSE'}>
<Toggle
tabIndex={-1}
{...quitOnCloseToggle}
/>
</Option>
}
{
shell.active &&
<Option label={'SETTINGS_FULLSCREEN_EXIT'}>
<Toggle
tabIndex={-1}
{...escExitFullscreenToggle}
/>
</Option>
}
<Option label={'SETTINGS_BLUR_UNWATCHED_IMAGE'}>
<Toggle
tabIndex={-1}
{...hideSpoilersToggle}
/>
</Option>
</Section>
);
});
export default Interface;

View file

@ -0,0 +1,2 @@
import Interface from './Interface';
export default Interface;

View file

@ -2,7 +2,7 @@ import { useMemo } from 'react';
import { interfaceLanguages, useLanguageSorting } from 'stremio/common';
import { useServices } from 'stremio/services';
const useGeneralOptions = (profile: Profile) => {
const useInterfaceOptions = (profile: Profile) => {
const { core } = useServices();
const interfaceLanguageOptions = useMemo(() =>
@ -89,4 +89,4 @@ const useGeneralOptions = (profile: Profile) => {
};
};
export default useGeneralOptions;
export default useInterfaceOptions;

View file

@ -26,6 +26,9 @@ const Menu = ({ selected, streamingServer, onSelect }: Props) => {
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.GENERAL })} title={t('SETTINGS_NAV_GENERAL')} data-section={SECTIONS.GENERAL} onClick={onSelect}>
{ t('SETTINGS_NAV_GENERAL') }
</Button>
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.INTERFACE })} title={t('INTERFACE')} data-section={SECTIONS.INTERFACE} onClick={onSelect}>
{ t('INTERFACE') }
</Button>
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.PLAYER })} title={t('SETTINGS_NAV_PLAYER')} data-section={SECTIONS.PLAYER} onClick={onSelect}>
{ t('SETTINGS_NAV_PLAYER') }
</Button>

View file

@ -19,6 +19,7 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
subtitlesTextColorInput,
subtitlesBackgroundColorInput,
subtitlesOutlineColorInput,
assSubtitlesStylingToggle,
audioLanguageSelect,
surroundSoundToggle,
seekTimeDurationSelect,
@ -149,6 +150,15 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
/>
</Option>
}
{
shell.active &&
<Option label={'SETTINGS_ASS_SUBTITLES_STYLING'}>
<Toggle
tabIndex={-1}
{...assSubtitlesStylingToggle}
/>
</Option>
}
</Category>
</Section>
);

View file

@ -92,6 +92,22 @@ const usePlayerOptions = (profile: Profile) => {
}
}), [profile.settings]);
const assSubtitlesStylingToggle = useMemo(() => ({
checked: profile.settings.assSubtitlesStyling,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
assSubtitlesStyling: !profile.settings.assSubtitlesStyling
}
}
});
}
}), [profile.settings]);
const subtitlesOutlineColorInput = useMemo(() => ({
value: profile.settings.subtitlesOutlineColor,
onChange: (value: string) => {
@ -341,6 +357,7 @@ const usePlayerOptions = (profile: Profile) => {
subtitlesTextColorInput,
subtitlesBackgroundColorInput,
subtitlesOutlineColorInput,
assSubtitlesStylingToggle,
audioLanguageSelect,
surroundSoundToggle,
seekTimeDurationSelect,

View file

@ -9,6 +9,7 @@ import { MainNavBars } from 'stremio/components';
import { SECTIONS } from './constants';
import Menu from './Menu';
import General from './General';
import Interface from './Interface';
import Player from './Player';
import Streaming from './Streaming';
import Shortcuts from './Shortcuts';
@ -23,12 +24,14 @@ const Settings = () => {
const sectionsContainerRef = useRef<HTMLDivElement>(null);
const generalSectionRef = useRef<HTMLDivElement>(null);
const interfaceSectionRef = useRef<HTMLDivElement>(null);
const playerSectionRef = useRef<HTMLDivElement>(null);
const streamingServerSectionRef = useRef<HTMLDivElement>(null);
const shortcutsSectionRef = useRef<HTMLDivElement>(null);
const sections = useMemo(() => ([
{ ref: generalSectionRef, id: SECTIONS.GENERAL },
{ ref: interfaceSectionRef, id: SECTIONS.INTERFACE },
{ ref: playerSectionRef, id: SECTIONS.PLAYER },
{ ref: streamingServerSectionRef, id: SECTIONS.STREAMING },
{ ref: shortcutsSectionRef, id: SECTIONS.SHORTCUTS },
@ -82,6 +85,10 @@ const Settings = () => {
ref={generalSectionRef}
profile={profile}
/>
<Interface
ref={interfaceSectionRef}
profile={profile}
/>
<Player
ref={playerSectionRef}
profile={profile}

View file

@ -69,7 +69,7 @@
.cancel {
&:hover {
.icon {
color: var(--color-trakt);
color: var(--danger-accent-color);
}
}
}

View file

@ -52,7 +52,7 @@
}
&.error {
background-color: var(--color-trakt);
background-color: var(--danger-accent-color);
}
}
@ -92,7 +92,7 @@
background-color: var(--overlay-color);
.icon {
color: var(--color-trakt);
color: var(--danger-accent-color);
opacity: 1 !important;
}
}

View file

@ -2,7 +2,7 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import isEqual from 'lodash.isequal';
import { deepEqual } from 'fast-equals';
import { useServices } from 'stremio/services';
const CACHE_SIZES = [0, 2147483648, 5368709120, 10737418240, null];
@ -160,7 +160,7 @@ const useStreamingOptions = (streamingServer: StreamingServer) => {
btRequestTimeout: settings.btRequestTimeout
};
const isCustomTorrentProfileSelected = Object.values(TORRENT_PROFILES).every((torrentProfile) => {
return !isEqual(torrentProfile, selectedTorrentProfile);
return !deepEqual(torrentProfile, selectedTorrentProfile);
});
return {
options: Object.keys(TORRENT_PROFILES)

View file

@ -22,8 +22,8 @@
gap: 0.75rem;
.icon {
width: 3rem;
height: 3rem;
width: 4rem;
height: 4rem;
color: var(--primary-foreground-color);
}

View file

@ -1,6 +1,7 @@
const SECTIONS = {
GENERAL: 'general',
PLAYER: 'player',
INTERFACE: 'interface',
STREAMING: 'streaming',
SHORTCUTS: 'shortcuts',
};

View file

@ -42,6 +42,7 @@ type Settings = {
subtitlesOutlineColor: string,
subtitlesSize: number,
subtitlesTextColor: string,
assSubtitlesStyling: boolean,
surroundSound: boolean,
pauseOnMinimize: boolean,
};

View file

@ -30,6 +30,23 @@ type SeriesInfo = {
season: number,
};
type SubtitlesTrackState = {
id: string,
embedded: boolean,
};
type AudioTrackState = {
id: string,
};
type StreamState = {
subtitleTrack?: SubtitlesTrackState,
subtitleDelay?: number,
subtitleSize?: number,
subtitleOffset?: number,
audioTrack?: AudioTrackState,
};
type Player = {
addon: Addon | null,
libraryItem: LibraryItemPlayer | null,
@ -42,6 +59,7 @@ type Player = {
subtitlesPath: ResourceRequestPath,
} | null,
seriesInfo: SeriesInfo | null,
streamState: StreamState | null,
subtitles: Subtitle[],
title: string | null,
};

View file

@ -7,11 +7,9 @@ const webpack = require('webpack');
const threadLoader = require('thread-loader');
const HtmlWebPackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
const WorkboxPlugin = require('workbox-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const TerserPlugin = require('terser-webpack-plugin');
const WebpackPwaManifest = require('webpack-pwa-manifest');
const packageJson = require('./package.json');
const COMMIT_HASH = execSync('git rev-parse HEAD').toString().trim();
@ -44,7 +42,8 @@ module.exports = (env, argv) => ({
},
output: {
path: path.join(__dirname, 'build'),
filename: `${COMMIT_HASH}/scripts/[name].js`
filename: `${COMMIT_HASH}/scripts/[name].js`,
clean: true,
},
module: {
rules: [
@ -155,7 +154,7 @@ module.exports = (env, argv) => ({
exclude: /node_modules/,
type: 'asset/resource',
generator: {
filename: `${COMMIT_HASH}/fonts/[name][ext][query]`
filename: 'fonts/[name][ext][query]'
}
},
{
@ -221,9 +220,6 @@ module.exports = (env, argv) => ({
new webpack.ProvidePlugin({
Buffer: ['buffer', 'Buffer']
}),
new CleanWebpackPlugin({
cleanOnceBeforeBuildPatterns: ['*']
}),
argv.mode === 'production' &&
new WorkboxPlugin.GenerateSW({
maximumFileSizeToCacheInBytes: 20000000,
@ -232,10 +228,11 @@ module.exports = (env, argv) => ({
}),
new CopyWebpackPlugin({
patterns: [
{ from: 'favicons', to: 'favicons' },
{ from: 'images', to: 'images' },
{ from: 'screenshots/*.webp', to: './' },
{ from: 'assets/favicons', to: 'favicons' },
{ from: 'assets/images', to: 'images' },
{ from: 'assets/screenshots/*.webp', to: 'screenshots/[name][ext]' },
{ from: '.well-known', to: '.well-known' },
{ from: 'manifest.json', to: 'manifest.json' },
]
}),
new MiniCssExtractPlugin({
@ -248,56 +245,5 @@ module.exports = (env, argv) => ({
faviconsPath: 'favicons',
imagesPath: 'images',
}),
new WebpackPwaManifest({
name: 'Stremio Web',
short_name: 'Stremio',
description: 'Freedom To Stream',
background_color: '#161523',
theme_color: '#2a2843',
orientation: 'any',
display: 'standalone',
display_override: ['standalone'],
scope: './',
start_url: './',
publicPath: './',
icons: [
{
src: 'images/icon.png',
destination: 'icons',
sizes: [196, 512],
purpose: 'any'
},
{
src: 'images/maskable_icon.png',
destination: 'maskable_icons',
sizes: [196, 512],
purpose: 'maskable',
ios: true
},
{
src: 'favicons/favicon.ico',
destination: 'favicons',
sizes: [256],
}
],
screenshots : [
{
src: 'screenshots/board_wide.webp',
sizes: '1440x900',
type: 'image/webp',
form_factor: 'wide',
label: 'Homescreen of Stremio'
},
{
src: 'screenshots/board_narrow.webp',
sizes: '414x896',
type: 'image/webp',
form_factor: 'narrow',
label: 'Homescreen of Stremio'
}
],
fingerprints: false,
ios: true
}),
].filter(Boolean)
});