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:
gh-61 2025-12-28 00:18:55 +01:00
parent c8dfc31e6b
commit 3bf3dbc3b7
13 changed files with 2311 additions and 201 deletions

View 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;

View file

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

View 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;
}
}
}

View file

@ -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 = {

View file

@ -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);
}
}

View file

@ -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,

View 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;

View 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;

View 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"
>
&#9998;
</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"
>
&#128269;
</button>
)}
{/* Close button */}
<button style={styles.closeButton} onClick={onClose} title="Close">&#10005;</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 &rarr;
</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"
>
&#128465;
</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()}`}
>
&#9881;
</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;

View file

@ -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'])}>

View file

@ -115,6 +115,7 @@ html:not(.active-slider-within) {
}
&.menu-layer {
z-index: 10;
top: initial;
left: initial;
right: 4rem;

View file

@ -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"
>
&#10005;
</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;

View 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;