Merge branch 'development' of https://github.com/Stremio/stremio-web into feat/react-router

This commit is contained in:
Tim 2025-06-20 10:36:33 +02:00
commit d33f78ee39
85 changed files with 1961 additions and 1525 deletions

35
package-lock.json generated
View file

@ -38,7 +38,7 @@
"react-router": "6.30.0",
"react-router-dom": "6.30.0",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#8efdffbcf6eeadf01ab658e54adcc6a236b7b10f",
"stremio-translations": "github:Stremio/stremio-translations#8212fa77c4febd22ddb611590e9fb574dc845416",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -50,6 +50,8 @@
"@stylistic/eslint-plugin": "^2.11.0",
"@stylistic/eslint-plugin-jsx": "^2.11.0",
"@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",
@ -3818,6 +3820,33 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.18",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.18.tgz",
"integrity": "sha512-KJ65INaxqxmU6EoCiJmRPZC9H9RVWCRd349tXM2M3O5NA7cY6YL7c0bHAHQ93NOfTObEQ004kd2QVHs/r0+m4g==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/lodash.isequal": {
"version": "4.5.8",
"resolved": "https://registry.npmjs.org/@types/lodash.isequal/-/lodash.isequal-4.5.8.tgz",
"integrity": "sha512-uput6pg4E/tj2LGxCZo9+y27JNyB2OZuuI/T5F+ylVDYuqICLG2/ktjxx0v6GvVntAf8TvEzeQLcV0ffRirXuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/lodash.throttle": {
"version": "4.1.9",
"resolved": "https://registry.npmjs.org/@types/lodash.throttle/-/lodash.throttle-4.1.9.tgz",
"integrity": "sha512-PCPVfpfueguWZQB7pJQK890F2scYKoDUL3iM522AptHWn7d5NQmeS/LTEHIcLr5PaTzl3dK2Z0xSUHHTHwaL5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/lodash": "*"
}
},
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@ -13448,8 +13477,8 @@
},
"node_modules/stremio-translations": {
"version": "1.44.12",
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#8efdffbcf6eeadf01ab658e54adcc6a236b7b10f",
"integrity": "sha512-b38OjGwlsvFm/aNn/ia18mPxPjZvnI/GaToppn1XaQqCuZuSHxQlYDddwOYTztskWo4VO/IZmCi3UFewqpsqCQ==",
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#8212fa77c4febd22ddb611590e9fb574dc845416",
"integrity": "sha512-5DladLUsghLlVRsZh2bBnb7UMqU8NEYMHc+YbzBvb1llgMk9elXFSHtAjInepZlC5zWx2pJYOQ8lQzzqogQdFw==",
"license": "MIT"
},
"node_modules/string_decoder": {

View file

@ -43,7 +43,7 @@
"react-router": "6.30.0",
"react-router-dom": "6.30.0",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#8efdffbcf6eeadf01ab658e54adcc6a236b7b10f",
"stremio-translations": "github:Stremio/stremio-translations#8212fa77c4febd22ddb611590e9fb574dc845416",
"url": "0.11.4",
"use-long-press": "^3.2.0"
},
@ -55,6 +55,8 @@
"@stylistic/eslint-plugin": "^2.11.0",
"@stylistic/eslint-plugin-jsx": "^2.11.0",
"@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",

11
src/common/Toast/useToast.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
type ToastOptions = {
type: string,
title: string,
timeout: number,
};
declare const useToast: () => {
show: (options: ToastOptions) => void,
};
export = useToast;

View file

