Merge pull request #707 from Stremio/feat/manage-streaming-urls

Feat: Manage multiple streaming URLs in the Settings
This commit is contained in:
Tim 2024-12-03 11:42:40 +01:00 committed by GitHub
commit d3dec89ff4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 894 additions and 300 deletions

29
package-lock.json generated
View file

@ -12,8 +12,8 @@
"@babel/runtime": "7.16.0",
"@sentry/browser": "6.13.3",
"@stremio/stremio-colors": "5.0.1",
"@stremio/stremio-core-web": "0.48.0",
"@stremio/stremio-icons": "5.2.0",
"@stremio/stremio-core-web": "0.48.1",
"@stremio/stremio-icons": "5.4.0",
"@stremio/stremio-video": "0.0.46",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
@ -36,7 +36,7 @@
"react-i18next": "^12.1.1",
"react-is": "18.2.0",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#57d66ecc8e2df4e73a613dc5e17123ce62ae63f7",
"stremio-translations": "github:Stremio/stremio-translations#f666d9a97cafa5aa150878b5c51a2896b5f4f1b2",
"url": "0.11.0",
"use-long-press": "^3.1.5"
},
@ -3132,9 +3132,9 @@
"license": "MIT"
},
"node_modules/@stremio/stremio-core-web": {
"version": "0.48.0",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.48.0.tgz",
"integrity": "sha512-UEVxb5weAIZ22Hz0iNKM8O1QkALcLShG9AyCe1P2WhZhyiridbwE7MtP5itBtLcLm9f/D6UeRrpUWMCS01n18Q==",
"version": "0.48.1",
"resolved": "https://registry.npmjs.org/@stremio/stremio-core-web/-/stremio-core-web-0.48.1.tgz",
"integrity": "sha512-bdWxBuuOOC0NdG1Mg60lEhpK7Bw/Ea6D89bRcvIvM3WnJrUpGA4jbx4xWj3KQRM08PM3WWCY9/FzctlWCxFMRg==",
"dependencies": {
"@babel/runtime": "7.24.1"
}
@ -3158,9 +3158,15 @@
"license": "MIT"
},
"node_modules/@stremio/stremio-icons": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@stremio/stremio-icons/-/stremio-icons-5.2.0.tgz",
"integrity": "sha512-rABlPBTFF17QcSm/4IizVoE/jh+REt+waqA0RvIxuGjQppXlvj7CalqVvTam0CC2wgY00zNG1v/9kVHUDVzo4Q=="
"version": "5.4.0",
"resolved": "https://registry.npmjs.org/@stremio/stremio-icons/-/stremio-icons-5.4.0.tgz",
"integrity": "sha512-rRWNER+wLgMjxd6sKT0MMq4lzXDOobY3GNdT3NDeeymBtB/CD0YmYqQuUOyYDjEZ1btIbNaniUOBoPW9d3ZQ8A==",
"workspaces": [
"react",
"react-native",
"solid",
"angularjs"
]
},
"node_modules/@stremio/stremio-video": {
"version": "0.0.46",
@ -13732,9 +13738,8 @@
},
"node_modules/stremio-translations": {
"version": "1.44.9",
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#57d66ecc8e2df4e73a613dc5e17123ce62ae63f7",
"integrity": "sha512-Q3Q++Tx3quu71tgTfS8CEP6CajdGyig92SdtRyGMsLHHkgBgzP9ggYBUHVbKAfXcKUegABIkW8CxMueEw758Xg==",
"license": "MIT"
"resolved": "git+ssh://git@github.com/Stremio/stremio-translations.git#f666d9a97cafa5aa150878b5c51a2896b5f4f1b2",
"integrity": "sha512-SzaIGUMqQuMAq58sI9L/RKSs5O4eF8VKPMqnWFddBSg/tZOU9xuNYqjRPKT07cp8MRfzzGQmCKMByozTYfjdIA=="
},
"node_modules/string_decoder": {
"version": "1.1.1",

View file

@ -16,8 +16,8 @@
"@babel/runtime": "7.16.0",
"@sentry/browser": "6.13.3",
"@stremio/stremio-colors": "5.0.1",
"@stremio/stremio-core-web": "0.48.0",
"@stremio/stremio-icons": "5.2.0",
"@stremio/stremio-core-web": "0.48.1",
"@stremio/stremio-icons": "5.4.0",
"@stremio/stremio-video": "0.0.46",
"a-color-picker": "1.2.1",
"bowser": "2.11.0",
@ -40,7 +40,7 @@
"react-i18next": "^12.1.1",
"react-is": "18.2.0",
"spatial-navigation-polyfill": "github:Stremio/spatial-navigation#64871b1422466f5f45d24ebc8bbd315b2ebab6a6",
"stremio-translations": "github:Stremio/stremio-translations#57d66ecc8e2df4e73a613dc5e17123ce62ae63f7",
"stremio-translations": "github:Stremio/stremio-translations#f666d9a97cafa5aa150878b5c51a2896b5f4f1b2",
"url": "0.11.0",
"use-long-press": "^3.1.5"
},

View file

