Merge pull request #72 from Stremio/core-settings

Core settings
This commit is contained in:
Nikola Hristov 2019-11-07 12:15:31 +02:00 committed by GitHub
commit ba0c4e29f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 229 additions and 190 deletions

View file

@ -1,41 +1,19 @@
const React = require('react');
const PropTypes = require('prop-types');
const AColorPicker = require('a-color-picker');
const Icon = require('stremio-icons/dom');
const { Modal } = require('stremio-router');
const Button = require('stremio/common/Button');
const useBinaryState = require('stremio/common/useBinaryState');
const ModalDialog = require('stremio/common/ModalDialog');
const useDataset = require('stremio/common/useDataset');
const ColorPicker = require('./ColorPicker');
const styles = require('./styles');
const COLOR_FORMAT = 'hexcss4';
const ColorInput = ({ className, value, onChange, ...props }) => {
value = AColorPicker.parseColor(value, COLOR_FORMAT);
const dataset = useDataset(props);
const [modalOpen, openModal, closeModal] = useBinaryState(false);
const [modalOpen, setModalOpen, setModalClosed] = useBinaryState(false);
const [tempValue, setTempValue] = React.useState(value);
const pickerLabelOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.openModalPrevented) {
openModal();
}
}, [props.onClick]);
const modalContainerOnClick = React.useCallback((event) => {
event.nativeEvent.openModalPrevented = true;
}, []);
const modalContainerOnMouseDown = React.useCallback((event) => {
if (!event.nativeEvent.closeModalPrevented) {
closeModal();
}
}, []);
const modalContentOnMouseDown = React.useCallback((event) => {
event.nativeEvent.closeModalPrevented = true;
}, []);
const colorPickerOnInput = React.useCallback((event) => {
setTempValue(event.value);
}, []);
@ -49,34 +27,23 @@ const ColorInput = ({ className, value, onChange, ...props }) => {
nativeEvent: event.nativeEvent
});
}
closeModal();
setModalClosed();
}, [onChange, tempValue, dataset]);
React.useEffect(() => {
setTempValue(value);
}, [value, modalOpen]);
return (
<Button title={value} {...props} style={{ ...props.style, backgroundColor: value }} className={className} onClick={pickerLabelOnClick}>
<React.Fragment>
<Button title={value} {...props} style={{ ...props.style, backgroundColor: value }} className={className} onClick={setModalOpen} />
{
modalOpen ?
<Modal className={styles['color-input-modal-container']} onMouseDown={modalContainerOnMouseDown} onClick={modalContainerOnClick}>
<div className={styles['color-input-container']} onMouseDown={modalContentOnMouseDown}>
<div className={styles['header-container']}>
<div className={styles['title']}>Choose a color:</div>
<Button className={styles['close-button-container']} title={'Close'} onClick={closeModal}>
<Icon className={styles['icon']} icon={'ic_x'} />
</Button>
</div>
<ColorPicker className={styles['color-picker']} value={tempValue} onInput={colorPickerOnInput} />
<Button className={styles['submit-button-container']} title={'Submit'} onClick={submitButtonOnClick}>
<div className={styles['label']}>Select</div>
</Button>
</div>
</Modal>
<ModalDialog title={'Choose a color:'} buttons={[{ label: 'Select', props: { onClick: submitButtonOnClick, 'data-autofocus': true } }]} onCloseRequest={setModalClosed}>
<ColorPicker value={tempValue} onInput={colorPickerOnInput} />
</ModalDialog>
:
null
}
</Button>
</React.Fragment>
);
};

View file