@ -82,6 +82,19 @@
transform: translateY(100%);
}
.fade-enter {
opacity: 0;
}
.fade-active {
opacity: 1;
transition: opacity 0.3s cubic-bezier(0.32, 0, 0.67, 0);
}
.fade-exit {
opacity: 0;
}
@keyframes fade-in-no-motion {
0% {
opacity: 0;

View file

@ -20,6 +20,7 @@ const useModelState = require('./useModelState');
const useNotifications = require('./useNotifications');
const useOnScrollToBottom = require('./useOnScrollToBottom');
const useProfile = require('./useProfile');
const { default: useRouteFocused } = require('./useRouteFocused');
const { default: useSettings } = require('./useSettings');
const { default: useShell } = require('./useShell');
const useStreamingServer = require('./useStreamingServer');
@ -53,6 +54,7 @@ module.exports = {
useNotifications,
useOnScrollToBottom,
useProfile,
useRouteFocused,
useSettings,
useShell,
useStreamingServer,

View file

@ -7,6 +7,7 @@ import styles from './Button.less';
type Props = {
className?: string,
style?: object,
href?: string,
target?: string
title?: string,

View file

@ -1,55 +1,62 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const AColorPicker = require('a-color-picker');
const { useTranslation } = require('react-i18next');
const { Button } = require('stremio/components');
const ModalDialog = require('stremio/components/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState');
const ColorPicker = require('./ColorPicker');
const styles = require('./styles');
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import classnames from 'classnames';
import * as AColorPicker from 'a-color-picker';
import { useTranslation } from 'react-i18next';
import { Button } from 'stremio/components';
import ModalDialog from 'stremio/components/ModalDialog';
import useBinaryState from 'stremio/common/useBinaryState';
import ColorPicker from './ColorPicker';
import styles from './ColorInput.less';
const parseColor = (value) => {
const parseColor = (value: string) => {
const color = AColorPicker.parseColor(value, 'hexcss4');
return typeof color === 'string' ? color : '#ffffffff';
};
const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
type Props = {
className: string,
value: string,
onChange?: (value: string) => void,
onClick?: (event: React.MouseEvent) => void,
};
const ColorInput = ({ className, value, onChange, ...props }: Props) => {
const { t } = useTranslation();
const [modalOpen, openModal, closeModal] = useBinaryState(false);
const [tempValue, setTempValue] = React.useState(() => {
const [tempValue, setTempValue] = useState(() => {
return parseColor(value);
});
const labelButtonStyle = React.useMemo(() => ({
const labelButtonStyle = useMemo(() => ({
backgroundColor: value
}), [value]);
const isTransparent = React.useMemo(() => {
const isTransparent = useMemo(() => {
return parseColor(value).endsWith('00');
}, [value]);
const labelButtonOnClick = React.useCallback((event) => {
const labelButtonOnClick = useCallback((event: React.MouseEvent) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
if (!event.nativeEvent.openModalPrevented) {
openModal();
}
}, [props.onClick]);
const modalDialogOnClick = React.useCallback((event) => {
const modalDialogOnClick = useCallback((event: React.MouseEvent) => {
// @ts-expect-error: Property 'openModalPrevented' does not exist on type 'MouseEvent'.
event.nativeEvent.openModalPrevented = true;
}, []);
const modalButtons = React.useMemo(() => {
const selectButtonOnClick = (event) => {
const modalButtons = useMemo(() => {
const selectButtonOnClick = () => {
if (typeof onChange === 'function') {
onChange({
type: 'change',
value: tempValue,
dataset: dataset,
reactEvent: event,
nativeEvent: event.nativeEvent
});
onChange(tempValue);
}
closeModal();
@ -63,13 +70,16 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
}
}
];
}, [tempValue, dataset, onChange]);
const colorPickerOnInput = React.useCallback((event) => {
setTempValue(parseColor(event.value));
}, [tempValue, onChange]);
const colorPickerOnInput = useCallback((color: string) => {
setTempValue(parseColor(color));
}, []);
React.useLayoutEffect(() => {
useLayoutEffect(() => {
setTempValue(parseColor(value));
}, [value, modalOpen]);
return (
<Button title={isTransparent ? t('BUTTON_COLOR_TRANSPARENT') : value} {...props} style={labelButtonStyle} className={classnames(className, styles['color-input-container'])} onClick={labelButtonOnClick}>
{
@ -92,12 +102,4 @@ const ColorInput = ({ className, value, dataset, onChange, ...props }) => {
);
};
ColorInput.propTypes = {
className: PropTypes.string,
value: PropTypes.string,
dataset: PropTypes.object,
onChange: PropTypes.func,
onClick: PropTypes.func
};
module.exports = ColorInput;
export default ColorInput;

View file

@ -29,10 +29,7 @@ const ColorPicker = ({ className, value, onInput }) => {
React.useLayoutEffect(() => {
if (typeof onInput === 'function') {
pickerRef.current.on('change', (picker, value) => {
onInput({
type: 'input',
value: parseColor(value)
});
onInput(parseColor(value));
});
}
return () => {

View file

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

View file

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

View file

@ -10,11 +10,11 @@ import styles from './Dropdown.less';
type Props = {
options: MultiselectMenuOption[];
value?: string | number;
value?: any;
menuOpen: boolean | (() => void);
level: number;
setLevel: (level: number) => void;
onSelect: (value: string | number) => void;
onSelect: (value: any) => void;
};
const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props) => {
@ -24,7 +24,7 @@ const Dropdown = ({ level, setLevel, options, onSelect, value, menuOpen }: Props
const selectedOption = options.find((opt) => opt.value === value);
const handleSetOptionRef = useCallback((optionValue: string | number) => (node: HTMLButtonElement | null) => {
const handleSetOptionRef = useCallback((optionValue: any) => (node: HTMLButtonElement | null) => {
if (node) {
optionsRef.current.set(optionValue, node);
} else {

View file

@ -8,8 +8,8 @@ import Icon from '@stremio/stremio-icons/react';
type Props = {
option: MultiselectMenuOption;
selectedValue?: string | number;
onSelect: (value: string | number) => void;
selectedValue?: any;
onSelect: (value: any) => void;
};
const Option = forwardRef<HTMLButtonElement, Props>(({ option, selectedValue, onSelect }, ref) => {

View file

@ -11,13 +11,14 @@ import useOutsideClick from 'stremio/common/useOutsideClick';
type Props = {
className?: string,
title?: string | (() => string);
title?: string | (() => string | null);
options: MultiselectMenuOption[];
value?: string | number;
onSelect: (value: string | number) => void;
value?: any;
disabled?: boolean,
onSelect: (value: any) => void;
};
const MultiselectMenu = ({ className, title, options, value, onSelect }: Props) => {
const MultiselectMenu = ({ className, title, options, value, disabled, onSelect }: Props) => {
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const multiselectMenuRef = useOutsideClick(() => closeMenu());
const [level, setLevel] = React.useState<number>(0);
@ -32,6 +33,7 @@ const MultiselectMenu = ({ className, title, options, value, onSelect }: Props)
<div className={classNames(styles['multiselect-menu'], { [styles['active']]: menuOpen }, className)} ref={multiselectMenuRef}>
<Button
className={classNames(styles['multiselect-button'], { [styles['open']]: menuOpen })}
disabled={disabled}
onClick={toggleMenu}
tabIndex={0}
aria-haspopup='listbox'

View file

@ -1,7 +1,7 @@
type MultiselectMenuOption = {
id?: number;
label: string;
value: number;
value: string | number | null;
destination?: string;
default?: boolean;
hidden?: boolean;

View file

@ -1,26 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Button } = require('stremio/components');
const styles = require('./styles');
const Toggle = React.forwardRef(({ className, checked, children, ...props }, ref) => {
return (
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
<div className={styles['toggle']} />
{children}
</Button>
);
});
Toggle.displayName = 'Toggle';
Toggle.propTypes = {
className: PropTypes.string,
checked: PropTypes.bool,
children: PropTypes.node
};
module.exports = Toggle;

View file

@ -0,0 +1,27 @@
// Copyright (C) 2017-2023 Smart code 203358507
import React, { forwardRef } from 'react';
import classnames from 'classnames';
import { Button } from 'stremio/components';
import styles from './Toggle.less';
type Props = {
className?: string,
checked: boolean,
disabled?: boolean,
tabIndex?: number,
children?: React.ReactNode,
};
const Toggle = forwardRef(({ className, checked, children, ...props }: Props, ref) => {
return (
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
<div className={styles['toggle']} />
{children}
</Button>
);
});
Toggle.displayName = 'Toggle';
export default Toggle;

View file

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

View file

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

2
src/modules.d.ts vendored
View file

@ -3,4 +3,6 @@ declare module '*.less' {
export = resource;
}
declare module 'stremio-router';
declare module 'stremio/components/NavBar';
declare module 'stremio/components/ModalDialog';

View file

@ -0,0 +1,23 @@
.indicator-container {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
height: 4rem;
user-select: none;
.indicator {
flex: none;
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
padding: 0 2rem;
border-radius: 4rem;
text-align: center;
font-weight: bold;
color: var(--primary-foreground-color);
background-color: var(--modal-background-color);
}
}

View file

@ -0,0 +1,73 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import classNames from 'classnames';
import { t } from 'i18next';
import { Transition } from 'stremio/components';
import { useBinaryState } from 'stremio/common';
import styles from './Indicator.less';
type Property = {
label: string,
format: (value: number) => string,
};
const PROPERTIES: Record<string, Property> = {
'extraSubtitlesDelay': {
label: 'SUBTITLES_DELAY',
format: (value) => `${(value / 1000).toFixed(2)}s`,
},
};
type VideoState = Record<string, number>;
type Props = {
className: string,
videoState: VideoState,
};
const Indicator = ({ className, videoState }: Props) => {
const timeout = useRef<NodeJS.Timeout | null>(null);
const prevVideoState = useRef<VideoState>(videoState);
const [shown, show, hide] = useBinaryState(false);
const [current, setCurrent] = useState<string | null>(null);
const label = useMemo(() => {
const property = current && PROPERTIES[current];
return property && t(property.label);
}, [current]);
const value = useMemo(() => {
const property = current && PROPERTIES[current];
const value = current && videoState[current];
return property && value && property.format(value);
}, [current, videoState]);
useEffect(() => {
for (const property of Object.keys(PROPERTIES)) {
const prev = prevVideoState.current[property];
const next = videoState[property];
if (next && next !== prev) {
setCurrent(property);
show();
timeout.current && clearTimeout(timeout.current);
timeout.current = setTimeout(hide, 1000);
}
}
prevVideoState.current = videoState;
}, [videoState]);
return (
<Transition when={shown} name={'fade'}>
<div className={classNames(className, styles['indicator-container'])}>
<div className={styles['indicator']}>
<div>{label} {value}</div>
</div>
</div>
</Transition>
);
};
export default Indicator;

View file

@ -28,6 +28,7 @@ const useStatistics = require('./useStatistics');
const useVideo = require('./useVideo');
const styles = require('./styles');
const Video = require('./Video');
const { default: Indicator } = require('./Indicator/Indicator');
const Player = () => {
const urlParams = useParams();
@ -220,6 +221,16 @@ const Player = () => {
video.setProp('extraSubtitlesDelay', delay);
}, []);
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) => {
updateSettings({ subtitlesSize: size });
}, [updateSettings]);
@ -591,6 +602,14 @@ const Player = () => {
break;
}
case 'KeyG': {
onDecreaseSubtitlesDelay();
break;
}
case 'KeyH': {
onIncreaseSubtitlesDelay();
break;
}
case 'Escape': {
closeMenus();
!settings.escExitFullscreen && navigate(-1);
@ -624,7 +643,29 @@ const Player = () => {
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]);
}, [
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,
]);
React.useEffect(() => {
video.events.on('error', onError);
@ -764,6 +805,10 @@ const Player = () => {
onMouseOver={onBarMouseMove}
onTouchEnd={onContainerMouseLeave}
/>
<Indicator
className={classnames(styles['layer'], styles['indicator-layer'])}
videoState={video.state}
/>
{
nextVideoPopupOpen ?
<NextVideoPopup

View file

@ -107,6 +107,13 @@ html:not(.active-slider-within) {
}
}
&.indicator-layer {
top: initial;
left: 0;
right: 0;
bottom: 10rem;
}
&.menu-layer {
top: initial;
left: initial;

View file

@ -0,0 +1,11 @@
:import('~stremio/routes/Settings/components/Option/Option.less') {
option-icon: icon;
}
.trakt-container {
margin-top: 2rem;
.option-icon {
color: var(--color-trakt) !important;
}
}

View file

@ -0,0 +1,182 @@
import React, { forwardRef, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, MultiselectMenu, Toggle } 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,
};
const General = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
const { t } = useTranslation();
const { core, shell } = 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(() => {
const trakt = profile?.auth?.user?.trakt;
return trakt && (Date.now() / 1000) < (trakt.created_at + trakt.expires_in);
}, [profile.auth]);
const onExportData = useCallback(() => {
loadDataExport();
}, []);
const onCalendarSubscribe = useCallback(() => {
if (!profile.auth) return;
const protocol = platform.name === 'ios' ? 'webcal' : 'https';
const url = `${protocol}://www.strem.io/calendar/${profile.auth.user._id}.ics`;
platform.openExternal(url);
toast.show({
type: 'success',
title: platform.name === 'ios' ?
t('SETTINGS_SUBSCRIBE_CALENDAR_IOS_TOAST') :
t('SETTINGS_SUBSCRIBE_CALENDAR_TOAST'),
timeout: 25000
});
// Stremio 4 emits not documented event subscribeCalendar
}, [profile.auth]);
const onToggleTrakt = useCallback(() => {
if (!isTraktAuthenticated && profile.auth !== null && profile.auth.user !== null && typeof profile.auth.user._id === 'string') {
platform.openExternal(`https://www.strem.io/trakt/auth/${profile.auth.user._id}`);
setTraktAuthStarted(true);
} else {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LogoutTrakt'
}
});
}
}, [isTraktAuthenticated, profile.auth]);
useEffect(() => {
if (dataExport.exportUrl) {
platform.openExternal(dataExport.exportUrl);
}
}, [dataExport.exportUrl]);
useEffect(() => {
if (isTraktAuthenticated && traktAuthStarted) {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'InstallTraktAddon'
}
});
setTraktAuthStarted(false);
}
}, [isTraktAuthenticated, traktAuthStarted]);
return <>
<Section ref={ref}>
<User profile={profile} />
</Section>
<Section>
{
profile?.auth?.user &&
<Link
label={t('SETTINGS_DATA_EXPORT')}
onClick={onExportData}
/>
}
{
profile?.auth?.user &&
<Link
label={t('SETTINGS_SUBSCRIBE_CALENDAR')}
onClick={onCalendarSubscribe}
/>
}
<Link
label={t('SETTINGS_SUPPORT')}
href={'https://stremio.zendesk.com/hc/en-us'}
/>
<Link
label={t('SETTINGS_SOURCE_CODE')}
href={`https://github.com/stremio/stremio-web/tree/${process.env.COMMIT_HASH}`}
/>
<Link
label={t('TERMS_OF_SERVICE')}
href={'https://www.stremio.com/tos'}
/>
<Link
label={t('PRIVACY_POLICY')}
href={'https://www.stremio.com/privacy'}
/>
{
profile?.auth?.user &&
<Link
label={t('SETTINGS_ACC_DELETE')}
href={'https://stremio.zendesk.com/hc/en-us/articles/360021428911-How-to-delete-my-account'}
/>
}
{
profile?.auth?.user?.email &&
<Link
label={t('SETTINGS_CHANGE_PASSWORD')}
href={`https://www.strem.io/reset-password/${profile.auth.user.email}`}
/>
}
<Option className={styles['trakt-container']} icon={'trakt'} label={t('SETTINGS_TRAKT')}>
<Button className={'button'} title={isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE')} disabled={profile.auth === null} tabIndex={-1} onClick={onToggleTrakt}>
{isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE')}
</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>
</>;
});
export default General;

View file

@ -0,0 +1,87 @@
@import (reference) '~stremio/common/screen-sizes.less';
.user {
gap: 1rem;
.user-info-content {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.avatar-container {
flex: none;
align-self: stretch;
height: 5rem;
width: 5rem;
margin-right: 1rem;
border: 2px solid var(--primary-accent-color);
border-radius: 50%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-origin: content-box;
background-clip: content-box;
opacity: 0.9;
background-color: var(--primary-foreground-color);
}
.email-logout-container {
flex: none;
display: flex;
flex-direction: column;
align-items: start;
.email-label-container {
display: flex;
flex-direction: row;
align-items: center;
}
.email-label-container {
.email-label {
flex: 1;
font-size: 1.1rem;
color: var(--primary-foreground-color);
opacity: 0.7;
}
}
}
}
.user-panel-container {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
width: 10rem;
height: 3.5rem;
border-radius: 3.5rem;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
.user-panel-label {
flex: 1;
max-height: 2.4em;
padding: 0 0.5rem;
font-weight: 500;
text-align: center;
color: var(--primary-foreground-color);
}
}
}
@media only screen and (max-width: @minimum) {
.user {
flex-direction: column;
align-items: flex-start;
.user-panel-container {
width: 100% !important;
}
}
}

View file

@ -0,0 +1,66 @@
import React, { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Link } from '../../components';
import styles from './User.less';
type Props = {
profile: Profile,
};
const User = ({ profile }: Props) => {
const { t } = useTranslation();
const { core } = useServices();
const avatar = useMemo(() => (
!profile.auth ?
`url('${require('/images/anonymous.png')}')`
:
profile.auth.user.avatar ?
`url('${profile.auth.user.avatar}')`
:
`url('${require('/images/default_avatar.png')}')`
), [profile.auth]);
const onLogout = useCallback(() => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'Logout'
}
});
}, []);
return (
<div className={styles['user']}>
<div className={styles['user-info-content']}>
<div
className={styles['avatar-container']}
style={{ backgroundImage: avatar }}
/>
<div className={styles['email-logout-container']}>
<div className={styles['email-label-container']} title={profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}>
<div className={styles['email-label']}>
{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
</div>
</div>
{
profile.auth !== null ?
<Link
label={t('LOG_OUT')}
onClick={onLogout}
/>
:
<Link
label={`${t('LOG_IN')} / ${t('SIGN_UP')}`}
href={'#/intro'}
target={'_self'}
/>
}
</div>
</div>
</div>
);
};
export default User;

View file

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

View file

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

View file

@ -0,0 +1,6 @@
declare const useDataExport: () => [
DataExport,
() => void,
];
export = useDataExport;

View file

@ -0,0 +1,84 @@
import { useMemo } from 'react';
import { interfaceLanguages } from 'stremio/common';
import { useServices } from 'stremio/services';
const useGeneralOptions = (profile: Profile) => {
const { core } = useServices();
const interfaceLanguageSelect = useMemo(() => ({
options: interfaceLanguages.map(({ name, codes }) => ({
value: codes[0],
label: name,
})),
value: interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage,
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
interfaceLanguage: value
}
}
});
}
}), [profile.settings]);
const escExitFullscreenToggle = useMemo(() => ({
checked: profile.settings.escExitFullscreen,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
escExitFullscreen: !profile.settings.escExitFullscreen
}
}
});
}
}), [profile.settings]);
const quitOnCloseToggle = useMemo(() => ({
checked: profile.settings.quitOnClose,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
quitOnClose: !profile.settings.quitOnClose
}
}
});
}
}), [profile.settings]);
const hideSpoilersToggle = useMemo(() => ({
checked: profile.settings.hideSpoilers,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
hideSpoilers: !profile.settings.hideSpoilers
}
}
});
}
}), [profile.settings]);
return {
interfaceLanguageSelect,
escExitFullscreenToggle,
quitOnCloseToggle,
hideSpoilersToggle,
};
};
export default useGeneralOptions;

View file

@ -0,0 +1,31 @@
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/routes/Settings/components/Option/Option.less') {
option-content: content;
}
.info {
display: none;
.option-content {
color: var(--primary-foreground-color);
overflow: hidden;
.label {
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
@media only screen and (max-width: @xsmall) {
.info {
display: flex;
}
}
@media only screen and (max-width: @minimum) {
.info {
display: flex;
}
}

View file

@ -0,0 +1,52 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Option, Section } from '../components';
import styles from './Info.less';
type Props = {
streamingServer: StreamingServer,
};
const Info = ({ streamingServer }: Props) => {
const { shell } = useServices();
const { t } = useTranslation();
const settings = useMemo(() => (
streamingServer?.settings?.type === 'Ready' ?
streamingServer.settings.content as StreamingServerSettings : null
), [streamingServer?.settings]);
return (
<Section className={styles['info']}>
<Option label={t('SETTINGS_APP_VERSION')}>
<div className={styles['label']}>
{process.env.VERSION}
</div>
</Option>
<Option label={t('SETTINGS_BUILD_VERSION')}>
<div className={styles['label']}>
{process.env.COMMIT_HASH}
</div>
</Option>
{
settings?.serverVersion &&
<Option label={t('SETTINGS_SERVER_VERSION')}>
<div className={styles['label']}>
{settings.serverVersion}
</div>
</Option>
}
{
typeof shell?.transport?.props?.shellVersion === 'string' &&
<Option label={t('SETTINGS_SHELL_VERSION')}>
<div className={styles['label']}>
{shell.transport.props.shellVersion}
</div>
</Option>
}
</Section>
);
};
export default Info;

View file

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

View file

@ -0,0 +1,62 @@
@import (reference) '~stremio/common/screen-sizes.less';
.menu {
flex: none;
align-self: stretch;
display: flex;
flex-direction: column;
width: 18rem;
padding: 3rem 1.5rem;
.button {
flex: none;
align-self: stretch;
display: flex;
align-items: center;
height: 4rem;
border-radius: 4rem;
padding: 2rem;
margin-bottom: 0.5rem;
font-size: 1.1rem;
font-weight: 500;
color: var(--primary-foreground-color);
opacity: 0.4;
&.selected {
font-weight: 600;
color: var(--primary-foreground-color);
background-color: var(--overlay-color);
opacity: 1;
}
&:hover {
background-color: var(--overlay-color);
}
}
.spacing {
flex: 1;
}
.version-info-label {
flex: 0 1 auto;
margin: 0.5rem 0;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
opacity: 0.3;
overflow: hidden;
}
}
@media only screen and (max-width: @xsmall) {
.menu {
display: none;
}
}
@media only screen and (max-width: @minimum) {
.menu {
display: none;
}
}

View file

@ -0,0 +1,62 @@
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { Button } from 'stremio/components';
import { SECTIONS } from '../constants';
import styles from './Menu.less';
type Props = {
selected: string,
streamingServer: StreamingServer,
onSelect: (event: React.MouseEvent<HTMLDivElement>) => void,
};
const Menu = ({ selected, streamingServer, onSelect }: Props) => {
const { t } = useTranslation();
const { shell } = useServices();
const settings = useMemo(() => (
streamingServer?.settings?.type === 'Ready' ?
streamingServer.settings.content as StreamingServerSettings : null
), [streamingServer?.settings]);
return (
<div className={styles['menu']}>
<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.PLAYER })} title={t('SETTINGS_NAV_PLAYER')} data-section={SECTIONS.PLAYER} onClick={onSelect}>
{ t('SETTINGS_NAV_PLAYER') }
</Button>
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.STREAMING })} title={t('SETTINGS_NAV_STREAMING')} data-section={SECTIONS.STREAMING} onClick={onSelect}>
{ t('SETTINGS_NAV_STREAMING') }
</Button>
<Button className={classNames(styles['button'], { [styles['selected']]: selected === SECTIONS.SHORTCUTS })} title={t('SETTINGS_NAV_SHORTCUTS')} data-section={SECTIONS.SHORTCUTS} onClick={onSelect}>
{ t('SETTINGS_NAV_SHORTCUTS') }
</Button>
<div className={styles['spacing']} />
<div className={styles['version-info-label']} title={process.env.VERSION}>
{t('SETTINGS_APP_VERSION')}: {process.env.VERSION}
</div>
<div className={styles['version-info-label']} title={process.env.COMMIT_HASH}>
{t('SETTINGS_BUILD_VERSION')}: {process.env.COMMIT_HASH}
</div>
{
settings?.serverVersion &&
<div className={styles['version-info-label']} title={settings.serverVersion}>
{t('SETTINGS_SERVER_VERSION')}: {settings.serverVersion}
</div>
}
{
typeof shell?.transport?.props?.shellVersion === 'string' &&
<div className={styles['version-info-label']} title={shell.transport.props.shellVersion}>
{t('SETTINGS_SHELL_VERSION')}: {shell.transport.props.shellVersion}
</div>
}
</div>
);
};
export default Menu;

