refactor: Create dedicated LanguageMultiselect component for Player settings

- Create new LanguageMultiselect component separate from MetaItem's Multiselect
- Add dynamic tooltip showing selected languages line-by-line on hover
- Preserve original Multiselect component for MetaItem usage
This commit is contained in:
gh-61 2025-12-28 02:16:06 +01:00
parent 3bf3dbc3b7
commit f23510a475
7 changed files with 507 additions and 195 deletions

View file

@ -0,0 +1,201 @@
// Copyright (C) 2017-2023 Smart code 203358507
const React = require('react');
const { useTranslation } = require('react-i18next');
const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button } = require('stremio/components');
const ModalDialog = require('stremio/components/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState');
const { default: useOutsideClick } = require('stremio/common/useOutsideClick');
const styles = require('./styles');
const menuStyles = require('../MultiselectMenu/MultiselectMenu.less');
const LanguageMultiselect = ({ className, mode, direction, title, disabled = false, dataset = undefined, options, renderLabelContent = undefined, renderLabelText = undefined, onOpen = undefined, onClose = undefined, onSelect, ...props }) => {
const { t } = useTranslation();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const multiselectMenuRef = useOutsideClick(() => {
if (menuOpen) {
closeMenu();
}
});
const filteredOptions = React.useMemo(() => {
return Array.isArray(options) ?
options.filter((option) => {
return option && (typeof option.value === 'string' || option.value === null);
})
:
[];
}, [options]);
const selected = React.useMemo(() => {
return Array.isArray(props.selected) ?
props.selected.filter((value) => {
return typeof value === 'string' || value === null;
})
:
[];
}, [props.selected]);
const optionOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
onSelect({
type: 'select',
value: event.currentTarget.dataset.value,
reactEvent: event,
nativeEvent: event.nativeEvent,
dataset: dataset
});
}
}, [dataset, onSelect]);
const mountedRef = React.useRef(false);
React.useLayoutEffect(() => {
if (mountedRef.current) {
if (menuOpen) {
if (typeof onOpen === 'function') {
onOpen({
type: 'open',
dataset: dataset
});
}
} else {
if (typeof onClose === 'function') {
onClose({
type: 'close',
dataset: dataset
});
}
}
}
mountedRef.current = true;
}, [menuOpen]);
const renderLabelContentNode = () => {
if (typeof renderLabelContent === 'function') {
return renderLabelContent();
}
return (
<React.Fragment>
<div className={classnames(menuStyles['label'], styles['multiselect-label-fix'])}>
{
typeof renderLabelText === 'function' ?
renderLabelText()
:
selected.length > 0 ?
(() => {
const MAX_ITEMS = 2;
const items = selected.slice(0, MAX_ITEMS).map((value) => {
const option = filteredOptions.find((option) => option.value === value);
return option && typeof option.label === 'string' ?
option.label
:
value;
});
return selected.length > MAX_ITEMS ? items.join(', ') + ', ...' : items.join(', ');
})()
:
title
}
</div>
<Icon className={classnames(menuStyles['icon'], { [menuStyles['open']]: menuOpen })} name={'caret-down'} />
</React.Fragment>
);
};
const renderMenu = () => (
<div className={classnames(styles['dropdown'], { [styles['open']]: menuOpen })}>
{
filteredOptions.length > 0 ?
filteredOptions.map(({ label, title, value }) => {
const isSelected = selected.includes(value);
return (
<Button
key={value}
className={classnames(styles['option'], { [styles['selected']]: isSelected })}
title={typeof title === 'string' ? title : typeof label === 'string' ? label : value}
data-value={value}
onClick={optionOnClick}
>
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
{isSelected && <div className={styles['icon']} />}
</Button>
);
})
:
<div className={styles['no-options-container']}>
<div className={styles['label']}>{t('NO_OPTIONS')}</div>
</div>
}
</div>
);
if (mode === 'modal') {
return (
<div className={classnames(menuStyles['multiselect-menu'], className)}>
<Button
className={classnames(menuStyles['multiselect-button'], styles['multiselect-button-fix'], { [menuStyles['open']]: menuOpen })}
disabled={disabled}
onClick={toggleMenu}
title={title}
>
{renderLabelContentNode()}
</Button>
{menuOpen ?
<ModalDialog className={styles['modal-container']} title={title} onCloseRequest={closeMenu}>
{renderMenu()}
</ModalDialog>
: null
}
</div>
);
}
return (
<div
className={classnames(menuStyles['multiselect-menu'], { [menuStyles['active']]: menuOpen, [menuStyles['disabled']]: disabled }, className)}
ref={multiselectMenuRef}
>
<Button
className={classnames(menuStyles['multiselect-button'], styles['multiselect-button-fix'], { [menuStyles['open']]: menuOpen })}
disabled={disabled}
onClick={toggleMenu}
tabIndex={0}
aria-haspopup='listbox'
aria-expanded={menuOpen}
title={title}
>
{renderLabelContentNode()}
</Button>
{renderMenu()}
</div>
);
};
LanguageMultiselect.propTypes = {
className: PropTypes.string,
mode: PropTypes.oneOf(['popup', 'modal']),
direction: PropTypes.any,
title: PropTypes.string,
options: PropTypes.arrayOf(PropTypes.shape({
value: PropTypes.string,
title: PropTypes.string,
label: PropTypes.string
})),
selected: PropTypes.arrayOf(PropTypes.string),
disabled: PropTypes.bool,
dataset: PropTypes.object,
renderLabelContent: PropTypes.func,
renderLabelText: PropTypes.func,
onOpen: PropTypes.func,
onClose: PropTypes.func,
onSelect: PropTypes.func,
onClick: PropTypes.func
};
module.exports = LanguageMultiselect;