@ -1,81 +0,0 @@
.color-input-modal-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
pointer-events: auto;
background-color: var(--color-backgrounddarker40);
.color-input-container {
flex: none;
display: flex;
flex-direction: column;
align-items: center;
max-width: 25rem;
padding: 1rem;
background-color: var(--color-surfacelighter);
.header-container {
flex: none;
align-self: stretch;
display: flex;
flex-direction: row;
align-items: flex-start;
.title {
flex: 1;
margin-right: 1rem;
font-size: 1.2rem;
max-height: 2.4em;
}
.close-button-container {
flex: none;
width: 1.5rem;
height: 1.5rem;
padding: 0.25rem;
&:hover, &:focus {
background-color: var(--color-surfacedark20);
}
&:focus {
outline-color: var(--color-surfacedarker);
}
.icon {
display: block;
width: 100%;
height: 100%;
fill: var(--color-surfacedarker);
}
}
}
.color-picker {
flex: none;
margin: 1rem;
}
.submit-button-container {
flex: none;
align-self: stretch;
padding: 1rem;
background-color: var(--color-signal5);
&:hover, &:focus {
filter: brightness(1.2);
}
&:focus {
outline-color: var(--color-surfacedarker);
}
.label {
max-height: 2.4em;
text-align: center;
color: var(--color-surfacelighter);
}
}
}
}

View file