@ -1,6 +1,7 @@
// Copyright (C) 2017-2023 Smart code 203358507
const CHROMECAST_RECEIVER_APP_ID = '1634F54B';
const DEFAULT_STREAMING_SERVER_URL = 'http://127.0.0.1:11470/';
const SUBTITLES_SIZES = [75, 100, 125, 150, 175, 200, 250];
const SUBTITLES_FONTS = ['PlusJakartaSans', 'Arial', 'Halvetica', 'Times New Roman', 'Verdana', 'Courier', 'Lucida Console', 'sans-serif', 'serif', 'monospace'];
const SEEK_TIME_DURATIONS = [3000, 5000, 10000, 15000, 20000, 30000];
@ -97,6 +98,7 @@ const WHITELISTED_HOSTS = ['stremio.com', 'strem.io', 'stremio.zendesk.com', 'go
module.exports = {
CHROMECAST_RECEIVER_APP_ID,
DEFAULT_STREAMING_SERVER_URL,
SUBTITLES_SIZES,
SUBTITLES_FONTS,
SEEK_TIME_DURATIONS,

View file

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

View file

@ -8,13 +8,13 @@ const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useRouteFocused } = require('stremio-router');
const Button = require('stremio/common/Button');
const TextInput = require('stremio/common/TextInput');
const useTorrent = require('stremio/common/useTorrent');
const { withCoreSuspender } = require('stremio/common/CoreSuspender');
const useSearchHistory = require('./useSearchHistory');
const useLocalSearch = require('./useLocalSearch');
const styles = require('./styles');
const useBinaryState = require('stremio/common/useBinaryState');
const { default: TextInput } = require('stremio/common/TextInput');
const SearchBar = React.memo(({ className, query, active }) => {
const { t } = useTranslation();

View file

@ -0,0 +1,66 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
.radio-button {
display: flex;
align-items: center;
overflow: visible;
.radio-container {
position: relative;
width: 1.75rem;
height: 1.75rem;
border: 3px solid var(--color-placeholder);
border-radius: 1rem;
background-color: transparent;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease-in-out;
cursor: pointer;
outline: none;
user-select: none;
margin-right: 0.75rem;
outline-width: var(--focus-outline-size);
outline-color: @color-surface-light5;
outline-offset: calc(-1 * var(--focus-outline-size));
input[type='radio'] {
opacity: 0;
width: 0;
height: 0;
position: absolute;
cursor: pointer;
}
.inner-circle {
width: 1.25rem;
height: 1.25rem;
border-radius: 0.675rem;
border: 2px solid var(--secondary-background-color);
transition: opacity 0.2s ease-in-out;
background-color: transparent;
opacity: 0;
}
&.disabled {
cursor: not-allowed;
}
&.error {
border-color: var(--color-trakt);
}
&.selected {
.inner-circle {
background-color: var(--primary-accent-color);
opacity: 1;
}
}
&:focus {
outline-style: solid;
}
}
}

View file

@ -0,0 +1,58 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useCallback, ChangeEvent, KeyboardEvent } from 'react';
import classNames from 'classnames';
import styles from './RadioButton.less';
type Props = {
disabled?: boolean;
selected?: boolean;
className?: string;
onChange?: (checked: boolean) => void;
error?: string;
};
const RadioButton = ({ disabled, selected, className, onChange, error }: Props) => {
const handleSelect = useCallback(({ target }: ChangeEvent<HTMLInputElement>) => {
if (!disabled && onChange) {
onChange(target.checked);
}
}, [disabled, onChange]);
const onKeyDown = useCallback(({ key }: KeyboardEvent<HTMLDivElement>) => {
if ((key === 'Enter' || key === ' ') && !disabled) {
onChange && onChange(!selected);
}
}, [disabled, selected, onChange]);
return (
<div className={classNames(styles['radio-button'], className)}>
<label>
<div
className={classNames(
styles['radio-container'],
{ [styles['selected']]: selected },
{ [styles['disabled']]: disabled },
{ [styles['error']]: error }
)}
role={'radio'}
tabIndex={disabled ? -1 : 0}
aria-checked={selected}
onKeyDown={onKeyDown}
>
<input
type={'radio'}
checked={selected}
disabled={disabled}
onChange={handleSelect}
className={styles['input']}
/>
<span className={styles['inner-circle']} />
</div>
</label>
</div>
);
};
export default RadioButton;

View file

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

View file

@ -4,7 +4,7 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const TextInput = require('stremio/common/TextInput');
const { default: TextInput } = require('../TextInput');
const SearchBarPlaceholder = require('./SearchBarPlaceholder');
const styles = require('./styles');

View file

@ -8,8 +8,8 @@ const { default: Icon } = require('@stremio/stremio-icons/react');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const useToast = require('stremio/common/Toast/useToast');
const { default: TextInput } = require('../TextInput');
const Button = require('stremio/common/Button');
const TextInput = require('stremio/common/TextInput');
const styles = require('./styles');
const SharePrompt = ({ className, url }) => {

View file

@ -1,43 +0,0 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const styles = require('./styles');
const TextInput = React.forwardRef((props, ref) => {
const onKeyDown = React.useCallback((event) => {
if (typeof props.onKeyDown === 'function') {
props.onKeyDown(event);
}
if (event.key === 'Enter' && !event.nativeEvent.submitPrevented && typeof props.onSubmit === 'function') {
props.onSubmit(event);
}
}, [props.onKeyDown, props.onSubmit]);
return (
<input
size={1}
autoCorrect={'off'}
autoCapitalize={'off'}
autoComplete={'off'}
spellCheck={false}
tabIndex={0}
{...props}
ref={ref}
className={classnames(props.className, styles['text-input'], { 'disabled': props.disabled })}
onKeyDown={onKeyDown}
/>
);
});
TextInput.displayName = 'TextInput';
TextInput.propTypes = {
className: PropTypes.string,
disabled: PropTypes.bool,
onKeyDown: PropTypes.func,
onSubmit: PropTypes.func
};
module.exports = TextInput;

View file

@ -0,0 +1,49 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React from 'react';
import classnames from 'classnames';
import styles from './styles.less';
type Props = React.InputHTMLAttributes<HTMLInputElement> & {
className?: string;
disabled?: boolean;
onKeyDown?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
onSubmit?: (event: React.KeyboardEvent<HTMLInputElement>) => void;
};
const TextInput = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
const { onSubmit, className, disabled, ...rest } = props;
const onKeyDown = React.useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
if (typeof props.onKeyDown === 'function') {
props.onKeyDown(event);
}
if (
event.key === 'Enter' &&
!(event.nativeEvent as any).submitPrevented &&
typeof onSubmit === 'function'
) {
onSubmit(event);
}
}, [props.onKeyDown, onSubmit]);
return (
<input
size={1}
autoCorrect={'off'}
autoCapitalize={'off'}
autoComplete={'off'}
spellCheck={false}
tabIndex={0}
ref={ref}
className={classnames(className, styles['text-input'], { disabled })}
onKeyDown={onKeyDown}
{...rest}
/>
);
});
TextInput.displayName = 'TextInput';
export default TextInput;