View file

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

View file

@ -0,0 +1,146 @@
import React, { forwardRef } from 'react';
import { ColorInput, MultiselectMenu, Toggle } from 'stremio/components';
import { useServices } from 'stremio/services';
import { Category, Option, Section } from '../components';
import usePlayerOptions from './usePlayerOptions';
type Props = {
profile: Profile,
};
const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
const { shell } = useServices();
const {
subtitlesLanguageSelect,
subtitlesSizeSelect,
subtitlesTextColorInput,
subtitlesBackgroundColorInput,
subtitlesOutlineColorInput,
audioLanguageSelect,
surroundSoundToggle,
seekTimeDurationSelect,
seekShortTimeDurationSelect,
playInExternalPlayerSelect,
nextVideoPopupDurationSelect,
bingeWatchingToggle,
playInBackgroundToggle,
hardwareDecodingToggle,
pauseOnMinimizeToggle,
} = usePlayerOptions(profile);
return (
<Section ref={ref} label={'SETTINGS_NAV_PLAYER'}>
<Category icon={'subtitles'} label={'SETTINGS_SECTION_SUBTITLES'}>
<Option label={'SETTINGS_SUBTITLES_LANGUAGE'}>
<MultiselectMenu
className={'multiselect'}
{...subtitlesLanguageSelect}
/>
</Option>
<Option label={'SETTINGS_SUBTITLES_SIZE'}>
<MultiselectMenu
className={'multiselect'}
{...subtitlesSizeSelect}
/>
</Option>
<Option label={'SETTINGS_SUBTITLES_COLOR'}>
<ColorInput
className={'color-input'}
{...subtitlesTextColorInput}
/>
</Option>
<Option label={'SETTINGS_SUBTITLES_COLOR_BACKGROUND'}>
<ColorInput
className={'color-input'}
{...subtitlesBackgroundColorInput}
/>
</Option>
<Option label={'SETTINGS_SUBTITLES_COLOR_OUTLINE'}>
<ColorInput
className={'color-input'}
{...subtitlesOutlineColorInput}
/>
</Option>
</Category>
<Category icon={'volume-medium'} label={'SETTINGS_SECTION_AUDIO'}>
<Option label={'SETTINGS_DEFAULT_AUDIO_TRACK'}>
<MultiselectMenu
className={'multiselect'}
{...audioLanguageSelect}
/>
</Option>
<Option label={'SETTINGS_SURROUND_SOUND'}>
<Toggle
tabIndex={-1}
{...surroundSoundToggle}
/>
</Option>
</Category>
<Category icon={'remote'} label={'SETTINGS_SECTION_CONTROLS'}>
<Option label={'SETTINGS_SEEK_KEY'}>
<MultiselectMenu
className={'multiselect'}
{...seekTimeDurationSelect}
/>
</Option>
<Option label={'SETTINGS_SEEK_KEY_SHIFT'}>
<MultiselectMenu
className={'multiselect'}
{...seekShortTimeDurationSelect}
/>
</Option>
<Option label={'SETTINGS_PLAY_IN_BACKGROUND'}>
<Toggle
disabled={true}
tabIndex={-1}
{...playInBackgroundToggle}
/>
</Option>
</Category>
<Category icon={'play'} label={'SETTINGS_SECTION_AUTO_PLAY'}>
<Option label={'AUTO_PLAY'}>
<Toggle
tabIndex={-1}
{...bingeWatchingToggle}
/>
</Option>
<Option label={'SETTINGS_NEXT_VIDEO_POPUP_DURATION'}>
<MultiselectMenu
className={'multiselect'}
disabled={!profile.settings.bingeWatching}
{...nextVideoPopupDurationSelect}
/>
</Option>
</Category>
<Category icon={'glasses'} label={'SETTINGS_SECTION_ADVANCED'}>
<Option label={'SETTINGS_PLAY_IN_EXTERNAL_PLAYER'}>
<MultiselectMenu
className={'multiselect'}
{...playInExternalPlayerSelect}
/>
</Option>
{
shell.active &&
<Option label={'SETTINGS_HWDEC'}>
<Toggle
tabIndex={-1}
{...hardwareDecodingToggle}
/>
</Option>
}
{
shell.active &&
<Option label={'SETTINGS_PAUSE_MINIMIZED'}>
<Toggle
tabIndex={-1}
{...pauseOnMinimizeToggle}
/>
</Option>
}
</Category>
</Section>
);
});
export default Player;

View file

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

View file