View file

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

View file

@ -0,0 +1,98 @@
// Copyright (C) 2017-2023 Smart code 203358507
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
@item-height: 3rem;
@parent-height: 12rem;
.dropdown {
background: var(--modal-background-color);
display: none;
position: absolute;
width: 100%;
top: 100%;
left: 0;
z-index: 10;
box-shadow: var(--outer-glow);
border-radius: var(--border-radius);
overflow: hidden;
&.open {
display: block;
max-height: calc(@item-height * 7);
overflow: auto;
}
}
.multiselect-label-fix {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.multiselect-button-fix {
max-width: 100%;
overflow: hidden;
}
.option {
height: @item-height;
font-size: var(--font-size-normal);
color: var(--primary-foreground-color);
align-items: center;
display: flex;
flex-direction: row;
padding: 1rem;
width: 100%;
text-align: left;
.label {
flex: 1;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
}
&:hover {
background-color: rgba(255, 255, 255, 0.15);
}
}
.no-options-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: @color-background;
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
font-size: 1.2rem;
font-weight: 500;
text-align: center;
color: @color-surface-light5-90;
}
}
@media (orientation: landscape) and (max-width: @xsmall) {
.dropdown {
&.open {
max-height: calc(100dvh - var(--horizontal-nav-bar-size) - @parent-height);
}
}
}

View file

