mirror of
https://github.com/Stremio/stremio-web.git
synced 2026-04-20 02:22:09 +00:00
feat: Add interactive subtitles overlay and translation popup
- Implemented SubtitleOverlay component for displaying interactive subtitles with click actions for translation and copying. - Added TranslationPopup component for showing translation results, editing words, and managing translation providers.
This commit is contained in:
parent
c8dfc31e6b
commit
3bf3dbc3b7
13 changed files with 2311 additions and 201 deletions
44
src/components/Input/Input.tsx
Normal file
44
src/components/Input/Input.tsx
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import React, { forwardRef, useCallback } from 'react';
|
||||
import { type KeyboardEvent, type InputHTMLAttributes } from 'react';
|
||||
import classnames from 'classnames';
|
||||
import styles from './styles.less';
|
||||
|
||||
type Props = InputHTMLAttributes<HTMLInputElement> & {
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
onKeyDown?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
onSubmit?: (event: KeyboardEvent<HTMLInputElement>) => void;
|
||||
};
|
||||
|
||||
const Input = forwardRef<HTMLInputElement, Props>((props, ref) => {
|
||||
const onKeyDown = useCallback((event: KeyboardEvent<HTMLInputElement>) => {
|
||||
props.onKeyDown && props.onKeyDown(event);
|
||||
|
||||
if (event.key === 'Enter' ) {
|
||||
props.onSubmit && props.onSubmit(event);
|
||||
}
|
||||
}, [props.onKeyDown, props.onSubmit]);
|
||||
|
||||
return (
|
||||
<label className={classnames(props.className, styles['input-container'], { [styles['disabled']]: props.disabled })}>
|
||||
<input
|
||||
size={1}
|
||||
autoCorrect={'off'}
|
||||
autoCapitalize={'off'}
|
||||
autoComplete={'off'}
|
||||
spellCheck={false}
|
||||
tabIndex={0}
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={styles['input']}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
});
|
||||
|
||||
Input.displayName = 'Input';
|
||||
|
||||
export default Input;
|
||||
5
src/components/Input/index.ts
Normal file
5
src/components/Input/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
import Input from './Input';
|
||||
|
||||
export default Input;
|
||||
43
src/components/Input/styles.less
Normal file
43
src/components/Input/styles.less
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
// Copyright (C) 2017-2024 Smart code 203358507
|
||||
|
||||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
|
||||
.input-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: 3rem;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 3rem;
|
||||
border: var(--focus-outline-size) solid transparent;
|
||||
background-color: var(--overlay-color);
|
||||
cursor: text;
|
||||
|
||||
&:focus-within {
|
||||
border: var(--focus-outline-size) solid var(--primary-foreground-color);
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
user-select: text;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&::-moz-focus-inner {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,14 +6,22 @@ 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, dataset, options, renderLabelContent, renderLabelText, onOpen, onClose, onSelect, ...props }) => {
|
||||
const Multiselect = ({ 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) => {
|
||||
|
|
@ -22,6 +30,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, opt
|
|||
:
|
||||
[];
|
||||
}, [options]);
|
||||
|
||||
const selected = React.useMemo(() => {
|
||||
return Array.isArray(props.selected) ?
|
||||
props.selected.filter((value) => {
|
||||
|
|
@ -30,21 +39,7 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, opt
|
|||
:
|
||||
[];
|
||||
}, [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({
|
||||
|
|
@ -55,11 +50,8 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, opt
|
|||
dataset: dataset
|
||||
});
|
||||
}
|
||||
|
||||
if (!event.nativeEvent.closeMenuPrevented) {
|
||||
closeMenu();
|
||||
}
|
||||
}, [dataset, onSelect]);
|
||||
|
||||
const mountedRef = React.useRef(false);
|
||||
React.useLayoutEffect(() => {
|
||||
if (mountedRef.current) {
|
||||
|
|
@ -82,79 +74,107 @@ const Multiselect = ({ className, mode, direction, title, disabled, dataset, opt
|
|||
|
||||
mountedRef.current = true;
|
||||
}, [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}>
|
||||
|
||||
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 }) => (
|
||||
<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>
|
||||
))
|
||||
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>
|
||||
), [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}
|
||||
/>;
|
||||
);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
Multiselect.propTypes = {
|
||||
|
|
|
|||
|
|
@ -3,113 +3,95 @@
|
|||
@import (reference) '~@stremio/stremio-colors/less/stremio-colors.less';
|
||||
@import (reference) '~stremio/common/screen-sizes.less';
|
||||
|
||||
:import('~stremio/components/Popup/styles.less') {
|
||||
popup-menu-container: menu-container;
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
@parent-height: 10rem;
|
||||
.multiselect-label-fix {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.label-container {
|
||||
.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;
|
||||
height: 2.75rem;
|
||||
padding: 0 1.5rem;
|
||||
border-radius: 2.75rem;
|
||||
background-color: var(--overlay-color);
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background-color: @color-background;
|
||||
|
||||
&:global(.active) {
|
||||
.icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
>.label {
|
||||
flex: 1;
|
||||
max-height: 2.4em;
|
||||
.label {
|
||||
flex-grow: 0;
|
||||
flex-shrink: 1;
|
||||
flex-basis: auto;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
color: var(--primary-foreground-color);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: 1rem;
|
||||
color: var(--primary-foreground-color);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.popup-menu-container {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-container, .popup-menu-container {
|
||||
.menu-container {
|
||||
max-height: calc(3rem * 7);
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
text-align: center;
|
||||
color: @color-surface-light5-90;
|
||||
}
|
||||
}
|
||||
|
||||
@media (orientation: landscape) and (max-width: @xsmall) {
|
||||
.modal-container, .popup-menu-container {
|
||||
.menu-container {
|
||||
.dropdown {
|
||||
&.open {
|
||||
max-height: calc(100dvh - var(--horizontal-nav-bar-size) - @parent-height);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import DelayedRenderer from './DelayedRenderer';
|
|||
import EventModal from './EventModal';
|
||||
import HorizontalScroll from './HorizontalScroll';
|
||||
import Image from './Image';
|
||||
import Input from './Input';
|
||||
import LibItem from './LibItem';
|
||||
import MainNavBars from './MainNavBars';
|
||||
import MetaItem from './MetaItem';
|
||||
|
|
@ -44,6 +45,7 @@ export {
|
|||
EventModal,
|
||||
HorizontalScroll,
|
||||
Image,
|
||||
Input,
|
||||
LibItem,
|
||||
MainNavBars,
|
||||
MetaItem,
|
||||
|
|
|
|||
391
src/routes/Player/InteractiveSubtitles/ClickActionHandler.js
Normal file
391
src/routes/Player/InteractiveSubtitles/ClickActionHandler.js
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
const { languageNames } = require('stremio/common');
|
||||
|
||||
// Comprehensive map of ISO 639-2/T (3-letter) codes to ISO 639-1 (2-letter) codes for Google Translate
|
||||
// Covers 100+ languages supported by Google Translate
|
||||
const LANG_CODE_MAP = {
|
||||
// Major languages
|
||||
'eng': 'en', 'spa': 'es', 'fra': 'fr', 'deu': 'de', 'ita': 'it',
|
||||
'por': 'pt', 'rus': 'ru', 'jpn': 'ja', 'kor': 'ko', 'zho': 'zh',
|
||||
'ara': 'ar', 'hin': 'hi', 'ben': 'bn', 'pol': 'pl', 'ukr': 'uk',
|
||||
'tur': 'tr', 'nld': 'nl', 'swe': 'sv', 'dan': 'da', 'fin': 'fi',
|
||||
'nor': 'no', 'ces': 'cs', 'hun': 'hu', 'ron': 'ro', 'tha': 'th',
|
||||
'vie': 'vi', 'ind': 'id', 'heb': 'he', 'ell': 'el', 'bul': 'bg',
|
||||
|
||||
// European languages
|
||||
'cat': 'ca', 'hrv': 'hr', 'lit': 'lt', 'lav': 'lv', 'slk': 'sk',
|
||||
'slv': 'sl', 'srp': 'sr', 'est': 'et', 'msa': 'ms', 'isl': 'is',
|
||||
'gle': 'ga', 'sqi': 'sq', 'bos': 'bs', 'mkd': 'mk', 'mlt': 'mt',
|
||||
'eus': 'eu', 'glg': 'gl', 'cym': 'cy', 'bel': 'be', 'aze': 'az',
|
||||
|
||||
// Asian languages
|
||||
'fil': 'tl', 'tel': 'te', 'tam': 'ta', 'mar': 'mr', 'guj': 'gu',
|
||||
'kan': 'kn', 'mal': 'ml', 'urd': 'ur', 'nep': 'ne', 'sin': 'si',
|
||||
'pan': 'pa', 'khm': 'km', 'lao': 'lo', 'mya': 'my', 'kat': 'ka',
|
||||
'hye': 'hy', 'mon': 'mn', 'uzb': 'uz', 'kaz': 'kk', 'tgk': 'tg',
|
||||
|
||||
// African languages
|
||||
'swa': 'sw', 'amh': 'am', 'hau': 'ha', 'yor': 'yo', 'ibo': 'ig',
|
||||
'zul': 'zu', 'xho': 'xh', 'som': 'so', 'afr': 'af',
|
||||
|
||||
// Other languages
|
||||
'fas': 'fa', 'pus': 'ps', 'kur': 'ku', 'snd': 'sd', 'uig': 'ug',
|
||||
'tuk': 'tk', 'kir': 'ky', 'tat': 'tt', 'orm': 'om', 'tir': 'ti',
|
||||
'mlg': 'mg', 'ceb': 'ceb', 'hmn': 'hmn', 'haw': 'haw', 'sun': 'su',
|
||||
'jav': 'jv', 'mri': 'mi', 'smo': 'sm', 'cos': 'co', 'fry': 'fy',
|
||||
'ltz': 'lb', 'lat': 'la', 'epo': 'eo', 'sna': 'sn', 'hat': 'ht',
|
||||
'nya': 'ny', 'sot': 'st', 'tgl': 'tl', 'kin': 'rw', 'que': 'qu'
|
||||
};
|
||||
|
||||
const ClickActionHandler = {
|
||||
// Convert 3-letter language code to 2-letter code for Google Translate
|
||||
convertLangCode(code) {
|
||||
if (!code || code === 'auto') return code;
|
||||
// If already 2-letter code, return as is
|
||||
if (code.length === 2) return code;
|
||||
// Convert 3-letter to 2-letter, fallback to original if not found
|
||||
return LANG_CODE_MAP[code.toLowerCase()] || code;
|
||||
},
|
||||
|
||||
async handleWordClick(word, actionType, config = {}) {
|
||||
switch (actionType) {
|
||||
case 'COPY':
|
||||
return this.handleCopy(word);
|
||||
case 'WEBHOOK':
|
||||
return this.handleWebhook(word, config.webhookUrl);
|
||||
case 'TRANSLATE':
|
||||
default:
|
||||
return this.handleTranslate(word, config);
|
||||
}
|
||||
},
|
||||
|
||||
async handleCopy(word) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(word);
|
||||
return { type: 'COPY', success: true, message: 'Copied!' };
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
return { type: 'COPY', success: false, message: 'Copy failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async handleWebhook(word, url) {
|
||||
if (!url) {
|
||||
return { type: 'WEBHOOK', success: false, message: 'No URL configured' };
|
||||
}
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ word, timestamp: Date.now() }),
|
||||
});
|
||||
return { type: 'WEBHOOK', success: true, message: 'Sent!' };
|
||||
} catch (err) {
|
||||
console.error('Webhook failed:', err);
|
||||
return { type: 'WEBHOOK', success: false, message: 'Failed' };
|
||||
}
|
||||
},
|
||||
|
||||
async handleTranslate(word, config = {}) {
|
||||
const provider = config.provider || 'GOOGLE';
|
||||
const sourceLang = config.sourceLang || 'auto';
|
||||
const targetLang = config.targetLang || 'en';
|
||||
|
||||
// console.log(`[Translate] Provider: ${provider}, Word: "${word}"`);
|
||||
|
||||
try {
|
||||
switch (provider) {
|
||||
case 'GOOGLE':
|
||||
return await this.translateGoogle(word, sourceLang, targetLang);
|
||||
case 'GEMINI':
|
||||
return await this.translateGemini(word, config);
|
||||
case 'OPENAI':
|
||||
return await this.translateOpenAI(word, config);
|
||||
case 'CLAUDE':
|
||||
return await this.translateClaude(word, config);
|
||||
case 'OLLAMA':
|
||||
return await this.translateOllama(word, config);
|
||||
case 'OPENROUTER':
|
||||
return await this.translateOpenRouter(word, config);
|
||||
case 'CUSTOM':
|
||||
return await this.translateCustom(word, config);
|
||||
default:
|
||||
return this.translateGoogle(word, sourceLang, targetLang);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Translation failed:', err);
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider,
|
||||
error: true,
|
||||
translation: `Error: ${err.message}`,
|
||||
externalLink: `https://translate.google.com/?sl=${sourceLang}&tl=${targetLang}&text=${encodeURIComponent(word)}`
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
async translateGoogle(word, sourceLang, targetLang) {
|
||||
// Convert 3-letter codes to 2-letter codes for Google Translate API
|
||||
const sl = this.convertLangCode(sourceLang) || 'auto';
|
||||
const tl = this.convertLangCode(targetLang);
|
||||
|
||||
// Free Google Translate endpoint (unofficial, for demo purposes)
|
||||
// For production, use the official API with a key
|
||||
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=${sl}&tl=${tl}&dt=t&q=${encodeURIComponent(word)}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const data = await response.json();
|
||||
const translation = data[0]?.[0]?.[0] || 'No translation found';
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'GOOGLE',
|
||||
translation,
|
||||
externalLink: `https://translate.google.com/?sl=${sl}&tl=${tl}&text=${encodeURIComponent(word)}&op=translate`
|
||||
};
|
||||
} catch (_) {
|
||||
// Fallback to link only
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'GOOGLE',
|
||||
translation: `Click to translate "${word}"`,
|
||||
externalLink: `https://translate.google.com/?sl=${sl}&tl=${tl}&text=${encodeURIComponent(word)}&op=translate`
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
resolveTargetLangName(code) {
|
||||
return languageNames[code] || code || 'English';
|
||||
},
|
||||
|
||||
constructPrompt(word, config) {
|
||||
const targetLangs = config.targetLangs || [config.targetLang || 'en'];
|
||||
const targetLangNames = targetLangs.map((code) => this.resolveTargetLangName(code));
|
||||
const targetLangName = this.resolveTargetLangName(config.targetLang);
|
||||
|
||||
// Get provider-specific prompt or fallback to general llmPrompt
|
||||
const provider = config.provider || 'GOOGLE';
|
||||
const prompt = config.providerPrompts?.[provider] || config.llmPrompt || 'Define "{word}" briefly in {targetLang}.';
|
||||
|
||||
return prompt
|
||||
.replace('{word}', word)
|
||||
.replace('{targetLang}', targetLangName)
|
||||
.replace('{targetLangs}', targetLangNames.join(', '));
|
||||
},
|
||||
|
||||
async translateOpenAI(word, config) {
|
||||
const apiKey = config.providerApiKeys?.OPENAI || config.apiKey;
|
||||
if (!apiKey) {
|
||||
return { type: 'TRANSLATE', word, provider: 'OPENAI', error: true, translation: 'API Key not configured' };
|
||||
}
|
||||
|
||||
const prompt = this.constructPrompt(word, config);
|
||||
const baseUrl = config.providerUrls?.OPENAI || 'https://api.openai.com/v1';
|
||||
const model = config.providerModels?.OPENAI || 'gpt-4o-mini';
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 150,
|
||||
temperature: 0.3
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'OPENAI',
|
||||
translation: data.choices?.[0]?.message?.content || 'No response'
|
||||
};
|
||||
},
|
||||
|
||||
async translateClaude(word, config) {
|
||||
const apiKey = config.providerApiKeys?.CLAUDE || config.apiKey;
|
||||
if (!apiKey) {
|
||||
return { type: 'TRANSLATE', word, provider: 'CLAUDE', error: true, translation: 'API Key not configured' };
|
||||
}
|
||||
|
||||
const prompt = this.constructPrompt(word, config);
|
||||
const baseUrl = config.providerUrls?.CLAUDE || 'https://api.anthropic.com/v1';
|
||||
const model = config.providerModels?.CLAUDE || 'claude-sonnet-4-20250514';
|
||||
|
||||
const response = await fetch(`${baseUrl}/messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
max_tokens: 150,
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'CLAUDE',
|
||||
translation: data.content?.[0]?.text || 'No response'
|
||||
};
|
||||
},
|
||||
|
||||
async translateOllama(word, config) {
|
||||
const baseUrl = config.providerUrls?.OLLAMA || 'http://localhost:11434';
|
||||
const model = config.providerModels?.OLLAMA || 'llama3.2';
|
||||
|
||||
const prompt = this.constructPrompt(word, config);
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
prompt: prompt,
|
||||
stream: false
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'OLLAMA',
|
||||
translation: data.response || 'No response'
|
||||
};
|
||||
},
|
||||
|
||||
async translateOpenRouter(word, config) {
|
||||
const apiKey = config.providerApiKeys?.OPENROUTER || config.apiKey;
|
||||
if (!apiKey) {
|
||||
return { type: 'TRANSLATE', word, provider: 'OPENROUTER', error: true, translation: 'API Key not configured' };
|
||||
}
|
||||
|
||||
const prompt = this.constructPrompt(word, config);
|
||||
const baseUrl = config.providerUrls?.OPENROUTER || 'https://openrouter.ai/api/v1';
|
||||
const model = config.providerModels?.OPENROUTER || 'openai/gpt-4o-mini';
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'OPENROUTER',
|
||||
translation: data.choices?.[0]?.message?.content || 'No response'
|
||||
};
|
||||
},
|
||||
|
||||
async translateGemini(word, config) {
|
||||
const apiKey = config.providerApiKeys?.GEMINI || config.apiKey;
|
||||
if (!apiKey) {
|
||||
return { type: 'TRANSLATE', word, provider: 'GEMINI', error: true, translation: 'API Key not configured' };
|
||||
}
|
||||
|
||||
const prompt = this.constructPrompt(word, config);
|
||||
const baseUrl = config.providerUrls?.GEMINI || 'https://generativelanguage.googleapis.com/v1beta';
|
||||
const model = config.providerModels?.GEMINI || 'gemma-3-27b-it';
|
||||
|
||||
const response = await fetch(`${baseUrl}/models/${model}:generateContent?key=${apiKey}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
contents: [{ parts: [{ text: prompt }] }],
|
||||
generationConfig: { maxOutputTokens: 200, temperature: 0.3 }
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
const text = data.candidates?.[0]?.content?.parts?.[0]?.text || 'No response';
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'GEMINI',
|
||||
translation: text
|
||||
};
|
||||
},
|
||||
|
||||
async translateCustom(word, config) {
|
||||
const apiKey = config.providerApiKeys?.CUSTOM || config.apiKey;
|
||||
if (!apiKey) {
|
||||
return { type: 'TRANSLATE', word, provider: 'CUSTOM', error: true, translation: 'API Key not configured' };
|
||||
}
|
||||
const baseUrl = config.providerUrls?.CUSTOM;
|
||||
if (!baseUrl) {
|
||||
return { type: 'TRANSLATE', word, provider: 'CUSTOM', error: true, translation: 'Base URL not configured' };
|
||||
}
|
||||
|
||||
const prompt = this.constructPrompt(word, config);
|
||||
const model = config.providerModels?.CUSTOM || 'gpt-4o-mini';
|
||||
|
||||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${apiKey}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
max_tokens: 150,
|
||||
temperature: 0.3
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.error) {
|
||||
throw new Error(data.error.message);
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'TRANSLATE',
|
||||
word,
|
||||
provider: 'CUSTOM',
|
||||
translation: data.choices?.[0]?.message?.content || 'No response'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ClickActionHandler;
|
||||
|
||||
421
src/routes/Player/InteractiveSubtitles/SubtitleOverlay.js
Normal file
421
src/routes/Player/InteractiveSubtitles/SubtitleOverlay.js
Normal file
|
|
@ -0,0 +1,421 @@
|
|||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
const ClickActionHandler = require('./ClickActionHandler');
|
||||
const TranslationPopup = require('./TranslationPopup');
|
||||
|
||||
const SubtitleOverlay = ({ videoElement, containerRef, settings = {}, onProviderChange, getCachedTranslation, setCachedTranslation }) => {
|
||||
const [currentCue, setCurrentCue] = React.useState(null);
|
||||
const [popupState, setPopupState] = React.useState({ visible: false, position: { x: 0, y: 0 }, data: null, fromCache: false });
|
||||
const [notification, setNotification] = React.useState({ visible: false, message: '', position: { x: 0, y: 0 } });
|
||||
const currentCueRef = React.useRef(null);
|
||||
|
||||
// Configurable action from settings (default to TRANSLATE)
|
||||
const clickAction = settings.clickAction || 'TRANSLATE';
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef?.current;
|
||||
if (!container) return;
|
||||
|
||||
const findAndProcessSubtitles = () => {
|
||||
try {
|
||||
// Convert live HTMLCollection to static array
|
||||
const allDivs = Array.from(container.getElementsByTagName('div'));
|
||||
|
||||
let foundText = null;
|
||||
let nodeToHide = null;
|
||||
|
||||
for (const node of allDivs) {
|
||||
// Skip our own interactive overlay elements
|
||||
if (node.closest('[data-interactive-overlay]')) continue;
|
||||
|
||||
// Skip if no style attribute
|
||||
if (!node.style) continue;
|
||||
|
||||
// Check if this div has text-shadow in computed or inline style
|
||||
const inlineStyle = node.getAttribute('style') || '';
|
||||
const hasTextShadow = inlineStyle.includes('text-shadow');
|
||||
|
||||
if (hasTextShadow) {
|
||||
const text = node.textContent || node.innerText || '';
|
||||
if (text.trim()) {
|
||||
foundText = text.trim();
|
||||
// Hide the parent wrapper
|
||||
nodeToHide = node.parentElement;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide the native subtitle container
|
||||
if (nodeToHide && nodeToHide.style) {
|
||||
nodeToHide.style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
// Update state if text changed
|
||||
if (foundText !== currentCueRef.current) {
|
||||
currentCueRef.current = foundText;
|
||||
setCurrentCue(foundText);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('SubtitleOverlay error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Run immediately
|
||||
findAndProcessSubtitles();
|
||||
|
||||
// Use both observer and interval for reliability
|
||||
const observer = new MutationObserver(findAndProcessSubtitles);
|
||||
observer.observe(container, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
attributes: true
|
||||
});
|
||||
|
||||
// Fallback interval for cases where observer misses changes
|
||||
const interval = setInterval(findAndProcessSubtitles, 100);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
const handleWordClick = async (e, word) => {
|
||||
e.stopPropagation(); // Prevent pausing video via container click
|
||||
|
||||
// Pause video based on action type
|
||||
if (videoElement) {
|
||||
if (clickAction === 'TRANSLATE' && settings.pauseOnTranslate !== false) {
|
||||
videoElement.pause();
|
||||
} else if (clickAction === 'COPY' && settings.pauseOnCopy) {
|
||||
videoElement.pause();
|
||||
}
|
||||
}
|
||||
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
|
||||
const position = {
|
||||
x: rect.left + (rect.width / 2) - 150,
|
||||
y: rect.top
|
||||
};
|
||||
|
||||
// Check cache first (only for TRANSLATE action)
|
||||
if (clickAction === 'TRANSLATE' && getCachedTranslation) {
|
||||
// Create cache key based on provider
|
||||
// Google uses single targetLang, LLMs use multiple targetLangs
|
||||
const isLLMProvider = settings.provider && settings.provider !== 'GOOGLE';
|
||||
const cacheKey = isLLMProvider
|
||||
? (settings.targetLangs || [settings.targetLang || 'eng']).sort().join(',')
|
||||
: (settings.targetLang || 'eng');
|
||||
const cached = getCachedTranslation(word, cacheKey);
|
||||
if (cached) {
|
||||
setPopupState({
|
||||
visible: true,
|
||||
loading: false,
|
||||
position: position,
|
||||
data: { word, type: 'TRANSLATE', provider: settings.provider, translation: cached },
|
||||
fromCache: true,
|
||||
cacheKey: cacheKey // Store cache key for deletion
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle COPY action with notification
|
||||
if (clickAction === 'COPY') {
|
||||
try {
|
||||
const result = await ClickActionHandler.handleWordClick(word, clickAction, settings);
|
||||
|
||||
// Show brief notification
|
||||
setNotification({
|
||||
visible: true,
|
||||
message: result.message || 'Copied!',
|
||||
position: position
|
||||
});
|
||||
|
||||
// Auto-hide notification after 1 second
|
||||
setTimeout(() => {
|
||||
setNotification({ visible: false, message: '', position: { x: 0, y: 0 } });
|
||||
}, 1000);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setNotification({
|
||||
visible: true,
|
||||
message: 'Copy failed',
|
||||
position: position
|
||||
});
|
||||
setTimeout(() => {
|
||||
setNotification({ visible: false, message: '', position: { x: 0, y: 0 } });
|
||||
}, 1000);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state immediately for TRANSLATE/WEBHOOK
|
||||
setPopupState({
|
||||
visible: true,
|
||||
loading: true,
|
||||
position: position,
|
||||
data: { word, type: 'LOADING' }
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await ClickActionHandler.handleWordClick(word, clickAction, settings);
|
||||
|
||||
// Save to cache if translation was successful
|
||||
if (result.type === 'TRANSLATE' && !result.error && result.translation && setCachedTranslation) {
|
||||
// Create cache key based on provider
|
||||
// Google uses single targetLang, LLMs use multiple targetLangs
|
||||
const isLLMProvider = settings.provider && settings.provider !== 'GOOGLE';
|
||||
const cacheKey = isLLMProvider
|
||||
? (settings.targetLangs || [settings.targetLang || 'eng']).sort().join(',')
|
||||
: (settings.targetLang || 'eng');
|
||||
setCachedTranslation(word, cacheKey, result.translation);
|
||||
}
|
||||
|
||||
setPopupState({
|
||||
visible: true,
|
||||
loading: false,
|
||||
position: position,
|
||||
data: result
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setPopupState({
|
||||
visible: true,
|
||||
loading: false,
|
||||
position: position,
|
||||
data: { word, error: true, translation: err.message }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const closePopup = (e) => {
|
||||
if (e) e.stopPropagation();
|
||||
setPopupState({ ...popupState, visible: false });
|
||||
};
|
||||
|
||||
// Delete cache entry for current word
|
||||
const handleDeleteCache = () => {
|
||||
if (!popupState.data?.word || !popupState.cacheKey) return;
|
||||
|
||||
try {
|
||||
const cached = localStorage.getItem('stremio_interactive_subtitles_cache');
|
||||
if (!cached) return;
|
||||
const cache = JSON.parse(cached);
|
||||
const word = popupState.data.word.toLowerCase();
|
||||
|
||||
if (cache[word] && cache[word][popupState.cacheKey]) {
|
||||
delete cache[word][popupState.cacheKey];
|
||||
if (Object.keys(cache[word]).length === 0) {
|
||||
delete cache[word];
|
||||
}
|
||||
localStorage.setItem('stremio_interactive_subtitles_cache', JSON.stringify(cache));
|
||||
|
||||
// Hide cache indicators after deletion
|
||||
setPopupState((prev) => ({
|
||||
...prev,
|
||||
fromCache: false,
|
||||
cacheKey: undefined
|
||||
}));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete cache entry:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Callback for translating a new/edited word from the popup
|
||||
const handleTranslateWord = async (word) => {
|
||||
// Check cache first (only for TRANSLATE action)
|
||||
if (clickAction === 'TRANSLATE' && getCachedTranslation) {
|
||||
// Create cache key based on provider
|
||||
// Google uses single targetLang, LLMs use multiple targetLangs
|
||||
const isLLMProvider = settings.provider && settings.provider !== 'GOOGLE';
|
||||
const cacheKey = isLLMProvider
|
||||
? (settings.targetLangs || [settings.targetLang || 'eng']).sort().join(',')
|
||||
: (settings.targetLang || 'eng');
|
||||
const cached = getCachedTranslation(word, cacheKey);
|
||||
if (cached) {
|
||||
setPopupState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
data: { word, type: 'TRANSLATE', provider: settings.provider, translation: cached },
|
||||
fromCache: true,
|
||||
cacheKey: cacheKey // Store cache key for deletion
|
||||
}));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state with new word
|
||||
setPopupState((prev) => ({
|
||||
...prev,
|
||||
loading: true,
|
||||
data: { word, type: 'LOADING' }
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await ClickActionHandler.handleWordClick(word, clickAction, settings);
|
||||
|
||||
// Save to cache if translation was successful
|
||||
if (result.type === 'TRANSLATE' && !result.error && result.translation && setCachedTranslation) {
|
||||
// Create cache key based on provider
|
||||
// Google uses single targetLang, LLMs use multiple targetLangs
|
||||
const isLLMProvider = settings.provider && settings.provider !== 'GOOGLE';
|
||||
const cacheKey = isLLMProvider
|
||||
? (settings.targetLangs || [settings.targetLang || 'eng']).sort().join(',')
|
||||
: (settings.targetLang || 'eng');
|
||||
setCachedTranslation(word, cacheKey, result.translation);
|
||||
}
|
||||
|
||||
setPopupState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
data: result
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setPopupState((prev) => ({
|
||||
...prev,
|
||||
loading: false,
|
||||
data: { word, error: true, translation: err.message }
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to Convert Percentage to vmin
|
||||
const getFontSize = () => {
|
||||
let sizeStr = settings.subtitleSize || '100%';
|
||||
if (typeof sizeStr === 'number') sizeStr = sizeStr + '%';
|
||||
|
||||
const percentage = parseInt(sizeStr) || 100;
|
||||
const vmin = (percentage / 100) * 3;
|
||||
return `${vmin}vmin`;
|
||||
};
|
||||
|
||||
// Get vertical position from offset setting (default 5% from bottom)
|
||||
const getVerticalPosition = () => {
|
||||
const offset = settings.subtitleOffset;
|
||||
if (offset === undefined || offset === null) return '5%';
|
||||
return `${offset}%`;
|
||||
};
|
||||
|
||||
// Build text-shadow from outline color
|
||||
const getTextShadow = () => {
|
||||
const outlineColor = settings.subtitleOutlineColor || 'rgb(0, 0, 0)';
|
||||
return `${outlineColor} -0.15rem -0.15rem 0.15rem, ${outlineColor} 0px -0.15rem 0.15rem, ${outlineColor} 0.15rem -0.15rem 0.15rem, ${outlineColor} -0.15rem 0px 0.15rem, ${outlineColor} 0.15rem 0px 0.15rem, ${outlineColor} -0.15rem 0.15rem 0.15rem, ${outlineColor} 0px 0.15rem 0.15rem, ${outlineColor} 0.15rem 0.15rem 0.15rem`;
|
||||
};
|
||||
|
||||
const fontSize = getFontSize();
|
||||
const textColor = settings.subtitleTextColor || 'rgb(255, 255, 255)';
|
||||
const backgroundColor = settings.subtitleBackgroundColor || 'rgba(0, 0, 0, 0)';
|
||||
const bottomPosition = getVerticalPosition();
|
||||
const textShadow = getTextShadow();
|
||||
|
||||
// Split text into words for interactive clicking using Intl.Segmenter
|
||||
const words = React.useMemo(() => {
|
||||
if (!currentCue) return [];
|
||||
|
||||
// Use Intl.Segmenter if available (better for all languages including CJK)
|
||||
if (typeof Intl !== 'undefined' && Intl.Segmenter) {
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||
const segments = segmenter.segment(currentCue);
|
||||
return Array.from(segments).map((seg) => seg.segment);
|
||||
}
|
||||
|
||||
// Fallback to simple split for older browsers
|
||||
return currentCue.split(/([\s\n]+)/);
|
||||
}, [currentCue]);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-interactive-overlay="true"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: bottomPosition,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Copy notification - small, auto-hide */}
|
||||
{notification.visible && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
left: notification.position.x + 'px',
|
||||
top: Math.max(10, notification.position.y - 50) + 'px',
|
||||
backgroundColor: '#1a1d26',
|
||||
color: '#ffffff',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
zIndex: 10,
|
||||
pointerEvents: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
|
||||
border: '1px solid #333'
|
||||
}}>
|
||||
{notification.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<TranslationPopup
|
||||
{...popupState}
|
||||
onClose={closePopup}
|
||||
onTranslate={handleTranslateWord}
|
||||
settings={settings}
|
||||
onProviderChange={onProviderChange}
|
||||
fromCache={popupState.fromCache}
|
||||
onDeleteCache={handleDeleteCache}
|
||||
/>
|
||||
|
||||
{words.length > 0 && (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
padding: '0.2em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: fontSize,
|
||||
color: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
textShadow: textShadow,
|
||||
lineHeight: '1.5',
|
||||
pointerEvents: 'auto'
|
||||
}}>
|
||||
{words.map((chunk, i) => {
|
||||
if (/^[\s\n]+$/.test(chunk)) return <span key={i} style={{ fontSize }}>{chunk}</span>;
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
onClick={(e) => handleWordClick(e, chunk)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
transition: 'color 0.1s',
|
||||
fontSize: fontSize
|
||||
}}
|
||||
onMouseOver={(e) => e.target.style.color = '#FFD700'}
|
||||
onMouseOut={(e) => e.target.style.color = textColor}
|
||||
>
|
||||
{chunk}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SubtitleOverlay.propTypes = {
|
||||
videoElement: PropTypes.object,
|
||||
containerRef: PropTypes.object,
|
||||
settings: PropTypes.object,
|
||||
onProviderChange: PropTypes.func,
|
||||
getCachedTranslation: PropTypes.func,
|
||||
setCachedTranslation: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = SubtitleOverlay;
|
||||
492
src/routes/Player/InteractiveSubtitles/TranslationPopup.js
Normal file
492
src/routes/Player/InteractiveSubtitles/TranslationPopup.js
Normal file
|
|
@ -0,0 +1,492 @@
|
|||
|
||||
const React = require('react');
|
||||
const PropTypes = require('prop-types');
|
||||
|
||||
// Provider list for cycling
|
||||
const PROVIDERS = ['GOOGLE', 'GEMINI', 'OPENAI', 'CLAUDE', 'OLLAMA', 'OPENROUTER', 'CUSTOM'];
|
||||
|
||||
const styles = {
|
||||
container: {
|
||||
position: 'fixed',
|
||||
zIndex: 5,
|
||||
backgroundColor: '#1a1d26',
|
||||
color: '#ffffff',
|
||||
padding: '12px',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||
border: '1px solid #333',
|
||||
maxWidth: '320px',
|
||||
minWidth: '200px',
|
||||
fontSize: '14px',
|
||||
lineHeight: '1.4',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '8px',
|
||||
borderBottom: '1px solid #444',
|
||||
paddingBottom: '6px',
|
||||
cursor: 'grab',
|
||||
userSelect: 'none',
|
||||
},
|
||||
wordSection: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
word: {
|
||||
fontWeight: 'bold',
|
||||
color: '#FFD700',
|
||||
fontSize: '1.1em',
|
||||
},
|
||||
actionsSection: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
marginLeft: '8px',
|
||||
},
|
||||
iconButton: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '2px 6px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s',
|
||||
},
|
||||
closeButton: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
padding: '2px 6px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s',
|
||||
},
|
||||
inlineInput: {
|
||||
background: '#2a2d36',
|
||||
border: '1px solid #444',
|
||||
borderRadius: '4px',
|
||||
color: '#fff',
|
||||
padding: '2px 8px',
|
||||
fontSize: '1em',
|
||||
outline: 'none',
|
||||
width: '100px',
|
||||
},
|
||||
searchContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
},
|
||||
message: {
|
||||
marginBottom: '8px',
|
||||
whiteSpace: 'pre-wrap',
|
||||
},
|
||||
link: {
|
||||
color: '#4daafc',
|
||||
textDecoration: 'none',
|
||||
fontSize: '0.85em',
|
||||
display: 'inline-block',
|
||||
marginTop: '6px'
|
||||
},
|
||||
provider: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '6px',
|
||||
fontSize: '0.75em',
|
||||
color: '#888',
|
||||
marginTop: '8px',
|
||||
borderTop: '1px solid #333',
|
||||
paddingTop: '6px',
|
||||
},
|
||||
providerIcon: {
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#888',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '2px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s',
|
||||
},
|
||||
loadingContainer: {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
},
|
||||
spinner: {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
border: '2px solid #333',
|
||||
borderTop: '2px solid #4daafc',
|
||||
borderRadius: '50%',
|
||||
animation: 'spin 0.8s linear infinite',
|
||||
}
|
||||
};
|
||||
|
||||
// Inject keyframes for spinner animation
|
||||
if (typeof document !== 'undefined' && !document.getElementById('translation-popup-styles')) {
|
||||
const styleSheet = document.createElement('style');
|
||||
styleSheet.id = 'translation-popup-styles';
|
||||
styleSheet.textContent = `
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(styleSheet);
|
||||
}
|
||||
|
||||
const TranslationPopup = ({ visible, loading, position, data, onClose, onTranslate, settings, onProviderChange, fromCache, onDeleteCache }) => {
|
||||
const [isEditing, setIsEditing] = React.useState(false);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
const [editValue, setEditValue] = React.useState('');
|
||||
const [searchValue, setSearchValue] = React.useState('');
|
||||
const [isPinned, setIsPinned] = React.useState(false);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const [dragOffset, setDragOffset] = React.useState({ x: 0, y: 0 });
|
||||
const [currentPosition, setCurrentPosition] = React.useState(position);
|
||||
const popupRef = React.useRef(null);
|
||||
|
||||
// Cycle to next provider
|
||||
const handleProviderCycle = (e) => {
|
||||
e.stopPropagation();
|
||||
if (!onProviderChange || !settings) return;
|
||||
const currentProvider = settings.provider || 'GOOGLE';
|
||||
const currentIndex = PROVIDERS.indexOf(currentProvider);
|
||||
const nextIndex = (currentIndex + 1) % PROVIDERS.length;
|
||||
onProviderChange(PROVIDERS[nextIndex]);
|
||||
};
|
||||
|
||||
// Get next provider name for tooltip
|
||||
const getNextProvider = () => {
|
||||
if (!settings) return PROVIDERS[0];
|
||||
const currentProvider = settings.provider || 'GOOGLE';
|
||||
const currentIndex = PROVIDERS.indexOf(currentProvider);
|
||||
const nextIndex = (currentIndex + 1) % PROVIDERS.length;
|
||||
return PROVIDERS[nextIndex];
|
||||
};
|
||||
|
||||
// Click outside to close (unless pinned)
|
||||
React.useEffect(() => {
|
||||
if (!visible || isPinned) return;
|
||||
|
||||
const handleClickOutside = (e) => {
|
||||
if (popupRef.current && !popupRef.current.contains(e.target)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [visible, isPinned, onClose]);
|
||||
|
||||
// Update current position when position prop changes (new word clicked)
|
||||
React.useEffect(() => {
|
||||
setCurrentPosition(position);
|
||||
}, [position]);
|
||||
|
||||
// Reset states when popup closes or word changes
|
||||
React.useEffect(() => {
|
||||
if (!visible) {
|
||||
setIsEditing(false);
|
||||
setIsSearching(false);
|
||||
setEditValue('');
|
||||
setSearchValue('');
|
||||
setIsPinned(false);
|
||||
setCurrentPosition(position);
|
||||
}
|
||||
}, [visible, position]);
|
||||
|
||||
// Drag handlers
|
||||
const handleMouseDown = (e) => {
|
||||
// Only allow dragging from the header, not from buttons or inputs
|
||||
if (e.target.tagName === 'BUTTON' || e.target.tagName === 'INPUT' || e.target.tagName === 'SPAN') {
|
||||
return;
|
||||
}
|
||||
setIsDragging(true);
|
||||
const rect = popupRef.current.getBoundingClientRect();
|
||||
setDragOffset({
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const handleMouseMove = (e) => {
|
||||
setCurrentPosition({
|
||||
x: e.clientX - dragOffset.x,
|
||||
y: e.clientY - dragOffset.y
|
||||
});
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
}, [isDragging, dragOffset]);
|
||||
|
||||
if (!visible || !data) return null;
|
||||
|
||||
const style = {
|
||||
...styles.container,
|
||||
left: currentPosition.x + 'px',
|
||||
top: Math.max(10, currentPosition.y - 150) + 'px',
|
||||
};
|
||||
|
||||
const handleEditClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setEditValue(data.word || '');
|
||||
setIsEditing(true);
|
||||
setIsSearching(false);
|
||||
};
|
||||
|
||||
const handleSearchClick = (e) => {
|
||||
e.stopPropagation();
|
||||
setSearchValue('');
|
||||
setIsSearching(true);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleEditSubmit = (e) => {
|
||||
e.stopPropagation();
|
||||
if (editValue.trim() && onTranslate) {
|
||||
onTranslate(editValue.trim());
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleSearchSubmit = (e) => {
|
||||
e.stopPropagation();
|
||||
if (searchValue.trim() && onTranslate) {
|
||||
onTranslate(searchValue.trim());
|
||||
}
|
||||
setIsSearching(false);
|
||||
setSearchValue('');
|
||||
};
|
||||
|
||||
const handleKeyDown = (e, submitFn) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
submitFn(e);
|
||||
} else if (e.key === 'Escape') {
|
||||
setIsEditing(false);
|
||||
setIsSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={popupRef} style={style} onClick={(e) => e.stopPropagation()}>
|
||||
<div
|
||||
style={{
|
||||
...styles.header,
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
}}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div style={styles.wordSection}>
|
||||
{/* Pencil/Edit button */}
|
||||
<button
|
||||
style={styles.iconButton}
|
||||
onClick={handleEditClick}
|
||||
title="Edit word"
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
|
||||
{/* Word or Edit Input */}
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
style={styles.inlineInput}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, handleEditSubmit)}
|
||||
onBlur={handleEditSubmit}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
data.word && <span style={styles.word}>{data.word}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.actionsSection}>
|
||||
{/* Pin button */}
|
||||
<button
|
||||
style={{
|
||||
...styles.iconButton,
|
||||
color: isPinned ? '#FFD700' : '#888'
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsPinned(!isPinned);
|
||||
}}
|
||||
title={isPinned ? 'Unpin' : 'Pin popup'}
|
||||
>
|
||||
{isPinned ? '📍' : '📌'}
|
||||
</button>
|
||||
|
||||
{/* Search section */}
|
||||
{isSearching ? (
|
||||
<div style={styles.searchContainer}>
|
||||
<input
|
||||
type="text"
|
||||
style={styles.inlineInput}
|
||||
value={searchValue}
|
||||
onChange={(e) => setSearchValue(e.target.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, handleSearchSubmit)}
|
||||
onBlur={() => setIsSearching(false)}
|
||||
placeholder="New word..."
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
style={styles.iconButton}
|
||||
onClick={handleSearchClick}
|
||||
title="Search new word"
|
||||
>
|
||||
🔍
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Close button */}
|
||||
<button style={styles.closeButton} onClick={onClose} title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div style={styles.loadingContainer}>
|
||||
<div style={styles.spinner}></div>
|
||||
<span>Translating...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{data.error && (
|
||||
<div style={{ ...styles.message, color: '#ff6b6b' }}>
|
||||
{data.translation}
|
||||
</div>
|
||||
)}
|
||||
{!data.error && data.translation && (
|
||||
<div style={styles.message}>{data.translation}</div>
|
||||
)}
|
||||
{!data.error && data.message && (
|
||||
<div style={styles.message}>{data.message}</div>
|
||||
)}
|
||||
|
||||
{data.externalLink && (
|
||||
<a
|
||||
href={data.externalLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={styles.link}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Open in Google Translate →
|
||||
</a>
|
||||
)}
|
||||
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: '8px',
|
||||
borderTop: '1px solid #333',
|
||||
paddingTop: '6px',
|
||||
}}>
|
||||
{/* Cache indicator (left side) */}
|
||||
{fromCache && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: '0.75em',
|
||||
color: '#4daafc'
|
||||
}}>
|
||||
<span title="Loaded from cache">🕒</span>
|
||||
{onDeleteCache && (
|
||||
<button
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
color: '#ff4444',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
padding: '2px',
|
||||
lineHeight: 1,
|
||||
transition: 'color 0.2s',
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteCache();
|
||||
}}
|
||||
title="Delete from cache"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Provider info (right side) */}
|
||||
{(data.provider || settings?.provider) && (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '6px',
|
||||
fontSize: '0.75em',
|
||||
color: '#888',
|
||||
marginLeft: 'auto'
|
||||
}}>
|
||||
<span>Provider: {settings?.provider || data.provider}</span>
|
||||
{onProviderChange && (
|
||||
<button
|
||||
style={styles.providerIcon}
|
||||
onClick={handleProviderCycle}
|
||||
title={`Switch to ${getNextProvider()}`}
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
TranslationPopup.propTypes = {
|
||||
visible: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
position: PropTypes.shape({ x: PropTypes.number, y: PropTypes.number }),
|
||||
data: PropTypes.object,
|
||||
onClose: PropTypes.func,
|
||||
onTranslate: PropTypes.func,
|
||||
settings: PropTypes.shape({
|
||||
provider: PropTypes.string
|
||||
}),
|
||||
onProviderChange: PropTypes.func,
|
||||
fromCache: PropTypes.bool,
|
||||
onDeleteCache: PropTypes.func
|
||||
};
|
||||
|
||||
module.exports = TranslationPopup;
|
||||
|
|
@ -27,6 +27,8 @@ const useStatistics = require('./useStatistics');
|
|||
const useVideo = require('./useVideo');
|
||||
const styles = require('./styles');
|
||||
const Video = require('./Video');
|
||||
const SubtitleOverlay = require('./InteractiveSubtitles/SubtitleOverlay');
|
||||
const { useInteractiveSettings } = require('../Settings/Player/useInteractiveSettings');
|
||||
const { default: Indicator } = require('./Indicator/Indicator');
|
||||
|
||||
const Player = ({ urlParams, queryParams }) => {
|
||||
|
|
@ -45,6 +47,7 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
const routeFocused = useRouteFocused();
|
||||
const platform = usePlatform();
|
||||
const toast = useToast();
|
||||
const { settings: interactiveSettings, setSettings: setInteractiveSettings, getCachedTranslation, setCachedTranslation } = useInteractiveSettings();
|
||||
|
||||
const [seeking, setSeeking] = React.useState(false);
|
||||
|
||||
|
|
@ -797,6 +800,26 @@ const Player = ({ urlParams, queryParams }) => {
|
|||
onClick={onVideoClick}
|
||||
onDoubleClick={onVideoDoubleClick}
|
||||
/>
|
||||
{/* INJECTED: Interactive Subtitle Overlay */}
|
||||
{video.containerRef.current && interactiveSettings.uiMode === 'OVERLAY' && (
|
||||
<div className={styles['layer']} style={{ zIndex: 0, pointerEvents: 'none' }}>
|
||||
<SubtitleOverlay
|
||||
videoElement={video.containerRef.current.querySelector('video')}
|
||||
containerRef={video.containerRef}
|
||||
settings={{
|
||||
...interactiveSettings,
|
||||
subtitleSize: settings.subtitlesSize,
|
||||
subtitleOffset: settings.subtitlesOffset,
|
||||
subtitleTextColor: settings.subtitlesTextColor,
|
||||
subtitleBackgroundColor: settings.subtitlesBackgroundColor,
|
||||
subtitleOutlineColor: settings.subtitlesOutlineColor
|
||||
}}
|
||||
onProviderChange={(provider) => setInteractiveSettings({ provider })}
|
||||
getCachedTranslation={getCachedTranslation}
|
||||
setCachedTranslation={setCachedTranslation}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{
|
||||
!video.state.loaded ?
|
||||
<div className={classnames(styles['layer'], styles['background-layer'])}>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ html:not(.active-slider-within) {
|
|||
}
|
||||
|
||||
&.menu-layer {
|
||||
z-index: 10;
|
||||
top: initial;
|
||||
left: initial;
|
||||
right: 4rem;
|
||||
|
|
|
|||
|
|
@ -1,17 +1,164 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { ColorInput, MultiselectMenu, Toggle } from 'stremio/components';
|
||||
import React, { forwardRef, useMemo } from 'react';
|
||||
import { ColorInput, MultiselectMenu, Toggle, Multiselect, Input } from 'stremio/components';
|
||||
import { useServices } from 'stremio/services';
|
||||
import { Category, Option, Section } from '../components';
|
||||
import usePlayerOptions from './usePlayerOptions';
|
||||
import { usePlatform } from 'stremio/common';
|
||||
import { usePlatform, languageNames, useLanguageSorting } from 'stremio/common';
|
||||
import useInteractiveSettings, { ClickAction, TranslationProvider, SubtitleUIMode, DEFAULT_PROVIDER_URLS, DEFAULT_PROVIDER_MODELS, DEFAULT_PROVIDER_PROMPTS } from './useInteractiveSettings';
|
||||
|
||||
type Props = {
|
||||
profile: Profile,
|
||||
};
|
||||
|
||||
const UI_MODE_OPTIONS: { value: SubtitleUIMode; label: string }[] = [
|
||||
{ value: 'OVERLAY', label: 'Interactive Overlay' },
|
||||
{ value: 'NATIVE', label: 'Native' },
|
||||
];
|
||||
|
||||
const CLICK_ACTION_OPTIONS: { value: ClickAction; label: string }[] = [
|
||||
{ value: 'TRANSLATE', label: 'Translate' },
|
||||
{ value: 'COPY', label: 'Copy to Clipboard' },
|
||||
//{ value: 'WEBHOOK', label: 'Send to Webhook' },
|
||||
];
|
||||
|
||||
const PROVIDER_OPTIONS: { value: TranslationProvider; label: string }[] = [
|
||||
{ value: 'GOOGLE', label: 'Google Translate' },
|
||||
{ value: 'GEMINI', label: 'Google Gemini' },
|
||||
{ value: 'OPENAI', label: 'OpenAI' },
|
||||
{ value: 'CLAUDE', label: 'Claude' },
|
||||
{ value: 'OLLAMA', label: 'Ollama (Local)' },
|
||||
{ value: 'OPENROUTER', label: 'OpenRouter' },
|
||||
{ value: 'CUSTOM', label: 'Custom (OpenAI Compatible)' },
|
||||
];
|
||||
|
||||
const LLM_PROVIDERS = ['GEMINI', 'OPENAI', 'CLAUDE', 'OLLAMA', 'OPENROUTER', 'CUSTOM'];
|
||||
|
||||
const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
||||
const { shell } = useServices();
|
||||
const platform = usePlatform();
|
||||
const { settings: interactiveSettings, setSettings: setInteractiveSettings, clearCache } = useInteractiveSettings();
|
||||
|
||||
// Cache management state
|
||||
const [cacheSearch, setCacheSearch] = React.useState('');
|
||||
const [showCache, setShowCache] = React.useState(false);
|
||||
const [cacheRefresh, setCacheRefresh] = React.useState(0);
|
||||
|
||||
// Get cache entries
|
||||
const cacheEntries = useMemo(() => {
|
||||
try {
|
||||
const cached = localStorage.getItem('stremio_interactive_subtitles_cache');
|
||||
if (!cached) return [];
|
||||
const cache = JSON.parse(cached);
|
||||
const entries: { word: string; lang: string; translation: string }[] = [];
|
||||
Object.keys(cache).forEach((word) => {
|
||||
Object.keys(cache[word]).forEach((lang) => {
|
||||
entries.push({ word, lang, translation: cache[word][lang] });
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [cacheRefresh]); // Re-calculate when cache is modified
|
||||
|
||||
// Filter cache entries by search
|
||||
const filteredCache = useMemo(() => {
|
||||
if (!cacheSearch.trim()) return cacheEntries;
|
||||
const searchLower = cacheSearch.toLowerCase();
|
||||
return cacheEntries.filter((entry) =>
|
||||
entry.word.toLowerCase().includes(searchLower) ||
|
||||
entry.translation.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}, [cacheEntries, cacheSearch]);
|
||||
|
||||
// Delete single cache entry
|
||||
const deleteCacheEntry = (word: string, lang: string) => {
|
||||
try {
|
||||
const cached = localStorage.getItem('stremio_interactive_subtitles_cache');
|
||||
if (!cached) return;
|
||||
const cache = JSON.parse(cached);
|
||||
if (cache[word] && cache[word][lang]) {
|
||||
delete cache[word][lang];
|
||||
if (Object.keys(cache[word]).length === 0) {
|
||||
delete cache[word];
|
||||
}
|
||||
localStorage.setItem('stremio_interactive_subtitles_cache', JSON.stringify(cache));
|
||||
setCacheRefresh((prev) => prev + 1); // Trigger re-render without hiding list
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete cache entry:', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Export cache as JSON
|
||||
const exportCache = () => {
|
||||
try {
|
||||
const cached = localStorage.getItem('stremio_interactive_subtitles_cache');
|
||||
if (!cached) {
|
||||
alert('Cache is empty');
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([cached], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `stremio-translation-cache-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
console.error('Failed to export cache:', e);
|
||||
alert('Failed to export cache');
|
||||
}
|
||||
};
|
||||
|
||||
// Import cache from JSON
|
||||
const importCache = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'application/json,.json';
|
||||
input.onchange = (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const content = event.target?.result as string;
|
||||
const importedCache = JSON.parse(content);
|
||||
|
||||
// Validate structure
|
||||
if (typeof importedCache !== 'object' || importedCache === null) {
|
||||
throw new Error('Invalid cache format');
|
||||
}
|
||||
|
||||
// Merge with existing cache
|
||||
const cached = localStorage.getItem('stremio_interactive_subtitles_cache');
|
||||
const existingCache = cached ? JSON.parse(cached) : {};
|
||||
|
||||
const mergedCache = { ...existingCache };
|
||||
Object.keys(importedCache).forEach((word) => {
|
||||
if (!mergedCache[word]) {
|
||||
mergedCache[word] = {};
|
||||
}
|
||||
Object.keys(importedCache[word]).forEach((lang) => {
|
||||
mergedCache[word][lang] = importedCache[word][lang];
|
||||
});
|
||||
});
|
||||
|
||||
localStorage.setItem('stremio_interactive_subtitles_cache', JSON.stringify(mergedCache));
|
||||
setCacheRefresh((prev) => prev + 1); // Trigger re-render without hiding list
|
||||
alert('Cache imported successfully');
|
||||
} catch (e) {
|
||||
console.error('Failed to import cache:', e);
|
||||
alert('Failed to import cache: Invalid JSON format');
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
input.click();
|
||||
};
|
||||
|
||||
const {
|
||||
subtitlesLanguageSelect,
|
||||
|
|
@ -32,8 +179,385 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
|||
pauseOnMinimizeToggle,
|
||||
} = usePlayerOptions(profile);
|
||||
|
||||
// Interactive Subtitles Options - use proper language sorting
|
||||
const languageOptions = useMemo(() => [
|
||||
{ value: 'auto', label: 'Auto Detect' },
|
||||
...Object.keys(languageNames).map((code) => ({
|
||||
value: code,
|
||||
label: languageNames[code as keyof typeof languageNames]
|
||||
}))
|
||||
], []);
|
||||
|
||||
const targetLanguageOptions = useMemo(() =>
|
||||
Object.keys(languageNames).map((code) => ({
|
||||
value: code,
|
||||
label: languageNames[code as keyof typeof languageNames]
|
||||
})), []);
|
||||
|
||||
// Use language sorting for better UX
|
||||
const { sortedOptions: sortedTargetLanguages } = useLanguageSorting(targetLanguageOptions);
|
||||
|
||||
const uiModeSelect = useMemo(() => ({
|
||||
options: UI_MODE_OPTIONS,
|
||||
value: interactiveSettings.uiMode,
|
||||
onSelect: (value: string) => setInteractiveSettings({ uiMode: value as SubtitleUIMode }),
|
||||
}), [interactiveSettings.uiMode, setInteractiveSettings]);
|
||||
|
||||
const clickActionSelect = useMemo(() => ({
|
||||
options: CLICK_ACTION_OPTIONS,
|
||||
value: interactiveSettings.clickAction,
|
||||
onSelect: (value: string) => setInteractiveSettings({ clickAction: value as ClickAction }),
|
||||
}), [interactiveSettings.clickAction, setInteractiveSettings]);
|
||||
|
||||
const providerSelect = useMemo(() => ({
|
||||
options: PROVIDER_OPTIONS,
|
||||
value: interactiveSettings.provider,
|
||||
onSelect: (value: string) => setInteractiveSettings({ provider: value as TranslationProvider }),
|
||||
}), [interactiveSettings.provider, setInteractiveSettings]);
|
||||
|
||||
const sourceLangSelect = useMemo(() => ({
|
||||
options: languageOptions,
|
||||
value: interactiveSettings.sourceLang,
|
||||
onSelect: (value: string) => setInteractiveSettings({ sourceLang: value }),
|
||||
}), [interactiveSettings.sourceLang, languageOptions, setInteractiveSettings]);
|
||||
|
||||
const targetLangSelect = useMemo(() => ({
|
||||
options: sortedTargetLanguages,
|
||||
value: interactiveSettings.targetLang,
|
||||
onSelect: (value: string) => setInteractiveSettings({ targetLang: value }),
|
||||
}), [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 isLLMProvider = LLM_PROVIDERS.includes(interactiveSettings.provider);
|
||||
const showApiKeyInput = ['GEMINI', 'OPENAI', 'CLAUDE', 'OPENROUTER', 'CUSTOM'].includes(interactiveSettings.provider);
|
||||
const isOverlayMode = interactiveSettings.uiMode === 'OVERLAY';
|
||||
const currentProvider = interactiveSettings.provider;
|
||||
const currentApiKey = interactiveSettings.providerApiKeys?.[currentProvider] ?? '';
|
||||
const currentUrl = interactiveSettings.providerUrls?.[currentProvider] ?? DEFAULT_PROVIDER_URLS[currentProvider] ?? '';
|
||||
const currentModel = interactiveSettings.providerModels?.[currentProvider] ?? DEFAULT_PROVIDER_MODELS[currentProvider] ?? '';
|
||||
const currentPrompt = interactiveSettings.providerPrompts?.[currentProvider] ?? DEFAULT_PROVIDER_PROMPTS[currentProvider] ?? '';
|
||||
|
||||
const pauseOnTranslateToggle = useMemo(() => ({
|
||||
checked: interactiveSettings.pauseOnTranslate,
|
||||
onClick: () => setInteractiveSettings({ pauseOnTranslate: !interactiveSettings.pauseOnTranslate }),
|
||||
}), [interactiveSettings.pauseOnTranslate, setInteractiveSettings]);
|
||||
|
||||
const pauseOnCopyToggle = useMemo(() => ({
|
||||
checked: interactiveSettings.pauseOnCopy,
|
||||
onClick: () => setInteractiveSettings({ pauseOnCopy: !interactiveSettings.pauseOnCopy }),
|
||||
}), [interactiveSettings.pauseOnCopy, setInteractiveSettings]);
|
||||
|
||||
const textareaStyle: React.CSSProperties = {
|
||||
width: '100%',
|
||||
minHeight: '100px',
|
||||
padding: '10px 14px',
|
||||
fontSize: '0.9rem',
|
||||
resize: 'vertical' as const,
|
||||
fontFamily: 'inherit',
|
||||
borderRadius: '1rem',
|
||||
border: 'none',
|
||||
backgroundColor: 'var(--overlay-color)',
|
||||
color: 'var(--primary-foreground-color)',
|
||||
};
|
||||
|
||||
return (
|
||||
<Section ref={ref} label={'SETTINGS_NAV_PLAYER'}>
|
||||
{/* Interactive Subtitles Settings */}
|
||||
<Category icon={'subtitles'} label={'Interactive Subtitles'}>
|
||||
<Option label={'Subtitle Mode'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...uiModeSelect}
|
||||
/>
|
||||
</Option>
|
||||
{isOverlayMode && (
|
||||
<>
|
||||
<Option label={'Click Action'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...clickActionSelect}
|
||||
/>
|
||||
</Option>
|
||||
{interactiveSettings.clickAction === 'TRANSLATE' && (
|
||||
<>
|
||||
<Option label={'Translation Provider'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...providerSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'Source Language'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...sourceLangSelect}
|
||||
/>
|
||||
</Option>
|
||||
{!isLLMProvider && (
|
||||
<Option label={'Target Language'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...targetLangSelect}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
{isLLMProvider && (
|
||||
<Option label={'Target Languages (Multi)'}>
|
||||
<Multiselect
|
||||
className={'multiselect'}
|
||||
{...multiTargetLangsSelect}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
{showApiKeyInput && (
|
||||
<Option label={'API Key'}>
|
||||
<Input
|
||||
type="password"
|
||||
inputMode='text'
|
||||
placeholder="Enter API Key"
|
||||
value={currentApiKey}
|
||||
onChange={(e) => setInteractiveSettings({
|
||||
providerApiKeys: {
|
||||
...interactiveSettings.providerApiKeys,
|
||||
[currentProvider]: e.target.value
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
{isLLMProvider && (
|
||||
<Option label={'Base URL'}>
|
||||
<Input
|
||||
type="url"
|
||||
placeholder={'https://api.example.com'}
|
||||
value={currentUrl}
|
||||
onChange={(e) => setInteractiveSettings({
|
||||
providerUrls: {
|
||||
...interactiveSettings.providerUrls,
|
||||
[currentProvider]: e.target.value
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
{isLLMProvider && (
|
||||
<Option label={'Model Name'}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={'model-name'}
|
||||
value={currentModel}
|
||||
onChange={(e) => setInteractiveSettings({
|
||||
providerModels: {
|
||||
...interactiveSettings.providerModels,
|
||||
[currentProvider]: e.target.value
|
||||
}
|
||||
})}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
{isLLMProvider && (
|
||||
<Option label={'Custom Prompt'}>
|
||||
<textarea
|
||||
placeholder='Define "{word}" briefly. Provide definitions in: {targetLangs}'
|
||||
value={currentPrompt}
|
||||
onChange={(e) => setInteractiveSettings({
|
||||
providerPrompts: {
|
||||
...interactiveSettings.providerPrompts,
|
||||
[currentProvider]: e.target.value
|
||||
}
|
||||
})}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
style={textareaStyle}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
<Option label={'Pause on Translate'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...pauseOnTranslateToggle}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'Translation Cache'}>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
||||
<button
|
||||
onClick={() => setShowCache(!showCache)}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
backgroundColor: 'var(--overlay-color)',
|
||||
color: 'var(--primary-foreground-color)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
{showCache ? 'Hide' : 'Show'} ({cacheEntries.length})
|
||||
</button>
|
||||
<button
|
||||
onClick={exportCache}
|
||||
style={{
|
||||
padding: '0.1rem 0.1rem',
|
||||
borderRadius: '0.2rem',
|
||||
border: 'none',
|
||||
backgroundColor: 'var(--overlay-color)',
|
||||
color: 'var(--primary-foreground-color)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
title="Export cache as JSON"
|
||||
>
|
||||
📤 Export
|
||||
</button>
|
||||
<button
|
||||
onClick={importCache}
|
||||
style={{
|
||||
padding: '0.1rem 0.1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
backgroundColor: 'var(--overlay-color)',
|
||||
color: 'var(--primary-foreground-color)',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.8rem'
|
||||
}}
|
||||
title="Import cache from JSON"
|
||||
>
|
||||
📥 Import
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (window.confirm('Delete all cached translations?')) {
|
||||
clearCache();
|
||||
setCacheRefresh((prev) => prev + 1);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
padding: '0.5rem 1rem',
|
||||
borderRadius: '0.5rem',
|
||||
border: 'none',
|
||||
backgroundColor: '#ff4444',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.9rem'
|
||||
}}
|
||||
>
|
||||
Clear All
|
||||
</button>
|
||||
</div>
|
||||
</Option>
|
||||
{showCache && (
|
||||
<Option label={''}>
|
||||
<div style={{ width: '100%' }}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search cache..."
|
||||
value={cacheSearch}
|
||||
onChange={(e) => setCacheSearch(e.target.value)}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
/>
|
||||
<div style={{
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
backgroundColor: 'var(--overlay-color)',
|
||||
borderRadius: '0.5rem',
|
||||
padding: '0.5rem'
|
||||
}}>
|
||||
{filteredCache.length === 0 ? (
|
||||
<div style={{ padding: '1rem', textAlign: 'center', color: '#888' }}>
|
||||
{cacheSearch ? 'No matches found' : 'Cache is empty'}
|
||||
</div>
|
||||
) : (
|
||||
filteredCache.map((entry, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
backgroundColor: 'rgba(255,255,255,0.05)',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.85rem'
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 'bold', color: '#FFD700', marginBottom: '0.25rem' }}>
|
||||
{entry.word}
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: '0.75rem', marginBottom: '0.25rem' }}>
|
||||
Lang: {entry.lang}
|
||||
</div>
|
||||
<div style={{ color: '#ccc', wordBreak: 'break-word' }}>
|
||||
{entry.translation}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteCacheEntry(entry.word, entry.lang)}
|
||||
style={{
|
||||
marginLeft: '0.5rem',
|
||||
padding: '0.25rem 0.5rem',
|
||||
borderRadius: '0.25rem',
|
||||
border: 'none',
|
||||
backgroundColor: '#ff4444',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
fontSize: '0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
title="Delete"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{interactiveSettings.clickAction === 'COPY' && (
|
||||
<Option label={'Pause on Copy'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...pauseOnCopyToggle}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
{interactiveSettings.clickAction === 'WEBHOOK' && (
|
||||
<Option label={'Webhook URL'}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="https://example.com/webhook"
|
||||
value={interactiveSettings.webhookUrl}
|
||||
onChange={(e) => setInteractiveSettings({ webhookUrl: e.target.value })}
|
||||
/>
|
||||
</Option>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Category>
|
||||
|
||||
<Category icon={'subtitles'} label={'SETTINGS_SECTION_SUBTITLES'}>
|
||||
<Option label={'SETTINGS_SUBTITLES_LANGUAGE'}>
|
||||
<MultiselectMenu
|
||||
|
|
@ -124,30 +648,30 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
|||
</Option>
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_HWDEC'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...hardwareDecodingToggle}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_HWDEC'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...hardwareDecodingToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
{
|
||||
shell.active && platform.name === 'windows' &&
|
||||
<Option label={'SETTINGS_VIDEO_MODE'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...videoModeSelect}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_VIDEO_MODE'}>
|
||||
<MultiselectMenu
|
||||
className={'multiselect'}
|
||||
{...videoModeSelect}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
{
|
||||
shell.active &&
|
||||
<Option label={'SETTINGS_PAUSE_MINIMIZED'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...pauseOnMinimizeToggle}
|
||||
/>
|
||||
</Option>
|
||||
<Option label={'SETTINGS_PAUSE_MINIMIZED'}>
|
||||
<Toggle
|
||||
tabIndex={-1}
|
||||
{...pauseOnMinimizeToggle}
|
||||
/>
|
||||
</Option>
|
||||
}
|
||||
</Category>
|
||||
</Section>
|
||||
|
|
@ -155,3 +679,4 @@ const Player = forwardRef<HTMLDivElement, Props>(({ profile }: Props, ref) => {
|
|||
});
|
||||
|
||||
export default Player;
|
||||
|
||||
|
|
|
|||
161
src/routes/Settings/Player/useInteractiveSettings.ts
Normal file
161
src/routes/Settings/Player/useInteractiveSettings.ts
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
|
||||
export type ClickAction = 'TRANSLATE' | 'COPY' | 'WEBHOOK';
|
||||
export type TranslationProvider = 'GOOGLE' | 'GEMINI' | 'OPENAI' | 'CLAUDE' | 'OLLAMA' | 'OPENROUTER' | 'CUSTOM';
|
||||
export type SubtitleUIMode = 'OVERLAY' | 'NATIVE';
|
||||
|
||||
// Default base URLs for LLM providers
|
||||
export const DEFAULT_PROVIDER_URLS: Record<string, string> = {
|
||||
GEMINI: 'https://generativelanguage.googleapis.com/v1beta',
|
||||
OPENAI: 'https://api.openai.com/v1',
|
||||
CLAUDE: 'https://api.anthropic.com/v1',
|
||||
OLLAMA: 'http://localhost:11434',
|
||||
OPENROUTER: 'https://openrouter.ai/api/v1',
|
||||
CUSTOM: '',
|
||||
};
|
||||
|
||||
// Default model names for LLM providers
|
||||
export const DEFAULT_PROVIDER_MODELS: Record<string, string> = {
|
||||
GEMINI: 'gemma-3-27b-it',
|
||||
OPENAI: 'gpt-4o-mini',
|
||||
CLAUDE: 'claude-sonnet-4-20250514',
|
||||
OLLAMA: 'llama3.2',
|
||||
OPENROUTER: 'openai/gpt-4o-mini',
|
||||
CUSTOM: '',
|
||||
};
|
||||
|
||||
// Default prompts for LLM providers
|
||||
export const DEFAULT_PROVIDER_PROMPTS: Record<string, string> = {
|
||||
GEMINI: '"{word}" - give one or more translations in {targetLangs}.\n-Use plain text.\n-No additional text except translations.\nFormat:\n[lang]: translation1, translation2...',
|
||||
OPENAI: 'Translate "{word}" to {targetLangs}. Provide concise translations only.\nFormat:\n[lang]: translation1, translation2...',
|
||||
CLAUDE: 'Translate "{word}" into {targetLangs}. Be concise and provide only translations.\nFormat:\n[lang]: translation1, translation2...',
|
||||
OLLAMA: 'Translate "{word}" to {targetLangs}. Return only translations, no explanations.\nFormat:\n[lang]: translation1, translation2...',
|
||||
OPENROUTER: 'Translate "{word}" to {targetLangs}. Provide only translations.\nFormat:\n[lang]: translation1, translation2...',
|
||||
CUSTOM: '"{word}" - give one or more translations in {targetLangs}.\n-Use plain text.\n-No additional text except translations.\nFormat:\n[lang]: translation1, translation2...',
|
||||
};
|
||||
|
||||
export interface InteractiveSettings {
|
||||
uiMode: SubtitleUIMode;
|
||||
clickAction: ClickAction;
|
||||
provider: TranslationProvider;
|
||||
sourceLang: string;
|
||||
targetLang: string;
|
||||
targetLangs: string[]; // Multiple target languages for LLMs
|
||||
apiKey: string; // Deprecated: kept for backward compatibility
|
||||
providerApiKeys: Record<string, string>; // API keys per provider
|
||||
providerUrls: Record<string, string>; // Base URLs per provider
|
||||
providerModels: Record<string, string>; // Model names per provider
|
||||
providerPrompts: Record<string, string>; // Custom prompts per provider
|
||||
llmPrompt: string; // Deprecated: kept for backward compatibility
|
||||
webhookUrl: string;
|
||||
pauseOnTranslate: boolean;
|
||||
pauseOnCopy: boolean;
|
||||
}
|
||||
|
||||
// Translation cache: word -> { lang -> translation }
|
||||
export type TranslationCache = Record<string, Record<string, string>>;
|
||||
|
||||
const STORAGE_KEY = 'stremio_interactive_subtitles_settings';
|
||||
const CACHE_KEY = 'stremio_interactive_subtitles_cache';
|
||||
|
||||
const DEFAULT_SETTINGS: InteractiveSettings = {
|
||||
uiMode: 'OVERLAY',
|
||||
clickAction: 'TRANSLATE',
|
||||
provider: 'GOOGLE',
|
||||
sourceLang: 'auto',
|
||||
targetLang: 'eng',
|
||||
targetLangs: ['eng'],
|
||||
apiKey: '',
|
||||
providerApiKeys: {},
|
||||
providerUrls: { ...DEFAULT_PROVIDER_URLS },
|
||||
providerModels: { ...DEFAULT_PROVIDER_MODELS },
|
||||
providerPrompts: { ...DEFAULT_PROVIDER_PROMPTS },
|
||||
llmPrompt: '"{word}" - give one or more translations in {targetLangs}.\n-Use plain text. \n -No additional text except translations. Format: \n[lang]: translation 1, translation2 ...',
|
||||
webhookUrl: '',
|
||||
pauseOnTranslate: true,
|
||||
pauseOnCopy: false,
|
||||
};
|
||||
|
||||
export const useInteractiveSettings = () => {
|
||||
const [settings, setSettingsState] = useState<InteractiveSettings>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
return { ...DEFAULT_SETTINGS, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load interactive settings:', e);
|
||||
}
|
||||
return DEFAULT_SETTINGS;
|
||||
});
|
||||
|
||||
const setSettings = useCallback((newSettings: Partial<InteractiveSettings>) => {
|
||||
setSettingsState((prev) => {
|
||||
const updated = { ...prev, ...newSettings };
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
} catch (e) {
|
||||
console.error('Failed to save interactive settings:', e);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetSettings = useCallback(() => {
|
||||
setSettingsState(DEFAULT_SETTINGS);
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
} catch (e) {
|
||||
console.error('Failed to reset interactive settings:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Cache management
|
||||
const getCache = useCallback((): TranslationCache => {
|
||||
try {
|
||||
const cached = localStorage.getItem(CACHE_KEY);
|
||||
return cached ? JSON.parse(cached) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getCachedTranslation = useCallback((word: string, lang: string): string | null => {
|
||||
const cache = getCache();
|
||||
return cache[word.toLowerCase()]?.[lang] || null;
|
||||
}, [getCache]);
|
||||
|
||||
const setCachedTranslation = useCallback((word: string, lang: string, translation: string) => {
|
||||
try {
|
||||
const cache = getCache();
|
||||
const wordKey = word.toLowerCase();
|
||||
if (!cache[wordKey]) {
|
||||
cache[wordKey] = {};
|
||||
}
|
||||
cache[wordKey][lang] = translation;
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(cache));
|
||||
} catch (e) {
|
||||
console.error('Failed to cache translation:', e);
|
||||
}
|
||||
}, [getCache]);
|
||||
|
||||
const clearCache = useCallback(() => {
|
||||
try {
|
||||
localStorage.removeItem(CACHE_KEY);
|
||||
} catch (e) {
|
||||
console.error('Failed to clear cache:', e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
settings,
|
||||
setSettings,
|
||||
resetSettings,
|
||||
DEFAULT_SETTINGS,
|
||||
getCachedTranslation,
|
||||
setCachedTranslation,
|
||||
clearCache
|
||||
};
|
||||
};
|
||||
|
||||
export default useInteractiveSettings;
|
||||
Loading…
Reference in a new issue