@ -1,77 +1,25 @@
// Copyright (C) 2017-2023 Smart code 203358507
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useServices } from 'stremio/services';
import { CONSTANTS, languageNames, usePlatform } from 'stremio/common';
const React = require('react');
const { useTranslation } = require('react-i18next');
const { useServices } = require('stremio/services');
const { CONSTANTS, usePlatform, interfaceLanguages, languageNames } = require('stremio/common');
const LANGUAGES_NAMES: Record<string, string> = languageNames;
const useProfileSettingsInputs = (profile) => {
const usePlayerOptions = (profile: Profile) => {
const { t } = useTranslation();
const { core } = useServices();
const platform = usePlatform();
// TODO combine those useMemo in one
const interfaceLanguageSelect = React.useMemo(() => ({
options: interfaceLanguages.map(({ name, codes }) => ({
value: codes[0],
label: name,
})),
value: interfaceLanguages.find(({ codes }) => codes[1] === profile.settings.interfaceLanguage)?.codes?.[0] || profile.settings.interfaceLanguage,
onSelect: (value) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
interfaceLanguage: value
}
}
});
}
}), [profile.settings]);
const hideSpoilersToggle = React.useMemo(() => ({
checked: profile.settings.hideSpoilers,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
hideSpoilers: !profile.settings.hideSpoilers
}
}
});
}
}), [profile.settings]);
const quitOnCloseToggle = React.useMemo(() => ({
checked: profile.settings.quitOnClose,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
quitOnClose: !profile.settings.quitOnClose
}
}
});
}
}), [profile.settings]);
const subtitlesLanguageSelect = React.useMemo(() => ({
const subtitlesLanguageSelect = useMemo(() => ({
options: [
{ value: null, label: t('NONE') },
...Object.keys(languageNames).map((code) => ({
...Object.keys(LANGUAGES_NAMES).map((code) => ({
value: code,
label: languageNames[code]
label: LANGUAGES_NAMES[code]
}))
],
value: profile.settings.subtitlesLanguage,
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -84,7 +32,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const subtitlesSizeSelect = React.useMemo(() => ({
const subtitlesSizeSelect = useMemo(() => ({
options: CONSTANTS.SUBTITLES_SIZES.map((size) => ({
value: `${size}`,
label: `${size}%`
@ -93,7 +42,7 @@ const useProfileSettingsInputs = (profile) => {
title: () => {
return `${profile.settings.subtitlesSize}%`;
},
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -106,9 +55,10 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const subtitlesTextColorInput = React.useMemo(() => ({
const subtitlesTextColorInput = useMemo(() => ({
value: profile.settings.subtitlesTextColor,
onChange: (value) => {
onChange: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -121,9 +71,10 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const subtitlesBackgroundColorInput = React.useMemo(() => ({
const subtitlesBackgroundColorInput = useMemo(() => ({
value: profile.settings.subtitlesBackgroundColor,
onChange: (value) => {
onChange: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -136,9 +87,10 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const subtitlesOutlineColorInput = React.useMemo(() => ({
const subtitlesOutlineColorInput = useMemo(() => ({
value: profile.settings.subtitlesOutlineColor,
onChange: (value) => {
onChange: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -151,13 +103,14 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const audioLanguageSelect = React.useMemo(() => ({
options: Object.keys(languageNames).map((code) => ({
const audioLanguageSelect = useMemo(() => ({
options: Object.keys(LANGUAGES_NAMES).map((code) => ({
value: code,
label: languageNames[code]
label: LANGUAGES_NAMES [code]
})),
value: profile.settings.audioLanguage,
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -170,7 +123,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const surroundSoundToggle = React.useMemo(() => ({
const surroundSoundToggle = useMemo(() => ({
checked: profile.settings.surroundSound,
onClick: () => {
core.transport.dispatch({
@ -185,23 +139,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const escExitFullscreenToggle = React.useMemo(() => ({
checked: profile.settings.escExitFullscreen,
onClick: () => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
escExitFullscreen: !profile.settings.escExitFullscreen
}
}
});
}
}), [profile.settings]);
const seekTimeDurationSelect = React.useMemo(() => ({
const seekTimeDurationSelect = useMemo(() => ({
options: CONSTANTS.SEEK_TIME_DURATIONS.map((size) => ({
value: `${size}`,
label: `${size / 1000} ${t('SECONDS')}`
@ -210,7 +149,7 @@ const useProfileSettingsInputs = (profile) => {
title: () => {
return `${profile.settings.seekTimeDuration / 1000} ${t('SECONDS')}`;
},
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -223,7 +162,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const seekShortTimeDurationSelect = React.useMemo(() => ({
const seekShortTimeDurationSelect = useMemo(() => ({
options: CONSTANTS.SEEK_TIME_DURATIONS.map((size) => ({
value: `${size}`,
label: `${size / 1000} ${t('SECONDS')}`
@ -232,7 +172,7 @@ const useProfileSettingsInputs = (profile) => {
title: () => {
return `${profile.settings.seekShortTimeDuration / 1000} ${t('SECONDS')}`;
},
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -245,7 +185,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const playInExternalPlayerSelect = React.useMemo(() => ({
const playInExternalPlayerSelect = useMemo(() => ({
options: CONSTANTS.EXTERNAL_PLAYERS
.filter(({ platforms }) => platforms.includes(platform.name))
.map(({ label, value }) => ({
@ -257,7 +198,7 @@ const useProfileSettingsInputs = (profile) => {
const selectedOption = CONSTANTS.EXTERNAL_PLAYERS.find(({ value }) => value === profile.settings.playerType);
return selectedOption ? t(selectedOption.label, { defaultValue: selectedOption.label }) : profile.settings.playerType;
},
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -270,7 +211,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const nextVideoPopupDurationSelect = React.useMemo(() => ({
const nextVideoPopupDurationSelect = useMemo(() => ({
options: CONSTANTS.NEXT_VIDEO_POPUP_DURATIONS.map((duration) => ({
value: `${duration}`,
label: duration === 0 ? 'Disabled' : `${duration / 1000} ${t('SECONDS')}`
@ -282,7 +224,7 @@ const useProfileSettingsInputs = (profile) => {
:
`${profile.settings.nextVideoNotificationDuration / 1000} ${t('SECONDS')}`;
},
onSelect: (value) => {
onSelect: (value: string) => {
core.transport.dispatch({
action: 'Ctx',
args: {
@ -295,7 +237,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const bingeWatchingToggle = React.useMemo(() => ({
const bingeWatchingToggle = useMemo(() => ({
checked: profile.settings.bingeWatching,
onClick: () => {
core.transport.dispatch({
@ -310,7 +253,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const playInBackgroundToggle = React.useMemo(() => ({
const playInBackgroundToggle = useMemo(() => ({
checked: profile.settings.playInBackground,
onClick: () => {
core.transport.dispatch({
@ -325,7 +269,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const hardwareDecodingToggle = React.useMemo(() => ({
const hardwareDecodingToggle = useMemo(() => ({
checked: profile.settings.hardwareDecoding,
onClick: () => {
core.transport.dispatch({
@ -340,7 +285,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const pauseOnMinimizeToggle = React.useMemo(() => ({
const pauseOnMinimizeToggle = useMemo(() => ({
checked: profile.settings.pauseOnMinimize,
onClick: () => {
core.transport.dispatch({
@ -355,9 +301,8 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
return {
interfaceLanguageSelect,
hideSpoilersToggle,
subtitlesLanguageSelect,
subtitlesSizeSelect,
subtitlesTextColorInput,
@ -365,8 +310,6 @@ const useProfileSettingsInputs = (profile) => {
subtitlesOutlineColorInput,
audioLanguageSelect,
surroundSoundToggle,
escExitFullscreenToggle,
quitOnCloseToggle,
seekTimeDurationSelect,
seekShortTimeDurationSelect,
playInExternalPlayerSelect,
@ -378,4 +321,4 @@ const useProfileSettingsInputs = (profile) => {
};
};
module.exports = useProfileSettingsInputs;
export default usePlayerOptions;

View file

@ -1,809 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const classnames = require('classnames');
const throttle = require('lodash.throttle');
const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { default: useRouteFocused } = require('stremio/common/useRouteFocused');
const { useServices } = require('stremio/services');
const { useProfile, usePlatform, useStreamingServer, withCoreSuspender, useToast } = require('stremio/common');
const { Button, ColorInput, MainNavBars, MultiselectMenu, Toggle } = require('stremio/components');
const useProfileSettingsInputs = require('./useProfileSettingsInputs');
const useStreamingServerSettingsInputs = require('./useStreamingServerSettingsInputs');
const useDataExport = require('./useDataExport');
const styles = require('./styles');
const { default: URLsManager } = require('./URLsManager/URLsManager');
const GENERAL_SECTION = 'general';
const PLAYER_SECTION = 'player';
const STREAMING_SECTION = 'streaming';
const SHORTCUTS_SECTION = 'shortcuts';
const Settings = () => {
const { t } = useTranslation();
const { core, shell } = useServices();
const routeFocused = useRouteFocused();
const profile = useProfile();
const [dataExport, loadDataExport] = useDataExport();
const streamingServer = useStreamingServer();
const platform = usePlatform();
const toast = useToast();
const {
interfaceLanguageSelect,
hideSpoilersToggle,
subtitlesLanguageSelect,
subtitlesSizeSelect,
subtitlesTextColorInput,
subtitlesBackgroundColorInput,
subtitlesOutlineColorInput,
audioLanguageSelect,
surroundSoundToggle,
seekTimeDurationSelect,
seekShortTimeDurationSelect,
escExitFullscreenToggle,
quitOnCloseToggle,
playInExternalPlayerSelect,
nextVideoPopupDurationSelect,
bingeWatchingToggle,
playInBackgroundToggle,
hardwareDecodingToggle,
pauseOnMinimizeToggle,
} = useProfileSettingsInputs(profile);
const {
streamingServerRemoteUrlInput,
remoteEndpointSelect,
cacheSizeSelect,
torrentProfileSelect,
transcodingProfileSelect,
} = useStreamingServerSettingsInputs(streamingServer);
const [traktAuthStarted, setTraktAuthStarted] = React.useState(false);
const isTraktAuthenticated = React.useMemo(() => {
return profile.auth !== null && profile.auth.user !== null && profile.auth.user.trakt !== null &&
(Date.now() / 1000) < (profile.auth.user.trakt.created_at + profile.auth.user.trakt.expires_in);
}, [profile.auth]);
const logoutButtonOnClick = React.useCallback(() => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'Logout'
}
});
}, []);
const toggleTraktOnClick = React.useCallback(() => {
if (!isTraktAuthenticated && profile.auth !== null && profile.auth.user !== null && typeof profile.auth.user._id === 'string') {
platform.openExternal(`https://www.strem.io/trakt/auth/${profile.auth.user._id}`);
setTraktAuthStarted(true);
} else {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'LogoutTrakt'
}
});
}
}, [isTraktAuthenticated, profile.auth]);
const subscribeCalendarOnClick = React.useCallback(() => {
if (!profile.auth) return;
const protocol = platform.name === 'ios' ? 'webcal' : 'https';
const url = `${protocol}://www.strem.io/calendar/${profile.auth.user._id}.ics`;
platform.openExternal(url);
toast.show({
type: 'success',
title: platform.name === 'ios' ? t('SETTINGS_SUBSCRIBE_CALENDAR_IOS_TOAST') : t('SETTINGS_SUBSCRIBE_CALENDAR_TOAST'),
timeout: 25000
});
// Stremio 4 emits not documented event subscribeCalendar
}, [profile.auth]);
const exportDataOnClick = React.useCallback(() => {
loadDataExport();
}, []);
const onCopyRemoteUrlClick = React.useCallback(() => {
if (streamingServer.remoteUrl) {
navigator.clipboard.writeText(streamingServer.remoteUrl);
toast.show({
type: 'success',
title: t('SETTINGS_REMOTE_URL_COPIED'),
timeout: 2500,
});
}
}, [streamingServer.remoteUrl]);
const sectionsContainerRef = React.useRef(null);
const generalSectionRef = React.useRef(null);
const playerSectionRef = React.useRef(null);
const streamingServerSectionRef = React.useRef(null);
const shortcutsSectionRef = React.useRef(null);
const sections = React.useMemo(() => ([
{ ref: generalSectionRef, id: GENERAL_SECTION },
{ ref: playerSectionRef, id: PLAYER_SECTION },
{ ref: streamingServerSectionRef, id: STREAMING_SECTION },
{ ref: shortcutsSectionRef, id: SHORTCUTS_SECTION },
]), []);
const [selectedSectionId, setSelectedSectionId] = React.useState(GENERAL_SECTION);
const updateSelectedSectionId = React.useCallback(() => {
if (sectionsContainerRef.current.scrollTop + sectionsContainerRef.current.clientHeight >= sectionsContainerRef.current.scrollHeight - 50) {
setSelectedSectionId(sections[sections.length - 1].id);
} else {
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].ref.current.offsetTop - sectionsContainerRef.current.offsetTop <= sectionsContainerRef.current.scrollTop) {
setSelectedSectionId(sections[i].id);
break;
}
}
}
}, []);
const sideMenuButtonOnClick = React.useCallback((event) => {
const section = sections.find((section) => {
return section.id === event.currentTarget.dataset.section;
});
sectionsContainerRef.current.scrollTo({
top: section.ref.current.offsetTop - sectionsContainerRef.current.offsetTop,
behavior: 'smooth'
});
}, []);
const sectionsContainerOnScroll = React.useCallback(throttle(() => {
updateSelectedSectionId();
}, 50), []);
React.useEffect(() => {
if (isTraktAuthenticated && traktAuthStarted) {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'InstallTraktAddon'
}
});
setTraktAuthStarted(false);
}
}, [isTraktAuthenticated, traktAuthStarted]);
React.useEffect(() => {
if (dataExport.exportUrl !== null && typeof dataExport.exportUrl === 'string') {
platform.openExternal(dataExport.exportUrl);
}
}, [dataExport.exportUrl]);
React.useLayoutEffect(() => {
if (routeFocused) {
updateSelectedSectionId();
}
}, [routeFocused]);
return (
<MainNavBars className={styles['settings-container']} route={'settings'}>
<div className={classnames(styles['settings-content'], 'animation-fade-in')}>
<div className={styles['side-menu-container']}>
<Button className={classnames(styles['side-menu-button'], { [styles['selected']]: selectedSectionId === GENERAL_SECTION })} title={ t('SETTINGS_NAV_GENERAL') } data-section={GENERAL_SECTION} onClick={sideMenuButtonOnClick}>
{ t('SETTINGS_NAV_GENERAL') }
</Button>
<Button className={classnames(styles['side-menu-button'], { [styles['selected']]: selectedSectionId === PLAYER_SECTION })} title={ t('SETTINGS_NAV_PLAYER') }data-section={PLAYER_SECTION} onClick={sideMenuButtonOnClick}>
{ t('SETTINGS_NAV_PLAYER') }
</Button>
<Button className={classnames(styles['side-menu-button'], { [styles['selected']]: selectedSectionId === STREAMING_SECTION })} title={ t('SETTINGS_NAV_STREAMING') } data-section={STREAMING_SECTION} onClick={sideMenuButtonOnClick}>
{ t('SETTINGS_NAV_STREAMING') }
</Button>
<Button className={classnames(styles['side-menu-button'], { [styles['selected']]: selectedSectionId === SHORTCUTS_SECTION })} title={ t('SETTINGS_NAV_SHORTCUTS') } data-section={SHORTCUTS_SECTION} onClick={sideMenuButtonOnClick}>
{ t('SETTINGS_NAV_SHORTCUTS') }
</Button>
<div className={styles['spacing']} />
<div className={styles['version-info-label']} title={process.env.VERSION}>
{`${t('SETTINGS_APP_VERSION')}: ${process.env.VERSION}`}
</div>
<div className={styles['version-info-label']} title={process.env.COMMIT_HASH}>
{`${t('SETTINGS_BUILD_VERSION')}: ${process.env.COMMIT_HASH}`}
</div>
{
streamingServer.settings !== null && streamingServer.settings.type === 'Ready' ?
<div className={styles['version-info-label']} title={streamingServer.settings.content.serverVersion}>{`${t('SETTINGS_SERVER_VERSION')}: ${streamingServer.settings.content.serverVersion}`}</div>
:
null
}
{
typeof shell?.transport?.props?.shellVersion === 'string' ?
<div className={styles['version-info-label']} title={shell.transport.props.shellVersion}>{`${t('SETTINGS_APP_VERSION')}: ${shell.transport.props.shellVersion}`}</div>
:
null
}
</div>
<div ref={sectionsContainerRef} className={styles['sections-container']} onScroll={sectionsContainerOnScroll}>
<div ref={generalSectionRef} className={styles['section-container']}>
<div className={classnames(styles['option-container'], styles['user-info-option-container'])}>
<div className={styles['user-info-content']}>
<div
className={styles['avatar-container']}
style={{
backgroundImage: profile.auth === null ?
`url('${require('/images/anonymous.png')}')`
:
profile.auth.user.avatar ?
`url('${profile.auth.user.avatar}')`
:
`url('${require('/images/default_avatar.png')}')`
}}
/>
<div className={styles['email-logout-container']}>
<div className={styles['email-label-container']} title={profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}>
<div className={styles['email-label']}>
{profile.auth === null ? t('ANONYMOUS_USER') : profile.auth.user.email}
</div>
</div>
{
profile.auth !== null ?
<Button className={styles['logout-button-container']} title={ t('LOG_OUT') } onClick={logoutButtonOnClick}>
<div className={styles['logout-label']}>{ t('LOG_OUT') }</div>
</Button>
:
null
}
</div>
</div>
</div>
{
profile.auth === null ?
<div className={styles['option-container']}>
<Button className={classnames(styles['option-input-container'], styles['button-container'])} title={`${t('LOG_IN')} / ${t('SIGN_UP')}`} href={'#/intro'}>
<div className={styles['label']}>{ t('LOG_IN') } / { t('SIGN_UP') }</div>
</Button>
</div>
:
null
}
</div>
<div className={styles['section-container']}>
<div className={classnames(styles['option-container'], styles['link-container'])}>
{
profile.auth ?
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('SETTINGS_DATA_EXPORT')} tabIndex={-1} onClick={exportDataOnClick}>
<div className={styles['label']}>{ t('SETTINGS_DATA_EXPORT') }</div>
</Button>
:
null
}
</div>
{
profile.auth !== null && profile.auth.user !== null && typeof profile.auth.user._id === 'string' ?
<div className={classnames(styles['option-container'], styles['link-container'])}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('SETTINGS_SUBSCRIBE_CALENDAR')} tabIndex={-1} onClick={subscribeCalendarOnClick}>
<div className={styles['label']}>{ t('SETTINGS_SUBSCRIBE_CALENDAR') }</div>
</Button>
</div>
:
null
}
<div className={classnames(styles['option-container'], styles['link-container'])}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('SETTINGS_SUPPORT')} target={'_blank'} href={'https://stremio.zendesk.com/hc/en-us'}>
<div className={styles['label']}>{ t('SETTINGS_SUPPORT') }</div>
</Button>
</div>
<div className={classnames(styles['option-container'], styles['link-container'])}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('SETTINGS_SOURCE_CODE')} target={'_blank'} href={`https://github.com/stremio/stremio-web/tree/${process.env.COMMIT_HASH}`}>
<div className={styles['label']}>{t('SETTINGS_SOURCE_CODE')}</div>
</Button>
</div>
<div className={classnames(styles['option-container'], styles['link-container'])}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('TERMS_OF_SERVICE')} target={'_blank'} href={'https://www.stremio.com/tos'}>
<div className={styles['label']}>{ t('TERMS_OF_SERVICE') }</div>
</Button>
</div>
<div className={classnames(styles['option-container'], styles['link-container'])}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('PRIVACY_POLICY')} target={'_blank'} href={'https://www.stremio.com/privacy'}>
<div className={styles['label']}>{ t('PRIVACY_POLICY') }</div>
</Button>
</div>
{
profile.auth !== null && profile.auth.user !== null ?
<div className={classnames(styles['option-container'], styles['link-container'])}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('SETTINGS_ACC_DELETE')} target={'_blank'} href={'https://stremio.zendesk.com/hc/en-us/articles/360021428911-How-to-delete-my-account'}>
<div className={styles['label']}>{ t('SETTINGS_ACC_DELETE') }</div>
</Button>
</div>
:
null
}
{
profile.auth !== null && profile.auth.user !== null && typeof profile.auth.user.email === 'string' ?
<div className={styles['option-container']}>
<Button className={classnames(styles['option-input-container'], styles['link-input-container'])} title={t('SETTINGS_CHANGE_PASSWORD')} target={'_blank'} href={`https://www.strem.io/reset-password/${profile.auth.user.email}`}>
<div className={styles['label']}>{ t('SETTINGS_CHANGE_PASSWORD') }</div>
</Button>
</div>
:
null
}
<div className={styles['option-container']}>
<div className={classnames(styles['option-name-container'], styles['trakt-icon'])}>
<Icon className={styles['icon']} name={'trakt'} />
<div className={styles['label']}>{t('SETTINGS_TRAKT')}</div>
</div>
<Button className={classnames(styles['option-input-container'], styles['button-container'])} title={t('SETTINGS_TRAKT_AUTHENTICATE')} disabled={profile.auth === null} tabIndex={-1} onClick={toggleTraktOnClick}>
<div className={styles['label']}>
{ isTraktAuthenticated ? t('LOG_OUT') : t('SETTINGS_TRAKT_AUTHENTICATE') }
</div>
</Button>
</div>
</div>
<div className={styles['section-container']}>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_UI_LANGUAGE') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
tabIndex={-1}
{...interfaceLanguageSelect}
/>
</div>
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_QUIT_ON_CLOSE') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...quitOnCloseToggle}
/>
</div>
}
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_FULLSCREEN_EXIT') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...escExitFullscreenToggle}
/>
</div>
}
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_BLUR_UNWATCHED_IMAGE') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...hideSpoilersToggle}
/>
</div>
</div>
<div ref={playerSectionRef} className={styles['section-container']}>
<div className={styles['section-title']}>{ t('SETTINGS_NAV_PLAYER') }</div>
<div className={styles['section-category-container']}>
<Icon className={styles['icon']} name={'subtitles'} />
<div className={styles['label']}>{t('SETTINGS_SECTION_SUBTITLES')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_LANGUAGE') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...subtitlesLanguageSelect}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_SIZE') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...subtitlesSizeSelect}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_COLOR') }</div>
</div>
<ColorInput
className={classnames(styles['option-input-container'], styles['color-input-container'])}
{...subtitlesTextColorInput}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_COLOR_BACKGROUND') }</div>
</div>
<ColorInput
className={classnames(styles['option-input-container'], styles['color-input-container'])}
{...subtitlesBackgroundColorInput}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SUBTITLES_COLOR_OUTLINE') }</div>
</div>
<ColorInput
className={classnames(styles['option-input-container'], styles['color-input-container'])}
{...subtitlesOutlineColorInput}
/>
</div>
</div>
<div className={styles['section-container']}>
<div className={styles['section-category-container']}>
<Icon className={styles['icon']} name={'volume-medium'} />
<div className={styles['label']}>{t('SETTINGS_SECTION_AUDIO')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_DEFAULT_AUDIO_TRACK') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...audioLanguageSelect}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SURROUND_SOUND') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...surroundSoundToggle}
/>
</div>
</div>
<div className={styles['section-container']}>
<div className={styles['section-category-container']}>
<Icon className={styles['icon']} name={'remote'} />
<div className={styles['label']}>{t('SETTINGS_SECTION_CONTROLS')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SEEK_KEY') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...seekTimeDurationSelect}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SEEK_KEY_SHIFT') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...seekShortTimeDurationSelect}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_PLAY_IN_BACKGROUND') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
disabled={true}
tabIndex={-1}
{...playInBackgroundToggle}
/>
</div>
</div>
<div className={styles['section-container']}>
<div className={styles['section-category-container']}>
<Icon className={styles['icon']} name={'play'} />
<div className={styles['label']}>{t('SETTINGS_SECTION_AUTO_PLAY')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('AUTO_PLAY') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...bingeWatchingToggle}
/>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_NEXT_VIDEO_POPUP_DURATION') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
disabled={!profile.settings.bingeWatching}
{...nextVideoPopupDurationSelect}
/>
</div>
</div>
<div className={styles['section-container']}>
<div className={styles['section-category-container']}>
<Icon className={styles['icon']} name={'glasses'} />
<div className={styles['label']}>{t('SETTINGS_SECTION_ADVANCED')}</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_PLAY_IN_EXTERNAL_PLAYER') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...playInExternalPlayerSelect}
/>
</div>
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_HWDEC') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...hardwareDecodingToggle}
/>
</div>
}
{
shell.active &&
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_PAUSE_MINIMIZED') }</div>
</div>
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...pauseOnMinimizeToggle}
/>
</div>
}
</div>
<div ref={streamingServerSectionRef} className={styles['section-container']}>
<div className={styles['section-title']}>{ t('SETTINGS_NAV_STREAMING') }</div>
<URLsManager />
{
streamingServerRemoteUrlInput.value !== null ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{t('SETTINGS_REMOTE_URL')}</div>
</div>
<div className={classnames(styles['option-input-container'], styles['configure-input-container'])}>
<div className={styles['label']} title={streamingServerRemoteUrlInput.value}>{streamingServerRemoteUrlInput.value}</div>
<Button className={styles['configure-button-container']} title={t('SETTINGS_COPY_REMOTE_URL')} onClick={onCopyRemoteUrlClick}>
<Icon className={styles['icon']} name={'link'} />
</Button>
</div>
</div>
:
null
}
{
profile.auth !== null && profile.auth.user !== null && remoteEndpointSelect !== null ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_HTTPS_ENDPOINT') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...remoteEndpointSelect}
/>
</div>
:
null
}
{
cacheSizeSelect !== null ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SERVER_CACHE_SIZE') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...cacheSizeSelect}
/>
</div>
:
null
}
{
torrentProfileSelect !== null ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SERVER_TORRENT_PROFILE') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...torrentProfileSelect}
/>
</div>
:
null
}
{
transcodingProfileSelect !== null ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_TRANSCODE_PROFILE') }</div>
</div>
<MultiselectMenu
className={classnames(styles['option-input-container'], styles['multiselect-container'])}
{...transcodingProfileSelect}
/>
</div>
:
null
}
</div>
<div ref={shortcutsSectionRef} className={styles['section-container']}>
<div className={styles['section-title']}>{ t('SETTINGS_NAV_SHORTCUTS') }</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_PLAY_PAUSE') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>{ t('SETTINGS_SHORTCUT_SPACE') }</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_SEEK_FORWARD') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd></kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_OR') }</div>
<kbd> { t('SETTINGS_SHORTCUT_SHIFT') }</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_SEEK_BACKWARD') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd></kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_OR') }</div>
<kbd> { t('SETTINGS_SHORTCUT_SHIFT') }</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_VOLUME_UP') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd></kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_VOLUME_DOWN') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd></kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_MENU_SUBTITLES') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>S</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_MENU_AUDIO') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>A</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_MENU_INFO') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>I</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_MENU_VIDEOS') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>V</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_FULLSCREEN') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>F</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_NAVIGATE_MENUS') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>1</kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_TO') }</div>
<kbd>6</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_GO_TO_SEARCH') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>0</kbd>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_EXIT_BACK') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['shortcut-container'])}>
<kbd>{ t('SETTINGS_SHORTCUT_ESC') }</kbd>
</div>
</div>
</div>
<div className={classnames(styles['section-container'], styles['versions-section-container'])}>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>
{t('SETTINGS_APP_VERSION')}
</div>
</div>
<div className={classnames(styles['option-input-container'], styles['info-container'])}>
<div className={styles['label']}>
{process.env.VERSION}
</div>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>
{t('SETTINGS_BUILD_VERSION')}
</div>
</div>
<div className={classnames(styles['option-input-container'], styles['info-container'])}>
<div className={styles['label']}>
{process.env.COMMIT_HASH}
</div>
</div>
</div>
{
streamingServer.settings !== null && streamingServer.settings.type === 'Ready' ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>
{t('SETTINGS_SERVER_VERSION')}
</div>
</div>
<div className={classnames(styles['option-input-container'], styles['info-container'])}>
<div className={styles['label']}>
{streamingServer.settings.content.serverVersion}
</div>
</div>
</div>
:
null
}
{
typeof shell?.transport?.props?.shellVersion === 'string' ?
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>
{t('SETTINGS_SHELL_VERSION')}
</div>
</div>
<div className={classnames(styles['option-input-container'], styles['info-container'])}>
<div className={styles['label']}>
{ shell.transport.props.shellVersion }
</div>
</div>
</div>
:
null
}
</div>
</div>
</div>
</MainNavBars>
);
};
const SettingsFallback = () => (
<MainNavBars className={styles['settings-container']} route={'settings'} />
);
module.exports = withCoreSuspender(Settings, SettingsFallback);

View file

@ -0,0 +1,35 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.settings-container {
height: calc(100% - var(--safe-area-inset-bottom));
width: 100%;
background-color: transparent;
.settings-content {
height: 100%;
width: 100%;
display: flex;
flex-direction: row;
.sections-container {
flex: 1;
align-self: stretch;
padding: 0 3rem;
overflow-y: auto;
}
}
}
@media only screen and (max-width: @minimum) {
.settings-container {
.settings-content {
flex-direction: column-reverse;
.sections-container {
padding: 0 1.5rem;
}
}
}
}

View file

@ -0,0 +1,108 @@
// Copyright (C) 2017-2023 Smart code 203358507
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import classnames from 'classnames';
import throttle from 'lodash.throttle';
import { useProfile, useStreamingServer, useRouteFocused, withCoreSuspender } from 'stremio/common';
import { MainNavBars } from 'stremio/components';
import { SECTIONS } from './constants';
import Menu from './Menu';
import General from './General';
import Player from './Player';
import Streaming from './Streaming';
import Shortcuts from './Shortcuts';
import Info from './Info';
import styles from './Settings.less';
const Settings = () => {
const routeFocused = useRouteFocused();
const profile = useProfile();
const streamingServer = useStreamingServer();
const sectionsContainerRef = useRef<HTMLDivElement>(null);
const generalSectionRef = 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: playerSectionRef, id: SECTIONS.PLAYER },
{ ref: streamingServerSectionRef, id: SECTIONS.STREAMING },
{ ref: shortcutsSectionRef, id: SECTIONS.SHORTCUTS },
]), []);
const [selectedSectionId, setSelectedSectionId] = useState(SECTIONS.GENERAL);
const updateSelectedSectionId = useCallback(() => {
const container = sectionsContainerRef.current;
if (container!.scrollTop + container!.clientHeight >= container!.scrollHeight - 50) {
setSelectedSectionId(sections[sections.length - 1].id);
} else {
for (let i = sections.length - 1; i >= 0; i--) {
if (sections[i].ref.current!.offsetTop - container!.offsetTop <= container!.scrollTop) {
setSelectedSectionId(sections[i].id);
break;
}
}
}
}, []);
const onMenuSelect = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
const section = sections.find((section) => {
return section.id === event.currentTarget.dataset.section;
});
const container = sectionsContainerRef.current;
section && container!.scrollTo({
top: section.ref.current!.offsetTop - container!.offsetTop,
behavior: 'smooth'
});
}, []);
const onContainerScroll = useCallback(throttle(() => {
updateSelectedSectionId();
}, 50), []);
useLayoutEffect(() => {
if (routeFocused) {
updateSelectedSectionId();
}
}, [routeFocused]);
return (
<MainNavBars className={styles['settings-container']} route={'settings'}>
<div className={classnames(styles['settings-content'], 'animation-fade-in')}>
<Menu
selected={selectedSectionId}
streamingServer={streamingServer}
onSelect={onMenuSelect}
/>
<div ref={sectionsContainerRef} className={styles['sections-container']} onScroll={onContainerScroll}>
<General
ref={generalSectionRef}
profile={profile}
/>
<Player
ref={playerSectionRef}
profile={profile}
/>
<Streaming
ref={streamingServerSectionRef}
profile={profile}
streamingServer={streamingServer}
/>
<Shortcuts ref={shortcutsSectionRef} />
<Info streamingServer={streamingServer} />
</div>
</div>
</MainNavBars>
);
};
const SettingsFallback = () => (
<MainNavBars className={styles['settings-container']} route={'settings'} />
);
export default withCoreSuspender(Settings, SettingsFallback);

View file

@ -0,0 +1,27 @@
.shortcut-container {
display: flex;
align-items: center;
justify-content: center;
padding: 0;
overflow: visible;
kbd {
flex: 0 1 auto;
height: 2.5rem;
min-width: 2.5rem;
line-height: 2.5rem;
padding: 0 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
border-radius: 0.25em;
box-shadow: 0 4px 0 1px var(--modal-background-color);
background-color: var(--overlay-color);
}
.label {
flex: none;
margin: 0 1rem;
white-space: nowrap;
color: var(--primary-foreground-color);
}
}

View file

@ -0,0 +1,97 @@
import React, { forwardRef } from 'react';
import { Section, Option } from '../components';
import styles from './Shortcuts.less';
import { useTranslation } from 'react-i18next';
const Shortcuts = forwardRef<HTMLDivElement>((_, ref) => {
const { t } = useTranslation();
return (
<Section ref={ref} label={'SETTINGS_NAV_SHORTCUTS'}>
<Option label={'SETTINGS_SHORTCUT_PLAY_PAUSE'}>
<div className={styles['shortcut-container']}>
<kbd>{t('SETTINGS_SHORTCUT_SPACE')}</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SEEK_FORWARD'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
<kbd> {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SEEK_BACKWARD'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_OR')}</div>
<kbd> {t('SETTINGS_SHORTCUT_SHIFT')}</kbd>
<div className={styles['label']}>+</div>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_VOLUME_UP'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_VOLUME_DOWN'}>
<div className={styles['shortcut-container']}>
<kbd></kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_SUBTITLES'}>
<div className={styles['shortcut-container']}>
<kbd>S</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_AUDIO'}>
<div className={styles['shortcut-container']}>
<kbd>A</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_INFO'}>
<div className={styles['shortcut-container']}>
<kbd>I</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_MENU_VIDEOS'}>
<div className={styles['shortcut-container']}>
<kbd>V</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_FULLSCREEN'}>
<div className={styles['shortcut-container']}>
<kbd>F</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_SUBTITLES_DELAY'}>
<div className={styles['shortcut-container']}>
<kbd>G</kbd>
<div className={styles['label']}>{ t('SETTINGS_SHORTCUT_AND') }</div>
<kbd>H</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_NAVIGATE_MENUS'}>
<div className={styles['shortcut-container']}>
<kbd>1</kbd>
<div className={styles['label']}>{t('SETTINGS_SHORTCUT_TO')}</div>
<kbd>6</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_GO_TO_SEARCH'}>
<div className={styles['shortcut-container']}>
<kbd>0</kbd>
</div>
</Option>
<Option label={'SETTINGS_SHORTCUT_EXIT_BACK'}>
<div className={styles['shortcut-container']}>
<kbd>{t('SETTINGS_SHORTCUT_ESC')}</kbd>
</div>
</Option>
</Section>
);
});
export default Shortcuts;

View file

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

View file

@ -0,0 +1,44 @@
:import('~stremio/routes/Settings/components/Option/Option.less') {
option-content: content;
}
.configure-input-container {
.option-content {
display: flex;
align-items: center;
gap: 1rem;
overflow: hidden;
.label {
flex: auto;
white-space: pre;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
padding: 0 1rem;
}
.configure-button-container {
flex: none;
width: 3rem;
height: 3rem;
border-radius: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
.icon {
flex: none;
width: 1rem;
height: 1rem;
margin: 0;
color: var(--primary-foreground-color);
}
}
}
}

View file

@ -0,0 +1,92 @@
import React, { forwardRef, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import Icon from '@stremio/stremio-icons/react';
import { Button, MultiselectMenu } from 'stremio/components';
import { useToast } from 'stremio/common';
import { Section, Option } from '../components';
import URLsManager from './URLsManager';
import useStreamingOptions from './useStreamingOptions';
import styles from './Streaming.less';
type Props = {
profile: Profile,
streamingServer: StreamingServer,
};
const Streaming = forwardRef<HTMLDivElement, Props>(({ profile, streamingServer }: Props, ref) => {
const { t } = useTranslation();
const toast = useToast();
const {
streamingServerRemoteUrlInput,
remoteEndpointSelect,
cacheSizeSelect,
torrentProfileSelect,
transcodingProfileSelect,
} = useStreamingOptions(streamingServer);
const onCopyRemoteUrl = useCallback(() => {
if (streamingServer.remoteUrl) {
navigator.clipboard.writeText(streamingServer.remoteUrl);
toast.show({
type: 'success',
title: t('SETTINGS_REMOTE_URL_COPIED'),
timeout: 2500,
});
}
}, [streamingServer.remoteUrl]);
return (
<Section ref={ref} label={'SETTINGS_NAV_STREAMING'}>
<URLsManager />
{
streamingServerRemoteUrlInput.value !== null &&
<Option className={styles['configure-input-container']} label={'SETTINGS_REMOTE_URL'}>
<div className={styles['label']} title={streamingServerRemoteUrlInput.value}>{streamingServerRemoteUrlInput.value}</div>
<Button className={styles['configure-button-container']} title={t('SETTINGS_COPY_REMOTE_URL')} onClick={onCopyRemoteUrl}>
<Icon className={styles['icon']} name={'link'} />
</Button>
</Option>
}
{
profile.auth !== null && profile.auth.user !== null && remoteEndpointSelect !== null &&
<Option label={'SETTINGS_HTTPS_ENDPOINT'}>
<MultiselectMenu
className={'multiselect'}
{...remoteEndpointSelect}
/>
</Option>
}
{
cacheSizeSelect !== null &&
<Option label={'SETTINGS_SERVER_CACHE_SIZE'}>
<MultiselectMenu
className={'multiselect'}
{...cacheSizeSelect}
/>
</Option>
}
{
torrentProfileSelect !== null &&
<Option label={'SETTINGS_SERVER_TORRENT_PROFILE'}>
<MultiselectMenu
className={'multiselect'}
{...torrentProfileSelect}
/>
</Option>
}
{
transcodingProfileSelect !== null &&
<Option label={'SETTINGS_TRANSCODE_PROFILE'}>
<MultiselectMenu
className={'multiselect'}
{...transcodingProfileSelect}
/>
</Option>
}
</Section>
);
});
export default Streaming;

View file

@ -1,6 +1,8 @@
// Copyright (C) 2017-2024 Smart code 203358507
.wrapper {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
max-width: 35rem;

View file

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

View file

@ -1,13 +1,13 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const isEqual = require('lodash.isequal');
const { useServices } = require('stremio/services');
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import isEqual from 'lodash.isequal';
import { useServices } from 'stremio/services';
const CACHE_SIZES = [0, 2147483648, 5368709120, 10737418240, null];
const cacheSizeToString = (size) => {
const cacheSizeToString = (size: number | null) => {
return size === null ?
'Infinite'
:
@ -17,7 +17,16 @@ const cacheSizeToString = (size) => {
`${Math.ceil(((size / 1024 / 1024 / 1024) + Number.EPSILON) * 100) / 100}GiB`;
};
const TORRENT_PROFILES = {
type TorrentProfile = {
btDownloadSpeedHardLimit: number,
btDownloadSpeedSoftLimit: number,
btHandshakeTimeout: number,
btMaxConnections: number,
btMinPeersForStable: number,
btRequestTimeout: number
};
const TORRENT_PROFILES: Record<string, TorrentProfile> = {
default: {
btDownloadSpeedHardLimit: 3670016,
btDownloadSpeedSoftLimit: 2621440,
@ -52,17 +61,32 @@ const TORRENT_PROFILES = {
}
};
const useStreamingServerSettingsInputs = (streamingServer) => {
const useStreamingOptions = (streamingServer: StreamingServer) => {
const { core } = useServices();
const { t } = useTranslation();
// TODO combine those useMemo in one
const streamingServerRemoteUrlInput = React.useMemo(() => ({
const settings = useMemo(() => (
streamingServer?.settings?.type === 'Ready' ?
streamingServer.settings.content as StreamingServerSettings : null
), [streamingServer.settings]);
const networkInfo = useMemo(() => (
streamingServer?.networkInfo?.type === 'Ready' ?
streamingServer.networkInfo.content as NetworkInfo : null
), [streamingServer.networkInfo]);
const deviceInfo = useMemo(() => (
streamingServer?.deviceInfo?.type === 'Ready' ?
streamingServer.deviceInfo.content as DeviceInfo : null
), [streamingServer.deviceInfo]);
const streamingServerRemoteUrlInput = useMemo(() => ({
value: streamingServer.remoteUrl,
}), [streamingServer.remoteUrl]);
const remoteEndpointSelect = React.useMemo(() => {
if (streamingServer.settings?.type !== 'Ready' || streamingServer.networkInfo?.type !== 'Ready') {
const remoteEndpointSelect = useMemo(() => {
if (!settings || !networkInfo) {
return null;
}
@ -72,29 +96,29 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
label: t('SETTINGS_DISABLED'),
value: '',
},
...streamingServer.networkInfo.content.availableInterfaces.map((address) => ({
...networkInfo.availableInterfaces.map((address) => ({
label: address,
value: address,
}))
],
value: streamingServer.settings.content.remoteHttps,
onSelect: (value) => {
value: settings.remoteHttps,
onSelect: (value: string | null) => {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'UpdateSettings',
args: {
...streamingServer.settings.content,
...settings,
remoteHttps: value,
}
}
});
}
};
}, [streamingServer.settings, streamingServer.networkInfo]);
}, [settings, networkInfo]);
const cacheSizeSelect = React.useMemo(() => {
if (streamingServer.settings === null || streamingServer.settings.type !== 'Ready') {
const cacheSizeSelect = useMemo(() => {
if (!settings) {
return null;
}
@ -103,36 +127,37 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
label: cacheSizeToString(size),
value: JSON.stringify(size)
})),
value: JSON.stringify(streamingServer.settings.content.cacheSize),
value: JSON.stringify(settings.cacheSize),
title: () => {
return cacheSizeToString(streamingServer.settings.content.cacheSize);
return cacheSizeToString(settings.cacheSize);
},
onSelect: (value) => {
onSelect: (value: any) => {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'UpdateSettings',
args: {
...streamingServer.settings.content,
...settings,
cacheSize: JSON.parse(value),
}
}
});
}
};
}, [streamingServer.settings]);
const torrentProfileSelect = React.useMemo(() => {
if (streamingServer.settings === null || streamingServer.settings.type !== 'Ready') {
}, [settings]);
const torrentProfileSelect = useMemo(() => {
if (!settings) {
return null;
}
const selectedTorrentProfile = {
btDownloadSpeedHardLimit: streamingServer.settings.content.btDownloadSpeedHardLimit,
btDownloadSpeedSoftLimit: streamingServer.settings.content.btDownloadSpeedSoftLimit,
btHandshakeTimeout: streamingServer.settings.content.btHandshakeTimeout,
btMaxConnections: streamingServer.settings.content.btMaxConnections,
btMinPeersForStable: streamingServer.settings.content.btMinPeersForStable,
btRequestTimeout: streamingServer.settings.content.btRequestTimeout
btDownloadSpeedHardLimit: settings.btDownloadSpeedHardLimit,
btDownloadSpeedSoftLimit: settings.btDownloadSpeedSoftLimit,
btHandshakeTimeout: settings.btHandshakeTimeout,
btMaxConnections: settings.btMaxConnections,
btMinPeersForStable: settings.btMinPeersForStable,
btRequestTimeout: settings.btRequestTimeout
};
const isCustomTorrentProfileSelected = Object.values(TORRENT_PROFILES).every((torrentProfile) => {
return !isEqual(torrentProfile, selectedTorrentProfile);
@ -153,22 +178,23 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
[]
),
value: JSON.stringify(selectedTorrentProfile),
onSelect: (value) => {
onSelect: (value: any) => {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'UpdateSettings',
args: {
...streamingServer.settings.content,
...settings,
...JSON.parse(value),
}
}
});
}
};
}, [streamingServer.settings]);
const transcodingProfileSelect = React.useMemo(() => {
if (streamingServer.settings?.type !== 'Ready' || streamingServer.deviceInfo?.type !== 'Ready') {
}, [settings]);
const transcodingProfileSelect = useMemo(() => {
if (!settings || !deviceInfo) {
return null;
}
@ -178,27 +204,34 @@ const useStreamingServerSettingsInputs = (streamingServer) => {
label: t('SETTINGS_DISABLED'),
value: null,
},
...streamingServer.deviceInfo.content.availableHardwareAccelerations.map((name) => ({
...deviceInfo.availableHardwareAccelerations.map((name) => ({
label: name,
value: name,
}))
],
value: streamingServer.settings.content.transcodeProfile,
onSelect: (value) => {
value: settings.transcodeProfile,
onSelect: (value: string | null) => {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'UpdateSettings',
args: {
...streamingServer.settings.content,
...settings,
transcodeProfile: value,
}
}
});
}
};
}, [streamingServer.settings, streamingServer.deviceInfo]);
return { streamingServerRemoteUrlInput, remoteEndpointSelect, cacheSizeSelect, torrentProfileSelect, transcodingProfileSelect };
}, [settings, deviceInfo]);
return {
streamingServerRemoteUrlInput,
remoteEndpointSelect,
cacheSizeSelect,
torrentProfileSelect,
transcodingProfileSelect,
};
};
module.exports = useStreamingServerSettingsInputs;
export default useStreamingOptions;

View file

@ -0,0 +1,37 @@
.category {
position: relative;
width: 100%;
display: flex;
flex-direction: column;
align-items: start;
margin-bottom: 1rem;
padding-bottom: 1rem;
overflow: visible;
&:not(:last-child) {
border-bottom: thin solid var(--overlay-color);
}
.heading {
position: relative;
height: 4rem;
display: flex;
flex-direction: row;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
.label {
flex: none;
font-size: 1.1rem;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
width: 2rem;
height: 2rem;
color: var(--primary-foreground-color);
}
}
}

View file

@ -0,0 +1,26 @@
import React from 'react';
import { t } from 'i18next';
import Icon from '@stremio/stremio-icons/react';
import styles from './Category.less';
type Props = {
icon: string,
label: string,
children: React.ReactNode,
};
const Category = ({ icon, label, children }: Props) => {
return (
<div className={styles['category']}>
<div className={styles['heading']}>
<Icon className={styles['icon']} name={icon} />
<div className={styles['label']}>
{t(label)}
</div>
</div>
{children}
</div>
);
};
export default Category;

View file

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

View file

@ -0,0 +1,16 @@
.link {
position: relative;
display: flex;
align-items: center;
height: 2rem;
.label {
color: var(--primary-accent-color);
}
&:hover {
.label {
text-decoration: underline;
}
}
}

View file

@ -0,0 +1,20 @@
import React from 'react';
import { Button } from 'stremio/components';
import styles from './Link.less';
type Props = {
label: string,
href?: string,
target?: string,
onClick?: () => void,
};
const Link = ({ label, href, target, onClick }: Props) => {
return (
<Button className={styles['link']} title={label} target={target ?? '_blank'} href={href} onClick={onClick}>
<div className={styles['label']}>{ label }</div>
</Button>
);
};
export default Link;

View file

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

View file

@ -0,0 +1,78 @@
.option {
position: relative;
width: 100%;
flex: none;
display: flex;
flex-direction: row;
align-items: center;
gap: 2rem;
margin-bottom: 2rem;
overflow: visible;
.heading, .content {
flex: 1 1 50%;
position: relative;
display: flex;
flex-direction: row;
align-items: center;
}
.heading {
display: flex;
gap: 0.75rem;
.icon {
width: 3rem;
height: 3rem;
color: var(--primary-foreground-color);
}
.label {
line-height: 1.5rem;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
}
}
.content {
justify-content: center;
overflow: visible;
:global(.multiselect) {
width: 100%;
padding: 0;
background: var(--overlay-color);
}
:global(.button) {
display: flex;
align-items: center;
justify-content: center;
height: 3.5rem;
width: 100%;
padding: 0 2rem;
border-radius: 3.5rem;
font-weight: 500;
color: var(--primary-foreground-color);
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
}
:global(.color-input) {
width: 100%;
padding: 1.3rem 1rem;
border-radius: 3rem;
border: 2px solid transparent;
transition: 0.3s all ease-in-out;
&:hover {
border-color: var(--overlay-color);
}
}
}
}

View file

@ -0,0 +1,36 @@
import React from 'react';
import classNames from 'classnames';
import { t } from 'i18next';
import styles from './Option.less';
import Icon from '@stremio/stremio-icons/react';
type Props = {
className?: string,
icon?: string,
label: string,
children: React.ReactNode,
};
const Option = ({ className, icon, label, children }: Props) => {
return (
<div className={classNames(className, styles['option'])}>
<div className={styles['heading']}>
{
icon &&
<Icon
className={styles['icon']}
name={icon}
/>
}
<div className={styles['label']}>
{t(label)}
</div>
</div>
<div className={styles['content']}>
{ children }
</div>
</div>
);
};
export default Option;

View file

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

View file

@ -0,0 +1,22 @@
.section {
position: relative;
max-width: 35rem;
display: flex;
flex-direction: column;
align-items: start;
padding: 3rem 0;
overflow: visible;
&:not(:last-child) {
border-bottom: thin solid var(--overlay-color);
}
.label {
flex: none;
align-self: stretch;
font-size: 1.8rem;
line-height: 3.4rem;
margin-bottom: 2rem;
color: var(--primary-foreground-color);
}
}

View file

@ -0,0 +1,26 @@
import React, { forwardRef } from 'react';
import classNames from 'classnames';
import { t } from 'i18next';
import styles from './Section.less';
type Props = {
className?: string,
label?: string,
children: React.ReactNode,
};
const Section = forwardRef<HTMLDivElement, Props>(({ className, label, children }: Props, ref) => {
return (
<div ref={ref} className={classNames(className, styles['section'])}>
{
label &&
<div className={styles['label']}>
{t(label)}
</div>
}
{ children }
</div>
);
});
export default Section;

View file

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

View file

@ -0,0 +1,11 @@
import Category from './Category';
import Link from './Link';
import Option from './Option';
import Section from './Section';
export {
Category,
Link,
Option,
Section,
};

View file

@ -0,0 +1,10 @@
const SECTIONS = {
GENERAL: 'general',
PLAYER: 'player',
STREAMING: 'streaming',
SHORTCUTS: 'shortcuts',
};
export {
SECTIONS,
};

View file

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

View file

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

View file

@ -1,466 +0,0 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/components/Toggle/styles.less') {
checkbox-icon: icon;
}
:import('~stremio/components/Multiselect/styles.less') {
multiselect-menu-container: menu-container;
multiselect-label: label;
}
.settings-container {
height: calc(100% - var(--safe-area-inset-bottom));
width: 100%;
background-color: transparent;
.settings-content {
height: 100%;
width: 100%;
display: flex;
flex-direction: row;
.side-menu-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: column;
width: 18rem;
padding: 3rem 1.5rem;
.side-menu-button {
flex: none;
align-self: stretch;
display: flex;
align-items: center;
height: 4rem;
border-radius: 4rem;
padding: 2rem;
margin-bottom: 0.5rem;
font-size: 1.1rem;
font-weight: 500;
color: var(--primary-foreground-color);
opacity: 0.4;
&.selected {
font-weight: 600;
color: var(--primary-foreground-color);
background-color: var(--overlay-color);
opacity: 1;
}
&:hover {
background-color: var(--overlay-color);
}
}
.spacing {
flex: 1;
}
.version-info-label {
flex: 0 1 auto;
margin: 0.5rem 0;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
opacity: 0.3;
overflow: hidden;
}
}
.sections-container {
flex: 1;
align-self: stretch;
padding: 0 3rem;
overflow-y: auto;
.section-container {
display: flex;
flex-direction: column;
padding: 3rem 0;
overflow: visible;
&:not(:last-child) {
border-bottom: thin solid var(--overlay-color);
}
.section-title {
flex: none;
align-self: stretch;
font-size: 1.8rem;
line-height: 3.4rem;
margin-bottom: 3rem;
color: var(--primary-foreground-color);
}
.section-category-container {
display: flex;
flex-direction: row;
align-items: center;
gap: 0 1em;
margin-bottom: 1.5rem;
line-height: 2.4rem;
.label {
flex: none;
font-size: 1.1rem;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
width: 2rem;
height: 2rem;
color: var(--primary-foreground-color);
}
}
.option-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
align-items: center;
max-width: 35rem;
margin-bottom: 2rem;
overflow: visible;
&.link-container {
margin-bottom: 0.5rem;
}
&:last-child {
margin-bottom: 0;
}
&.user-info-option-container {
gap: 1rem;
.user-info-content {
flex: 1;
display: flex;
flex-direction: row;
align-items: center;
.avatar-container {
flex: none;
align-self: stretch;
height: 5rem;
width: 5rem;
margin-right: 1rem;
border: 2px solid var(--primary-accent-color);
border-radius: 50%;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
background-origin: content-box;
background-clip: content-box;
opacity: 0.9;
background-color: var(--primary-foreground-color);
}
.email-logout-container {
flex: none;
display: flex;
flex-direction: column;
.email-label-container, .logout-button-container {
display: flex;
flex-direction: row;
align-items: center;
}
.email-label-container {
.email-label {
flex: 1;
font-size: 1.1rem;
color: var(--primary-foreground-color);
opacity: 0.7;
}
}
.logout-button-container {
&:hover, &:focus {
outline: none;
.logout-label {
text-decoration: underline;
}
}
.logout-label {
flex: 1;
color: var(--primary-accent-color);
}
}
}
}
.user-panel-container {
flex: none;
display: flex;
flex-direction: row;
align-items: center;
width: 10rem;
height: 3.5rem;
border-radius: 3.5rem;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
.user-panel-label {
flex: 1;
max-height: 2.4em;
padding: 0 0.5rem;
font-weight: 500;
text-align: center;
color: var(--primary-foreground-color);
}
}
}
.option-name-container, .option-input-container {
flex: 1 1 50%;
display: flex;
flex-direction: row;
align-items: center;
.icon {
flex: none;
width: 1.5rem;
height: 1.5rem;
margin-right: 0.5rem;
color: var(--primary-foreground-color);
}
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
line-height: 1.5rem;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--primary-foreground-color);
}
&.trakt-icon {
.icon {
width: 3rem;
height: 3rem;
color: var(--color-trakt);
}
}
}
.option-name-container {
justify-content: flex-start;
padding: 1rem 1rem 1rem 0;
margin-right: 2rem;
}
.option-input-container {
padding: 1rem 1.5rem;
&.multiselect-container {
padding: 0;
background: var(--overlay-color);
}
&.button-container {
justify-content: center;
height: 3.5rem;
border-radius: 3.5rem;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
.label {
font-weight: 500;
}
}
&.multiselect-container {
>.multiselect-label {
line-height: 1.5rem;
max-height: 1.5rem;
}
.multiselect-menu-container {
overflow: auto;
}
}
&.link-input-container {
flex: 0 1 auto;
padding: 0;
.label {
color: var(--primary-accent-color);
}
&:hover {
.label {
text-decoration: underline;
}
}
}
&.checkbox-container {
justify-content: center;
.checkbox-icon {
width: 1.5rem;
height: 1.5rem;
}
}
&.color-input-container {
padding: 1.3rem 1rem;
border-radius: 3rem;
border: 2px solid transparent;
transition: 0.3s all ease-in-out;
&:hover {
border-color: var(--overlay-color);
}
}
&.info-container {
justify-content: center;
&.selectable {
user-select: text;
.label {
user-select: text;
}
}
}
&.configure-input-container {
padding: 0;
.label {
flex-grow: 1;
white-space: pre;
text-overflow: ellipsis;
padding: 0 1rem;
}
.configure-button-container {
flex: none;
width: 3rem;
height: 3rem;
border-radius: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: var(--overlay-color);
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color);
background-color: transparent;
}
.icon {
flex: none;
width: 1rem;
height: 1rem;
margin: 0;
color: var(--primary-foreground-color);
}
}
}
&.shortcut-container {
justify-content: center;
padding: 0;
overflow: visible;
kbd {
flex: 0 1 auto;
height: 2.5rem;
min-width: 2.5rem;
line-height: 2.5rem;
padding: 0 1rem;
font-weight: 500;
color: var(--primary-foreground-color);
border-radius: 0.25em;
box-shadow: 0 4px 0 1px var(--modal-background-color);
background-color: var(--overlay-color);
}
.label {
margin: 0 1rem;
white-space: nowrap;
color: var(--primary-foreground-color);
}
}
}
}
}
.versions-section-container {
display: none;
}
}
}
}
@media only screen and (max-width: @xsmall) {
.settings-container {
.settings-content {
.side-menu-container {
display: none;
}
.sections-container {
.versions-section-container {
display: flex;
}
}
}
}
}
@media only screen and (max-width: @minimum) {
.settings-container {
.settings-content {
flex-direction: column-reverse;
.side-menu-container {
display: none;
}
.sections-container {
padding: 0 1.5rem;
.section-container {
.user-info-option-container {
flex-direction: column;
align-items: flex-start;
.user-panel-container {
width: 100% !important;
}
}
}
.versions-section-container {
display: flex;
}
}
}
}
}

View file

@ -8,7 +8,7 @@ const Calendar = require('./Calendar').default;
const MetaDetails = require('./MetaDetails');
const NotFound = require('./NotFound');
const Search = require('./Search');
const Settings = require('./Settings');
const { default: Settings } = require('./Settings');
const Player = require('./Player');
const Intro = require('./Intro');

11
src/services/Shell/Shell.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
type ShellTransportProps = {
shellVersion: string,
};
type ShellTransport = {
props: ShellTransportProps,
};
interface ShellService {
transport: ShellTransport,
}

View file

@ -21,6 +21,7 @@ type Settings = {
hardwareDecoding: boolean,
escExitFullscreen: boolean,
interfaceLanguage: string,
quitOnClose: boolean,
hideSpoilers: boolean,
nextVideoNotificationDuration: number,
playInBackground: boolean,
@ -41,6 +42,7 @@ type Settings = {
subtitlesSize: number,
subtitlesTextColor: string,
surroundSound: boolean,
pauseOnMinimize: boolean,
};
type Profile = {

3
src/types/models/DataExport.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
type DataExport = {
exportUrl: string | null,
};

View file

@ -23,6 +23,8 @@ type StreamingServerSettings = {
cacheRoot: string,
cacheSize: number,
serverVersion: string,
remoteHttps: string | null,
transcodeProfile: string | null,
};
type SFile = {
@ -93,6 +95,14 @@ type Statistics = {
swarmSize: number,
};
type NetworkInfo = {
availableInterfaces: string[],
};
type DeviceInfo = {
availableHardwareAccelerations: string[],
};
type PlaybackDevice = {
id: string,
name: string,
@ -115,4 +125,6 @@ type StreamingServer = {
torrent: [string, Loadable<Torrent>] | null,
statistics: Loadable<Statistics> | null,
playbackDevices: Loadable<PlaybackDevice[]> | null,
networkInfo: Loadable<NetworkInfo> | null,
deviceInfo: Loadable<DeviceInfo> | null,
};