View file

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

View file

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

View file

@ -6,21 +6,21 @@ const classnames = require('classnames');
const Button = require('stremio/common/Button');
const styles = require('./styles');
const Checkbox = React.forwardRef(({ className, checked, children, ...props }, ref) => {
const Toggle = React.forwardRef(({ className, checked, children, ...props }, ref) => {
return (
<Button {...props} ref={ref} className={classnames(className, styles['checkbox-container'], { 'checked': checked })}>
<Button {...props} ref={ref} className={classnames(className, styles['toggle-container'], { 'checked': checked })}>
<div className={styles['toggle']} />
{children}
</Button>
);
});
Checkbox.displayName = 'Checkbox';
Toggle.displayName = 'Toggle';
Checkbox.propTypes = {
Toggle.propTypes = {
className: PropTypes.string,
checked: PropTypes.bool,
children: PropTypes.node
};
module.exports = Checkbox;
module.exports = Toggle;

View file

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

View file

@ -8,7 +8,7 @@
@thumb-size: calc(@height - @thumb-margin);
.checkbox-container {
.toggle-container {
position: relative;
.toggle {

View file

@ -3,7 +3,7 @@
const AddonDetailsModal = require('./AddonDetailsModal');
const { default: BottomSheet } = require('./BottomSheet');
const Button = require('./Button');
const Checkbox = require('./Checkbox');
const Toggle = require('./Toggle');
const { default: Chips } = require('./Chips');
const ColorInput = require('./ColorInput');
const ContinueWatchingItem = require('./ContinueWatchingItem');
@ -26,7 +26,7 @@ const SearchBar = require('./SearchBar');
const StreamingServerWarning = require('./StreamingServerWarning');
const SharePrompt = require('./SharePrompt');
const Slider = require('./Slider');
const TextInput = require('./TextInput');
const { default: TextInput } = require('./TextInput');
const { ToastProvider, useToast } = require('./Toast');
const { TooltipProvider, Tooltip } = require('./Tooltips');
const comparatorWithPriorities = require('./comparatorWithPriorities');
@ -53,7 +53,7 @@ module.exports = {
AddonDetailsModal,
BottomSheet,
Button,
Checkbox,
Toggle,
Chips,
ColorInput,
ContinueWatchingItem,

View file

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

View file

@ -3,11 +3,11 @@
const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { Button, Checkbox } = require('stremio/common');
const { Button, Toggle } = require('stremio/common');
const styles = require('./styles');
const ConsentCheckbox = React.forwardRef(({ className, label, link, href, onToggle, ...props }, ref) => {
const checkboxOnClick = React.useCallback((event) => {
const ConsentToggle = React.forwardRef(({ className, label, link, href, onToggle, ...props }, ref) => {
const toggleOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
@ -24,7 +24,7 @@ const ConsentCheckbox = React.forwardRef(({ className, label, link, href, onTogg
event.nativeEvent.togglePrevented = true;
}, []);
return (
<Checkbox {...props} ref={ref} className={classnames(className, styles['consent-checkbox-container'])} onClick={checkboxOnClick}>
<Toggle {...props} ref={ref} className={classnames(className, styles['consent-toggle-container'])} onClick={toggleOnClick}>
<div className={styles['label']}>
{label}
{' '}
@ -37,13 +37,13 @@ const ConsentCheckbox = React.forwardRef(({ className, label, link, href, onTogg
null
}
</div>
</Checkbox>
</Toggle>
);
});
ConsentCheckbox.displayName = 'ConsentCheckbox';
ConsentToggle.displayName = 'ConsentToggle';
ConsentCheckbox.propTypes = {
ConsentToggle.propTypes = {
className: PropTypes.string,
checked: PropTypes.bool,
label: PropTypes.string,
@ -53,4 +53,4 @@ ConsentCheckbox.propTypes = {
onClick: PropTypes.func
};
module.exports = ConsentCheckbox;
module.exports = ConsentToggle;

View file

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

View file

@ -2,11 +2,11 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
:import('~stremio/common/Checkbox/styles.less') {
:import('~stremio/common/Toggle/styles.less') {
checkbox-icon: icon;
}
.consent-checkbox-container {
.consent-toggle-container {
display: flex;
flex-direction: row;
align-items: center;

View file

@ -2,7 +2,7 @@
const React = require('react');
const PropTypes = require('prop-types');
const { TextInput } = require('stremio/common');
const { default: TextInput } = require('stremio/common/TextInput');
const CredentialsTextInput = React.forwardRef((props, ref) => {
const onKeyDown = React.useCallback((event) => {

View file

@ -9,7 +9,7 @@ const { Modal, useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { Button, Image, useBinaryState } = require('stremio/common');
const CredentialsTextInput = require('./CredentialsTextInput');
const ConsentCheckbox = require('./ConsentCheckbox');
const ConsentToggle = require('./ConsentToggle');
const PasswordResetModal = require('./PasswordResetModal');
const useFacebookLogin = require('./useFacebookLogin');
const styles = require('./styles');
@ -307,27 +307,27 @@ const Intro = ({ queryParams }) => {
onChange={confirmPasswordOnChange}
onSubmit={confirmPasswordOnSubmit}
/>
<ConsentCheckbox
<ConsentToggle
ref={termsRef}
className={styles['consent-checkbox']}
className={styles['consent-toggle']}
label={'I have read and agree with the Stremio'}
link={'Terms and conditions'}
href={'https://www.stremio.com/tos'}
checked={state.termsAccepted}
onToggle={toggleTermsAccepted}
/>
<ConsentCheckbox
<ConsentToggle
ref={privacyPolicyRef}
className={styles['consent-checkbox']}
className={styles['consent-toggle']}
label={'I have read and agree with the Stremio'}
link={'Privacy Policy'}
href={'https://www.stremio.com/privacy'}
checked={state.privacyPolicyAccepted}
onToggle={togglePrivacyPolicyAccepted}
/>
<ConsentCheckbox
<ConsentToggle
ref={marketingRef}
className={styles['consent-checkbox']}
className={styles['consent-toggle']}
label={'I agree to receive marketing communications from Stremio'}
checked={state.marketingAccepted}
onToggle={toggleMarketingAccepted}

View file

@ -4,7 +4,7 @@ const React = require('react');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { t } = require('i18next');
const { Image, SearchBar, Checkbox } = require('stremio/common');
const { Image, SearchBar, Toggle } = require('stremio/common');
const SeasonsBar = require('./SeasonsBar');
const Video = require('./Video');
const styles = require('./styles');
@ -84,9 +84,9 @@ const VideosList = ({ className, metaItem, libraryItem, season, seasonOnSelect,
<React.Fragment>
{
showNotificationsToggle && libraryItem ?
<Checkbox className={styles['notifications-checkbox']} checked={!libraryItem.state.noNotif} onClick={toggleNotifications}>
<Toggle className={styles['notifications-toggle']} checked={!libraryItem.state.noNotif} onClick={toggleNotifications}>
{t('DETAIL_RECEIVE_NOTIF_SERIES')}
</Checkbox>
</Toggle>
:
null
}

View file

@ -36,7 +36,7 @@
}
}
.notifications-checkbox {
.notifications-toggle {
flex: none;
display: flex;
flex-direction: row;

View file

@ -44,7 +44,7 @@ const ControlBar = ({
}) => {
const { chromecast } = useServices();
const [chromecastServiceActive, setChromecastServiceActive] = React.useState(() => chromecast.active);
const [buttonsMenuOpen, , , toogleButtonsMenu] = useBinaryState(false);
const [buttonsMenuOpen, , , toggleButtonsMenu] = useBinaryState(false);
const onSubtitlesButtonMouseDown = React.useCallback((event) => {
event.nativeEvent.subtitlesMenuClosePrevented = true;
}, []);
@ -141,7 +141,7 @@ const ControlBar = ({
onVolumeChangeRequested={onVolumeChangeRequested}
/>
<div className={styles['spacing']} />
<Button className={styles['control-bar-buttons-menu-button']} onClick={toogleButtonsMenu}>
<Button className={styles['control-bar-buttons-menu-button']} onClick={toggleButtonsMenu}>
<Icon className={styles['icon']} name={'more-vertical'} />
</Button>
<div className={classnames(styles['control-bar-buttons-menu-container'], { 'open': buttonsMenuOpen })}>

View file

@ -7,11 +7,12 @@ const { useTranslation } = require('react-i18next');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { useRouteFocused } = require('stremio-router');
const { useServices } = require('stremio/services');
const { Button, Checkbox, MainNavBars, Multiselect, ColorInput, TextInput, ModalDialog, useProfile, usePlatform, useStreamingServer, useBinaryState, withCoreSuspender, useToast } = require('stremio/common');
const { Button, Toggle, MainNavBars, Multiselect, ColorInput, useProfile, usePlatform, useStreamingServer, withCoreSuspender, useToast } = require('stremio/common');
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';
@ -35,16 +36,15 @@ const Settings = () => {
subtitlesBackgroundColorInput,
subtitlesOutlineColorInput,
audioLanguageSelect,
surroundSoundCheckbox,
surroundSoundToggle,
seekTimeDurationSelect,
seekShortTimeDurationSelect,
escExitFullscreenCheckbox,
escExitFullscreenToggle,
playInExternalPlayerSelect,
nextVideoPopupDurationSelect,
bingeWatchingCheckbox,
playInBackgroundCheckbox,
hardwareDecodingCheckbox,
streamingServerUrlInput
bingeWatchingToggle,
playInBackgroundToggle,
hardwareDecodingToggle,
} = useProfileSettingsInputs(profile);
const {
streamingServerRemoteUrlInput,
@ -53,34 +53,11 @@ const Settings = () => {
torrentProfileSelect,
transcodingProfileSelect,
} = useStreamingServerSettingsInputs(streamingServer);
const [configureServerUrlModalOpen, openConfigureServerUrlModal, closeConfigureServerUrlModal] = useBinaryState(false);
const configureServerUrlInputRef = React.useRef(null);
const configureServerUrlOnSubmit = React.useCallback(() => {
streamingServerUrlInput.onChange(configureServerUrlInputRef.current.value);
closeConfigureServerUrlModal();
}, [streamingServerUrlInput]);
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 configureServerUrlModalButtons = React.useMemo(() => {
return [
{
className: styles['cancel-button'],
label: 'Cancel',
props: {
onClick: closeConfigureServerUrlModal
}
},
{
label: 'Submit',
props: {
onClick: configureServerUrlOnSubmit,
}
}
];
}, [configureServerUrlOnSubmit]);
const logoutButtonOnClick = React.useCallback(() => {
core.transport.dispatch({
action: 'Ctx',
@ -118,14 +95,6 @@ const Settings = () => {
const exportDataOnClick = React.useCallback(() => {
loadDataExport();
}, []);
const reloadStreamingServer = React.useCallback(() => {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'Reload'
}
});
}, []);
const onCopyRemoteUrlClick = React.useCallback(() => {
if (streamingServer.remoteUrl) {
navigator.clipboard.writeText(streamingServer.remoteUrl);
@ -192,7 +161,6 @@ const Settings = () => {
if (routeFocused) {
updateSelectedSectionId();
}
closeConfigureServerUrlModal();
}, [routeFocused]);
return (
<MainNavBars className={styles['settings-container']} route={'settings'}>
@ -369,9 +337,9 @@ const Settings = () => {
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_FULLSCREEN_EXIT') }</div>
</div>
<Checkbox
className={classnames(styles['option-input-container'], styles['checkbox-container'])}
{...escExitFullscreenCheckbox}
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...escExitFullscreenToggle}
/>
</div>
:
@ -432,10 +400,10 @@ const Settings = () => {
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_SURROUND_SOUND') }</div>
</div>
<Checkbox
className={classnames(styles['option-input-container'], styles['checkbox-container'])}
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
tabIndex={-1}
{...surroundSoundCheckbox}
{...surroundSoundToggle}
/>
</div>
</div>
@ -466,11 +434,11 @@ const Settings = () => {
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_PLAY_IN_BACKGROUND') }</div>
</div>
<Checkbox
className={classnames(styles['option-input-container'], styles['checkbox-container'])}
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
disabled={true}
tabIndex={-1}
{...playInBackgroundCheckbox}
{...playInBackgroundToggle}
/>
</div>
</div>
@ -483,9 +451,9 @@ const Settings = () => {
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('AUTO_PLAY') }</div>
</div>
<Checkbox
className={classnames(styles['option-input-container'], styles['checkbox-container'])}
{...bingeWatchingCheckbox}
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
{...bingeWatchingToggle}
/>
</div>
<div className={styles['option-container']}>
@ -517,53 +485,17 @@ const Settings = () => {
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('SETTINGS_HWDEC') }</div>
</div>
<Checkbox
className={classnames(styles['option-input-container'], styles['checkbox-container'])}
<Toggle
className={classnames(styles['option-input-container'], styles['toggle-container'])}
disabled={true}
tabIndex={-1}
{...hardwareDecodingCheckbox}
{...hardwareDecodingToggle}
/>
</div>
</div>
<div ref={streamingServerSectionRef} className={styles['section-container']}>
<div className={styles['section-title']}>{ t('SETTINGS_NAV_STREAMING') }</div>
<div className={styles['option-container']}>
<Button className={classnames(styles['option-input-container'], styles['button-container'])} title={'Reload'} onClick={reloadStreamingServer}>
<div className={styles['label']}>{ t('RELOAD') }</div>
</Button>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>{ t('STATUS') }</div>
</div>
<div className={classnames(styles['option-input-container'], styles['info-container'])}>
<div className={styles['label']}>
{
streamingServer.settings === null ?
'NotLoaded'
:
streamingServer.settings.type === 'Ready' ?
t('SETTINGS_SERVER_STATUS_ONLINE')
:
streamingServer.settings.type === 'Err' ?
t('SETTINGS_SERVER_STATUS_ERROR')
:
streamingServer.settings.type
}
</div>
</div>
</div>
<div className={styles['option-container']}>
<div className={styles['option-name-container']}>
<div className={styles['label']}>Url</div>
</div>
<div className={classnames(styles['option-input-container'], styles['configure-input-container'])}>
<div className={styles['label']} title={streamingServerUrlInput.value}>{streamingServerUrlInput.value}</div>
<Button className={styles['configure-button-container']} title={'Configure server url'} onClick={openConfigureServerUrlModal}>
<Icon className={styles['icon']} name={'settings'} />
</Button>
</div>
</div>
<URLsManager />
{
streamingServerRemoteUrlInput.value !== null ?
<div className={styles['option-container']}>
@ -779,26 +711,6 @@ const Settings = () => {
</div>
</div>
</div>
{
configureServerUrlModalOpen ?
<ModalDialog
className={styles['configure-server-url-modal-container']}
title={t('SETTINGS_SERVER_CONFIGURE_TITLE')}
buttons={configureServerUrlModalButtons}
onCloseRequest={closeConfigureServerUrlModal}>
<TextInput
ref={configureServerUrlInputRef}
autoFocus={true}
className={styles['server-url-input']}
type={'text'}
defaultValue={streamingServerUrlInput.value}
placeholder={t('SETTINGS_SERVER_CONFIGURE_INPUT')}
onSubmit={configureServerUrlOnSubmit}
/>
</ModalDialog>
:
null
}
</MainNavBars>
);
};

View file

@ -0,0 +1,89 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.add-item {
display: flex;
padding: 0.5rem 1.5rem;
gap: 1rem;
border-radius: var(--border-radius);
transition: 0.3s all ease-in-out;
background-color: transparent;
border: 2px solid transparent;
justify-content: space-between;
position: relative;
.input {
background-color: var(--overlay-color);
border-radius: var(--border-radius);
color: var(--primary-foreground-color);
padding: 0.5rem 0.75rem;
border: 1px solid transparent;
width: 70%;
&:focus {
border: 1px solid var(--primary-foreground-color);
}
}
.actions {
display: flex;
gap: 0.25rem;
margin-right: 0;
.add, .cancel {
display: flex;
gap: 0.5rem;
padding: 0.25rem;
align-items: center;
justify-content: center;
background-color: transparent;
transition: 0.3s all ease-in-out;
border-radius: var(--border-radius);
width: 3rem;
opacity: 0.6;
.icon {
width: 2rem;
height: 2rem;
color: var(--primary-foreground-color);
}
&:hover {
opacity: 1;
background-color: var(--overlay-color);
}
}
.add {
.icon {
width: 1.8rem;
height: 1.8rem;
}
&:hover {
.icon {
color: var(--secondary-accent-color);
}
}
}
.cancel {
&:hover {
.icon {
color: var(--color-trakt);
}
}
}
}
&:hover {
border: 2px solid transparent;
background-color: var(--overlay-color);
}
}
@media only screen and (max-width: @minimum) {
.add-item {
padding: 0.5rem;
}
}

View file

@ -0,0 +1,46 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { ChangeEvent, useCallback, useState } from 'react';
import Button from 'stremio/common/Button';
import Icon from '@stremio/stremio-icons/react';
import TextInput from 'stremio/common/TextInput';
import styles from './AddItem.less';
type Props = {
onCancel: () => void;
handleAddUrl: (url: string) => void;
};
const AddItem = ({ onCancel, handleAddUrl }: Props) => {
const [inputValue, setInputValue] = useState('');
const handleValueChange = useCallback(({ target }: ChangeEvent<HTMLInputElement>) => {
setInputValue(target.value);
}, []);
const onSumbit = useCallback(() => {
handleAddUrl(inputValue);
}, [inputValue]);
return (
<div className={styles['add-item']}>
<TextInput
className={styles['input']}
value={inputValue}
onChange={handleValueChange}
onSubmit={onSumbit}
placeholder={'Enter URL'}
/>
<div className={styles['actions']}>
<Button className={styles['add']} onClick={onSumbit}>
<Icon name={'checkmark'} className={styles['icon']} />
</Button>
<Button className={styles['cancel']} onClick={onCancel}>
<Icon name={'close'} className={styles['icon']} />
</Button>
</div>
</div>
);
};
export default AddItem;

View file

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

View file

@ -0,0 +1,131 @@
// Copyright (C) 2017-2024 Smart code 203358507
@import (reference) '~stremio/common/screen-sizes.less';
.item {
display: flex;
padding: 1rem 1.5rem;
border-radius: var(--border-radius);
transition: 0.3s all ease-in-out;
background-color: transparent;
border: 2px solid transparent;
justify-content: space-between;
position: relative;
.content {
display: flex;
gap: 1rem;
align-items: center;
justify-content: center;
max-width: 60%;
.selectable {
overflow: visible;
}
.label {
color: var(--primary-foreground-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.actions {
display: flex;
gap: 1rem;
margin-right: 5rem;
.status {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
.icon {
width: 0.75rem;
height: 0.75rem;
border-radius: 1rem;
&.ready {
background-color: var(--secondary-accent-color);
}
&.error {
background-color: var(--color-trakt);
}
}
.label {
font-size: 1rem;
color: var(--primary-foreground-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
.delete {
position: absolute;
display: flex;
right: 1.5rem;
top: 50%;
gap: 0.5rem;
padding: 0.5rem 0.25rem;
align-items: center;
justify-content: center;
background-color: transparent;
transition: 0.3s all ease-in-out;
border-radius: var(--border-radius);
transform: translateY(-50%);
width: 3rem;
.icon {
width: 1.5rem;
height: 1.5rem;
opacity: 0;
transition: 0.3s all ease-in-out;
color: var(--primary-foreground-color);
}
&:hover, &:focus {
background-color: var(--overlay-color);
.icon {
color: var(--color-trakt);
opacity: 1 !important;
}
}
}
}
&:hover {
background-color: var(--overlay-color);
.actions {
.delete {
.icon {
opacity: 0.6;
}
}
}
}
}
@media only screen and (max-width: @minimum) {
.item {
padding: 1rem 0.5rem;
.actions {
margin-right: 4rem;
.delete {
right: 0.5rem;
.icon {
opacity: 0.6;
}
}
}
}
}

View file

@ -0,0 +1,77 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useCallback, useMemo } from 'react';
import { useProfile } from 'stremio/common';
import { DEFAULT_STREAMING_SERVER_URL } from 'stremio/common/CONSTANTS';
import { useTranslation } from 'react-i18next';
import Button from 'stremio/common/Button';
import useStreamingServer from 'stremio/common/useStreamingServer';
import Icon from '@stremio/stremio-icons/react';
import styles from './Item.less';
import classNames from 'classnames';
import RadioButton from 'stremio/common/RadioButton/RadioButton';
import useStreamingServerUrls from '../useStreamingServerUrls';
type Props = {
url: string;
};
const Item = ({ url }: Props) => {
const { t } = useTranslation();
const profile = useProfile();
const streamingServer = useStreamingServer();
const { deleteServerUrl, selectServerUrl } = useStreamingServerUrls();
const selected = useMemo(() => profile.settings.streamingServerUrl === url, [url, profile.settings]);
const defaultUrl = useMemo(() => url === DEFAULT_STREAMING_SERVER_URL, [url]);
const handleDelete = useCallback(() => {
deleteServerUrl(url);
selectServerUrl(DEFAULT_STREAMING_SERVER_URL);
}, [url]);
const handleSelect = useCallback(() => {
selectServerUrl(url);
}, [url]);
return (
<div className={styles['item']}>
<div className={styles['content']}>
<RadioButton className={styles['selectable']} selected={selected} onChange={handleSelect} disabled={selected} />
<div className={styles['label']}>{url}</div>
</div>
<div className={styles['actions']}>
{
selected ?
<div className={styles['status']}>
<div className={classNames(styles['icon'], { [styles['ready']]: streamingServer.settings?.type === 'Ready' }, { [styles['error']]: streamingServer.settings?.type === 'Err' })} />
<div className={styles['label']}>
{
streamingServer.settings === null ?
'NotLoaded'
:
streamingServer.settings.type === 'Ready' ?
t('SETTINGS_SERVER_STATUS_ONLINE')
:
streamingServer.settings.type === 'Err' ?
t('SETTINGS_SERVER_STATUS_ERROR')
:
streamingServer.settings.type
}
</div>
</div>
: null
}
{
!defaultUrl ?
<Button className={styles['delete']} onClick={handleDelete}>
<Icon name={'bin'} className={styles['icon']} />
</Button>
: null
}
</div>
</div>
);
};
export default Item;

View file

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

View file

@ -0,0 +1,92 @@
// Copyright (C) 2017-2024 Smart code 203358507
.wrapper {
display: flex;
flex-direction: column;
max-width: 35rem;
margin-bottom: 2rem;
.header {
display: flex;
justify-content: space-around;
align-items: center;
.label {
font-size: 1rem;
color: var(--primary-foreground-color);
font-weight: 400;
opacity: 0.6;
}
}
.content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.5rem 0;
}
.item {
display: flex;
padding: 1rem 1.5rem;
border-radius: var(--border-radius);
transition: 0.3s all ease-in-out;
background-color: transparent;
border: 2px solid transparent;
justify-content: space-between;
position: relative;
}
.footer {
display: flex;
justify-content: space-between;
.add-url {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
padding: 0.5rem 1.5rem;
background-color: var(--secondary-accent-color);
transition: 0.3s all ease-in-out;
border-radius: 1.5rem;
color: var(--primary-foreground-color);
border: 2px solid transparent;
.icon {
width: 1rem;
height: 1rem;
color: var(--primary-foreground-color);
}
&:hover {
background-color: transparent;
border: 2px solid var(--primary-foreground-color);
}
}
.reload {
display: flex;
gap: 0.5rem;
align-items: center;
justify-content: center;
padding: 0.5rem 1.5rem;
background-color: var(--overlay-color);
border-radius: 1.5rem;
transition: 0.3s all ease-in-out;
color: var(--primary-foreground-color);
border: 2px solid transparent;
.icon {
width: 1rem;
height: 1rem;
color: var(--primary-foreground-color);
}
&:hover {
background-color: transparent;
border: 2px solid var(--primary-foreground-color);
}
}
}
}

View file

@ -0,0 +1,62 @@
// Copyright (C) 2017-2024 Smart code 203358507
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import styles from './URLsManager.less';
import Button from 'stremio/common/Button';
import Item from './Item';
import AddItem from './AddItem';
import Icon from '@stremio/stremio-icons/react';
import useStreamingServerUrls from './useStreamingServerUrls';
const URLsManager = () => {
const { t } = useTranslation();
const [addMode, setAddMode] = useState(false);
const { streamingServerUrls, addServerUrl, reloadServer } = useStreamingServerUrls();
const onAdd = () => {
setAddMode(true);
};
const onCancel = () => {
setAddMode(false);
};
const handleAddUrl = useCallback((url: string) => {
addServerUrl(url);
setAddMode(false);
}, []);
return (
<div className={styles['wrapper']}>
<div className={styles['header']}>
<div className={styles['label']}>URL</div>
<div className={styles['label']}>{t('STATUS')}</div>
</div>
<div className={styles['content']}>
{
streamingServerUrls.map((item: StreamingServerUrl) => (
<Item key={item.url} {...item} />
))
}
{
addMode ?
<AddItem onCancel={onCancel} handleAddUrl={handleAddUrl} />
: null
}
</div>
<div className={styles['footer']}>
<Button label={'Add URL'} className={styles['add-url']} onClick={onAdd}>
<Icon name={'add'} className={styles['icon']} />
{t('SETTINGS_SERVER_ADD_URL')}
</Button>
<Button className={styles['reload']} title={'Reload'} onClick={reloadServer}>
<Icon name={'reset'} className={styles['icon']} />
<div className={styles['label']}>{t('RELOAD')}</div>
</Button>
</div>
</div>
);
};
export default URLsManager;

View file

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

View file

@ -0,0 +1,89 @@
// Copyright (C) 2017-2024 Smart code 203358507
import { useCallback } from 'react';
import { useModelState, useToast } from 'stremio/common';
import useProfile from 'stremio/common/useProfile';
import { useServices } from 'stremio/services';
const useStreamingServerUrls = () => {
const { core } = useServices();
const profile = useProfile();
const toast = useToast();
const ctx = useModelState({ model: 'ctx' });
const streamingServerUrls = ctx.streamingServerUrls;
const addServerUrl = useCallback((url) => {
const isValidUrl = (url) => {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
};
if (isValidUrl(url)) {
toast.show({
type: 'success',
title: 'New URL added',
message: 'The new URL has been added successfully',
timeout: 4000
});
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'AddServerUrl',
args: url,
}
});
} else {
toast.show({
type: 'error',
title: 'Invalid URL',
message: 'Please provide a valid URL',
timeout: 4000
});
}
}, []);
const deleteServerUrl = useCallback((url) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'DeleteServerUrl',
args: url,
}
});
}, []);
const selectServerUrl = useCallback((url) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
streamingServerUrl: url
}
}
});
}, [profile.settings]);
const reloadServer = useCallback(() => {
core.transport.dispatch({
action: 'StreamingServer',
args: {
action: 'Reload'
}
});
}, []);
return {
streamingServerUrls,
addServerUrl,
deleteServerUrl,
selectServerUrl,
reloadServer
};
};
export default useStreamingServerUrls;

View file

@ -3,7 +3,7 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
:import('~stremio/common/Checkbox/styles.less') {
:import('~stremio/common/Toggle/styles.less') {
checkbox-icon: icon;
}
@ -12,11 +12,6 @@
multiselect-label: label;
}
:import('~stremio/common/ModalDialog/styles.less') {
configure-server-url-modal-content: modal-dialog-content;
cancel-button-label: label;
}
.settings-container {
width: 100%;
height: 100%;
@ -407,48 +402,6 @@
}
}
.configure-server-url-modal-container {
.configure-server-url-modal-content {
width: 30rem;
.server-url-input {
width: 100%;
padding: 1rem;
color: var(--primary-foreground-color);
border-radius: var(--border-radius);
background-color: var(--overlay-color);
outline: var(--focus-outline-size) solid var(--overlay-color);
outline-offset: calc(-1 * var(--focus-outline-size));
&:hover {
outline-color: var(--primary-foreground-color);
}
&:focus {
outline-color: var(--primary-foreground-color);
}
}
}
.cancel-button {
background-color: transparent;
opacity: 0.3;
&:hover {
outline: var(--focus-outline-size) solid var(--primary-foreground-color) inset;
opacity: 1;
}
&:focus {
outline-color: var(--primary-foreground-color);
}
.cancel-button-label {
color: var(--primary-foreground-color);
}
}
}
@media only screen and (max-width: @xsmall) {
.settings-container {
.settings-content {
@ -494,10 +447,4 @@
}
}
}
.configure-server-url-modal-container {
.configure-server-url-modal-content {
width: auto;
}
}
}

View file

@ -136,7 +136,7 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const surroundSoundCheckbox = React.useMemo(() => ({
const surroundSoundToggle = React.useMemo(() => ({
checked: profile.settings.surroundSound,
onClick: () => {
core.transport.dispatch({
@ -151,7 +151,7 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const escExitFullscreenCheckbox = React.useMemo(() => ({
const escExitFullscreenToggle = React.useMemo(() => ({
checked: profile.settings.escExitFullscreen,
onClick: () => {
core.transport.dispatch({
@ -261,7 +261,7 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const bingeWatchingCheckbox = React.useMemo(() => ({
const bingeWatchingToggle = React.useMemo(() => ({
checked: profile.settings.bingeWatching,
onClick: () => {
core.transport.dispatch({
@ -276,7 +276,7 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const playInBackgroundCheckbox = React.useMemo(() => ({
const playInBackgroundToggle = React.useMemo(() => ({
checked: profile.settings.playInBackground,
onClick: () => {
core.transport.dispatch({
@ -291,7 +291,7 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const hardwareDecodingCheckbox = React.useMemo(() => ({
const hardwareDecodingToggle = React.useMemo(() => ({
checked: profile.settings.hardwareDecoding,
onClick: () => {
core.transport.dispatch({
@ -306,21 +306,6 @@ const useProfileSettingsInputs = (profile) => {
});
}
}), [profile.settings]);
const streamingServerUrlInput = React.useMemo(() => ({
value: profile.settings.streamingServerUrl,
onChange: (value) => {
core.transport.dispatch({
action: 'Ctx',
args: {
action: 'UpdateSettings',
args: {
...profile.settings,
streamingServerUrl: value
}
}
});
}
}), [profile.settings]);
return {
interfaceLanguageSelect,
subtitlesLanguageSelect,
@ -329,16 +314,15 @@ const useProfileSettingsInputs = (profile) => {
subtitlesBackgroundColorInput,
subtitlesOutlineColorInput,
audioLanguageSelect,
surroundSoundCheckbox,
escExitFullscreenCheckbox,
surroundSoundToggle,
escExitFullscreenToggle,
seekTimeDurationSelect,
seekShortTimeDurationSelect,
playInExternalPlayerSelect,
nextVideoPopupDurationSelect,
bingeWatchingCheckbox,
playInBackgroundCheckbox,
hardwareDecodingCheckbox,
streamingServerUrlInput
bingeWatchingToggle,
playInBackgroundToggle,
hardwareDecodingToggle,
};
};

View file

@ -67,8 +67,16 @@ type SearchHistoryItem = {
type SearchHistory = SearchHistoryItem[];
type StreamingServerUrl = {
url: string,
mtime: Date,
};
type StreamingServerUrls = StreamingServerUrl[];
type Ctx = {
profile: Profile,
notifications: Notifications,
searchHistory: SearchHistory,
streamingServerUrls: StreamingServerUrls,
};