@ -98,12 +98,16 @@ const Multiselect = ({ className, direction, title, renderLabelContent, renderLa
<div className={styles['menu-container']} onClick={popupMenuOnClick}>
{
options.length > 0 ?
options.map(({ label, value }) => (
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
<Icon className={styles['icon']} icon={'ic_check'} />
</Button>
))
options.map(({ label, value }) => {
const isSelected = selected.includes(value);
const title = typeof label === 'string' ? label : value;
return (
<Button key={value} className={classnames(styles['option-container'], { 'selected': isSelected })} title={title} data-value={value} data-autofocus={isSelected ? true : null} onClick={optionOnClick}>
<div className={styles['label']}>{title}</div>
<Icon className={styles['icon']} icon={'ic_check'} />
</Button>
)
})
:
<div className={styles['no-options-container']}>
<div className={styles['label']}>No options available</div>

View file

@ -1,28 +1,36 @@
const React = require('react');
const PropTypes = require('prop-types');
const { Button, Dropdown, Checkbox, ColorInput } = require('stremio/common');
const { Button, Multiselect, Checkbox, ColorInput } = require('stremio/common');
const Icon = require('stremio-icons/dom');
const classnames = require('classnames');
const styles = require('./styles');
const SectionsList = React.forwardRef(({ className, sections, preferences, onPreferenceChanged, onScroll }, ref) => {
const toggleCheckbox = (id) => {
onPreferenceChanged(id, !preferences[id]);
onPreferenceChanged(id, preferences[id] === 'true' ? 'false' : 'true');
};
const colorChanged = React.useCallback((event) => {
const id = event.currentTarget.dataset.id;
const color = event.nativeEvent.value;
const id = event.dataset.id;
const color = event.value;
onPreferenceChanged(id, color);
}, [onPreferenceChanged]);
const updateDropdown = React.useCallback((event) => {
var data = event.currentTarget.dataset;
onPreferenceChanged(data.name, data.value);
const name = event.dataset.name;
const value = event.reactEvent.currentTarget.dataset.value;
onPreferenceChanged(name, value);
}, [onPreferenceChanged]);
const updateStreamingDropdown = React.useCallback((event) => {
const name = event.dataset.name;
const value = event.reactEvent.currentTarget.dataset.value;
const newPrefs = { ...preferences.streaming, [name]: value };
onPreferenceChanged('streaming', newPrefs);
}, [onPreferenceChanged, preferences.streaming]);
const checkUser = React.useCallback((event) => {
if(! preferences.user) {
if (!preferences.user) {
// Here in Stremio 4 we show a toast with a message, asking the anonymous user to log in/register
console.log('No user found');
event.preventDefault();
@ -37,6 +45,45 @@ const SectionsList = React.forwardRef(({ className, sections, preferences, onPre
const changePasswordUrl = preferences.user && 'https://www.strem.io/reset-password/' + preferences.user.email;
const webCalUrl = preferences.user && 'webcal://www.strem.io/calendar/' + preferences.user._id + '.ics';
const formatBytes = inBytes => {
if (inBytes === '0') return 'no caching';
if (inBytes === 'Infinity') return '∞';
const bytes = parseInt(inBytes, 10);
const kilo = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'];
const power = Math.floor(Math.log(bytes) / Math.log(kilo));
// More than 1024 yotta bytes
if (power >= sizes.length) {
power = sizes.length - 1;
}
return parseFloat((bytes / Math.pow(kilo, power)).toFixed(2)) + ' ' + sizes[power];
}
const cacheSizes = ['0', '2147483648', '5368709120', '10737418240', 'Infinity'];
const mkCacheSizeOptions = sizes => sizes.map(size => ({
label: formatBytes(size), // TODO: translation
value: size.toString(),
}))
const supportedProfiles = ['default', 'soft', 'fast'];
const mkProfiles = profiles => profiles.map(profile => ({
label: profile[0].toUpperCase() + profile.slice(1).toLowerCase(), // TODO: translation
value: profile,
}))
const [cachingOptions, setCachingOptions] = React.useState(mkProfiles(supportedProfiles));
const [streamingProfiles, setStreamingProfiles] = React.useState(mkProfiles(supportedProfiles));
React.useEffect(() => {
if (!preferences.streaming || typeof preferences.streaming.cacheSize === 'undefined') return;
setCachingOptions(mkCacheSizeOptions([...new Set(cacheSizes.concat(preferences.streaming.cacheSize))]));
}, [preferences.streaming && preferences.streaming.cacheSize]);
React.useEffect(() => {
if (preferences.streaming && preferences.streaming.profile && !supportedProfiles.includes(preferences.streaming.profile)) {
setStreamingProfiles(mkProfiles(supportedProfiles.concat(preferences.streaming.profile)));
}
}, [preferences.streaming && preferences.streaming.profile]);
const sectionsElements = sections.map((section) =>
<div key={section.id} ref={section.ref} className={styles['section']} data-section={section.id}>
<div className={styles['section-header']}>{section.id}</div>
@ -104,11 +151,49 @@ const SectionsList = React.forwardRef(({ className, sections, preferences, onPre
</div>
</React.Fragment>
);
} else if (input.type === 'streaming') {
return (
preferences.streaming_loaded
?
<React.Fragment key={'streaming'}>
{
// The streaming server settings are shown only if server is available
preferences.streaming_error
?
null
:
<React.Fragment>
<div className={classnames(styles['input-container'], styles['select-container'])}>
<div className={styles['input-header']}>Caching</div>
<Multiselect options={cachingOptions} selected={[preferences.streaming.cacheSize]} data-name={'cacheSize'} className={styles['dropdown']} onSelect={updateStreamingDropdown} />
</div>
<div className={classnames(styles['input-container'], styles['select-container'])}>
<div className={styles['input-header']}>Torrent Profile</div>
<Multiselect options={streamingProfiles} selected={[preferences.streaming.profile]} data-name={'profile'} className={styles['dropdown']} onSelect={updateStreamingDropdown} />
</div>
</React.Fragment>
}
{/* From here there is only presentation */}
<div key={'server_url'} className={classnames(styles['input-container'], styles['text-container'])}>
<div className={styles['input-header']}><strong>Streaming server URL:</strong> {preferences.server_url}</div>
</div>
<div key={'server_available'} className={classnames(styles['input-container'], styles['text-container'])}>
<div className={styles['text']}>
<Icon className={classnames(styles['icon'], { [styles['x-icon']]: preferences.streaming_error })} icon={preferences.streaming_error ? 'ic_x' : 'ic_check'} />
<div className={styles['label']}>{'Streaming server is ' + (preferences.streaming_error ? 'not ' : '') + 'available.'}{preferences.streaming_error && ' Reason: ' + preferences.streaming_error}</div>
</div>
</div>
</React.Fragment>
:
<div key={'server_url'} className={classnames(styles['input-container'], styles['text-container'])}>
<div className={styles['input-header']}>Loading streaming settgins...</div>
</div>
);
} else if (input.type === 'select') {
return (
<div key={input.id} className={classnames(styles['input-container'], styles['select-container'])}>
{input.header ? <div className={styles['input-header']}>{input.header}</div> : null}
<Dropdown options={input.options} selected={[preferences[input.id]]} name={input.id} key={input.id} className={styles['dropdown']} onSelect={updateDropdown} />
<Multiselect options={input.options} selected={[preferences[input.id]]} data-name={input.id} key={input.id} className={styles['dropdown']} onSelect={updateDropdown} />
</div>
);
} else if (input.type === 'link') {
@ -134,7 +219,7 @@ const SectionsList = React.forwardRef(({ className, sections, preferences, onPre
return (
<div key={input.id} className={classnames(styles['input-container'], styles['checkbox-container'])}>
{input.header ? <div className={styles['input-header']}>{input.header}</div> : null}
<Checkbox ref={input.ref} className={styles['checkbox']} checked={preferences[input.id]} onClick={toggleCheckbox.bind(null, input.id)}>
<Checkbox ref={input.ref} className={styles['checkbox']} checked={preferences[input.id] === 'true'} onClick={toggleCheckbox.bind(null, input.id)}>
<div className={styles['label']}>{input.label}</div>
</Checkbox>
</div>
@ -153,7 +238,13 @@ const SectionsList = React.forwardRef(({ className, sections, preferences, onPre
return (
<div key={input.id} className={classnames(styles['input-container'], styles['color-container'])}>
{input.header ? <div className={styles['input-header']}>{input.header}</div> : null}
<ColorInput className={styles['color-picker']} id={input.id} value={preferences[input.id]} onChange={colorChanged} />
<ColorInput className={styles['color-picker']} data-id={input.id} value={preferences[input.id]} onChange={colorChanged} />
</div>
);
} else if (input.type === 'info') {
return (
<div key={input.id} className={classnames(styles['input-container'])}>
<div className={styles['input-header']}><strong>{input.header}</strong> {preferences[input.id]}</div>
</div>
);
}

View file

@ -1,20 +1,32 @@
:import('~stremio/common/Checkbox/styles.less') {
checkbox-icon: icon;
}
:import('~stremio/common/Multiselect/styles.less') {
menu-container: menu-container;
}
.menu-container {
max-height: 30rem;
overflow-y: auto;
}
.section {
padding: 4rem 2rem;
padding: 4rem 0;
margin: 0 2rem;
width: var(--input-width);
overflow: visible;
.section-header {
margin: 0 1.5rem 1.5rem 1.5rem;
margin: 1.5rem 0;
font-size: 2rem;
color: var(--color-surfacelighter);
}
.input-container {
margin: 1.5rem;
margin: 2rem 0;
display: flex;
flex-direction: column;
overflow: visible;
.input-header {
margin-bottom: 0.5rem;
@ -51,14 +63,13 @@
&.select-container {
.dropdown {
height: 3rem;
width: var(--input-width);
}
}
&.link-container {
margin: 1rem 1.5rem;
margin: 0;
.link {
padding: .75rem 0;
display: block;
color: var(--color-secondarylight);
@ -75,7 +86,6 @@
&.button-container {
.button {
padding: 0.7rem;
width: var(--input-width);
min-height: calc(var(--input-width) * 0.09);
display: flex;
align-items: center;
@ -132,10 +142,12 @@
}
&.text-container {
margin: 0;
.text {
display: flex;
flex-direction: row;
align-items: center;
padding: .75rem 0;
.icon {
margin-right: 0.5rem;
@ -160,7 +172,7 @@
&.color-container {
.color-picker {
width: var(--input-width);
box-shadow: inset 0px 0px .2rem 0px var(--color-surfacelighter20);
height: calc(var(--input-width) * 0.08);
cursor: pointer;

View file

@ -6,21 +6,20 @@ const SectionsList = require('./SectionsList');
const { settingsSections } = require('./constants');
const useSettings = require('./useSettings');
const devTestWithUser = true;
const Settings = () => {
const [preferences, setPreferences] = useSettings(devTestWithUser);
const sections = React.useMemo(()=>Object.keys(settingsSections)
const [preferences, setPreferences] = useSettings();
const [dynamicSections, setDynamicSections] = React.useState(settingsSections);
// TODO: The Streaming section should be handled separately
const sections = React.useMemo(()=>Object.keys(dynamicSections)
.map((section) => ({
id: section,
inputs: settingsSections[section],
inputs: dynamicSections[section],
ref: React.createRef()
})), []);
})), [dynamicSections]);
const [selectedSectionId, setSelectedSectionId] = React.useState(sections[0].id);
const scrollContainerRef = React.useRef(null);
/////////////////
const updatePreference = (option, value) => {
setPreferences({ ...preferences, [option]: value });
}

File diff suppressed because one or more lines are too long

View file

@ -1,22 +1,68 @@
const React = require('react');
const { useServices } = require('stremio/services');
module.exports = (devTestWithUser) => React.useState({
"user": devTestWithUser ? {
"_id": "neo",
"email": "neo@example.com",
"avatar": "https://www.thenational.ae/image/policy:1.891803:1566372420/AC17-Matrix-20-04.jpg?f=16x9&w=1200&$p$f$w=5867e40",
} : null,
"ui_language": "eng",
"default_subtitles_language": "bul",
"default_subtitles_size": "100%",
"subtitles_background": "",
"subtitles_color": "#ffffff",
"subtitles_outline_color": "#000",
"auto-play_next_episode": true,
"pause_playback_when_minimized": false,
"hardware-accelerated_decoding": true,
"launch_player_in_a_separate_window_(advanced)": true,
"caching": "2048",
"torrent_profile": "profile-default",
"streaming_server_is_available.": true,
});
const IGNORED_SETTINGS = Object.freeze(['user', 'streaming']);
module.exports = () => {
const { core } = useServices();
const [settings, setSettings] = React.useState({
user: null,
streaming: {},
streaming_loaded: false,
streaming_error: ""
});
React.useEffect(() => {
const onNewState = () => {
const { ctx, streaming_server_settings } = core.getState()
try {
const newSettings = {
...settings,
...ctx.content.settings,
user: ctx.content.auth ? ctx.content.auth.user : null,
streaming: streaming_server_settings && streaming_server_settings.ready || {},
streaming_loaded: streaming_server_settings && !!(streaming_server_settings.error || streaming_server_settings.ready),
streaming_error: streaming_server_settings && streaming_server_settings.error || "",
};
setSettings(newSettings);
} catch (e) {
console.log('Cannot update settings state', e);
}
};
const onStoreError = ({ event, args }) => {
if (event !== "SettingsStoreError") return;
// TODO: Notify with maybe a toast?
console.log(args)
}
core.on('NewModel', onNewState);
core.on('Event', onStoreError);
onNewState();
return () => {
// Destructor function
core.off('NewModel', onNewState);
core.off('Event', onStoreError);
};
}, []);
const setTheSettings = React.useCallback(newSettings => {
const event = { action: 'Settings', args: { args: {} } };
// This can be done with React.useEffect and newSettings.streaming as dependency
const streamingServerSettingChanged = settings.streaming && Object.keys(newSettings.streaming)
.some(prop => settings.streaming[prop] !== newSettings.streaming[prop]);
if (streamingServerSettingChanged) {
event.args = { settings: 'StoreStreamingServer', args: newSettings.streaming };
} else {
event.args.settings = 'Store';
Object.keys(newSettings)
.filter(prop => !IGNORED_SETTINGS.includes(prop))
.forEach(key => event.args.args[key] = newSettings[key].toString());
}
core.dispatch(event);
}, [settings])
return [settings, setTheSettings];
};