@ -6,22 +6,14 @@ const PropTypes = require('prop-types');
const classnames = require('classnames');
const { default: Icon } = require('@stremio/stremio-icons/react');
const { Button } = require('stremio/components');
const Popup = require('stremio/components/Popup');
const ModalDialog = require('stremio/components/ModalDialog');
const useBinaryState = require('stremio/common/useBinaryState');
const { default: useOutsideClick } = require('stremio/common/useOutsideClick');
const styles = require('./styles');
const menuStyles = require('../MultiselectMenu/MultiselectMenu.less');
const Multiselect = ({ className, mode, direction, title, disabled = false, dataset = undefined, options, renderLabelContent = undefined, renderLabelText = undefined, onOpen = undefined, onClose = undefined, onSelect, ...props }) => {
const Multiselect = ({ className, mode, direction, title, disabled, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
const { t } = useTranslation();
const [menuOpen, , closeMenu, toggleMenu] = useBinaryState(false);
const multiselectMenuRef = useOutsideClick(() => {
if (menuOpen) {
closeMenu();
}
});
const filteredOptions = React.useMemo(() => {
return Array.isArray(options) ?
options.filter((option) => {
@ -30,7 +22,6 @@ const Multiselect = ({ className, mode, direction, title, disabled = false, data
:
[];
}, [options]);
const selected = React.useMemo(() => {
return Array.isArray(props.selected) ?
props.selected.filter((value) => {
@ -39,7 +30,21 @@ const Multiselect = ({ className, mode, direction, title, disabled = false, data
:
[];
}, [props.selected]);
const labelOnClick = React.useCallback((event) => {
if (typeof props.onClick === 'function') {
props.onClick(event);
}
if (!event.nativeEvent.toggleMenuPrevented) {
toggleMenu();
}
}, [props.onClick, toggleMenu]);
const menuOnClick = React.useCallback((event) => {
event.nativeEvent.toggleMenuPrevented = true;
}, []);
const menuOnKeyDown = React.useCallback((event) => {
event.nativeEvent.buttonClickPrevented = true;
}, []);
const optionOnClick = React.useCallback((event) => {
if (typeof onSelect === 'function') {
onSelect({
@ -50,8 +55,11 @@ const Multiselect = ({ className, mode, direction, title, disabled = false, data
dataset: dataset
});
}
}, [dataset, onSelect]);
if (!event.nativeEvent.closeMenuPrevented) {
closeMenu();
}
}, [dataset, onSelect]);
const mountedRef = React.useRef(false);
React.useLayoutEffect(() => {
if (mountedRef.current) {
@ -74,107 +82,79 @@ const Multiselect = ({ className, mode, direction, title, disabled = false, data
mountedRef.current = true;
}, [menuOpen]);
const renderLabelContentNode = () => {
if (typeof renderLabelContent === 'function') {
return renderLabelContent();
}
return (
<React.Fragment>
<div className={classnames(menuStyles['label'], styles['multiselect-label-fix'])}>
{
typeof renderLabelText === 'function' ?
renderLabelText()
:
selected.length > 0 ?
(() => {
const MAX_ITEMS = 2;
const items = selected.slice(0, MAX_ITEMS).map((value) => {
const option = filteredOptions.find((option) => option.value === value);
return option && typeof option.label === 'string' ?
option.label
:
value;
});
return selected.length > MAX_ITEMS ? items.join(', ') + ', ...' : items.join(', ');
})()
:
title
}
</div>
<Icon className={classnames(menuStyles['icon'], { [menuStyles['open']]: menuOpen })} name={'caret-down'} />
</React.Fragment>
);
};
const renderMenu = () => (
<div className={classnames(styles['dropdown'], { [styles['open']]: menuOpen })}>
const renderLabel = React.useCallback(({ children, className, ...props }) => (
<Button {...props} className={classnames(className, styles['label-container'], { 'active': menuOpen })} title={title} disabled={disabled} onClick={labelOnClick}>
{
typeof renderLabelContent === 'function' ?
renderLabelContent()
:
<React.Fragment>
<div className={styles['label']}>
{
typeof renderLabelText === 'function' ?
renderLabelText()
:
selected.length > 0 ?
selected.map((value) => {
const option = filteredOptions.find((option) => option.value === value);
return option && typeof option.label === 'string' ?
option.label
:
value;
}).join(', ')
:
title
}
</div>
<Icon className={styles['icon']} name={'caret-down'} />
</React.Fragment>
}
{children}
</Button>
), [menuOpen, title, disabled, filteredOptions, selected, labelOnClick, renderLabelContent, renderLabelText]);
const renderMenu = React.useCallback(() => (
<div className={styles['menu-container']} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
{
filteredOptions.length > 0 ?
filteredOptions.map(({ label, title, value }) => {
const isSelected = selected.includes(value);
return (
<Button
key={value}
className={classnames(styles['option'], { [styles['selected']]: isSelected })}
title={typeof title === 'string' ? title : typeof label === 'string' ? label : value}
data-value={value}
onClick={optionOnClick}
>
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
{isSelected && <div className={styles['icon']} />}
</Button>
);
})
filteredOptions.map(({ label, title, value }) => (
<Button key={value} className={classnames(styles['option-container'], { 'selected': selected.includes(value) })} title={typeof title === 'string' ? title : typeof label === 'string' ? label : value} data-value={value} onClick={optionOnClick}>
<div className={styles['label']}>{typeof label === 'string' ? label : value}</div>
<div className={styles['icon']} />
</Button>
))
:
<div className={styles['no-options-container']}>
<div className={styles['label']}>{t('NO_OPTIONS')}</div>
</div>
}
</div>
);
if (mode === 'modal') {
return (
<div className={classnames(menuStyles['multiselect-menu'], className)}>
<Button
className={classnames(menuStyles['multiselect-button'], styles['multiselect-button-fix'], { [menuStyles['open']]: menuOpen })}
disabled={disabled}
onClick={toggleMenu}
title={title}
>
{renderLabelContentNode()}
</Button>
{menuOpen ?
<ModalDialog className={styles['modal-container']} title={title} onCloseRequest={closeMenu}>
{renderMenu()}
</ModalDialog>
: null
}
</div>
);
}
return (
<div
className={classnames(menuStyles['multiselect-menu'], { [menuStyles['active']]: menuOpen, [menuStyles['disabled']]: disabled }, className)}
ref={multiselectMenuRef}
>
<Button
className={classnames(menuStyles['multiselect-button'], styles['multiselect-button-fix'], { [menuStyles['open']]: menuOpen })}
disabled={disabled}
onClick={toggleMenu}
tabIndex={0}
aria-haspopup='listbox'
aria-expanded={menuOpen}
title={title}
>
{renderLabelContentNode()}
</Button>
{renderMenu()}
</div>
);
), [filteredOptions, selected, menuOnKeyDown, menuOnClick, optionOnClick]);
const renderPopupLabel = React.useMemo(() => (labelProps) => {
return renderLabel({
...labelProps,
...props,
className: classnames(className, labelProps.className)
});
}, [props, className, renderLabel]);
return mode === 'modal' ?
renderLabel({
...props,
className,
children: menuOpen ?
<ModalDialog className={styles['modal-container']} title={title} onCloseRequest={closeMenu} onKeyDown={menuOnKeyDown} onClick={menuOnClick}>
{renderMenu()}
</ModalDialog>
:
null
})
:
<Popup
open={menuOpen}
direction={direction}
onCloseRequest={closeMenu}
renderLabel={renderPopupLabel}
renderMenu={renderMenu}
/>;
};
Multiselect.propTypes = {

View file

@ -3,95 +3,113 @@
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
@import (reference) '~stremio/common/screen-sizes.less';
@item-height: 3rem;
@parent-height: 12rem;
.dropdown {
background: var(--modal-background-color);
display: none;
position: absolute;
width: 100%;
top: 100%;
left: 0;
z-index: 10;
box-shadow: var(--outer-glow);
border-radius: var(--border-radius);
overflow: hidden;
&.open {
display: block;
max-height: calc(@item-height * 7);
overflow: auto;
}
:import('~stremio/components/Popup/styles.less') {
popup-menu-container: menu-container;
}
.multiselect-label-fix {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
@parent-height: 10rem;
.multiselect-button-fix {
max-width: 100%;
overflow: hidden;
}
.option {
height: @item-height;
font-size: var(--font-size-normal);
color: var(--primary-foreground-color);
align-items: center;
.label-container {
display: flex;
flex-direction: row;
padding: 1rem;
width: 100%;
text-align: left;
align-items: center;
height: 2.75rem;
padding: 0 1.5rem;
border-radius: 2.75rem;
background-color: var(--overlay-color);
.label {
flex: 1;
color: var(--primary-foreground-color);
&:global(.active) {
.icon {
transform: rotate(180deg);
}
}
>.label {
flex: 1;
max-height: 2.4em;
font-weight: 500;
color: var(--primary-foreground-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.icon {
flex: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
width: 1rem;
height: 1rem;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
color: var(--primary-foreground-color);
opacity: 0.4;
}
&:hover {
background-color: rgba(255, 255, 255, 0.15);
.popup-menu-container {
width: 100%;
}
}
.no-options-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: @color-background;
.modal-container, .popup-menu-container {
.menu-container {
max-height: calc(3rem * 7);
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
font-size: 1.2rem;
font-weight: 500;
text-align: center;
color: @color-surface-light5-90;
.option-container {
display: flex;
flex-direction: row;
align-items: center;
padding: 1rem;
&:global(.selected) {
.icon {
display: block;
}
}
&:hover, &:focus {
background-color: var(--overlay-color);
}
.label {
flex: 1;
max-height: 4.8em;
color: var(--primary-foreground-color);
}
.icon {
flex: none;
display: none;
width: 0.5rem;
height: 0.5rem;
border-radius: 100%;
margin-left: 1rem;
background-color: var(--secondary-accent-color);
opacity: 1;
}
}
.no-options-container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: @color-background;
.label {
flex-grow: 0;
flex-shrink: 1;
flex-basis: auto;
font-size: 1.2rem;
font-weight: 500;
text-align: center;
color: @color-surface-light5-90;
}
}
}
}
@media (orientation: landscape) and (max-width: @xsmall) {
.dropdown {
&.open {
.modal-container, .popup-menu-container {
.menu-container {
max-height: calc(100dvh - var(--horizontal-nav-bar-size) - @parent-height);
}
}

1
src/modules.d.ts vendored
View file

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

View file

@ -1,5 +1,6 @@
import React, { forwardRef, useMemo } from 'react';
import { ColorInput, MultiselectMenu, Toggle, Multiselect, Input } from 'stremio/components';
import { ColorInput, MultiselectMenu, Toggle, Input } from 'stremio/components';
import LanguageMultiselect from 'stremio/components/LanguageMultiselect';
import { useServices } from 'stremio/services';
import { Category, Option, Section } from '../components';
import usePlayerOptions from './usePlayerOptions';
@ -228,25 +229,33 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
}), [interactiveSettings.targetLang, sortedTargetLanguages, setInteractiveSettings]);
// Multi-select for LLM target languages - matches Multiselect component API
const multiTargetLangsSelect = useMemo(() => ({
mode: 'popup' as const,
direction: 'bottom-left' as const,
title: 'Target Languages',
options: sortedTargetLanguages,
selected: interactiveSettings.targetLangs,
onSelect: (event: { type: string; value: string; nativeEvent?: { closeMenuPrevented?: boolean } }) => {
// Prevent menu from closing on selection
if (event.nativeEvent) {
event.nativeEvent.closeMenuPrevented = true;
}
const value = event.value;
const current = interactiveSettings.targetLangs;
const updated = current.includes(value)
? current.filter((l) => l !== value)
: [...current, value];
setInteractiveSettings({ targetLangs: updated.length > 0 ? updated : [] });
},
}), [interactiveSettings.targetLangs, sortedTargetLanguages, setInteractiveSettings]);
const multiTargetLangsSelect = useMemo(() => {
// Generate tooltip with selected languages line by line
const selectedLanguageNames = interactiveSettings.targetLangs
.map((code) => languageNames[code as keyof typeof languageNames] || code)
.join('\n');
const tooltipTitle = selectedLanguageNames || 'Target Languages';
return {
mode: 'popup' as const,
direction: 'bottom-left' as const,
title: tooltipTitle,
options: sortedTargetLanguages,
selected: interactiveSettings.targetLangs,
onSelect: (event: { type: string; value: string; nativeEvent?: { closeMenuPrevented?: boolean } }) => {
// Prevent menu from closing on selection
if (event.nativeEvent) {
event.nativeEvent.closeMenuPrevented = true;
}
const value = event.value;
const current = interactiveSettings.targetLangs;
const updated = current.includes(value)
? current.filter((l) => l !== value)
: [...current, value];
setInteractiveSettings({ targetLangs: updated.length > 0 ? updated : [] });
},
};
}, [interactiveSettings.targetLangs, sortedTargetLanguages, setInteractiveSettings]);
const isLLMProvider = LLM_PROVIDERS.includes(interactiveSettings.provider);
const showApiKeyInput = ['GEMINI', 'OPENAI', 'CLAUDE', 'OPENROUTER', 'CUSTOM'].includes(interactiveSettings.provider);
@ -322,7 +331,7 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
)}
{isLLMProvider && (
<Option label={'Target Languages (Multi)'}>
<Multiselect
<LanguageMultiselect
className={'multiselect'}
{...multiTargetLangsSelect}
/>