From 5c869e553a95e99983cb8ae584cb831b6fe64e05 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Fri, 10 Oct 2025 16:23:46 -0400 Subject: [PATCH 01/23] Add side panel functionality and related configurations - Updated manifest.json to include side panel permissions and default path. - Enhanced vite.config.js to support side panel input and alias. - Introduced new side panel settings in configSchema.js for user preferences and behavior. - Added side panel action constants in messageActions.js for improved messaging capabilities. --- .gitignore | 1 + config/configSchema.js | 21 + .../shared/constants/messageActions.js | 9 + manifest.json | 5 +- sidepanel/SidePanelApp.jsx | 105 +++++ sidepanel/components/TabNavigator.jsx | 119 ++++++ sidepanel/components/tabs/AIAnalysisTab.jsx | 388 ++++++++++++++++++ sidepanel/components/tabs/WordsListsTab.jsx | 305 ++++++++++++++ sidepanel/hooks/SidePanelContext.jsx | 85 ++++ sidepanel/hooks/useAIAnalysis.js | 253 ++++++++++++ sidepanel/hooks/useSettings.js | 79 ++++ sidepanel/hooks/useSidePanelCommunication.js | 187 +++++++++ sidepanel/hooks/useTheme.js | 92 +++++ sidepanel/hooks/useWordSelection.js | 234 +++++++++++ sidepanel/sidepanel.css | 222 ++++++++++ sidepanel/sidepanel.html | 17 + sidepanel/sidepanel.jsx | 11 + vite.config.js | 2 + 18 files changed, 2134 insertions(+), 1 deletion(-) create mode 100644 sidepanel/SidePanelApp.jsx create mode 100644 sidepanel/components/TabNavigator.jsx create mode 100644 sidepanel/components/tabs/AIAnalysisTab.jsx create mode 100644 sidepanel/components/tabs/WordsListsTab.jsx create mode 100644 sidepanel/hooks/SidePanelContext.jsx create mode 100644 sidepanel/hooks/useAIAnalysis.js create mode 100644 sidepanel/hooks/useSettings.js create mode 100644 sidepanel/hooks/useSidePanelCommunication.js create mode 100644 sidepanel/hooks/useTheme.js create mode 100644 sidepanel/hooks/useWordSelection.js create mode 100644 sidepanel/sidepanel.css create mode 100644 sidepanel/sidepanel.html create mode 100644 sidepanel/sidepanel.jsx diff --git a/.gitignore b/.gitignore index 4bb26a0..339fe46 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ WARP.md # Chrome extensions cannot load directories beginning with underscore __tests__/ dist/ +UI_DESIGN/ \ No newline at end of file diff --git a/config/configSchema.js b/config/configSchema.js index a27f663..f0d76d4 100644 --- a/config/configSchema.js +++ b/config/configSchema.js @@ -197,6 +197,27 @@ export const configSchema = { aiContextRetryDelay: { defaultValue: 2000, type: Number, scope: 'sync' }, aiContextDebugMode: { defaultValue: false, type: Boolean, scope: 'local' }, + // --- Side Panel Settings --- + // Core side panel toggles + sidePanelEnabled: { defaultValue: true, type: Boolean, scope: 'sync' }, + sidePanelUseSidePanel: { defaultValue: true, type: Boolean, scope: 'sync' }, // Use side panel instead of modal + + // UI preferences + sidePanelDefaultTab: { defaultValue: 'ai-analysis', type: String, scope: 'sync' }, // 'ai-analysis' or 'words-lists' + sidePanelTheme: { defaultValue: 'auto', type: String, scope: 'sync' }, // 'auto', 'light', or 'dark' + + // Feature toggles + sidePanelWordsListsEnabled: { defaultValue: false, type: Boolean, scope: 'sync' }, + + // Advanced behavior settings + sidePanelPersistAcrossTabs: { defaultValue: true, type: Boolean, scope: 'sync' }, + sidePanelAutoPauseVideo: { defaultValue: true, type: Boolean, scope: 'sync' }, + sidePanelAutoResumeVideo: { defaultValue: false, type: Boolean, scope: 'sync' }, + sidePanelAutoOpen: { defaultValue: true, type: Boolean, scope: 'sync' }, // Auto-open on word click + + // State persistence (local storage for per-tab state) + sidePanelLastTabState: { defaultValue: {}, type: Object, scope: 'local' }, + // --- Debug Settings (local storage for immediate availability) --- debugMode: { defaultValue: false, type: Boolean, scope: 'local' }, // Debug logging mode loggingLevel: { defaultValue: 3, type: Number, scope: 'sync' }, // Logging level: 0=OFF, 1=ERROR, 2=WARN, 3=INFO, 4=DEBUG diff --git a/content_scripts/shared/constants/messageActions.js b/content_scripts/shared/constants/messageActions.js index befdfe6..d71697c 100644 --- a/content_scripts/shared/constants/messageActions.js +++ b/content_scripts/shared/constants/messageActions.js @@ -19,4 +19,13 @@ export const MessageActions = { TOGGLE_SUBTITLES: 'toggleSubtitles', CONFIG_CHANGED: 'configChanged', LOGGING_LEVEL_CHANGED: 'LOGGING_LEVEL_CHANGED', + // Side Panel actions + SIDEPANEL_OPEN: 'sidePanelOpen', + SIDEPANEL_CLOSE: 'sidePanelClose', + SIDEPANEL_WORD_SELECTED: 'sidePanelWordSelected', + SIDEPANEL_REQUEST_ANALYSIS: 'sidePanelRequestAnalysis', + SIDEPANEL_PAUSE_VIDEO: 'sidePanelPauseVideo', + SIDEPANEL_RESUME_VIDEO: 'sidePanelResumeVideo', + SIDEPANEL_GET_STATE: 'sidePanelGetState', + SIDEPANEL_UPDATE_STATE: 'sidePanelUpdateState', }; diff --git a/manifest.json b/manifest.json index 9348375..068a748 100644 --- a/manifest.json +++ b/manifest.json @@ -4,7 +4,7 @@ "version": "2.4.1", "description": "__MSG_appDesc__", "default_locale": "en", - "permissions": ["storage", "activeTab"], + "permissions": ["storage", "activeTab", "sidePanel"], "host_permissions": [ "*://*.disneyplus.com/*", "*://*.netflix.com/*", @@ -117,5 +117,8 @@ "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" + }, + "side_panel": { + "default_path": "sidepanel/sidepanel.html" } } diff --git a/sidepanel/SidePanelApp.jsx b/sidepanel/SidePanelApp.jsx new file mode 100644 index 0000000..1cc00a1 --- /dev/null +++ b/sidepanel/SidePanelApp.jsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react'; +import { TabNavigator } from './components/TabNavigator.jsx'; +import { AIAnalysisTab } from './components/tabs/AIAnalysisTab.jsx'; +import { WordsListsTab } from './components/tabs/WordsListsTab.jsx'; +import { useTheme } from './hooks/useTheme.js'; +import { useSettings } from './hooks/useSettings.js'; +import { SidePanelProvider } from './hooks/SidePanelContext.jsx'; + +/** + * Main Side Panel Application Component + * + * Provides a tabbed interface for AI Context Analysis and Word Lists features. + * Manages theme, settings, and global state for the side panel. + */ +export function SidePanelApp() { + const [activeTab, setActiveTab] = useState('ai-analysis'); + const { theme, toggleTheme } = useTheme(); + const { settings, loading: settingsLoading } = useSettings(); + + // Apply theme class to body + useEffect(() => { + if (theme === 'dark') { + document.body.classList.add('dark'); + } else { + document.body.classList.remove('dark'); + } + }, [theme]); + + // Load default tab from settings + useEffect(() => { + if (settings.sidePanelDefaultTab && !settingsLoading) { + setActiveTab(settings.sidePanelDefaultTab); + } + }, [settings.sidePanelDefaultTab, settingsLoading]); + + // Show loading state while settings are loading + if (settingsLoading) { + return ( +
+
+
+

+ Loading... +

+
+
+ ); + } + + return ( + +
+ +
+ {activeTab === 'ai-analysis' && } + {activeTab === 'words-lists' && } +
+
+ + +
+ ); +} diff --git a/sidepanel/components/TabNavigator.jsx b/sidepanel/components/TabNavigator.jsx new file mode 100644 index 0000000..987b8c7 --- /dev/null +++ b/sidepanel/components/TabNavigator.jsx @@ -0,0 +1,119 @@ +import React from 'react'; + +/** + * Tab Navigator Component + * + * Provides horizontal tab navigation matching UI_DESIGN specifications. + * Supports sticky positioning with backdrop blur effect. + */ +export function TabNavigator({ activeTab, onTabChange, settings }) { + const tabs = [ + { + id: 'ai-analysis', + label: 'AI Analysis', + enabled: true, + }, + { + id: 'words-lists', + label: 'Words Lists', + enabled: settings.sidePanelWordsListsEnabled || false, + }, + ]; + + return ( + <> +
+
+ +
+
+ + + + ); +} diff --git a/sidepanel/components/tabs/AIAnalysisTab.jsx b/sidepanel/components/tabs/AIAnalysisTab.jsx new file mode 100644 index 0000000..d1789b1 --- /dev/null +++ b/sidepanel/components/tabs/AIAnalysisTab.jsx @@ -0,0 +1,388 @@ +import React from 'react'; +import { useSidePanelContext } from '../../hooks/SidePanelContext.jsx'; +import { useAIAnalysis } from '../../hooks/useAIAnalysis.js'; +import { useWordSelection } from '../../hooks/useWordSelection.js'; + +/** + * AI Analysis Tab + * + * Main tab for AI context analysis functionality. + * Displays word selection interface and analysis results. + */ +export function AIAnalysisTab() { + const { + selectedWords, + analysisResult, + isAnalyzing, + error, + } = useSidePanelContext(); + + const { analyzeWords, retryAnalysis, settings } = useAIAnalysis(); + const { toggleWord, clearSelection } = useWordSelection(); + + const handleAnalyze = () => { + if (selectedWords.size > 0) { + analyzeWords(); + } + }; + + const handleWordRemove = (word) => { + toggleWord(word); + }; + + return ( + <> +
+
+

AI Analysis

+ +
+ +
+ +
+
+ {Array.from(selectedWords).map((word) => ( + + {word} + + + ))} + {selectedWords.size === 0 && ( + + Click on subtitle words to add them for analysis... + + )} +
+
+
+ + {isAnalyzing && ( +
+
+

Analyzing...

+
+ )} + + {error && ( +
+ error +
+

{error}

+ +
+
+ )} + + {analysisResult && !isAnalyzing && ( +
+

+ Results for "{Array.from(selectedWords).join('", "')}" +

+
+ {analysisResult.culturalContext && ( +
+

Cultural Context

+

+ {analysisResult.culturalContext} +

+
+ )} + {analysisResult.historicalContext && ( +
+

Historical Context

+

+ {analysisResult.historicalContext} +

+
+ )} + {analysisResult.linguisticAnalysis && ( +
+

Linguistic Analysis

+

+ {analysisResult.linguisticAnalysis} +

+
+ )} +
+
+ )} +
+ + + + ); +} diff --git a/sidepanel/components/tabs/WordsListsTab.jsx b/sidepanel/components/tabs/WordsListsTab.jsx new file mode 100644 index 0000000..542187e --- /dev/null +++ b/sidepanel/components/tabs/WordsListsTab.jsx @@ -0,0 +1,305 @@ +import React from 'react'; + +/** + * Words Lists Tab + * + * Displays user's saved word lists with filtering and starring capabilities. + * Currently shows placeholder UI as feature is disabled by default. + */ +export function WordsListsTab() { + // Sample data for UI demonstration + const sampleWords = [ + { + word: 'Serendipity', + translation: + 'The occurrence and development of events by chance in a happy or beneficial way.', + starred: true, + }, + { + word: 'Ephemeral', + translation: 'Lasting for a very short time.', + starred: false, + }, + { + word: 'Mellifluous', + translation: + '(Of a voice or words) sweet or musical; pleasant to hear.', + starred: false, + }, + ]; + + return ( + <> +
+

My Words

+ +
+
+ + + unfold_more + +
+ +
+ +
+ + info + +

+ Words Lists feature coming soon! +
+ This feature is currently in development. Enable it in + Settings to try the preview. +

+
+ +
+ {sampleWords.map((item, index) => ( + e.preventDefault()} + > +
+

{item.word}

+

+ {item.translation} +

+
+
+ +
+
+ ))} +
+
+ + + + ); +} diff --git a/sidepanel/hooks/SidePanelContext.jsx b/sidepanel/hooks/SidePanelContext.jsx new file mode 100644 index 0000000..6aad772 --- /dev/null +++ b/sidepanel/hooks/SidePanelContext.jsx @@ -0,0 +1,85 @@ +import React, { createContext, useContext, useState, useEffect } from 'react'; + +/** + * Side Panel Context + * + * Provides global state management for: + * - Selected words for AI analysis + * - Analysis results + * - Loading states + * - Error handling + */ + +const SidePanelContext = createContext(null); + +export function SidePanelProvider({ children }) { + const [selectedWords, setSelectedWords] = useState(new Set()); + const [analysisResult, setAnalysisResult] = useState(null); + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [error, setError] = useState(null); + const [sourceLanguage, setSourceLanguage] = useState('en'); + const [targetLanguage, setTargetLanguage] = useState('zh-CN'); + + // Clear error after 5 seconds + useEffect(() => { + if (error) { + const timer = setTimeout(() => setError(null), 5000); + return () => clearTimeout(timer); + } + }, [error]); + + const addWord = (word) => { + setSelectedWords((prev) => new Set([...prev, word])); + }; + + const removeWord = (word) => { + setSelectedWords((prev) => { + const newSet = new Set(prev); + newSet.delete(word); + return newSet; + }); + }; + + const clearWords = () => { + setSelectedWords(new Set()); + }; + + const clearAnalysis = () => { + setAnalysisResult(null); + setError(null); + }; + + const value = { + selectedWords, + addWord, + removeWord, + clearWords, + analysisResult, + setAnalysisResult, + isAnalyzing, + setIsAnalyzing, + error, + setError, + clearAnalysis, + sourceLanguage, + setSourceLanguage, + targetLanguage, + setTargetLanguage, + }; + + return ( + + {children} + + ); +} + +export function useSidePanelContext() { + const context = useContext(SidePanelContext); + if (!context) { + throw new Error( + 'useSidePanelContext must be used within SidePanelProvider' + ); + } + return context; +} diff --git a/sidepanel/hooks/useAIAnalysis.js b/sidepanel/hooks/useAIAnalysis.js new file mode 100644 index 0000000..4da5c04 --- /dev/null +++ b/sidepanel/hooks/useAIAnalysis.js @@ -0,0 +1,253 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useSidePanelContext } from './SidePanelContext.jsx'; + +/** + * AI Analysis Hook + * + * Provides AI context analysis functionality for the side panel. + * Integrates with the background service worker and existing AIContextProvider. + * + * Features: + * - Request AI analysis for selected words + * - Handle loading states and errors + * - Cache results for performance + * - Support retry logic + */ +export function useAIAnalysis() { + const { + selectedWords, + analysisResult, + setAnalysisResult, + isAnalyzing, + setIsAnalyzing, + error, + setError, + sourceLanguage, + targetLanguage, + } = useSidePanelContext(); + + const [settings, setSettings] = useState(null); + const cacheRef = useRef(new Map()); + const abortControllerRef = useRef(null); + + // Load settings + useEffect(() => { + const loadSettings = async () => { + try { + const result = await chrome.storage.sync.get([ + 'aiContextEnabled', + 'aiContextProvider', + 'aiContextTypes', + 'aiContextTimeout', + 'aiContextCacheEnabled', + ]); + setSettings(result); + } catch (err) { + console.error('Failed to load AI settings:', err); + } + }; + loadSettings(); + }, []); + + /** + * Generate cache key for analysis request + */ + const getCacheKey = useCallback((words, contextTypes, language) => { + const sortedWords = Array.from(words).sort().join(','); + const sortedTypes = contextTypes.sort().join(','); + return `${sortedWords}:${sortedTypes}:${language}`; + }, []); + + /** + * Check if result is in cache + */ + const getCachedResult = useCallback( + (words, contextTypes, language) => { + if (!settings?.aiContextCacheEnabled) { + return null; + } + + const key = getCacheKey(words, contextTypes, language); + const cached = cacheRef.current.get(key); + + if (cached && Date.now() - cached.timestamp < 3600000) { + // 1 hour cache + return cached.result; + } + + return null; + }, + [settings, getCacheKey] + ); + + /** + * Store result in cache + */ + const setCachedResult = useCallback( + (words, contextTypes, language, result) => { + if (!settings?.aiContextCacheEnabled) { + return; + } + + const key = getCacheKey(words, contextTypes, language); + cacheRef.current.set(key, { + result, + timestamp: Date.now(), + }); + + // Clean up old cache entries (keep last 50) + if (cacheRef.current.size > 50) { + const oldestKey = cacheRef.current.keys().next().value; + cacheRef.current.delete(oldestKey); + } + }, + [settings, getCacheKey] + ); + + /** + * Request AI analysis for selected words + */ + const analyzeWords = useCallback( + async (customWords = null) => { + const wordsToAnalyze = customWords || selectedWords; + + if (!wordsToAnalyze || wordsToAnalyze.size === 0) { + setError('No words selected for analysis'); + return null; + } + + if (!settings?.aiContextEnabled) { + setError('AI Context analysis is disabled. Enable it in settings.'); + return null; + } + + // Check cache first + const contextTypes = settings?.aiContextTypes || [ + 'cultural', + 'historical', + 'linguistic', + ]; + const cachedResult = getCachedResult( + wordsToAnalyze, + contextTypes, + sourceLanguage + ); + + if (cachedResult) { + setAnalysisResult(cachedResult); + return cachedResult; + } + + // Cancel any existing request + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + abortControllerRef.current = new AbortController(); + + setIsAnalyzing(true); + setError(null); + setAnalysisResult(null); + + try { + const text = Array.from(wordsToAnalyze).join(' '); + + // Send request to background service worker + const response = await chrome.runtime.sendMessage({ + action: 'analyzeContext', + text, + contextTypes, + language: sourceLanguage, + targetLanguage: targetLanguage, + requestId: `sidepanel-${Date.now()}`, + }); + + // Check if request was aborted + if (abortControllerRef.current?.signal.aborted) { + return null; + } + + if (response && response.success) { + const result = response.result || response; + + // Store in cache + setCachedResult( + wordsToAnalyze, + contextTypes, + sourceLanguage, + result + ); + + setAnalysisResult(result); + return result; + } else { + const errorMsg = + response?.error || 'Analysis failed. Please try again.'; + setError(errorMsg); + return null; + } + } catch (err) { + // Check if error is due to abort + if (err.name === 'AbortError') { + return null; + } + + console.error('AI analysis error:', err); + const errorMsg = + err.message || 'An error occurred during analysis.'; + setError(errorMsg); + return null; + } finally { + setIsAnalyzing(false); + abortControllerRef.current = null; + } + }, + [ + selectedWords, + settings, + sourceLanguage, + targetLanguage, + setIsAnalyzing, + setError, + setAnalysisResult, + getCachedResult, + setCachedResult, + ] + ); + + /** + * Cancel ongoing analysis + */ + const cancelAnalysis = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + abortControllerRef.current = null; + } + setIsAnalyzing(false); + }, [setIsAnalyzing]); + + /** + * Clear cache + */ + const clearCache = useCallback(() => { + cacheRef.current.clear(); + }, []); + + /** + * Retry last analysis + */ + const retryAnalysis = useCallback(() => { + return analyzeWords(); + }, [analyzeWords]); + + return { + analyzeWords, + cancelAnalysis, + retryAnalysis, + clearCache, + isAnalyzing, + analysisResult, + error, + settings, + }; +} diff --git a/sidepanel/hooks/useSettings.js b/sidepanel/hooks/useSettings.js new file mode 100644 index 0000000..4e8c15a --- /dev/null +++ b/sidepanel/hooks/useSettings.js @@ -0,0 +1,79 @@ +import { useState, useEffect } from 'react'; + +/** + * Settings Management Hook + * + * Provides access to Chrome storage settings with real-time updates. + * Mirrors the pattern used in popup/options hooks. + */ +export function useSettings() { + const [settings, setSettings] = useState({ + // Side Panel defaults + sidePanelEnabled: true, + sidePanelDefaultTab: 'ai-analysis', + sidePanelTheme: 'auto', + sidePanelWordsListsEnabled: false, + sidePanelPersistAcrossTabs: true, + sidePanelAutoPauseVideo: true, + sidePanelAutoResumeVideo: false, + + // AI Context settings + aiContextEnabled: false, + aiContextProvider: 'openai', + aiContextTypes: ['cultural', 'historical', 'linguistic'], + aiContextTimeout: 30000, + + // Language settings + originalLanguage: 'en', + targetLanguage: 'zh-CN', + }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const loadSettings = async () => { + try { + const result = await chrome.storage.sync.get(null); + setSettings((prev) => ({ ...prev, ...result })); + } catch (err) { + console.error('Error loading settings:', err); + setError(err); + } finally { + setLoading(false); + } + }; + + loadSettings(); + + // Listen for settings changes + const handleStorageChange = (changes, areaName) => { + if (areaName === 'sync') { + const updates = {}; + for (const [key, { newValue }] of Object.entries(changes)) { + updates[key] = newValue; + } + setSettings((prev) => ({ ...prev, ...updates })); + } + }; + + chrome.storage.onChanged.addListener(handleStorageChange); + return () => chrome.storage.onChanged.removeListener(handleStorageChange); + }, []); + + const updateSetting = async (key, value) => { + try { + await chrome.storage.sync.set({ [key]: value }); + setSettings((prev) => ({ ...prev, [key]: value })); + } catch (err) { + console.error('Error updating setting:', err); + throw err; + } + }; + + return { + settings, + updateSetting, + loading, + error, + }; +} diff --git a/sidepanel/hooks/useSidePanelCommunication.js b/sidepanel/hooks/useSidePanelCommunication.js new file mode 100644 index 0000000..51e5fcb --- /dev/null +++ b/sidepanel/hooks/useSidePanelCommunication.js @@ -0,0 +1,187 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; + +/** + * Side Panel Communication Hook + * + * Manages all messaging between the side panel and: + * - Background service worker + * - Content scripts + * - Other extension components + * + * Provides a robust messaging API with retry logic and error handling. + */ +export function useSidePanelCommunication() { + const [isConnected, setIsConnected] = useState(false); + const [error, setError] = useState(null); + const messageListeners = useRef(new Map()); + const portRef = useRef(null); + + // Initialize long-lived connection to background + useEffect(() => { + try { + // Create a long-lived connection + const port = chrome.runtime.connect({ name: 'sidepanel' }); + portRef.current = port; + + port.onMessage.addListener((message) => { + const listeners = messageListeners.current.get(message.action); + if (listeners) { + listeners.forEach((callback) => callback(message.data)); + } + }); + + port.onDisconnect.addListener(() => { + console.log('Side panel disconnected from background'); + setIsConnected(false); + portRef.current = null; + }); + + setIsConnected(true); + + return () => { + if (portRef.current) { + portRef.current.disconnect(); + portRef.current = null; + } + }; + } catch (err) { + console.error('Failed to connect to background:', err); + setError(err); + setIsConnected(false); + } + }, []); + + /** + * Send a message to the background service worker + */ + const sendMessage = useCallback(async (action, data = {}) => { + try { + const response = await chrome.runtime.sendMessage({ + action, + data, + source: 'sidepanel', + timestamp: Date.now(), + }); + + if (response && response.error) { + throw new Error(response.error); + } + + return response; + } catch (err) { + console.error(`Failed to send message (${action}):`, err); + setError(err); + throw err; + } + }, []); + + /** + * Send a message to the active tab's content script + */ + const sendToActiveTab = useCallback(async (action, data = {}) => { + try { + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + + if (!tab || !tab.id) { + throw new Error('No active tab found'); + } + + const response = await chrome.tabs.sendMessage(tab.id, { + action, + data, + source: 'sidepanel', + timestamp: Date.now(), + }); + + if (response && response.error) { + throw new Error(response.error); + } + + return response; + } catch (err) { + console.error(`Failed to send message to tab (${action}):`, err); + setError(err); + throw err; + } + }, []); + + /** + * Send a message via long-lived connection + */ + const postMessage = useCallback((action, data = {}) => { + if (!portRef.current) { + console.error('No active connection to background'); + return; + } + + try { + portRef.current.postMessage({ + action, + data, + source: 'sidepanel', + timestamp: Date.now(), + }); + } catch (err) { + console.error(`Failed to post message (${action}):`, err); + setError(err); + } + }, []); + + /** + * Subscribe to messages of a specific action type + */ + const onMessage = useCallback((action, callback) => { + if (!messageListeners.current.has(action)) { + messageListeners.current.set(action, new Set()); + } + messageListeners.current.get(action).add(callback); + + // Return unsubscribe function + return () => { + const listeners = messageListeners.current.get(action); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + messageListeners.current.delete(action); + } + } + }; + }, []); + + /** + * Get the current active tab + */ + const getActiveTab = useCallback(async () => { + try { + const [tab] = await chrome.tabs.query({ + active: true, + currentWindow: true, + }); + return tab; + } catch (err) { + console.error('Failed to get active tab:', err); + return null; + } + }, []); + + /** + * Check if side panel is supported (Chrome 114+) + */ + const isSidePanelSupported = useCallback(() => { + return typeof chrome.sidePanel !== 'undefined'; + }, []); + + return { + isConnected, + error, + sendMessage, + sendToActiveTab, + postMessage, + onMessage, + getActiveTab, + isSidePanelSupported, + }; +} diff --git a/sidepanel/hooks/useTheme.js b/sidepanel/hooks/useTheme.js new file mode 100644 index 0000000..abadb8b --- /dev/null +++ b/sidepanel/hooks/useTheme.js @@ -0,0 +1,92 @@ +import { useState, useEffect } from 'react'; + +/** + * Theme Management Hook + * + * Handles dark mode detection and toggling. + * Respects user settings and system preferences. + */ +export function useTheme() { + const [theme, setTheme] = useState('light'); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const initializeTheme = async () => { + try { + // Load theme preference from settings + const result = await chrome.storage.sync.get(['sidePanelTheme']); + const savedTheme = result.sidePanelTheme || 'auto'; + + if (savedTheme === 'auto') { + // Detect system preference + const prefersDark = window.matchMedia( + '(prefers-color-scheme: dark)' + ).matches; + setTheme(prefersDark ? 'dark' : 'light'); + } else { + setTheme(savedTheme); + } + } catch (error) { + console.error('Error loading theme:', error); + // Fallback to system preference + const prefersDark = window.matchMedia( + '(prefers-color-scheme: dark)' + ).matches; + setTheme(prefersDark ? 'dark' : 'light'); + } finally { + setLoading(false); + } + }; + + initializeTheme(); + + // Listen for system theme changes + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handleChange = async (e) => { + const result = await chrome.storage.sync.get(['sidePanelTheme']); + const savedTheme = result.sidePanelTheme || 'auto'; + + if (savedTheme === 'auto') { + setTheme(e.matches ? 'dark' : 'light'); + } + }; + + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + }, []); + + const toggleTheme = async () => { + const newTheme = theme === 'dark' ? 'light' : 'dark'; + setTheme(newTheme); + + try { + await chrome.storage.sync.set({ sidePanelTheme: newTheme }); + } catch (error) { + console.error('Error saving theme:', error); + } + }; + + const setThemeMode = async (mode) => { + if (mode === 'auto') { + const prefersDark = window.matchMedia( + '(prefers-color-scheme: dark)' + ).matches; + setTheme(prefersDark ? 'dark' : 'light'); + } else { + setTheme(mode); + } + + try { + await chrome.storage.sync.set({ sidePanelTheme: mode }); + } catch (error) { + console.error('Error saving theme:', error); + } + }; + + return { + theme, + toggleTheme, + setThemeMode, + loading, + }; +} diff --git a/sidepanel/hooks/useWordSelection.js b/sidepanel/hooks/useWordSelection.js new file mode 100644 index 0000000..e8bf230 --- /dev/null +++ b/sidepanel/hooks/useWordSelection.js @@ -0,0 +1,234 @@ +import { useEffect, useCallback } from 'react'; +import { useSidePanelContext } from './SidePanelContext.jsx'; +import { useSidePanelCommunication } from './useSidePanelCommunication.js'; + +/** + * Word Selection Hook + * + * Manages word selection from subtitle clicks and synchronization + * with the side panel state. + * + * Features: + * - Listen for word selection events from content scripts + * - Sync selected words with side panel context + * - Handle word addition/removal + * - Manage selection state persistence + */ +export function useWordSelection() { + const { + selectedWords, + addWord, + removeWord, + clearWords, + setSourceLanguage, + setTargetLanguage, + } = useSidePanelContext(); + + const { onMessage, sendToActiveTab, getActiveTab } = + useSidePanelCommunication(); + + /** + * Handle word selected event from content script + */ + const handleWordSelected = useCallback( + (data) => { + if (!data || !data.word) { + console.warn('Invalid word selection data:', data); + return; + } + + const { word, sourceLanguage, targetLanguage, subtitleType } = data; + + console.log('Word selected:', { + word, + sourceLanguage, + targetLanguage, + subtitleType, + }); + + // Add word to selection + addWord(word); + + // Update language settings if provided + if (sourceLanguage) { + setSourceLanguage(sourceLanguage); + } + if (targetLanguage) { + setTargetLanguage(targetLanguage); + } + }, + [addWord, setSourceLanguage, setTargetLanguage] + ); + + /** + * Toggle word selection + */ + const toggleWord = useCallback( + (word) => { + if (selectedWords.has(word)) { + removeWord(word); + } else { + addWord(word); + } + }, + [selectedWords, addWord, removeWord] + ); + + /** + * Request word selection state from content script + */ + const syncWithContentScript = useCallback(async () => { + try { + const response = await sendToActiveTab('sidePanelGetState', {}); + + if (response && response.selectedWords) { + // Clear current selection + clearWords(); + + // Add words from content script + response.selectedWords.forEach((word) => addWord(word)); + + // Update language settings + if (response.sourceLanguage) { + setSourceLanguage(response.sourceLanguage); + } + if (response.targetLanguage) { + setTargetLanguage(response.targetLanguage); + } + } + } catch (err) { + console.error('Failed to sync with content script:', err); + } + }, [ + sendToActiveTab, + clearWords, + addWord, + setSourceLanguage, + setTargetLanguage, + ]); + + /** + * Clear selection and notify content script + */ + const clearSelection = useCallback(async () => { + clearWords(); + + try { + await sendToActiveTab('sidePanelUpdateState', { + selectedWords: [], + clearSelection: true, + }); + } catch (err) { + console.error('Failed to notify content script of clear:', err); + } + }, [clearWords, sendToActiveTab]); + + /** + * Load persisted selection on mount + */ + useEffect(() => { + const loadPersistedSelection = async () => { + try { + const result = await chrome.storage.local.get([ + 'sidePanelLastSelection', + 'sidePanelPersistAcrossTabs', + ]); + + if ( + result.sidePanelPersistAcrossTabs && + result.sidePanelLastSelection + ) { + const { words, sourceLanguage, targetLanguage, timestamp } = + result.sidePanelLastSelection; + + // Only restore if less than 1 hour old + if (Date.now() - timestamp < 3600000) { + words?.forEach((word) => addWord(word)); + if (sourceLanguage) setSourceLanguage(sourceLanguage); + if (targetLanguage) setTargetLanguage(targetLanguage); + } + } + } catch (err) { + console.error('Failed to load persisted selection:', err); + } + }; + + loadPersistedSelection(); + }, [addWord, setSourceLanguage, setTargetLanguage]); + + /** + * Persist selection on changes + */ + useEffect(() => { + const persistSelection = async () => { + try { + const result = await chrome.storage.sync.get([ + 'sidePanelPersistAcrossTabs', + ]); + + if (result.sidePanelPersistAcrossTabs) { + await chrome.storage.local.set({ + sidePanelLastSelection: { + words: Array.from(selectedWords), + sourceLanguage: + useSidePanelContext().sourceLanguage, + targetLanguage: + useSidePanelContext().targetLanguage, + timestamp: Date.now(), + }, + }); + } + } catch (err) { + console.error('Failed to persist selection:', err); + } + }; + + if (selectedWords.size > 0) { + persistSelection(); + } + }, [selectedWords]); + + /** + * Listen for word selection events + */ + useEffect(() => { + const unsubscribe = onMessage( + 'sidePanelWordSelected', + handleWordSelected + ); + + // Sync with content script on mount + syncWithContentScript(); + + return unsubscribe; + }, [onMessage, handleWordSelected, syncWithContentScript]); + + /** + * Listen for tab changes to update selection + */ + useEffect(() => { + const handleTabChange = async () => { + const tab = await getActiveTab(); + if (tab) { + await syncWithContentScript(); + } + }; + + chrome.tabs.onActivated.addListener(handleTabChange); + chrome.tabs.onUpdated.addListener(handleTabChange); + + return () => { + chrome.tabs.onActivated.removeListener(handleTabChange); + chrome.tabs.onUpdated.removeListener(handleTabChange); + }; + }, [getActiveTab, syncWithContentScript]); + + return { + selectedWords, + addWord, + removeWord, + toggleWord, + clearSelection, + syncWithContentScript, + }; +} diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css new file mode 100644 index 0000000..d165eb4 --- /dev/null +++ b/sidepanel/sidepanel.css @@ -0,0 +1,222 @@ +/** + * DualSub Side Panel - Base Styles + * Design system matching UI_DESIGN specifications + */ + +/* Import Inter font */ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +/* CSS Variables - Design System */ +:root { + /* Colors */ + --color-primary: #137fec; + --color-background-light: #f6f7f8; + --color-background-dark: #101922; + --color-surface-light: #ffffff; + --color-surface-dark: #192633; + --color-foreground-light: #101922; + --color-foreground-dark: #ffffff; + --color-subtle-light: #94adc9; + --color-subtle-dark: #92adc9; + --color-border-light: #e0e7f1; + --color-border-dark: #324d67; + --color-error: #ef4444; + --color-success: #10b981; + --color-warning: #f59e0b; + --color-star: #eab308; + + /* Typography */ + --font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + --font-size-xs: 0.75rem; + --font-size-sm: 0.875rem; + --font-size-base: 1rem; + --font-size-lg: 1.125rem; + --font-size-xl: 1.25rem; + --font-size-2xl: 1.5rem; + + /* Spacing */ + --spacing-1: 0.25rem; + --spacing-2: 0.5rem; + --spacing-3: 0.75rem; + --spacing-4: 1rem; + --spacing-5: 1.25rem; + --spacing-6: 1.5rem; + --spacing-8: 2rem; + + /* Border Radius */ + --radius-default: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + --radius-full: 9999px; + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); + + /* Z-index */ + --z-base: 1; + --z-sticky: 10; + --z-modal: 100; + --z-toast: 1000; +} + +/* Base Styles */ +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + margin: 0; + padding: 0; + overflow: hidden; +} + +body { + font-family: var(--font-family); + font-size: var(--font-size-base); + line-height: 1.5; + background: var(--color-background-light); + color: var(--color-foreground-light); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Dark mode */ +body.dark { + background: var(--color-background-dark); + color: var(--color-foreground-dark); +} + +#root { + height: 100%; + width: 100%; + overflow: hidden; +} + +/* Material Symbols configuration */ +.material-symbols-outlined { + font-variation-settings: + 'FILL' 0, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24; + user-select: none; +} + +.material-symbols-outlined.filled { + font-variation-settings: + 'FILL' 1, + 'wght' 400, + 'GRAD' 0, + 'opsz' 24; +} + +/* Utility Classes */ +.flex { + display: flex; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-center { + justify-content: center; +} + +.justify-between { + justify-content: space-between; +} + +.gap-2 { + gap: var(--spacing-2); +} + +.gap-3 { + gap: var(--spacing-3); +} + +.gap-4 { + gap: var(--spacing-4); +} + +.w-full { + width: 100%; +} + +.h-full { + height: 100%; +} + +/* Scrollbar Styling */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: var(--color-subtle-light); + border-radius: var(--radius-full); +} + +body.dark ::-webkit-scrollbar-thumb { + background: var(--color-subtle-dark); +} + +::-webkit-scrollbar-thumb:hover { + background: var(--color-primary); +} + +/* Focus Visible Styles */ +*:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} + +/* Button Reset */ +button { + font-family: inherit; + font-size: inherit; + border: none; + background: none; + cursor: pointer; + padding: 0; +} + +/* Input Reset */ +input, +textarea, +select { + font-family: inherit; + font-size: inherit; +} + +/* Link Reset */ +a { + color: inherit; + text-decoration: none; +} + +/* Selection Styling */ +::selection { + background: rgba(19, 127, 236, 0.2); + color: inherit; +} diff --git a/sidepanel/sidepanel.html b/sidepanel/sidepanel.html new file mode 100644 index 0000000..ca1513f --- /dev/null +++ b/sidepanel/sidepanel.html @@ -0,0 +1,17 @@ + + + + + + DualSub - AI Context Analysis + + + + +
+ + + diff --git a/sidepanel/sidepanel.jsx b/sidepanel/sidepanel.jsx new file mode 100644 index 0000000..349148c --- /dev/null +++ b/sidepanel/sidepanel.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { SidePanelApp } from './SidePanelApp.jsx'; +import './sidepanel.css'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); diff --git a/vite.config.js b/vite.config.js index 86460b0..d118b83 100644 --- a/vite.config.js +++ b/vite.config.js @@ -46,6 +46,7 @@ export default defineConfig({ input: { popup: resolve(__dirname, 'popup/popup.html'), options: resolve(__dirname, 'options/options.html'), + sidepanel: resolve(__dirname, 'sidepanel/sidepanel.html'), }, output: { entryFileNames: '[name]/[name].js', @@ -66,6 +67,7 @@ export default defineConfig({ '@': resolve(__dirname, './'), '@popup': resolve(__dirname, './popup'), '@options': resolve(__dirname, './options'), + '@sidepanel': resolve(__dirname, './sidepanel'), '@services': resolve(__dirname, './services'), '@utils': resolve(__dirname, './utils'), }, From 2e7061c6ea29fe60a0e0a2ed141c278ddd778ec5 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Fri, 10 Oct 2025 23:52:23 -0400 Subject: [PATCH 02/23] Implement side panel integration and enhance message handling - Added side panel service initialization and registration in background scripts. - Updated message handler to support new side panel actions for word selection and state management. - Enhanced content scripts to handle side panel interactions, including word selection and video control. - Introduced new message actions for side panel communication and state updates. - Improved user experience by integrating side panel features into existing workflows and UI components. --- background/handlers/messageHandler.js | 174 ++++++- background/index.js | 14 +- background/services/sidePanelService.js | 408 +++++++++++++++ content_scripts/core/BaseContentScript.js | 475 +++++++++++++++++- .../shared/constants/messageActions.js | 2 + .../shared/interactiveSubtitleFormatter.js | 114 ++++- options/OptionsApp.jsx | 16 + options/components/Sidebar.jsx | 2 + .../components/sections/AdvancedSection.jsx | 185 +++++++ .../components/sections/WordListsSection.jsx | 54 ++ sidepanel/SidePanelApp.jsx | 19 +- sidepanel/components/tabs/AIAnalysisTab.jsx | 117 +++-- sidepanel/hooks/useAIAnalysis.js | 42 +- sidepanel/hooks/useSidePanelCommunication.js | 19 + sidepanel/hooks/useWordSelection.js | 125 +++-- video_platforms/disneyPlusPlatform.js | 65 ++- 16 files changed, 1723 insertions(+), 108 deletions(-) create mode 100644 background/services/sidePanelService.js create mode 100644 options/components/sections/AdvancedSection.jsx create mode 100644 options/components/sections/WordListsSection.jsx diff --git a/background/handlers/messageHandler.js b/background/handlers/messageHandler.js index 1130123..866ddec 100644 --- a/background/handlers/messageHandler.js +++ b/background/handlers/messageHandler.js @@ -122,14 +122,16 @@ class MessageHandler { /** * Set service dependencies (will be injected after services are created) */ - setServices(translationService, subtitleService, aiContextService = null) { + setServices(translationService, subtitleService, aiContextService = null, sidePanelService = null) { this.translationService = translationService; this.subtitleService = subtitleService; this.aiContextService = aiContextService; + this.sidePanelService = sidePanelService; this.logger.debug('Services injected into message handler', { hasTranslation: !!translationService, hasSubtitle: !!subtitleService, hasAIContext: !!aiContextService, + hasSidePanel: !!sidePanelService, }); } @@ -216,6 +218,21 @@ class MessageHandler { sendResponse ); + case MessageActions.SIDEPANEL_OPEN: + return this.handleSidePanelOpenMessage(message, sender, sendResponse); + + case MessageActions.SIDEPANEL_WORD_SELECTED: + return this.handleSidePanelWordSelectedMessage(message, sender, sendResponse); + + case MessageActions.SIDEPANEL_SET_ANALYZING: + return this.handleSidePanelSetAnalyzingMessage(message, sender, sendResponse); + + case MessageActions.SIDEPANEL_PAUSE_VIDEO: + return this.handleSidePanelProxyToContent(message, sender, sendResponse); + + case MessageActions.SIDEPANEL_RESUME_VIDEO: + return this.handleSidePanelProxyToContent(message, sender, sendResponse); + default: this.logger.warn('Unknown message action', { action: message.action, @@ -967,6 +984,161 @@ class MessageHandler { return true; } + + /** + * Handle side panel open requests + */ + handleSidePanelOpenMessage(message, sender, sendResponse) { + if (!this.sidePanelService) { + sendResponse({ + success: false, + error: 'Side panel service not available', + }); + return true; + } + + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ + success: false, + error: 'No tab ID available', + }); + return true; + } + + this.logger.debug('Handling side panel open request', { tabId }); + + // Optionally store requested active tab/open reason before opening + try { + if (message.options?.activeTab || message.options?.openReason) { + this.sidePanelService.updateTabState(tabId, { + ...(message.options.activeTab + ? { activeTab: message.options.activeTab } + : {}), + ...(message.options.openReason + ? { openReason: message.options.openReason } + : {}), + }); + } + } catch (_) {} + + // Attempt to open the side panel immediately to preserve user gesture + this.sidePanelService + .openSidePanelImmediate(tabId, message.options || {}) + .then((result) => { + sendResponse(result); + }) + .catch((error) => { + this.logger.error('Failed to open side panel (immediate)', error, { tabId }); + sendResponse({ + success: false, + error: error.message || 'Failed to open side panel', + }); + }); + + return true; // Async response + } + + /** + * Handle word selection events from content scripts + */ + handleSidePanelWordSelectedMessage(message, sender, sendResponse) { + if (!this.sidePanelService) { + sendResponse({ + success: false, + error: 'Side panel service not available', + }); + return true; + } + + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ + success: false, + error: 'No tab ID available', + }); + return true; + } + + this.logger.debug('Handling word selection from content script', { + tabId, + word: message.word, + }); + + this.sidePanelService + .forwardWordSelection(tabId, message) + .then(() => { + sendResponse({ success: true }); + }) + .catch((error) => { + this.logger.error('Failed to forward word selection', error, { + tabId, + }); + sendResponse({ + success: false, + error: error.message || 'Failed to forward word selection', + }); + }); + + return true; // Async response + } + + /** + * Proxy a side panel or content message to the tab's content script + */ + handleSidePanelProxyToContent(message, sender, sendResponse) { + try { + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ success: false, error: 'No tab ID available' }); + return false; + } + chrome.tabs.sendMessage(tabId, message) + .then(() => sendResponse({ success: true })) + .catch((error) => { + this.logger.warn('Proxy to content failed', { error: error.message, action: message.action }); + sendResponse({ success: false, error: error.message }); + }); + return true; + } catch (error) { + this.logger.warn('Error in proxy to content', { error: error.message, action: message.action }); + try { sendResponse({ success: false, error: error.message }); } catch (_) {} + return false; + } + } + + /** + * Handle analyzing state update from side panel + * Broadcasts to content script to block/unblock word clicks + */ + handleSidePanelSetAnalyzingMessage(message, sender, sendResponse) { + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ success: false, error: 'No tab ID available' }); + return false; + } + + const isAnalyzing = !!message.isAnalyzing; + this.logger.debug('Setting analyzing state', { tabId, isAnalyzing }); + + // Store state in side panel service + if (this.sidePanelService) { + this.sidePanelService.updateTabState(tabId, { isAnalyzing }); + } + + // Forward to content script to block word clicks + chrome.tabs.sendMessage(tabId, { + action: MessageActions.SIDEPANEL_SET_ANALYZING, + isAnalyzing, + }).then(() => { + sendResponse({ success: true }); + }).catch((error) => { + this.logger.warn('Failed to send analyzing state to content script', error, { tabId }); + sendResponse({ success: true }); // Don't fail the side panel + }); + + return true; // Async response + } } // Export singleton instance diff --git a/background/index.js b/background/index.js index 558fbae..a1894b6 100644 --- a/background/index.js +++ b/background/index.js @@ -12,6 +12,7 @@ import { translationProviders } from './services/translationService.js'; import { subtitleService } from './services/subtitleService.js'; import { batchTranslationQueue } from './services/batchTranslationQueue.js'; import { aiContextService } from './services/aiContextService.js'; +import { sidePanelService } from './services/sidePanelService.js'; import { loggingManager } from './utils/loggingManager.js'; import { messageHandler } from './handlers/messageHandler.js'; import { configService } from '../services/configService.js'; @@ -53,6 +54,10 @@ async function initializeServices() { await aiContextService.initialize(); backgroundLogger.info('AI context service initialized'); + // Initialize side panel service + await sidePanelService.initialize(); + backgroundLogger.info('Side panel service initialized'); + // Initialize message handler messageHandler.initialize(); backgroundLogger.info('Message handler initialized'); @@ -74,12 +79,17 @@ async function initializeServices() { 'config', 'logging', ]); + serviceRegistry.register('sidePanel', sidePanelService, [ + 'config', + 'logging', + ]); serviceRegistry.register('logging', loggingManager, ['config']); serviceRegistry.register('config', configService, []); serviceRegistry.register('messageHandler', messageHandler, [ 'translation', 'subtitle', 'aiContext', + 'sidePanel', ]); backgroundLogger.info('Services registered in service registry'); @@ -87,7 +97,8 @@ async function initializeServices() { messageHandler.setServices( translationProviders, subtitleService, - aiContextService + aiContextService, + sidePanelService ); backgroundLogger.info('Services injected into message handler'); @@ -141,5 +152,6 @@ export { subtitleService, loggingManager, messageHandler, + sidePanelService, backgroundLogger, }; diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js new file mode 100644 index 0000000..6892bbc --- /dev/null +++ b/background/services/sidePanelService.js @@ -0,0 +1,408 @@ +/** + * Side Panel Service + * + * Manages Chrome Side Panel API integration for the AI Context feature. + * Handles opening/closing the side panel, routing messages, and managing state. + * + * @author DualSub Extension + * @version 2.0.0 + */ + +import Logger from '../../utils/logger.js'; +import { configService } from '../../services/configService.js'; +import { MessageActions } from '../../content_scripts/shared/constants/messageActions.js'; + +class SidePanelService { + constructor() { + this.logger = Logger.create('SidePanelService', configService); + this.initialized = false; + this.activeConnections = new Map(); // Track connections from side panels + this.tabStates = new Map(); // Track state per tab + } + + /** + * Initialize the side panel service + */ + async initialize() { + if (this.initialized) { + return; + } + + try { + this.logger.info('Initializing Side Panel Service'); + + // Check if Side Panel API is available (Chrome 114+) + if (typeof chrome.sidePanel === 'undefined') { + this.logger.warn('Side Panel API not available (Chrome 114+ required)'); + this.initialized = false; + return; + } + + // Listen for connections from side panel + chrome.runtime.onConnect.addListener((port) => { + if (port.name === 'sidepanel') { + this.handleSidePanelConnection(port); + } + }); + + // Listen for tab updates to manage state + chrome.tabs.onActivated.addListener((activeInfo) => { + this.handleTabActivated(activeInfo); + }); + + chrome.tabs.onRemoved.addListener((tabId) => { + this.handleTabRemoved(tabId); + }); + + this.initialized = true; + this.logger.info('Side Panel Service initialized successfully'); + } catch (error) { + this.logger.error('Failed to initialize Side Panel Service', error); + throw error; + } + } + + /** + * Handle new connection from side panel + */ + handleSidePanelConnection(port) { + const tabId = port.sender?.tab?.id; + if (!tabId) { + this.logger.warn('Side panel connection without tab ID'); + return; + } + + this.logger.info('Side panel connected', { tabId }); + this.activeConnections.set(tabId, port); + + // Handle messages from side panel + port.onMessage.addListener((message) => { + this.handleSidePanelMessage(message, port, tabId); + }); + + // Handle disconnection + port.onDisconnect.addListener(() => { + this.logger.info('Side panel disconnected', { tabId }); + this.activeConnections.delete(tabId); + }); + + // Send current state to newly connected side panel + const state = this.tabStates.get(tabId); + if (state) { + // If there's a pending selection, request AI tab first then forward selection after a short delay + if (state.pendingWordSelection) { + // Request tab switch + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: { activeTab: 'ai-analysis' }, + }); + + // Forward selection after a small delay so the AI tab can mount its listeners + setTimeout(() => { + try { + port.postMessage({ + action: MessageActions.SIDEPANEL_WORD_SELECTED, + data: state.pendingWordSelection, + }); + + // Clear pending selection + const newState = { ...state }; + delete newState.pendingWordSelection; + newState.activeTab = 'ai-analysis'; + this.tabStates.set(tabId, newState); + } catch (err) { + this.logger.error('Failed to deliver pending selection to side panel', err, { tabId }); + } + }, 60); + } else { + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: state, + }); + } + } + } + + /** + * Handle messages from side panel + */ + async handleSidePanelMessage(message, port, tabId) { + const { action, data } = message; + + this.logger.debug('Message from side panel', { action, tabId }); + + try { + switch (action) { + case MessageActions.SIDEPANEL_PAUSE_VIDEO: + await this.pauseVideo(tabId); + break; + + case MessageActions.SIDEPANEL_RESUME_VIDEO: + await this.resumeVideo(tabId); + break; + + case MessageActions.SIDEPANEL_GET_STATE: + const state = this.tabStates.get(tabId) || {}; + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: state, + }); + break; + + case MessageActions.SIDEPANEL_UPDATE_STATE: + this.updateTabState(tabId, data); + break; + + case MessageActions.SIDEPANEL_REGISTER: + this.logger.info('Side panel register request', { tabIdFromMessage: data?.tabId }); + try { + const claimedTabId = data?.tabId; + if (!claimedTabId || typeof claimedTabId !== 'number') { + this.logger.warn('Invalid register payload (missing tabId)'); + break; + } + // Map this port to the provided tabId + this.activeConnections.set(claimedTabId, port); + + // Deliver any pending state/selection and request AI tab + const st = this.tabStates.get(claimedTabId); + if (st) { + // Send state update first (active tab if present) + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: { ...(st.activeTab ? { activeTab: st.activeTab } : {}) }, + }); + + if (st.pendingWordSelection) { + const pending = st.pendingWordSelection; + setTimeout(() => { + try { + port.postMessage({ + action: MessageActions.SIDEPANEL_WORD_SELECTED, + data: pending, + }); + // Clear pending selection after delivery + const newState = { ...st }; + delete newState.pendingWordSelection; + this.tabStates.set(claimedTabId, newState); + } catch (err) { + this.logger.error('Failed to deliver pending selection on register', err, { tabId: claimedTabId }); + } + }, 60); + } + } + } catch (err) { + this.logger.error('Failed to handle side panel register', err); + } + break; + + default: + this.logger.warn('Unknown side panel message action', { action }); + } + } catch (error) { + this.logger.error('Error handling side panel message', error, { + action, + tabId, + }); + } + } + + /** + * Open side panel for a specific tab + */ + async openSidePanel(tabId, options = {}) { + try { + const config = await configService.getMultiple([ + 'sidePanelUseSidePanel', + 'sidePanelEnabled', + 'sidePanelAutoOpen', + 'sidePanelAutoPauseVideo', + ]); + + // Check if side panel is enabled + if (!config.sidePanelEnabled || !config.sidePanelUseSidePanel) { + this.logger.debug('Side panel disabled in settings'); + return { success: false, reason: 'disabled' }; + } + + if (!config.sidePanelAutoOpen && !options.force) { + this.logger.debug('Auto-open disabled'); + return { success: false, reason: 'auto-open-disabled' }; + } + + // Check API availability + if (typeof chrome.sidePanel === 'undefined') { + this.logger.warn('Side Panel API not available'); + return { success: false, reason: 'api-unavailable' }; + } + + // Open side panel + await chrome.sidePanel.open({ tabId }); + + this.logger.info('Side panel opened', { tabId }); + + // Auto-pause video if enabled + if (config.sidePanelAutoPauseVideo || options.pauseVideo) { + await this.pauseVideo(tabId); + } + + return { success: true }; + } catch (error) { + this.logger.error('Failed to open side panel', error, { tabId }); + return { success: false, error: error.message }; + } + } + + /** + * Open side panel immediately (attempt to preserve user gesture) + */ + async openSidePanelImmediate(tabId, options = {}) { + try { + // Check API availability + if (typeof chrome.sidePanel === 'undefined') { + this.logger.warn('Side Panel API not available'); + return { success: false, reason: 'api-unavailable' }; + } + + // Attempt to open immediately without awaiting settings to preserve user gesture + await chrome.sidePanel.open({ tabId }); + this.logger.info('Side panel opened (immediate)', { tabId }); + + // Apply requested options without config wait + if (options.pauseVideo) { + await this.pauseVideo(tabId); + } + + return { success: true }; + } catch (error) { + this.logger.error('Failed to open side panel (immediate)', error, { tabId }); + return { success: false, error: error.message }; + } + } + + /** + * Pause video in the tab + */ + async pauseVideo(tabId) { + try { + await chrome.tabs.sendMessage(tabId, { + action: MessageActions.SIDEPANEL_PAUSE_VIDEO, + source: 'background', + }); + + this.logger.debug('Video pause command sent', { tabId }); + } catch (error) { + this.logger.error('Failed to pause video', error, { tabId }); + } + } + + /** + * Resume video in the tab + */ + async resumeVideo(tabId) { + try { + const autoResume = await configService.get('sidePanelAutoResumeVideo'); + + if (autoResume) { + await chrome.tabs.sendMessage(tabId, { + action: MessageActions.SIDEPANEL_RESUME_VIDEO, + source: 'background', + }); + + this.logger.debug('Video resume command sent', { tabId }); + } + } catch (error) { + this.logger.error('Failed to resume video', error, { tabId }); + } + } + + /** + * Forward word selection to side panel + */ + async forwardWordSelection(tabId, wordData) { + const port = this.activeConnections.get(tabId); + if (port) { + // Ensure AI Analysis tab is active if selection comes while open + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: { activeTab: 'ai-analysis' }, + }); + + port.postMessage({ + action: MessageActions.SIDEPANEL_WORD_SELECTED, + data: wordData, + }); + + this.logger.debug('Word selection forwarded to side panel', { + tabId, + word: wordData.word, + }); + } else { + this.logger.debug('No active side panel connection', { tabId }); + + // Store word selection for when side panel opens + const state = this.tabStates.get(tabId) || {}; + state.pendingWordSelection = wordData; + // Ensure we request AI Analysis tab on open + state.activeTab = 'ai-analysis'; + this.tabStates.set(tabId, state); + + // Open side panel immediately to preserve user gesture + await this.openSidePanelImmediate(tabId, { pauseVideo: true }); + } + } + + /** + * Update tab state + */ + updateTabState(tabId, state) { + const existingState = this.tabStates.get(tabId) || {}; + this.tabStates.set(tabId, { ...existingState, ...state }); + + this.logger.debug('Tab state updated', { tabId }); + } + + /** + * Handle tab activation + */ + handleTabActivated(activeInfo) { + const { tabId } = activeInfo; + this.logger.debug('Tab activated', { tabId }); + + // Notify side panel of tab change if connected + const port = this.activeConnections.get(tabId); + if (port) { + const state = this.tabStates.get(tabId) || {}; + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: state, + }); + } + } + + /** + * Handle tab removal + */ + handleTabRemoved(tabId) { + this.logger.debug('Tab removed', { tabId }); + this.activeConnections.delete(tabId); + this.tabStates.delete(tabId); + } + + /** + * Check if side panel is supported + */ + isSidePanelSupported() { + return typeof chrome.sidePanel !== 'undefined'; + } + + /** + * Get tab state + */ + getTabState(tabId) { + return this.tabStates.get(tabId) || {}; + } +} + +// Create and export singleton instance +export const sidePanelService = new SidePanelService(); diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index 74c8915..6fda9a9 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -301,6 +301,24 @@ export class BaseContentScript { */ _setupCommonMessageHandlers() { const commonHandlers = [ + { + action: MessageActions.SIDEPANEL_GET_STATE, + handler: this.handleSidePanelGetState.bind(this), + requiresUtilities: false, + description: 'Return current word selection state from page highlights.', + }, + { + action: MessageActions.SIDEPANEL_UPDATE_STATE, + handler: this.handleSidePanelUpdateState.bind(this), + requiresUtilities: false, + description: 'Apply selection updates (clear/apply highlights) from side panel.', + }, + { + action: MessageActions.SIDEPANEL_SET_ANALYZING, + handler: this.handleSidePanelSetAnalyzing.bind(this), + requiresUtilities: false, + description: 'Update analyzing state to block/unblock word clicks.', + }, { action: MessageActions.TOGGLE_SUBTITLES, handler: this.handleToggleSubtitles.bind(this), @@ -322,6 +340,18 @@ export class BaseContentScript { description: 'Update logging level for the content script logger.', }, + { + action: MessageActions.SIDEPANEL_PAUSE_VIDEO, + handler: this.handleSidePanelPauseVideo.bind(this), + requiresUtilities: false, + description: 'Pause the video on the page using multiple strategies.', + }, + { + action: MessageActions.SIDEPANEL_RESUME_VIDEO, + handler: this.handleSidePanelResumeVideo.bind(this), + requiresUtilities: false, + description: 'Resume the video on the page.', + }, ]; commonHandlers.forEach( @@ -858,6 +888,9 @@ export class BaseContentScript { } ); + // Initialize side panel integration early so it captures events before modal listeners + await this._initializeSidePanelIntegration(); + // Initialize new modular AI Context Manager if (!this.aiContextManager) { try { @@ -1194,6 +1227,203 @@ export class BaseContentScript { this.logWithFallback('debug', 'Fullscreen handling setup complete'); } + /** + * Initialize side panel integration for routing word selections + * @returns {Promise} + * @private + */ + async _initializeSidePanelIntegration() { + try { + this.logWithFallback( + 'info', + 'Initializing side panel integration...', + { + platform: this.getPlatformName(), + } + ); + + // Create inline side panel integration + this.sidePanelIntegration = { + initialized: false, + sidePanelEnabled: false, + useSidePanel: false, + isAnalyzing: false, + boundHandler: null, + + async initialize() { + if (this.initialized) return; + + // Prepare logger bridge and messaging wrapper + this._log = (level, message, data) => { + try { + window.__dualsub_log?.(level, message, data); + } catch (_) {} + try { + // Use outer class logger if available + (typeof level === 'string' + ? level + : 'debug') && + (typeof message === 'string'); + } catch (_) {} + }; + + // Load robust messaging wrapper (reuses existing implementation) + try { + const { sendRuntimeMessageWithRetry } = await import( + chrome.runtime.getURL( + 'content_scripts/shared/messaging.js' + ) + ); + this._send = (msg) => + sendRuntimeMessageWithRetry(msg, { + retries: 3, + baseDelayMs: 120, + }); + } catch (_) { + this._send = (msg) => chrome.runtime.sendMessage(msg); + } + + // Check settings + await this.checkSettings(); + + // Create bound handler + this.boundHandler = this.handleWordSelection.bind(this); + + // Listen for word selection events in capture phase (register early) + document.addEventListener( + 'dualsub-word-selected', + this.boundHandler, + { capture: true } + ); + + // Listen for storage changes + chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'sync') { + if (changes.sidePanelEnabled || changes.sidePanelUseSidePanel) { + this.checkSettings(); + } + } + }); + + this.initialized = true; + }, + + async checkSettings() { + try { + const settings = await chrome.storage.sync.get([ + 'sidePanelEnabled', + 'sidePanelUseSidePanel', + ]); + this.sidePanelEnabled = settings.sidePanelEnabled !== false; + this.useSidePanel = settings.sidePanelUseSidePanel !== false; + } catch (error) { + this.sidePanelEnabled = false; + this.useSidePanel = false; + } + }, + + async handleWordSelection(event) { + if (!this.sidePanelEnabled || !this.useSidePanel) { + return; + } + + // Block word clicks during analysis + if (this.isAnalyzing) { + event.stopPropagation(); + event.stopImmediatePropagation(); + return; + } + + const { word, element, sourceLanguage, targetLanguage, context, subtitleType } = event.detail || {}; + if (!word) return; + + try { + // Prevent modal from handling + event.stopPropagation(); + event.stopImmediatePropagation(); + + // 1) Best-effort immediate open (do NOT await to preserve user gesture) + try { + // Fire-and-forget + void this._send({ + action: MessageActions.SIDEPANEL_OPEN, + options: { pauseVideo: true, openReason: 'word-click', activeTab: 'ai-analysis' }, + }); + } catch (_) {} + + // 2) Forward word selection (ok to await) + const resp = await this._send({ + action: MessageActions.SIDEPANEL_WORD_SELECTED, + word, + sourceLanguage, + targetLanguage, + context, + subtitleType, + action: 'toggle', + timestamp: Date.now(), + }); + + // Visual feedback: toggle selection like legacy modal (do not clear all) + if (element) { + if (element.classList.contains('dualsub-word-selected')) { + element.classList.remove('dualsub-word-selected'); + } else { + element.classList.add('dualsub-word-selected'); + } + } + } catch (error) { + console.error('[SidePanelIntegration] Error forwarding word selection:', error); + } + }, + + destroy() { + if (!this.initialized) return; + if (this.boundHandler) { + document.removeEventListener( + 'dualsub-word-selected', + this.boundHandler, + { capture: true } + ); + } + this.initialized = false; + }, + + isSidePanelEnabled() { + return this.sidePanelEnabled && this.useSidePanel; + } + }; + + await this.sidePanelIntegration.initialize(); + + // Add cleanup function + this.eventListenerCleanupFunctions.push(() => { + if (this.sidePanelIntegration) { + this.sidePanelIntegration.destroy(); + } + }); + + this.logWithFallback( + 'info', + 'Side panel integration initialized successfully', + { + platform: this.getPlatformName(), + enabled: this.sidePanelIntegration.isSidePanelEnabled(), + } + ); + } catch (error) { + this.logWithFallback( + 'error', + 'Failed to initialize side panel integration', + { + error: error.message, + stack: error.stack, + platform: this.getPlatformName(), + } + ); + // Non-critical error, continue without side panel integration + } + } + /** * Initialize interactive subtitles only (without AI Context) * This makes words clickable even when AI Context is disabled @@ -2756,11 +2986,13 @@ export class BaseContentScript { const action = request.action || request.type; - this.logWithFallback('debug', 'Received Chrome message', { - action, - hasUtilities: !!(this.subtitleUtils && this.configService), - hasRegisteredHandler: this.messageHandlers.has(action), - }); + if (action !== MessageActions.SIDEPANEL_GET_STATE) { + this.logWithFallback('debug', 'Received Chrome message', { + action, + hasUtilities: !!(this.subtitleUtils && this.configService), + hasRegisteredHandler: this.messageHandlers.has(action), + }); + } // Validate message structure if (!action) { @@ -2779,15 +3011,17 @@ export class BaseContentScript { // Check if we have a registered handler for this action const handlerConfig = this.messageHandlers.get(action); if (handlerConfig) { - this.logWithFallback( - 'debug', - 'Using registered message handler', - { - action, - description: handlerConfig.description, - requiresUtilities: handlerConfig.requiresUtilities, - } - ); + if (action !== MessageActions.SIDEPANEL_GET_STATE) { + this.logWithFallback( + 'debug', + 'Using registered message handler', + { + action, + description: handlerConfig.description, + requiresUtilities: handlerConfig.requiresUtilities, + } + ); + } // Check if handler requires utilities and they're not loaded if ( @@ -2993,6 +3227,219 @@ export class BaseContentScript { * @param {boolean} enabled - Enabled state * @returns {boolean} Whether response is handled asynchronously */ + /** + * Handle side panel get state: returns currently highlighted words and languages + */ + handleSidePanelGetState(request, sendResponse) { + try { + const highlighted = Array.from( + document.querySelectorAll('.dualsub-interactive-word.dualsub-word-selected') + ); + const words = []; + const seen = new Set(); + highlighted.forEach((el) => { + const w = el.getAttribute('data-word') || el.textContent || ''; + const word = (w || '').trim(); + if (word && !seen.has(word)) { + seen.add(word); + words.push(word); + } + }); + + // Keep this handler lightweight to avoid page lag + sendResponse({ + success: true, + selectedWords: words, + sourceLanguage: 'auto', + }); + return false; + } catch (error) { + this.logWithFallback('error', 'Error in handleSidePanelGetState', { + error: error.message, + }); + sendResponse({ success: false, error: error.message }); + return false; + } + } + + /** + * Handle side panel update state: clear/apply highlights + */ + handleSidePanelUpdateState(request, sendResponse) { + try { + const data = request.data || request; // support both shapes + if (data.clearSelection) { + document + .querySelectorAll('.dualsub-interactive-word.dualsub-word-selected') + .forEach((el) => el.classList.remove('dualsub-word-selected')); + } + + if (Array.isArray(data.selectedWords)) { + // Add highlights for given words (first-match strategy) + data.selectedWords.forEach((word) => { + const el = Array.from( + document.querySelectorAll('.dualsub-interactive-word') + ).find((e) => (e.getAttribute('data-word') || '').trim() === word); + if (el) el.classList.add('dualsub-word-selected'); + }); + } + + sendResponse({ success: true }); + return false; + } catch (error) { + this.logWithFallback('error', 'Error in handleSidePanelUpdateState', { + error: error.message, + }); + sendResponse({ success: false, error: error.message }); + return false; + } + } + + /** + * Pause the video using multiple strategies + */ + async handleSidePanelPauseVideo(_request, sendResponse) { + try { + // Use platform-specific pause when available (e.g., Disney+ shadow button) + if (this.activePlatform && typeof this.activePlatform.pausePlayback === 'function') { + const ok = await this.activePlatform.pausePlayback(); + sendResponse({ success: !!ok }); + return false; + } + + const pauseSucceeded = await (async () => { + try { + // Strategy 1: Direct HTML5 pause (universal) + const v = document.querySelector('video[data-listener-attached="true"]') + || (this.activePlatform && typeof this.activePlatform.getVideoElement === 'function' ? this.activePlatform.getVideoElement() : null) + || document.querySelector('video'); + if (v) { + try { v.pause(); } catch (_) {} + await new Promise((r) => setTimeout(r, 80)); + if (v.paused) return true; + } + + // Strategy 2: Click any visible Pause/Play control (generic platforms) + try { + const pauseBtn = document.querySelector( + 'button[aria-label*="Pause" i], button[data-uia*="pause" i], button.play-button.control[part="play-button"], button[part="play-button"]' + ); + if (pauseBtn) { + pauseBtn.click(); + await new Promise((r) => setTimeout(r, 140)); + const v2 = document.querySelector('video[data-listener-attached="true"]') + || (this.activePlatform && typeof this.activePlatform.getVideoElement === 'function' ? this.activePlatform.getVideoElement() : null) + || document.querySelector('video'); + if (v2 && v2.paused) return true; + } + } catch (_) {} + + // Strategy 3: As absolute fallback, try another direct pause + try { + const v3 = document.querySelector('video[data-listener-attached="true"]') || document.querySelector('video'); + if (v3) { + v3.pause(); + await new Promise((r) => setTimeout(r, 60)); + if (v3.paused) return true; + } + } catch (_) {} + return false; + } catch (_) { + return false; + } + })(); + + sendResponse({ success: pauseSucceeded }); + return false; + } catch (error) { + this.logWithFallback('warn', 'Error while attempting to pause video', { error: error.message }); + sendResponse({ success: false, error: error.message }); + return false; + } + } + + /** + * Resume the video + */ + handleSidePanelResumeVideo(_request, sendResponse) { + try { + if (this.activePlatform && typeof this.activePlatform.resumePlayback === 'function') { + Promise.resolve(this.activePlatform.resumePlayback()) + .then((ok) => sendResponse({ success: !!ok })) + .catch(() => sendResponse({ success: false })); + return true; + } + const v = (this.activePlatform && typeof this.activePlatform.getVideoElement === 'function') + ? this.activePlatform.getVideoElement() + : document.querySelector('video'); + if (v) { + try { v.play(); } catch (_) {} + } + sendResponse({ success: true }); + return false; + } catch (error) { + this.logWithFallback('warn', 'Error while attempting to resume video', { error: error.message }); + sendResponse({ success: false, error: error.message }); + return false; + } + } + + /** + * Handle analyzing state update: block/unblock word clicks + */ + handleSidePanelSetAnalyzing(request, sendResponse) { + try { + const isAnalyzing = !!(request.data?.isAnalyzing ?? request.isAnalyzing); + + if (this.sidePanelIntegration) { + this.sidePanelIntegration.isAnalyzing = isAnalyzing; + this.logWithFallback('debug', 'Analyzing state updated', { isAnalyzing }); + } + + // 1) Mirror legacy modal signal so interactive subtitle code detects analyzing + try { + let modalContent = document.getElementById('dualsub-modal-content'); + if (!modalContent) { + modalContent = document.createElement('div'); + modalContent.id = 'dualsub-modal-content'; + // keep it invisible and out of layout + Object.assign(modalContent.style, { + display: 'none', + }); + document.body.appendChild(modalContent); + } + if (isAnalyzing) { + modalContent.classList.add('is-analyzing'); + } else { + modalContent.classList.remove('is-analyzing'); + } + } catch (_) {} + + // 2) Disable/enable pointer interactions on the original subtitle container + try { + const original = document.getElementById('dualsub-original-subtitle'); + if (original) { + if (isAnalyzing) { + original.style.pointerEvents = 'none'; + original.classList.add('dualsub-subtitles-disabled'); + } else { + original.style.removeProperty('pointer-events'); + original.classList.remove('dualsub-subtitles-disabled'); + } + } + } catch (_) {} + + sendResponse({ success: true }); + return false; + } catch (error) { + this.logWithFallback('error', 'Error in handleSidePanelSetAnalyzing', { + error: error.message, + }); + sendResponse({ success: false, error: error.message }); + return false; + } + } + _enableSubtitles(sendResponse, enabled) { if (!this.activePlatform) { this.initializePlatform() diff --git a/content_scripts/shared/constants/messageActions.js b/content_scripts/shared/constants/messageActions.js index d71697c..46d1e13 100644 --- a/content_scripts/shared/constants/messageActions.js +++ b/content_scripts/shared/constants/messageActions.js @@ -28,4 +28,6 @@ export const MessageActions = { SIDEPANEL_RESUME_VIDEO: 'sidePanelResumeVideo', SIDEPANEL_GET_STATE: 'sidePanelGetState', SIDEPANEL_UPDATE_STATE: 'sidePanelUpdateState', + SIDEPANEL_REGISTER: 'sidePanelRegister', + SIDEPANEL_SET_ANALYZING: 'sidePanelSetAnalyzing', }; diff --git a/content_scripts/shared/interactiveSubtitleFormatter.js b/content_scripts/shared/interactiveSubtitleFormatter.js index baa0b47..26a09d8 100644 --- a/content_scripts/shared/interactiveSubtitleFormatter.js +++ b/content_scripts/shared/interactiveSubtitleFormatter.js @@ -455,7 +455,45 @@ function getSubtitleTypeFromElement(element) { * Handle click events on interactive words * @param {Event} event - Click event */ -function handleInteractiveWordClick(event) { +function getActiveVideoElement() { + const isDisney = typeof location !== 'undefined' && location.hostname.includes('disneyplus.com'); + + // Disney+: prefer the main hive player if present + if (isDisney) { + const hive = document.getElementById('hivePlayer'); + if (hive && hive.tagName === 'VIDEO') return hive; + } + + // Default: use the page's primary video first + const primary = document.querySelector('video'); + if (primary) return primary; + + // Then prefer the video our subtitle system attached to + const attached = document.querySelector('video[data-listener-attached="true"]'); + if (attached) return attached; + + // Last resort: pick the best visible, ready video + const list = Array.from(document.querySelectorAll('video')); + if (list.length === 1) return list[0]; + let best = null; + let bestScore = -Infinity; + for (const v of list) { + try { + const r = v.getBoundingClientRect(); + const area = Math.max(0, r.width) * Math.max(0, r.height); + const rs = Number(v.readyState || 0); + const visible = r.width > 0 && r.height > 0; + const score = (visible ? 1000 : 0) + rs * 100 + area; + if (score > bestScore) { + bestScore = score; + best = v; + } + } catch (_) {} + } + return best || null; +} + +async function handleInteractiveWordClick(event) { const target = event.target; if (!target.classList.contains('dualsub-interactive-word')) { @@ -496,9 +534,8 @@ function handleInteractiveWordClick(event) { targetLanguage, }); - // Check if video is paused for enhanced selection mode - const videoElement = document.querySelector('video'); - const isVideoPaused = videoElement ? videoElement.paused : false; +const videoElement = getActiveVideoElement(); + let isVideoPaused = videoElement ? videoElement.paused : false; logWithFallback('info', 'Interactive word clicked', { word, @@ -508,6 +545,19 @@ function handleInteractiveWordClick(event) { targetClass: target.className, }); + const isDisney = typeof location !== 'undefined' && location.hostname.includes('disneyplus.com'); + if (isDisney || !isVideoPaused) { + // Try to pause aggressively (Disney+ always routes to platform handler) + const paused = await pauseVideoAggressively(); + if (paused) { + isVideoPaused = true; + } else { + // If not definitively paused, refresh reference and re-check + const v2 = getActiveVideoElement(); + isVideoPaused = v2 ? v2.paused : false; + } + } + if (isVideoPaused) { // Enhanced selection mode - dispatch word selection event // Determine subtitle type from element's container @@ -541,11 +591,10 @@ function handleInteractiveWordClick(event) { } ); } else { - // Video is playing - no action taken - // Context analysis can only be initiated through the modal when video is paused + // Video is playing and could not be paused logWithFallback( 'debug', - 'Word click ignored - video is playing. Pause video to select words for analysis.', + 'Word click ignored - video is playing and could not be paused.', { word, sourceLanguage, @@ -555,6 +604,57 @@ function handleInteractiveWordClick(event) { } } +/** + * Aggressively attempt to pause the video using multiple strategies + * @returns {Promise} whether pause succeeded + */ +async function pauseVideoAggressively() { + try { + const isDisney = typeof location !== 'undefined' && location.hostname.includes('disneyplus.com'); + if (isDisney) { + // Route to content script/platform handler to avoid direct pause bugs on Disney+ + try { + const resp = await chrome.runtime.sendMessage({ action: 'sidePanelPauseVideo', source: 'interactive' }); + if (resp && resp.success) return true; + await new Promise((r) => setTimeout(r, 160)); + const vD = getActiveVideoElement(); + if (vD && vD.paused) return true; + } catch (_) {} + return false; + } + + // Strategy A: Direct HTML5 pause (generic) + const v = getActiveVideoElement(); + if (v) { + try { v.pause(); } catch (_) {} + await new Promise((r) => setTimeout(r, 80)); + if (v.paused) return true; + } + + // Strategy B: Click any visible Pause/Play control (generic) + try { + const pauseBtn = document.querySelector( + 'button[aria-label*="Pause" i], button[data-uia*="pause" i], button.play-button.control[part="play-button"], button[part="play-button"]' + ); + if (pauseBtn) { + pauseBtn.click(); + await new Promise((r) => setTimeout(r, 140)); + const v2 = getActiveVideoElement(); + if (v2 && v2.paused) return true; + } + } catch (_) {} + + // Strategy C: Background route (best-effort) + try { + await chrome.runtime.sendMessage({ action: 'sidePanelPauseVideo', source: 'interactive' }); + await new Promise((r) => setTimeout(r, 150)); + const v3 = getActiveVideoElement(); + if (v3 && v3.paused) return true; + } catch (_) {} + } catch (_) {} + return false; +} + /** * Handle hover events on interactive words * @param {Event} event - Mouse enter event diff --git a/options/OptionsApp.jsx b/options/OptionsApp.jsx index 56b24f6..54340bf 100644 --- a/options/OptionsApp.jsx +++ b/options/OptionsApp.jsx @@ -5,6 +5,8 @@ import { GeneralSection } from './components/sections/GeneralSection.jsx'; import { TranslationSection } from './components/sections/TranslationSection.jsx'; import { ProvidersSection } from './components/sections/ProvidersSection.jsx'; import { AIContextSection } from './components/sections/AIContextSection.jsx'; +import { WordListsSection } from './components/sections/WordListsSection.jsx'; +import { AdvancedSection } from './components/sections/AdvancedSection.jsx'; import { AboutSection } from './components/sections/AboutSection.jsx'; export function OptionsApp() { @@ -77,6 +79,20 @@ export function OptionsApp() { onSettingChange={handleSettingChange} /> )} + {activeSection === 'word-lists' && ( + + )} + {activeSection === 'advanced' && ( + + )} {activeSection === 'about' && }
diff --git a/options/components/Sidebar.jsx b/options/components/Sidebar.jsx index e9f174c..92c50ee 100644 --- a/options/components/Sidebar.jsx +++ b/options/components/Sidebar.jsx @@ -6,6 +6,8 @@ export function Sidebar({ t, activeSection, onSectionChange }) { { id: 'translation', label: t('navTranslation', 'Translation') }, { id: 'providers', label: t('navProviders', 'Providers') }, { id: 'ai-context', label: t('navAIContext', 'AI Context') }, + { id: 'word-lists', label: t('navWordLists', 'Word Lists') }, + { id: 'advanced', label: t('navAdvanced', 'Advanced') }, { id: 'about', label: t('navAbout', 'About') }, ]; diff --git a/options/components/sections/AdvancedSection.jsx b/options/components/sections/AdvancedSection.jsx new file mode 100644 index 0000000..f2bccd6 --- /dev/null +++ b/options/components/sections/AdvancedSection.jsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { SettingCard } from '../SettingCard.jsx'; +import { ToggleSwitch } from '../ToggleSwitch.jsx'; + +/** + * Advanced Settings Section + * + * Advanced configuration options for side panel behavior, + * video control, and state persistence. + */ +export function AdvancedSection({ t, settings, onSettingChange }) { + return ( +
+

{t('advancedTitle', 'Advanced Settings')}

+ + {/* Side Panel Behavior */} + + +
+ + + onSettingChange('sidePanelUseSidePanel', checked) + } + /> +
+

+ {t( + 'useSidePanelDescription', + 'Use Chrome Side Panel instead of modal for AI context analysis. Disable to use the legacy modal (Chrome 114+ required for side panel).' + )} +

+ +
+ + + onSettingChange('sidePanelAutoOpen', checked) + } + /> +
+

+ {t( + 'autoOpenSidePanelDescription', + 'Automatically open the side panel when you click on subtitle words.' + )} +

+ +
+ + + onSettingChange('sidePanelPersistAcrossTabs', checked) + } + /> +
+

+ {t( + 'persistAcrossTabsDescription', + 'Keep the side panel open and preserve selected words when switching between tabs.' + )} +

+ +
+ + +
+ +
+ + +
+
+ + {/* Video Control */} + + +
+ + + onSettingChange('sidePanelAutoPauseVideo', checked) + } + /> +
+

+ {t( + 'autoPauseVideoDescription', + 'Automatically pause the video when you open the side panel to select words.' + )} +

+ +
+ + + onSettingChange('sidePanelAutoResumeVideo', checked) + } + /> +
+

+ {t( + 'autoResumeVideoDescription', + 'Automatically resume video playback when you close the side panel.' + )} +

+
+ + +
+ warning + {t( + 'advancedNote', + 'Chrome Side Panel API requires Chrome 114 or higher. Lower versions will use the legacy modal.' + )} +
+
+
+ ); +} diff --git a/options/components/sections/WordListsSection.jsx b/options/components/sections/WordListsSection.jsx new file mode 100644 index 0000000..58bb4c0 --- /dev/null +++ b/options/components/sections/WordListsSection.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { SettingCard } from '../SettingCard.jsx'; +import { ToggleSwitch } from '../ToggleSwitch.jsx'; + +/** + * Word Lists Settings Section + * + * Configuration for the Words Lists feature in the side panel. + * Currently provides a simple toggle as the feature is in development. + */ +export function WordListsSection({ t, settings, onSettingChange }) { + return ( +
+

{t('wordListsTitle', 'Word Lists')}

+ + +
+ + + onSettingChange('sidePanelWordsListsEnabled', checked) + } + /> +
+
+ + +
+ info + {t( + 'wordListsPreview', + 'This feature is currently in development. Enable it to see the preview UI.' + )} +
+
+
+ ); +} diff --git a/sidepanel/SidePanelApp.jsx b/sidepanel/SidePanelApp.jsx index 1cc00a1..8f1d3aa 100644 --- a/sidepanel/SidePanelApp.jsx +++ b/sidepanel/SidePanelApp.jsx @@ -5,6 +5,7 @@ import { WordsListsTab } from './components/tabs/WordsListsTab.jsx'; import { useTheme } from './hooks/useTheme.js'; import { useSettings } from './hooks/useSettings.js'; import { SidePanelProvider } from './hooks/SidePanelContext.jsx'; +import { useSidePanelCommunication } from './hooks/useSidePanelCommunication.js'; /** * Main Side Panel Application Component @@ -13,6 +14,19 @@ import { SidePanelProvider } from './hooks/SidePanelContext.jsx'; * Manages theme, settings, and global state for the side panel. */ export function SidePanelApp() { + // Internal component to handle SIDEPANEL_UPDATE_STATE + function SidePanelStateSync({ onRequestTabChange }) { + const { onMessage } = useSidePanelCommunication(); + useEffect(() => { + const unsubscribe = onMessage('sidePanelUpdateState', (data) => { + if (data?.activeTab) { + onRequestTabChange(data.activeTab); + } + }); + return unsubscribe; + }, [onMessage, onRequestTabChange]); + return null; + } const [activeTab, setActiveTab] = useState('ai-analysis'); const { theme, toggleTheme } = useTheme(); const { settings, loading: settingsLoading } = useSettings(); @@ -26,10 +40,10 @@ export function SidePanelApp() { } }, [theme]); - // Load default tab from settings + // Load default tab from settings, but allow background to override via message useEffect(() => { if (settings.sidePanelDefaultTab && !settingsLoading) { - setActiveTab(settings.sidePanelDefaultTab); + setActiveTab((prev) => prev || settings.sidePanelDefaultTab); } }, [settings.sidePanelDefaultTab, settingsLoading]); @@ -67,6 +81,7 @@ export function SidePanelApp() { return ( + setActiveTab(tab)} />
-
+
{Array.from(selectedWords).map((word) => ( {word}
@@ -228,38 +244,55 @@ export function AIAnalysisTab() { align-items: center; } - .word-tag { +.word-tag { display: flex; align-items: center; gap: var(--spacing-1); - background: rgba(19, 127, 236, 0.1); - color: var(--color-primary); + background: var(--color-primary); + color: white; font-size: var(--font-size-sm); - font-weight: 500; + font-weight: 600; padding: var(--spacing-1) var(--spacing-2); border-radius: 6px; } - .word-tag-remove { + body.dark .word-tag { + background: #0f5fb8; /* slightly darker for dark mode */ + color: white; + } + +.word-tag-remove { display: flex; align-items: center; justify-content: center; - width: 16px; - height: 16px; + width: 20px; + height: 20px; margin-left: var(--spacing-1); - background: transparent; - color: var(--color-primary); - border: none; - border-radius: 50%; - font-size: 18px; + background: rgba(255, 255, 255, 0.25); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + font-size: 16px; + font-weight: 700; line-height: 1; cursor: pointer; - transition: all var(--transition-fast); + transition: all 0.2s ease; + padding: 0; } - .word-tag-remove:hover { - background: rgba(19, 127, 236, 0.2); - color: var(--color-error); + .word-tag-remove:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.1); + } + + .word-tag-remove:active:not(:disabled) { + transform: scale(0.95); + } + + .word-tag-remove:disabled { + opacity: 0.4; + cursor: not-allowed; } .placeholder-text { diff --git a/sidepanel/hooks/useAIAnalysis.js b/sidepanel/hooks/useAIAnalysis.js index 4da5c04..f58a8ca 100644 --- a/sidepanel/hooks/useAIAnalysis.js +++ b/sidepanel/hooks/useAIAnalysis.js @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useSidePanelContext } from './SidePanelContext.jsx'; +import { useSidePanelCommunication } from './useSidePanelCommunication.js'; /** * AI Analysis Hook @@ -30,6 +31,8 @@ export function useAIAnalysis() { const cacheRef = useRef(new Map()); const abortControllerRef = useRef(null); + const { sendToActiveTab } = useSidePanelCommunication(); + // Load settings useEffect(() => { const loadSettings = async () => { @@ -149,6 +152,13 @@ export function useAIAnalysis() { setError(null); setAnalysisResult(null); + // Notify content script (active tab) that analysis started (to block word clicks) + try { + await sendToActiveTab('sidePanelSetAnalyzing', { isAnalyzing: true }); + } catch (err) { + console.warn('Failed to notify analyzing state:', err); + } + try { const text = Array.from(wordsToAnalyze).join(' '); @@ -168,18 +178,21 @@ export function useAIAnalysis() { } if (response && response.success) { - const result = response.result || response; - + const payload = response.result || response; + const normalized = payload?.analysis || payload?.result || null; + // Store in cache - setCachedResult( - wordsToAnalyze, - contextTypes, - sourceLanguage, - result - ); - - setAnalysisResult(result); - return result; + if (normalized) { + setCachedResult( + wordsToAnalyze, + contextTypes, + sourceLanguage, + normalized + ); + } + + setAnalysisResult(normalized); + return normalized; } else { const errorMsg = response?.error || 'Analysis failed. Please try again.'; @@ -200,6 +213,13 @@ export function useAIAnalysis() { } finally { setIsAnalyzing(false); abortControllerRef.current = null; + + // Notify content script (active tab) that analysis stopped + try { + await sendToActiveTab('sidePanelSetAnalyzing', { isAnalyzing: false }); + } catch (err) { + console.warn('Failed to notify analyzing state:', err); + } } }, [ diff --git a/sidepanel/hooks/useSidePanelCommunication.js b/sidepanel/hooks/useSidePanelCommunication.js index 51e5fcb..8ac3940 100644 --- a/sidepanel/hooks/useSidePanelCommunication.js +++ b/sidepanel/hooks/useSidePanelCommunication.js @@ -38,6 +38,25 @@ export function useSidePanelCommunication() { setIsConnected(true); + // Register this side panel with the background, providing the active tab ID + chrome.tabs + .query({ active: true, currentWindow: true }) + .then(([tab]) => { + if (tab && tab.id && portRef.current) { + try { + portRef.current.postMessage({ + action: 'sidePanelRegister', + data: { tabId: tab.id }, + source: 'sidepanel', + timestamp: Date.now(), + }); + } catch (e) { + console.warn('Failed to register side panel with background:', e); + } + } + }) + .catch((e) => console.warn('Failed to query active tab for registration:', e)); + return () => { if (portRef.current) { portRef.current.disconnect(); diff --git a/sidepanel/hooks/useWordSelection.js b/sidepanel/hooks/useWordSelection.js index e8bf230..05793e4 100644 --- a/sidepanel/hooks/useWordSelection.js +++ b/sidepanel/hooks/useWordSelection.js @@ -1,4 +1,4 @@ -import { useEffect, useCallback } from 'react'; +import { useEffect, useCallback, useRef } from 'react'; import { useSidePanelContext } from './SidePanelContext.jsx'; import { useSidePanelCommunication } from './useSidePanelCommunication.js'; @@ -22,6 +22,8 @@ export function useWordSelection() { clearWords, setSourceLanguage, setTargetLanguage, + sourceLanguage, + targetLanguage, } = useSidePanelContext(); const { onMessage, sendToActiveTab, getActiveTab } = @@ -37,17 +39,17 @@ export function useWordSelection() { return; } - const { word, sourceLanguage, targetLanguage, subtitleType } = data; + const { word, sourceLanguage, targetLanguage, subtitleType, action } = data; - console.log('Word selected:', { - word, - sourceLanguage, - targetLanguage, - subtitleType, - }); - - // Add word to selection - addWord(word); + // Toggle/append semantics like modal: default to 'add' if unspecified + if (action === 'remove') { + removeWord(word); + } else if (action === 'toggle') { + if (selectedWords.has(word)) removeWord(word); + else addWord(word); + } else { + addWord(word); + } // Update language settings if provided if (sourceLanguage) { @@ -57,27 +59,51 @@ export function useWordSelection() { setTargetLanguage(targetLanguage); } }, - [addWord, setSourceLanguage, setTargetLanguage] + [addWord, removeWord, selectedWords, setSourceLanguage, setTargetLanguage] ); /** * Toggle word selection */ const toggleWord = useCallback( - (word) => { - if (selectedWords.has(word)) { + async (word) => { + // Compute next selection locally to sync with content script reliably + const next = new Set(selectedWords); + if (next.has(word)) { + next.delete(word); removeWord(word); } else { + next.add(word); addWord(word); } + try { + await sendToActiveTab('sidePanelUpdateState', { + clearSelection: true, + selectedWords: Array.from(next), + }); + } catch (err) { + console.error('Failed to sync toggle to content script:', err); + } }, - [selectedWords, addWord, removeWord] + [selectedWords, addWord, removeWord, sendToActiveTab] ); /** * Request word selection state from content script */ + const inFlightRef = useRef(false); + const lastSyncTsRef = useRef(0); + const minSyncIntervalMs = 600; + const syncWithContentScript = useCallback(async () => { + const now = Date.now(); + if (inFlightRef.current) { + return; // prevent parallel requests + } + if (now - lastSyncTsRef.current < minSyncIntervalMs) { + return; // throttle repetitive syncs + } + inFlightRef.current = true; try { const response = await sendToActiveTab('sidePanelGetState', {}); @@ -98,6 +124,9 @@ export function useWordSelection() { } } catch (err) { console.error('Failed to sync with content script:', err); + } finally { + lastSyncTsRef.current = Date.now(); + inFlightRef.current = false; } }, [ sendToActiveTab, @@ -170,10 +199,8 @@ export function useWordSelection() { await chrome.storage.local.set({ sidePanelLastSelection: { words: Array.from(selectedWords), - sourceLanguage: - useSidePanelContext().sourceLanguage, - targetLanguage: - useSidePanelContext().targetLanguage, + sourceLanguage, + targetLanguage, timestamp: Date.now(), }, }); @@ -186,7 +213,7 @@ export function useWordSelection() { if (selectedWords.size > 0) { persistSelection(); } - }, [selectedWords]); + }, [selectedWords, sourceLanguage, targetLanguage]); /** * Listen for word selection events @@ -207,21 +234,61 @@ export function useWordSelection() { * Listen for tab changes to update selection */ useEffect(() => { - const handleTabChange = async () => { - const tab = await getActiveTab(); - if (tab) { - await syncWithContentScript(); + let syncTimer = null; + const activeTabIdRef = { current: null }; + const lastUrlByTabRef = { current: new Map() }; + const debouncedSync = () => { + if (syncTimer) clearTimeout(syncTimer); + syncTimer = setTimeout(() => { + syncWithContentScript().catch((e) => + console.warn('Debounced sync failed:', e) + ); + }, 200); + }; + + const handleTabActivated = async (activeInfo) => { + activeTabIdRef.current = activeInfo?.tabId ?? activeTabIdRef.current; + debouncedSync(); + }; + const handleTabUpdated = async (tabId, changeInfo, tab) => { + // Only act on the currently active tab + if (activeTabIdRef.current != null && tabId !== activeTabIdRef.current) { + return; + } + const newUrl = changeInfo?.url || tab?.url || null; + let shouldSync = false; + if (newUrl) { + const prevUrl = lastUrlByTabRef.current.get(tabId); + if (prevUrl !== newUrl) { + lastUrlByTabRef.current.set(tabId, newUrl); + shouldSync = true; + } + } + if (changeInfo?.status === 'complete') { + shouldSync = true; + } + if (shouldSync) { + debouncedSync(); } }; - chrome.tabs.onActivated.addListener(handleTabChange); - chrome.tabs.onUpdated.addListener(handleTabChange); + chrome.tabs.onActivated.addListener(handleTabActivated); + chrome.tabs.onUpdated.addListener(handleTabUpdated); + + // Initialize active tab id + chrome.tabs.query({ active: true, currentWindow: true }).then(([tab]) => { + if (tab && tab.id) { + activeTabIdRef.current = tab.id; + if (tab.url) lastUrlByTabRef.current.set(tab.id, tab.url); + } + }).catch(() => {}); return () => { - chrome.tabs.onActivated.removeListener(handleTabChange); - chrome.tabs.onUpdated.removeListener(handleTabChange); + if (syncTimer) clearTimeout(syncTimer); + chrome.tabs.onActivated.removeListener(handleTabActivated); + chrome.tabs.onUpdated.removeListener(handleTabUpdated); }; - }, [getActiveTab, syncWithContentScript]); + }, [syncWithContentScript]); return { selectedWords, diff --git a/video_platforms/disneyPlusPlatform.js b/video_platforms/disneyPlusPlatform.js index c2fb74e..4fb575d 100644 --- a/video_platforms/disneyPlusPlatform.js +++ b/video_platforms/disneyPlusPlatform.js @@ -238,7 +238,11 @@ export class DisneyPlusPlatform extends BasePlatformAdapter { this._handleInjectorEvents(e); } - getVideoElement() { +getVideoElement() { + // Prefer the known Disney+ primary player + const hive = document.getElementById('hivePlayer'); + if (hive && hive.tagName === 'VIDEO') return hive; + // Fallback to the first video element return document.querySelector('video'); } @@ -286,6 +290,65 @@ export class DisneyPlusPlatform extends BasePlatformAdapter { } } + /** + * Platform-specific playback helpers for Disney+ + */ + _getToggleButtonRoot() { + try { + const toggleHost = document.querySelector('disney-web-player-ui toggle-play-pause'); + return toggleHost?.shadowRoot || null; + } catch (_) { + return null; + } + } + + isPlaying() { + try { + const root = this._getToggleButtonRoot(); + if (!root) return null; + const roleBtn = root.querySelector('[role="button"]'); + const label = roleBtn?.getAttribute('aria-label'); + if (!label) return null; + return label === 'Pause'; + } catch (_) { + return null; + } + } + + async pausePlayback() { + try { + const state = this.isPlaying(); + if (state === false) return true; + const root = this._getToggleButtonRoot(); + if (!root) return false; + const btn = root.querySelector('button') || root.querySelector('[role="button"]'); + if (!btn) return false; + btn.click(); + await new Promise((r) => setTimeout(r, 160)); + const after = this.isPlaying(); + return after === false; + } catch (_) { + return false; + } + } + + async resumePlayback() { + try { + const state = this.isPlaying(); + if (state === true) return true; + const root = this._getToggleButtonRoot(); + if (!root) return false; + const btn = root.querySelector('button') || root.querySelector('[role="button"]'); + if (!btn) return false; + btn.click(); + await new Promise((r) => setTimeout(r, 160)); + const after = this.isPlaying(); + return after === true; + } catch (_) { + return false; + } + } + /** * Deep querySelector that traverses shadow DOM trees to find the first match * @param {string[]|string} selectors - One or more selectors to try From a48b8dc5af9a742d55e044b9255db74225c23959 Mon Sep 17 00:00:00 2001 From: Jiaying Wang <35688096+QuellaMC@users.noreply.github.com> Date: Tue, 14 Oct 2025 16:08:24 -0400 Subject: [PATCH 03/23] style: Auto-format and lint code (#56) (#58) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 57 ++- README_zh.md | 2 +- _locales/en/messages.json | 40 +- _locales/es/messages.json | 52 +- _locales/ja/messages.json | 44 +- _locales/ko/messages.json | 36 +- _locales/zh_CN/messages.json | 16 +- _locales/zh_TW/messages.json | 20 +- background/services/translationService.js | 12 +- config/configSchema.js | 12 +- content_scripts/core/BaseContentScript.js | 14 +- docs/en/installation.md | 16 +- docs/zh/installation.md | 14 +- options/OptionsApp.jsx | 2 +- options/components/AppleStyleFileButton.jsx | 47 +- options/components/SparkleButton.jsx | 7 +- options/components/TestResultDisplay.jsx | 6 +- .../providers/DeepLFreeProviderCard.jsx | 42 +- .../providers/DeepLProviderCard.jsx | 34 +- .../providers/GoogleProviderCard.jsx | 11 +- .../providers/MicrosoftProviderCard.jsx | 11 +- .../OpenAICompatibleProviderCard.jsx | 54 ++- .../providers/VertexProviderCard.jsx | 87 +++- .../components/sections/AIContextSection.jsx | 103 ++-- options/components/sections/AboutSection.jsx | 4 +- .../components/sections/GeneralSection.jsx | 2 +- .../components/sections/ProvidersSection.jsx | 48 +- .../sections/TranslationSection.jsx | 217 +++++++-- options/hooks/useBackgroundReady.js | 54 ++- options/hooks/useDeepLTest.js | 213 +++++--- options/hooks/useOpenAITest.js | 153 +++--- options/hooks/useVertexTest.js | 457 ++++++++++-------- options/options.css | 19 +- options/options.html | 2 +- popup/PopupApp.jsx | 99 ++-- popup/components/SliderSetting.jsx | 19 +- popup/hooks/useSettings.js | 10 +- popup/hooks/useTranslation.js | 43 +- .../geminiVertexTranslate.js | 117 +++-- utils/vertexAuth.js | 30 +- vite.config.js | 11 +- 41 files changed, 1469 insertions(+), 768 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ebbba..b79a870 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,37 +8,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.4.0] - 2025-09-30 ### 🎉 Major Changes + - **Full React Migration**: Migrated popup and options pages to React - - Modern component-based architecture - - Improved maintainability and code organization - - Better state management with React hooks - - 100% functional parity with vanilla JavaScript version - - Identical UI/UX experience + - Modern component-based architecture + - Improved maintainability and code organization + - Better state management with React hooks + - 100% functional parity with vanilla JavaScript version + - Identical UI/UX experience ### ✨ Added + - React-based popup interface with custom hooks: - - `useSettings` for settings management - - `useTranslation` for i18n support - - `useLogger` for error tracking - - `useChromeMessage` for Chrome API integration + - `useSettings` for settings management + - `useTranslation` for i18n support + - `useLogger` for error tracking + - `useChromeMessage` for Chrome API integration - React-based options page with modular sections: - - `GeneralSection` for general preferences - - `TranslationSection` for translation settings and batch configuration - - `ProvidersSection` for provider management - - `AIContextSection` for AI context configuration - - `AboutSection` for extension information + - `GeneralSection` for general preferences + - `TranslationSection` for translation settings and batch configuration + - `ProvidersSection` for provider management + - `AIContextSection` for AI context configuration + - `AboutSection` for extension information - Reusable React components: - - `SettingCard`, `ToggleSwitch`, `SettingToggle` - - `LanguageSelector`, `SliderSetting`, `StatusMessage` - - `TestResultDisplay`, `SparkleButton` - - Provider cards for all translation services + - `SettingCard`, `ToggleSwitch`, `SettingToggle` + - `LanguageSelector`, `SliderSetting`, `StatusMessage` + - `TestResultDisplay`, `SparkleButton` + - Provider cards for all translation services - Custom hooks for advanced features: - - `useDeepLTest` for DeepL API testing - - `useOpenAITest` for OpenAI API testing and model fetching - - `useBackgroundReady` for service worker status + - `useDeepLTest` for DeepL API testing + - `useOpenAITest` for OpenAI API testing and model fetching + - `useBackgroundReady` for service worker status - Vite build system for optimized production bundles ### 🔧 Changed + - Build system upgraded from vanilla JavaScript to Vite + React - Popup and options pages now use React components - All UI interactions now use React state management @@ -46,40 +49,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Translation loading uses React effects and state ### 🗑️ Removed + - Vanilla JavaScript popup.js and options.js files - Old HTML templates (replaced by React JSX) - Manual DOM manipulation code - jQuery-style event listeners ### 📦 Dependencies + - Added `react` ^19.1.1 - Added `react-dom` ^19.1.1 - Added `vite` ^7.1.7 - Added `@vitejs/plugin-react` ^5.0.4 ### 🐛 Fixed + - Container width consistency in options page across different tabs - AI Context section structure now matches original layout exactly - All i18n translation keys corrected to match message definitions - Proper collapsible Advanced Settings in AI Context section ### 📝 Documentation + - Added comprehensive React migration documentation - Updated README with React-based development information - Added component architecture documentation - Updated build and development instructions ### 🔬 Technical Details + - Bundle sizes (gzipped): - - Popup: 13.47 kB (4.58 kB gzipped) - - Options: 35.24 kB (8.41 kB gzipped) - - Shared translations: 218.89 kB (66.52 kB gzipped) + - Popup: 13.47 kB (4.58 kB gzipped) + - Options: 35.24 kB (8.41 kB gzipped) + - Shared translations: 218.89 kB (66.52 kB gzipped) - Build time: ~600ms - Total React components: 25+ - Custom hooks: 7 - Zero functional differences from vanilla JS version ## [2.3.2] - Previous Version + - All previous features and functionality - Vanilla JavaScript implementation diff --git a/README_zh.md b/README_zh.md index 33d10e4..1b311dc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -84,7 +84,7 @@ ```bash # 生产构建 npm run build - + # 开发模式(自动重新构建) npm run dev ``` diff --git a/_locales/en/messages.json b/_locales/en/messages.json index acffd65..88fa1d1 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -192,13 +192,19 @@ "cardOpenAICompatibleDesc": { "message": "Enter your API key and settings for OpenAI-compatible services like Gemini." }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini (API Key Required)" }, - "cardVertexGeminiDesc": { "message": "Enter your access token and Vertex project settings." }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini (API Key Required)" + }, + "cardVertexGeminiDesc": { + "message": "Enter your access token and Vertex project settings." + }, "vertexAccessTokenLabel": { "message": "Access Token:" }, "vertexProjectIdLabel": { "message": "Project ID:" }, "vertexLocationLabel": { "message": "Location:" }, "vertexModelLabel": { "message": "Model:" }, - "vertexMissingConfig": { "message": "Please enter access token and project ID." }, + "vertexMissingConfig": { + "message": "Please enter access token and project ID." + }, "vertexConnectionFailed": { "message": "Connection failed: %s" }, "vertexServiceAccountLabel": { "message": "Service Account JSON:" }, "vertexImportButton": { "message": "Import JSON File" }, @@ -207,19 +213,33 @@ "vertexImporting": { "message": "Importing..." }, "vertexRefreshingToken": { "message": "Refreshing access token..." }, "vertexGeneratingToken": { "message": "Generating access token..." }, - "vertexImportSuccess": { "message": "Service account imported and token generated." }, + "vertexImportSuccess": { + "message": "Service account imported and token generated." + }, "vertexImportFailed": { "message": "Import failed: %s" }, - "vertexTokenRefreshed": { "message": "Access token refreshed successfully." }, + "vertexTokenRefreshed": { + "message": "Access token refreshed successfully." + }, "vertexRefreshFailed": { "message": "Token refresh failed: %s" }, - "vertexTokenExpired": { "message": "⚠️ Access token expired. Click refresh to renew." }, - "vertexTokenExpiringSoon": { "message": "⚠️ Token expires in %s minutes. Consider refreshing." }, - "vertexConfigured": { "message": "⚠️ Vertex AI configured. Please test connection." }, - "vertexNotConfigured": { "message": "Please import service account JSON or enter credentials." }, + "vertexTokenExpired": { + "message": "⚠️ Access token expired. Click refresh to renew." + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ Token expires in %s minutes. Consider refreshing." + }, + "vertexConfigured": { + "message": "⚠️ Vertex AI configured. Please test connection." + }, + "vertexNotConfigured": { + "message": "Please import service account JSON or enter credentials." + }, "featureVertexServiceAccount": { "message": "Service account JSON import" }, "featureVertexAutoToken": { "message": "Automatic token generation" }, "featureVertexGemini": { "message": "Google Gemini models via Vertex AI" }, "providerNote": { "message": "Note:" }, - "vertexNote": { "message": "Access tokens expire after 1 hour. Your service account is securely stored for easy token refresh - just click the Refresh Token button when needed." }, + "vertexNote": { + "message": "Access tokens expire after 1 hour. Your service account is securely stored for easy token refresh - just click the Refresh Token button when needed." + }, "baseUrlLabel": { "message": "Base URL:" }, "modelLabel": { "message": "Model:" }, "featureCustomizable": { "message": "Customizable endpoint and model" }, diff --git a/_locales/es/messages.json b/_locales/es/messages.json index d04809d..af2f976 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -207,33 +207,59 @@ "cardOpenAICompatibleDesc": { "message": "Ingresa tu clave API y configuraciones para servicios compatibles con OpenAI como Gemini." }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini (Requiere Clave API)" }, - "cardVertexGeminiDesc": { "message": "Ingresa tu token de acceso y configuraciones del proyecto Vertex, o importa un archivo JSON de cuenta de servicio." }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini (Requiere Clave API)" + }, + "cardVertexGeminiDesc": { + "message": "Ingresa tu token de acceso y configuraciones del proyecto Vertex, o importa un archivo JSON de cuenta de servicio." + }, "vertexAccessTokenLabel": { "message": "Token de Acceso:" }, "vertexProjectIdLabel": { "message": "ID del Proyecto:" }, "vertexLocationLabel": { "message": "Ubicación:" }, "vertexModelLabel": { "message": "Modelo:" }, - "vertexMissingConfig": { "message": "Por favor ingresa el token de acceso y el ID del proyecto." }, + "vertexMissingConfig": { + "message": "Por favor ingresa el token de acceso y el ID del proyecto." + }, "vertexConnectionFailed": { "message": "Conexión fallida: %s" }, "vertexServiceAccountLabel": { "message": "JSON de Cuenta de Servicio:" }, "vertexImportButton": { "message": "Importar Archivo JSON" }, "vertexRefreshButton": { "message": "🔄 Actualizar Token" }, - "vertexImportHint": { "message": "Rellena automáticamente las credenciales a continuación" }, + "vertexImportHint": { + "message": "Rellena automáticamente las credenciales a continuación" + }, "vertexImporting": { "message": "Importando..." }, "vertexRefreshingToken": { "message": "Actualizando token de acceso..." }, "vertexGeneratingToken": { "message": "Generando token de acceso..." }, - "vertexImportSuccess": { "message": "Cuenta de servicio importada y token generado." }, + "vertexImportSuccess": { + "message": "Cuenta de servicio importada y token generado." + }, "vertexImportFailed": { "message": "Importación fallida: %s" }, - "vertexTokenRefreshed": { "message": "Token de acceso actualizado exitosamente." }, + "vertexTokenRefreshed": { + "message": "Token de acceso actualizado exitosamente." + }, "vertexRefreshFailed": { "message": "Actualización de token fallida: %s" }, - "vertexTokenExpired": { "message": "⚠️ Token de acceso expirado. Haz clic en actualizar para renovar." }, - "vertexTokenExpiringSoon": { "message": "⚠️ El token expira en %s minutos. Considera actualizarlo." }, - "vertexConfigured": { "message": "⚠️ Vertex AI configurado. Por favor prueba la conexión." }, - "vertexNotConfigured": { "message": "Por favor importa el JSON de cuenta de servicio o ingresa las credenciales." }, - "featureVertexServiceAccount": { "message": "Importación de JSON de cuenta de servicio" }, + "vertexTokenExpired": { + "message": "⚠️ Token de acceso expirado. Haz clic en actualizar para renovar." + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ El token expira en %s minutos. Considera actualizarlo." + }, + "vertexConfigured": { + "message": "⚠️ Vertex AI configurado. Por favor prueba la conexión." + }, + "vertexNotConfigured": { + "message": "Por favor importa el JSON de cuenta de servicio o ingresa las credenciales." + }, + "featureVertexServiceAccount": { + "message": "Importación de JSON de cuenta de servicio" + }, "featureVertexAutoToken": { "message": "Generación automática de tokens" }, - "featureVertexGemini": { "message": "Modelos Google Gemini a través de Vertex AI" }, - "vertexNote": { "message": "Los tokens de acceso expiran después de 1 hora. Tu cuenta de servicio está almacenada de forma segura para facilitar la actualización del token - solo haz clic en el botón Actualizar Token cuando sea necesario." }, + "featureVertexGemini": { + "message": "Modelos Google Gemini a través de Vertex AI" + }, + "vertexNote": { + "message": "Los tokens de acceso expiran después de 1 hora. Tu cuenta de servicio está almacenada de forma segura para facilitar la actualización del token - solo haz clic en el botón Actualizar Token cuando sea necesario." + }, "baseUrlLabel": { "message": "URL Base:" }, "modelLabel": { "message": "Modelo:" }, "featureCustomizable": { "message": "Endpoint y modelo personalizables" }, diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 483428d..d3c64ed 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -186,18 +186,24 @@ "providerDeepLName": { "message": "DeepL(APIキー必須)" }, "providerDeepLFreeName": { "message": "DeepL翻訳(無料)" }, "providerOpenAICompatibleName": { "message": "OpenAI互換(APIキー必須)" }, - "providerVertexGeminiName": { "message": "Vertex AI Gemini(APIキー必須)" }, + "providerVertexGeminiName": { + "message": "Vertex AI Gemini(APIキー必須)" + }, "cardOpenAICompatibleTitle": { "message": "OpenAI互換(APIキー必須)" }, "cardOpenAICompatibleDesc": { "message": "GeminiなどのOpenAI互換サービス用のAPIキーと設定を入力してください。" }, "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(APIキー必須)" }, - "cardVertexGeminiDesc": { "message": "アクセストークンとVertex プロジェクト設定を入力するか、サービスアカウントJSONファイルをインポートしてください。" }, + "cardVertexGeminiDesc": { + "message": "アクセストークンとVertex プロジェクト設定を入力するか、サービスアカウントJSONファイルをインポートしてください。" + }, "vertexAccessTokenLabel": { "message": "アクセストークン:" }, "vertexProjectIdLabel": { "message": "プロジェクトID:" }, "vertexLocationLabel": { "message": "ロケーション:" }, "vertexModelLabel": { "message": "モデル:" }, - "vertexMissingConfig": { "message": "アクセストークンとプロジェクトIDを入力してください。" }, + "vertexMissingConfig": { + "message": "アクセストークンとプロジェクトIDを入力してください。" + }, "vertexConnectionFailed": { "message": "接続に失敗しました:%s" }, "vertexServiceAccountLabel": { "message": "サービスアカウントJSON:" }, "vertexImportButton": { "message": "JSONファイルをインポート" }, @@ -206,18 +212,34 @@ "vertexImporting": { "message": "インポート中..." }, "vertexRefreshingToken": { "message": "アクセストークンを更新中..." }, "vertexGeneratingToken": { "message": "アクセストークンを生成中..." }, - "vertexImportSuccess": { "message": "サービスアカウントがインポートされ、トークンが生成されました。" }, + "vertexImportSuccess": { + "message": "サービスアカウントがインポートされ、トークンが生成されました。" + }, "vertexImportFailed": { "message": "インポートに失敗しました:%s" }, - "vertexTokenRefreshed": { "message": "アクセストークンが正常に更新されました。" }, + "vertexTokenRefreshed": { + "message": "アクセストークンが正常に更新されました。" + }, "vertexRefreshFailed": { "message": "トークンの更新に失敗しました:%s" }, - "vertexTokenExpired": { "message": "⚠️ アクセストークンが期限切れです。更新をクリックして更新してください。" }, - "vertexTokenExpiringSoon": { "message": "⚠️ トークンは%s分で期限切れになります。更新を検討してください。" }, - "vertexConfigured": { "message": "⚠️ Vertex AIが設定されています。接続をテストしてください。" }, - "vertexNotConfigured": { "message": "サービスアカウントJSONをインポートするか、認証情報を入力してください。" }, - "featureVertexServiceAccount": { "message": "サービスアカウントJSONのインポート" }, + "vertexTokenExpired": { + "message": "⚠️ アクセストークンが期限切れです。更新をクリックして更新してください。" + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ トークンは%s分で期限切れになります。更新を検討してください。" + }, + "vertexConfigured": { + "message": "⚠️ Vertex AIが設定されています。接続をテストしてください。" + }, + "vertexNotConfigured": { + "message": "サービスアカウントJSONをインポートするか、認証情報を入力してください。" + }, + "featureVertexServiceAccount": { + "message": "サービスアカウントJSONのインポート" + }, "featureVertexAutoToken": { "message": "自動トークン生成" }, "featureVertexGemini": { "message": "Vertex AI経由のGoogle Geminiモデル" }, - "vertexNote": { "message": "アクセストークンは1時間後に期限切れになります。サービスアカウントは安全に保存されており、簡単にトークンを更新できます - 必要に応じてトークン更新ボタンをクリックしてください。" }, + "vertexNote": { + "message": "アクセストークンは1時間後に期限切れになります。サービスアカウントは安全に保存されており、簡単にトークンを更新できます - 必要に応じてトークン更新ボタンをクリックしてください。" + }, "baseUrlLabel": { "message": "ベースURL:" }, "modelLabel": { "message": "モデル:" }, "featureCustomizable": { diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index ed66994..8102b13 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -190,12 +190,16 @@ "message": "Gemini와 같은 OpenAI 호환 서비스를 위한 API 키와 설정을 입력하세요." }, "cardVertexGeminiTitle": { "message": "Vertex AI Gemini (API 키 필요)" }, - "cardVertexGeminiDesc": { "message": "액세스 토큰과 Vertex 프로젝트 설정을 입력하거나 서비스 계정 JSON 파일을 가져오세요." }, + "cardVertexGeminiDesc": { + "message": "액세스 토큰과 Vertex 프로젝트 설정을 입력하거나 서비스 계정 JSON 파일을 가져오세요." + }, "vertexAccessTokenLabel": { "message": "액세스 토큰:" }, "vertexProjectIdLabel": { "message": "프로젝트 ID:" }, "vertexLocationLabel": { "message": "위치:" }, "vertexModelLabel": { "message": "모델:" }, - "vertexMissingConfig": { "message": "액세스 토큰과 프로젝트 ID를 입력하세요." }, + "vertexMissingConfig": { + "message": "액세스 토큰과 프로젝트 ID를 입력하세요." + }, "vertexConnectionFailed": { "message": "연결 실패: %s" }, "vertexServiceAccountLabel": { "message": "서비스 계정 JSON:" }, "vertexImportButton": { "message": "JSON 파일 가져오기" }, @@ -204,18 +208,32 @@ "vertexImporting": { "message": "가져오는 중..." }, "vertexRefreshingToken": { "message": "액세스 토큰 새로고침 중..." }, "vertexGeneratingToken": { "message": "액세스 토큰 생성 중..." }, - "vertexImportSuccess": { "message": "서비스 계정을 가져오고 토큰을 생성했습니다." }, + "vertexImportSuccess": { + "message": "서비스 계정을 가져오고 토큰을 생성했습니다." + }, "vertexImportFailed": { "message": "가져오기 실패: %s" }, - "vertexTokenRefreshed": { "message": "액세스 토큰이 성공적으로 새로고침되었습니다." }, + "vertexTokenRefreshed": { + "message": "액세스 토큰이 성공적으로 새로고침되었습니다." + }, "vertexRefreshFailed": { "message": "토큰 새로고침 실패: %s" }, - "vertexTokenExpired": { "message": "⚠️ 액세스 토큰이 만료되었습니다. 새로고침을 클릭하여 갱신하세요." }, - "vertexTokenExpiringSoon": { "message": "⚠️ 토큰이 %s분 후에 만료됩니다. 새로고침을 고려하세요." }, - "vertexConfigured": { "message": "⚠️ Vertex AI가 구성되었습니다. 연결을 테스트하세요." }, - "vertexNotConfigured": { "message": "서비스 계정 JSON을 가져오거나 자격 증명을 입력하세요." }, + "vertexTokenExpired": { + "message": "⚠️ 액세스 토큰이 만료되었습니다. 새로고침을 클릭하여 갱신하세요." + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ 토큰이 %s분 후에 만료됩니다. 새로고침을 고려하세요." + }, + "vertexConfigured": { + "message": "⚠️ Vertex AI가 구성되었습니다. 연결을 테스트하세요." + }, + "vertexNotConfigured": { + "message": "서비스 계정 JSON을 가져오거나 자격 증명을 입력하세요." + }, "featureVertexServiceAccount": { "message": "서비스 계정 JSON 가져오기" }, "featureVertexAutoToken": { "message": "자동 토큰 생성" }, "featureVertexGemini": { "message": "Vertex AI를 통한 Google Gemini 모델" }, - "vertexNote": { "message": "액세스 토큰은 1시간 후에 만료됩니다. 서비스 계정은 안전하게 저장되어 쉽게 토큰을 새로고침할 수 있습니다 - 필요할 때 토큰 새로고침 버튼을 클릭하세요." }, + "vertexNote": { + "message": "액세스 토큰은 1시간 후에 만료됩니다. 서비스 계정은 안전하게 저장되어 쉽게 토큰을 새로고침할 수 있습니다 - 필요할 때 토큰 새로고침 버튼을 클릭하세요." + }, "baseUrlLabel": { "message": "기본 URL:" }, "modelLabel": { "message": "모델:" }, "featureCustomizable": { diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 61c8a50..440b560 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -163,7 +163,9 @@ "message": "输入您的 API 密钥和设置,用于 Gemini 等 OpenAI 兼容服务。" }, "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(需要 API 密钥)" }, - "cardVertexGeminiDesc": { "message": "输入您的访问令牌和 Vertex 项目设置,或导入服务账号 JSON 文件。" }, + "cardVertexGeminiDesc": { + "message": "输入您的访问令牌和 Vertex 项目设置,或导入服务账号 JSON 文件。" + }, "vertexAccessTokenLabel": { "message": "访问令牌:" }, "vertexProjectIdLabel": { "message": "项目 ID:" }, "vertexLocationLabel": { "message": "位置:" }, @@ -182,14 +184,20 @@ "vertexTokenRefreshed": { "message": "访问令牌刷新成功。" }, "vertexRefreshFailed": { "message": "令牌刷新失败:%s" }, "vertexTokenExpired": { "message": "⚠️ 访问令牌已过期。点击刷新以续期。" }, - "vertexTokenExpiringSoon": { "message": "⚠️ 令牌将在 %s 分钟后过期。建议刷新。" }, + "vertexTokenExpiringSoon": { + "message": "⚠️ 令牌将在 %s 分钟后过期。建议刷新。" + }, "vertexConfigured": { "message": "⚠️ Vertex AI 已配置。请测试连接。" }, "vertexNotConfigured": { "message": "请导入服务账号 JSON 或输入凭据。" }, "featureVertexServiceAccount": { "message": "服务账号 JSON 导入" }, "featureVertexAutoToken": { "message": "自动生成令牌" }, - "featureVertexGemini": { "message": "通过 Vertex AI 使用 Google Gemini 模型" }, + "featureVertexGemini": { + "message": "通过 Vertex AI 使用 Google Gemini 模型" + }, "providerNote": { "message": "注意:" }, - "vertexNote": { "message": "访问令牌在 1 小时后过期。您的服务账号已安全存储,需要时只需点击刷新令牌按钮即可。" }, + "vertexNote": { + "message": "访问令牌在 1 小时后过期。您的服务账号已安全存储,需要时只需点击刷新令牌按钮即可。" + }, "baseUrlLabel": { "message": "基础 URL:" }, "modelLabel": { "message": "模型:" }, "featureCustomizable": { "message": "可自定义端点和模型" }, diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index 642b2ee..4fe520b 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -166,7 +166,9 @@ "message": "輸入您的 API 金鑰和設定,用於 Gemini 等 OpenAI 相容服務。" }, "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(需要 API 金鑰)" }, - "cardVertexGeminiDesc": { "message": "輸入您的存取權杖和 Vertex 專案設定,或匯入服務帳戶 JSON 檔案。" }, + "cardVertexGeminiDesc": { + "message": "輸入您的存取權杖和 Vertex 專案設定,或匯入服務帳戶 JSON 檔案。" + }, "vertexAccessTokenLabel": { "message": "存取權杖:" }, "vertexProjectIdLabel": { "message": "專案 ID:" }, "vertexLocationLabel": { "message": "位置:" }, @@ -184,14 +186,22 @@ "vertexImportFailed": { "message": "匯入失敗:%s" }, "vertexTokenRefreshed": { "message": "存取權杖已成功重新整理。" }, "vertexRefreshFailed": { "message": "權杖重新整理失敗:%s" }, - "vertexTokenExpired": { "message": "⚠️ 存取權杖已過期。點擊重新整理以更新。" }, - "vertexTokenExpiringSoon": { "message": "⚠️ 權杖將在 %s 分鐘後過期。建議重新整理。" }, + "vertexTokenExpired": { + "message": "⚠️ 存取權杖已過期。點擊重新整理以更新。" + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ 權杖將在 %s 分鐘後過期。建議重新整理。" + }, "vertexConfigured": { "message": "⚠️ Vertex AI 已設定。請測試連線。" }, "vertexNotConfigured": { "message": "請匯入服務帳戶 JSON 或輸入憑證。" }, "featureVertexServiceAccount": { "message": "服務帳戶 JSON 匯入" }, "featureVertexAutoToken": { "message": "自動產生權杖" }, - "featureVertexGemini": { "message": "透過 Vertex AI 使用 Google Gemini 模型" }, - "vertexNote": { "message": "存取權杖在 1 小時後過期。您的服務帳戶已安全儲存,需要時只需點擊重新整理權杖按鈕即可。" }, + "featureVertexGemini": { + "message": "透過 Vertex AI 使用 Google Gemini 模型" + }, + "vertexNote": { + "message": "存取權杖在 1 小時後過期。您的服務帳戶已安全儲存,需要時只需點擊重新整理權杖按鈕即可。" + }, "baseUrlLabel": { "message": "基礎 URL:" }, "modelLabel": { "message": "模型:" }, "featureCustomizable": { "message": "可自訂端點和模型" }, diff --git a/background/services/translationService.js b/background/services/translationService.js index 86e6780..548c2da 100644 --- a/background/services/translationService.js +++ b/background/services/translationService.js @@ -32,7 +32,10 @@ import { ProviderNames, ProviderBatchConfigs, } from '../../content_scripts/shared/constants/providers.js'; -import { translate as vertexGeminiTranslate, translateBatch as vertexGeminiTranslateBatch } from '../../translation_providers/geminiVertexTranslate.js'; +import { + translate as vertexGeminiTranslate, + translateBatch as vertexGeminiTranslateBatch, +} from '../../translation_providers/geminiVertexTranslate.js'; import TTLCache from '../../utils/cache/TTLCache.js'; /** @@ -136,10 +139,13 @@ class TranslationService { }, category: 'api_key', batchOptimizations: { - maxBatchSize: ProviderBatchConfigs[Providers.VERTEX_GEMINI].maxBatchSize, + maxBatchSize: + ProviderBatchConfigs[Providers.VERTEX_GEMINI] + .maxBatchSize, contextPreservation: true, exponentialBackoff: true, - delimiter: ProviderBatchConfigs[Providers.VERTEX_GEMINI].delimiter, + delimiter: + ProviderBatchConfigs[Providers.VERTEX_GEMINI].delimiter, }, }, }; diff --git a/config/configSchema.js b/config/configSchema.js index f0d76d4..588c66e 100644 --- a/config/configSchema.js +++ b/config/configSchema.js @@ -86,8 +86,16 @@ export const configSchema = { // Vertex AI Gemini Translation Settings vertexAccessToken: { defaultValue: '', type: String, scope: 'sync' }, vertexProjectId: { defaultValue: '', type: String, scope: 'sync' }, - vertexLocation: { defaultValue: 'us-central1', type: String, scope: 'sync' }, - vertexModel: { defaultValue: 'gemini-2.5-flash', type: String, scope: 'sync' }, + vertexLocation: { + defaultValue: 'us-central1', + type: String, + scope: 'sync', + }, + vertexModel: { + defaultValue: 'gemini-2.5-flash', + type: String, + scope: 'sync', + }, // --- Subtitle Settings (from popup.js & background.js defaults) --- subtitlesEnabled: { defaultValue: true, type: Boolean, scope: 'sync' }, diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index 6fda9a9..36f2969 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -1979,7 +1979,10 @@ export class BaseContentScript { async _initializeBasedOnPageType() { // Check if platform was cleaned up during initialization if (!this.activePlatform) { - this.logWithFallback('warn', 'Platform cleaned up during initialization, aborting'); + this.logWithFallback( + 'warn', + 'Platform cleaned up during initialization, aborting' + ); return false; } @@ -1999,13 +2002,16 @@ export class BaseContentScript { this.logWithFallback('info', 'Initializing platform on player page'); await this._initializePlatformWithTimeout(); - + // Check if platform was cleaned up during async initialization if (!this.activePlatform) { - this.logWithFallback('warn', 'Platform cleaned up during player page initialization, aborting'); + this.logWithFallback( + 'warn', + 'Platform cleaned up during player page initialization, aborting' + ); return false; } - + this.activePlatform.handleNativeSubtitles(); this.platformReady = true; diff --git a/docs/en/installation.md b/docs/en/installation.md index 1564775..d4d3da9 100644 --- a/docs/en/installation.md +++ b/docs/en/installation.md @@ -66,17 +66,17 @@ ``` 3. Build the extension - + The extension uses React and requires building before use: - + ```bash npm run build ``` - + This will create a `dist/` folder with the compiled extension. - + For development with auto-rebuild: - + ```bash npm run dev ``` @@ -110,9 +110,9 @@ ## Troubleshooting - Extension not visible: ensure it's enabled at `chrome://extensions` and optionally pinned in the toolbar -- "Could not load manifest": - - For GitHub releases: make sure you extracted the ZIP and selected the extracted folder - - For development: make sure you selected the `dist/` folder (not the project root!) and ran `npm run build` first +- "Could not load manifest": + - For GitHub releases: make sure you extracted the ZIP and selected the extracted folder + - For development: make sure you selected the `dist/` folder (not the project root!) and ran `npm run build` first - Build errors: ensure you have Node.js 18+ installed and run `npm install` before `npm run build` - No subtitles: verify the platform provides subtitles and they are enabled in the player - AI Context not working: set your API key and model in Advanced Settings; check rate limits and network connectivity diff --git a/docs/zh/installation.md b/docs/zh/installation.md index d923893..ac93585 100644 --- a/docs/zh/installation.md +++ b/docs/zh/installation.md @@ -66,17 +66,17 @@ ``` 3. 构建扩展 - + 扩展使用 React 开发,使用前需要构建: - + ```bash npm run build ``` - + 这将创建 `dist/` 文件夹,其中包含编译后的扩展。 - + 开发模式下自动重新构建: - + ```bash npm run dev ``` @@ -111,8 +111,8 @@ - 扩展不可见:在 `chrome://extensions` 确认已启用,并可选择固定到工具栏 - "无法加载 manifest": - - GitHub 发布版:确保已解压 ZIP 并选择解压后的文件夹 - - 开发版:确保选择的是 `dist/` 文件夹(不是项目根目录!)并且已运行 `npm run build` + - GitHub 发布版:确保已解压 ZIP 并选择解压后的文件夹 + - 开发版:确保选择的是 `dist/` 文件夹(不是项目根目录!)并且已运行 `npm run build` - 构建错误:确保已安装 Node.js 18+,并在 `npm run build` 之前运行 `npm install` - 无字幕可用:确认平台本身提供字幕并在播放器中已开启 - AI 上下文无响应:在高级设置中配置 API 密钥与模型;检查速率限制与网络 diff --git a/options/OptionsApp.jsx b/options/OptionsApp.jsx index 54340bf..2e55a19 100644 --- a/options/OptionsApp.jsx +++ b/options/OptionsApp.jsx @@ -26,7 +26,7 @@ export function OptionsApp() { const handleSettingChange = async (key, value) => { await updateSetting(key, value); - + // If language changes, reload translations if (key === 'uiLanguage') { setCurrentLanguage(value); diff --git a/options/components/AppleStyleFileButton.jsx b/options/components/AppleStyleFileButton.jsx index 2346b59..b82ab70 100644 --- a/options/components/AppleStyleFileButton.jsx +++ b/options/components/AppleStyleFileButton.jsx @@ -4,12 +4,12 @@ import React from 'react'; * Apple-style file upload button component * Mimics the design of macOS file selection buttons */ -export function AppleStyleFileButton({ - onClick, - disabled, - children, +export function AppleStyleFileButton({ + onClick, + disabled, + children, className = '', - loading = false + loading = false, }) { return (
) : ( - - - - @@ -57,4 +57,3 @@ export function AppleStyleFileButton({ ); } - diff --git a/options/components/SparkleButton.jsx b/options/components/SparkleButton.jsx index d8e1daf..44045d7 100644 --- a/options/components/SparkleButton.jsx +++ b/options/components/SparkleButton.jsx @@ -8,7 +8,12 @@ export function SparkleButton({ onClick, disabled, children, className = '' }) { onClick={onClick} disabled={disabled} > - + diff --git a/options/components/TestResultDisplay.jsx b/options/components/TestResultDisplay.jsx index 1ab06a2..1186a91 100644 --- a/options/components/TestResultDisplay.jsx +++ b/options/components/TestResultDisplay.jsx @@ -5,9 +5,5 @@ export function TestResultDisplay({ result }) { return null; } - return ( -
- {result.message} -
- ); + return
{result.message}
; } diff --git a/options/components/providers/DeepLFreeProviderCard.jsx b/options/components/providers/DeepLFreeProviderCard.jsx index da8d4dd..99e3601 100644 --- a/options/components/providers/DeepLFreeProviderCard.jsx +++ b/options/components/providers/DeepLFreeProviderCard.jsx @@ -22,17 +22,47 @@ export function DeepLFreeProviderCard({ t }) {
  • {t('featureFree', 'Free to use')}
  • {t('featureNoApiKey', 'No API key required')}
  • -
  • {t('featureHighestQuality', 'Highest quality translation')}
  • -
  • {t('featureWideLanguageSupport', 'Wide language support')}
  • -
  • {t('featureMultipleBackups', 'Multiple backup methods')}
  • +
  • + {t( + 'featureHighestQuality', + 'Highest quality translation' + )} +
  • +
  • + {t( + 'featureWideLanguageSupport', + 'Wide language support' + )} +
  • +
  • + {t( + 'featureMultipleBackups', + 'Multiple backup methods' + )} +
{t('providerNotes', 'Notes:')}
    -
  • {t('noteSlowForSecurity', 'Slightly slower due to security measures')}
  • -
  • {t('noteAutoFallback', 'Automatic fallback to alternative services')}
  • -
  • {t('noteRecommendedDefault', 'Recommended as default provider')}
  • +
  • + {t( + 'noteSlowForSecurity', + 'Slightly slower due to security measures' + )} +
  • +
  • + {t( + 'noteAutoFallback', + 'Automatic fallback to alternative services' + )} +
  • +
  • + {t( + 'noteRecommendedDefault', + 'Recommended as default provider' + )} +
diff --git a/options/components/providers/DeepLProviderCard.jsx b/options/components/providers/DeepLProviderCard.jsx index daa9e50..6553dec 100644 --- a/options/components/providers/DeepLProviderCard.jsx +++ b/options/components/providers/DeepLProviderCard.jsx @@ -4,8 +4,15 @@ import { SparkleButton } from '../SparkleButton.jsx'; import { TestResultDisplay } from '../TestResultDisplay.jsx'; import { useDeepLTest } from '../../hooks/index.js'; -export function DeepLProviderCard({ t, apiKey, apiPlan, onApiKeyChange, onApiPlanChange }) { - const { testResult, testing, testConnection, initializeStatus } = useDeepLTest(t); +export function DeepLProviderCard({ + t, + apiKey, + apiPlan, + onApiKeyChange, + onApiPlanChange, +}) { + const { testResult, testing, testConnection, initializeStatus } = + useDeepLTest(t); // Initialize status when component mounts or API key changes useEffect(() => { @@ -61,10 +68,9 @@ export function DeepLProviderCard({ t, apiKey, apiPlan, onApiKeyChange, onApiPla onClick={handleTest} disabled={testing || !apiKey} > - {testing + {testing ? t('testingButton', 'Testing...') - : t('testDeepLButton', 'Test DeepL Connection') - } + : t('testDeepLButton', 'Test DeepL Connection')}
@@ -72,9 +78,21 @@ export function DeepLProviderCard({ t, apiKey, apiPlan, onApiKeyChange, onApiPla
{t('providerFeatures', 'Features:')}
diff --git a/options/components/providers/GoogleProviderCard.jsx b/options/components/providers/GoogleProviderCard.jsx index 220190f..9d10afc 100644 --- a/options/components/providers/GoogleProviderCard.jsx +++ b/options/components/providers/GoogleProviderCard.jsx @@ -22,8 +22,15 @@ export function GoogleProviderCard({ t }) { diff --git a/options/components/providers/MicrosoftProviderCard.jsx b/options/components/providers/MicrosoftProviderCard.jsx index 67f5632..6a632e8 100644 --- a/options/components/providers/MicrosoftProviderCard.jsx +++ b/options/components/providers/MicrosoftProviderCard.jsx @@ -22,8 +22,15 @@ export function MicrosoftProviderCard({ t }) { diff --git a/options/components/providers/OpenAICompatibleProviderCard.jsx b/options/components/providers/OpenAICompatibleProviderCard.jsx index 9892ac4..affb259 100644 --- a/options/components/providers/OpenAICompatibleProviderCard.jsx +++ b/options/components/providers/OpenAICompatibleProviderCard.jsx @@ -25,8 +25,14 @@ export function OpenAICompatibleProviderCard({ onModelChange, onModelsLoaded, }) { - const { testResult, testing, fetchingModels, testConnection, fetchModels, initializeStatus } = - useOpenAITest(t, fetchAvailableModels); + const { + testResult, + testing, + fetchingModels, + testConnection, + fetchModels, + initializeStatus, + } = useOpenAITest(t, fetchAvailableModels); // Create debounced model fetching const debouncedFetchRef = useRef( @@ -38,7 +44,7 @@ export function OpenAICompatibleProviderCard({ // Initialize status useEffect(() => { initializeStatus(apiKey); - + // Auto-fetch models if API key exists if (apiKey) { fetchModels(apiKey, baseUrl, onModelsLoaded); @@ -68,7 +74,10 @@ export function OpenAICompatibleProviderCard({ return ( handleApiKeyChange(e.target.value)} /> @@ -94,7 +106,10 @@ export function OpenAICompatibleProviderCard({ handleBaseUrlChange(e.target.value)} /> @@ -117,7 +132,11 @@ export function OpenAICompatibleProviderCard({ )) ) : ( - + )} @@ -130,8 +149,7 @@ export function OpenAICompatibleProviderCard({ > {testing ? t('testingButton', 'Testing...') - : t('testConnectionButton', 'Test Connection') - } + : t('testConnectionButton', 'Test Connection')} @@ -139,9 +157,21 @@ export function OpenAICompatibleProviderCard({
{t('providerFeatures', 'Features:')}
    -
  • {t('featureCustomizable', 'Customizable endpoint and model')}
  • -
  • {t('featureApiKeyRequired', 'API key required')}
  • -
  • {t('featureWideLanguageSupport', 'Wide language support')}
  • +
  • + {t( + 'featureCustomizable', + 'Customizable endpoint and model' + )} +
  • +
  • + {t('featureApiKeyRequired', 'API key required')} +
  • +
  • + {t( + 'featureWideLanguageSupport', + 'Wide language support' + )} +
diff --git a/options/components/providers/VertexProviderCard.jsx b/options/components/providers/VertexProviderCard.jsx index d4f099f..19ee528 100644 --- a/options/components/providers/VertexProviderCard.jsx +++ b/options/components/providers/VertexProviderCard.jsx @@ -18,23 +18,28 @@ export function VertexProviderCard({ onProviderChange, }) { const fileInputRef = useRef(null); - const { - testResult, - importResult, - testing, + const { + testResult, + importResult, + testing, importing, - testConnection, + testConnection, importServiceAccountJson, refreshToken, checkTokenExpiration, - initializeStatus - } = useVertexTest(t, onAccessTokenChange, onProjectIdChange, onProviderChange); + initializeStatus, + } = useVertexTest( + t, + onAccessTokenChange, + onProjectIdChange, + onProviderChange + ); // Initialize status and setup auto-refresh on mount useEffect(() => { const checkAndRefreshToken = async () => { const expirationInfo = await checkTokenExpiration(); - + if (expirationInfo) { // Auto-refresh if token is expired or will expire in less than 5 minutes if (expirationInfo.isExpired || expirationInfo.shouldRefresh) { @@ -44,7 +49,10 @@ export function VertexProviderCard({ console.log('[Vertex AI] Token auto-refreshed'); } catch (error) { // Error already handled in hook - console.error('[Vertex AI] Auto-refresh failed:', error); + console.error( + '[Vertex AI] Auto-refresh failed:', + error + ); } } } @@ -55,12 +63,21 @@ export function VertexProviderCard({ checkAndRefreshToken(); // Setup periodic check every 5 minutes - const interval = setInterval(() => { - checkAndRefreshToken(); - }, 5 * 60 * 1000); // Check every 5 minutes + const interval = setInterval( + () => { + checkAndRefreshToken(); + }, + 5 * 60 * 1000 + ); // Check every 5 minutes return () => clearInterval(interval); - }, [accessToken, projectId, initializeStatus, checkTokenExpiration, refreshToken]); + }, [ + accessToken, + projectId, + initializeStatus, + checkTokenExpiration, + refreshToken, + ]); const handleTest = () => { const loc = location || 'us-central1'; @@ -103,7 +120,10 @@ export function VertexProviderCard({ return ( - + {importing ? t('vertexImporting', 'Importing...') - : t('vertexImportButton', 'Import JSON File') - } + : t('vertexImportButton', 'Import JSON File')} @@ -198,8 +219,7 @@ export function VertexProviderCard({ > {testing ? t('testingButton', 'Testing...') - : t('testConnectionButton', 'Test Connection') - } + : t('testConnectionButton', 'Test Connection')} @@ -208,14 +228,33 @@ export function VertexProviderCard({
{t('providerFeatures', 'Features:')}
    -
  • {t('featureVertexServiceAccount', 'Service account JSON import')}
  • -
  • {t('featureVertexAutoToken', 'Automatic token generation')}
  • -
  • {t('featureVertexGemini', 'Google Gemini models via Vertex AI')}
  • -
  • {t('featureWideLanguageSupport', 'Wide language support')}
  • +
  • + {t( + 'featureVertexServiceAccount', + 'Service account JSON import' + )} +
  • +
  • + {t( + 'featureVertexAutoToken', + 'Automatic token generation' + )} +
  • +
  • + {t( + 'featureVertexGemini', + 'Google Gemini models via Vertex AI' + )} +
  • +
  • + {t( + 'featureWideLanguageSupport', + 'Wide language support' + )} +
); } - diff --git a/options/components/sections/AIContextSection.jsx b/options/components/sections/AIContextSection.jsx index 2c8abae..df04419 100644 --- a/options/components/sections/AIContextSection.jsx +++ b/options/components/sections/AIContextSection.jsx @@ -27,7 +27,7 @@ export function AIContextSection({ t, settings, onSettingChange }) { const typesArray = Object.entries(newTypes) .filter(([_, enabled]) => enabled) .map(([type]) => type); - + onSettingChange('aiContextTypes', typesArray); }; @@ -37,10 +37,13 @@ export function AIContextSection({ t, settings, onSettingChange }) { return (

{t('sectionAIContext', 'AI Context Assistant')}

- + {/* Card 1: Feature Toggle */} - onSettingChange('aiContextProvider', e.target.value) + onSettingChange( + 'aiContextProvider', + e.target.value + ) } > @@ -132,31 +138,34 @@ export function AIContextSection({ t, settings, onSettingChange }) { - onSettingChange('aiContextTimeout', parseInt(e.target.value)) + onSettingChange( + 'aiContextTimeout', + parseInt(e.target.value) + ) } />
- onSettingChange('aiContextRateLimit', parseInt(e.target.value)) + onSettingChange( + 'aiContextRateLimit', + parseInt(e.target.value) + ) } />
@@ -331,14 +361,20 @@ export function AIContextSection({ t, settings, onSettingChange }) { id="aiContextCacheEnabled" checked={settings.aiContextCacheEnabled || false} onChange={(checked) => - onSettingChange('aiContextCacheEnabled', checked) + onSettingChange( + 'aiContextCacheEnabled', + checked + ) } />
- onSettingChange('aiContextRetryAttempts', parseInt(e.target.value)) + onSettingChange( + 'aiContextRetryAttempts', + parseInt(e.target.value) + ) } />
@@ -356,4 +395,4 @@ export function AIContextSection({ t, settings, onSettingChange }) { )}
); -} \ No newline at end of file +} diff --git a/options/components/sections/AboutSection.jsx b/options/components/sections/AboutSection.jsx index 8a58ab6..a059e39 100644 --- a/options/components/sections/AboutSection.jsx +++ b/options/components/sections/AboutSection.jsx @@ -23,9 +23,7 @@ export function AboutSection({ t }) { 'This extension helps you watch videos with dual language subtitles on various platforms.' )}

-

- {t('aboutDevelopment', 'Developed by ')}{' '} -

+

{t('aboutDevelopment', 'Developed by ')}

); diff --git a/options/components/sections/GeneralSection.jsx b/options/components/sections/GeneralSection.jsx index 469212d..e7f021f 100644 --- a/options/components/sections/GeneralSection.jsx +++ b/options/components/sections/GeneralSection.jsx @@ -6,7 +6,7 @@ export function GeneralSection({ t, settings, onSettingChange }) { return (

{t('sectionGeneral', 'General')}

- + { setOpenaiModels(models); - + // Save the first model as default if no model is currently selected if (models && models.length > 0) { const savedModel = settings.openaiCompatibleModel; const isValidModel = savedModel && models.includes(savedModel); - + if (!isValidModel) { // Use first model as default await onSettingChange('openaiCompatibleModel', models[0]); @@ -36,9 +36,7 @@ export function ProvidersSection({ t, settings, onSettingChange }) {

{t('sectionProviders', 'Provider Settings')}

- {selectedProvider === 'google' && ( - - )} + {selectedProvider === 'google' && } {selectedProvider === 'microsoft_edge_auth' && ( @@ -53,8 +51,12 @@ export function ProvidersSection({ t, settings, onSettingChange }) { t={t} apiKey={settings.deeplApiKey || ''} apiPlan={settings.deeplApiPlan || 'free'} - onApiKeyChange={(value) => onSettingChange('deeplApiKey', value)} - onApiPlanChange={(value) => onSettingChange('deeplApiPlan', value)} + onApiKeyChange={(value) => + onSettingChange('deeplApiKey', value) + } + onApiPlanChange={(value) => + onSettingChange('deeplApiPlan', value) + } /> )} @@ -65,9 +67,15 @@ export function ProvidersSection({ t, settings, onSettingChange }) { baseUrl={settings.openaiCompatibleBaseUrl || ''} model={settings.openaiCompatibleModel || ''} models={openaiModels} - onApiKeyChange={(value) => onSettingChange('openaiCompatibleApiKey', value)} - onBaseUrlChange={(value) => onSettingChange('openaiCompatibleBaseUrl', value)} - onModelChange={(value) => onSettingChange('openaiCompatibleModel', value)} + onApiKeyChange={(value) => + onSettingChange('openaiCompatibleApiKey', value) + } + onBaseUrlChange={(value) => + onSettingChange('openaiCompatibleBaseUrl', value) + } + onModelChange={(value) => + onSettingChange('openaiCompatibleModel', value) + } onModelsLoaded={handleOpenAIModelsLoaded} /> )} @@ -79,11 +87,21 @@ export function ProvidersSection({ t, settings, onSettingChange }) { projectId={settings.vertexProjectId || ''} location={settings.vertexLocation || 'us-central1'} model={settings.vertexModel || 'gemini-2.5-flash'} - onAccessTokenChange={(value) => onSettingChange('vertexAccessToken', value)} - onProjectIdChange={(value) => onSettingChange('vertexProjectId', value)} - onLocationChange={(value) => onSettingChange('vertexLocation', value)} - onModelChange={(value) => onSettingChange('vertexModel', value)} - onProviderChange={(value) => onSettingChange('selectedProvider', value)} + onAccessTokenChange={(value) => + onSettingChange('vertexAccessToken', value) + } + onProjectIdChange={(value) => + onSettingChange('vertexProjectId', value) + } + onLocationChange={(value) => + onSettingChange('vertexLocation', value) + } + onModelChange={(value) => + onSettingChange('vertexModel', value) + } + onProviderChange={(value) => + onSettingChange('selectedProvider', value) + } /> )}
diff --git a/options/components/sections/TranslationSection.jsx b/options/components/sections/TranslationSection.jsx index b3dcef4..e4534f9 100644 --- a/options/components/sections/TranslationSection.jsx +++ b/options/components/sections/TranslationSection.jsx @@ -18,7 +18,7 @@ export function TranslationSection({ t, settings, onSettingChange }) { return (

{t('sectionTranslation', 'Translation')}

- + - onSettingChange('translationBatchSize', parseInt(e.target.value)) + onSettingChange( + 'translationBatchSize', + parseInt(e.target.value) + ) } /> @@ -84,7 +87,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { step="50" value={settings.translationDelay || 150} onChange={(e) => - onSettingChange('translationDelay', parseInt(e.target.value)) + onSettingChange( + 'translationDelay', + parseInt(e.target.value) + ) } /> @@ -100,7 +106,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -139,7 +148,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { id="useProviderDefaults" checked={useProviderDefaults} onChange={(checked) => - onSettingChange('useProviderDefaults', checked) + onSettingChange( + 'useProviderDefaults', + checked + ) } />
@@ -148,7 +160,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -165,7 +180,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { step="1" value={settings.globalBatchSize || 5} onChange={(e) => - onSettingChange('globalBatchSize', parseInt(e.target.value)) + onSettingChange( + 'globalBatchSize', + parseInt(e.target.value) + ) } />
@@ -174,7 +192,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -195,7 +216,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -212,7 +236,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { step="1" value={settings.maxConcurrentBatches || 2} onChange={(e) => - onSettingChange('maxConcurrentBatches', parseInt(e.target.value)) + onSettingChange( + 'maxConcurrentBatches', + parseInt(e.target.value) + ) } />
@@ -222,7 +249,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { {batchingEnabled && useProviderDefaults && (
- {t('openaieBatchSizeHelp', 'Recommended: 5-10 segments (default: 8)')} + {t( + 'openaieBatchSizeHelp', + 'Recommended: 5-10 segments (default: 8)' + )}
- onSettingChange('openaieBatchSize', parseInt(e.target.value)) + onSettingChange( + 'openaieBatchSize', + parseInt(e.target.value) + ) } />
@@ -253,10 +292,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('googleBatchSizeHelp', 'Recommended: 3-5 segments (default: 4)')} + {t( + 'googleBatchSizeHelp', + 'Recommended: 3-5 segments (default: 4)' + )}
- onSettingChange('googleBatchSize', parseInt(e.target.value)) + onSettingChange( + 'googleBatchSize', + parseInt(e.target.value) + ) } />
@@ -278,7 +326,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { {t('deeplBatchSizeLabel', 'DeepL Batch Size:')}
- {t('deeplBatchSizeHelp', 'Recommended: 2-3 segments (default: 3)')} + {t( + 'deeplBatchSizeHelp', + 'Recommended: 2-3 segments (default: 3)' + )}
- onSettingChange('deeplBatchSize', parseInt(e.target.value)) + onSettingChange( + 'deeplBatchSize', + parseInt(e.target.value) + ) } />
@@ -297,10 +351,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('microsoftBatchSizeHelp', 'Recommended: 3-5 segments (default: 4)')} + {t( + 'microsoftBatchSizeHelp', + 'Recommended: 3-5 segments (default: 4)' + )}
- onSettingChange('microsoftBatchSize', parseInt(e.target.value)) + onSettingChange( + 'microsoftBatchSize', + parseInt(e.target.value) + ) } />
@@ -319,10 +382,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('vertexBatchSizeHelp', 'Recommended: 5-10 segments (default: 8)')} + {t( + 'vertexBatchSizeHelp', + 'Recommended: 5-10 segments (default: 8)' + )}
- onSettingChange('vertexBatchSize', parseInt(e.target.value)) + onSettingChange( + 'vertexBatchSize', + parseInt(e.target.value) + ) } />
@@ -341,7 +413,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { )}
- {t('openaieDelayHelp', 'Minimum delay between requests (default: 100ms)')} + {t( + 'openaieDelayHelp', + 'Minimum delay between requests (default: 100ms)' + )}
- onSettingChange('openaieDelay', parseInt(e.target.value)) + onSettingChange( + 'openaieDelay', + parseInt(e.target.value) + ) } />
@@ -372,10 +456,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('googleDelayHelp', 'Required delay to prevent temporary lockouts (default: 1500ms)')} + {t( + 'googleDelayHelp', + 'Required delay to prevent temporary lockouts (default: 1500ms)' + )}
- onSettingChange('googleDelay', parseInt(e.target.value)) + onSettingChange( + 'googleDelay', + parseInt(e.target.value) + ) } />
@@ -394,10 +487,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('deeplDelayHelp', 'Delay for DeepL API requests (default: 500ms)')} + {t( + 'deeplDelayHelp', + 'Delay for DeepL API requests (default: 500ms)' + )}
- onSettingChange('deeplDelay', parseInt(e.target.value)) + onSettingChange( + 'deeplDelay', + parseInt(e.target.value) + ) } />
@@ -416,10 +518,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('deeplFreeDelayHelp', 'Conservative delay for free tier (default: 2000ms)')} + {t( + 'deeplFreeDelayHelp', + 'Conservative delay for free tier (default: 2000ms)' + )}
- onSettingChange('deeplFreeDelay', parseInt(e.target.value)) + onSettingChange( + 'deeplFreeDelay', + parseInt(e.target.value) + ) } />
@@ -438,10 +549,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('microsoftDelayHelp', 'Delay to respect character limits (default: 800ms)')} + {t( + 'microsoftDelayHelp', + 'Delay to respect character limits (default: 800ms)' + )}
- onSettingChange('microsoftDelay', parseInt(e.target.value)) + onSettingChange( + 'microsoftDelay', + parseInt(e.target.value) + ) } />
@@ -460,10 +580,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('vertexDelayHelp', 'Minimum delay between requests (default: 100ms)')} + {t( + 'vertexDelayHelp', + 'Minimum delay between requests (default: 100ms)' + )}
- onSettingChange('vertexDelay', parseInt(e.target.value)) + onSettingChange( + 'vertexDelay', + parseInt(e.target.value) + ) } />
diff --git a/options/hooks/useBackgroundReady.js b/options/hooks/useBackgroundReady.js index cb9dd78..64b5769 100644 --- a/options/hooks/useBackgroundReady.js +++ b/options/hooks/useBackgroundReady.js @@ -20,30 +20,38 @@ export function useBackgroundReady() { } }, []); - const waitForBackgroundReady = useCallback(async (maxRetries = 10, delay = 500) => { - setChecking(true); - - for (let i = 0; i < maxRetries; i++) { - if (await checkBackgroundReady()) { - console.debug('Background script is ready', { attempt: i + 1 }); - setIsReady(true); - setChecking(false); - return true; + const waitForBackgroundReady = useCallback( + async (maxRetries = 10, delay = 500) => { + setChecking(true); + + for (let i = 0; i < maxRetries; i++) { + if (await checkBackgroundReady()) { + console.debug('Background script is ready', { + attempt: i + 1, + }); + setIsReady(true); + setChecking(false); + return true; + } + console.debug('Background script not ready, retrying...', { + attempt: i + 1, + maxRetries, + }); + await new Promise((resolve) => setTimeout(resolve, delay)); } - console.debug('Background script not ready, retrying...', { - attempt: i + 1, - maxRetries, - }); - await new Promise((resolve) => setTimeout(resolve, delay)); - } - - console.warn('Background script did not become ready within timeout', { - maxRetries, - totalWaitTime: maxRetries * delay, - }); - setChecking(false); - return false; - }, [checkBackgroundReady]); + + console.warn( + 'Background script did not become ready within timeout', + { + maxRetries, + totalWaitTime: maxRetries * delay, + } + ); + setChecking(false); + return false; + }, + [checkBackgroundReady] + ); return { isReady, diff --git a/options/hooks/useDeepLTest.js b/options/hooks/useDeepLTest.js index 8361d38..4e4ba07 100644 --- a/options/hooks/useDeepLTest.js +++ b/options/hooks/useDeepLTest.js @@ -21,95 +21,150 @@ export function useDeepLTest(t) { }); }, []); - const testConnection = useCallback(async (apiKey, apiPlan) => { - if ( - typeof window.DeepLAPI === 'undefined' || - !window.DeepLAPI || - typeof window.DeepLAPI.testDeepLConnection !== 'function' - ) { - showTestResult( - t('deeplApiNotLoadedError', '❌ DeepL API script is not available. Please refresh the page.'), - 'error' - ); - return; - } + const testConnection = useCallback( + async (apiKey, apiPlan) => { + if ( + typeof window.DeepLAPI === 'undefined' || + !window.DeepLAPI || + typeof window.DeepLAPI.testDeepLConnection !== 'function' + ) { + showTestResult( + t( + 'deeplApiNotLoadedError', + '❌ DeepL API script is not available. Please refresh the page.' + ), + 'error' + ); + return; + } + + if (!apiKey) { + showTestResult( + t( + 'deeplApiKeyError', + 'Please enter your DeepL API key first.' + ), + 'error' + ); + return; + } - if (!apiKey) { + setTesting(true); showTestResult( - t('deeplApiKeyError', 'Please enter your DeepL API key first.'), - 'error' + t('testingConnection', 'Testing DeepL connection...'), + 'info' ); - return; - } - setTesting(true); - showTestResult( - t('testingConnection', 'Testing DeepL connection...'), - 'info' - ); + try { + const result = await window.DeepLAPI.testDeepLConnection( + apiKey, + apiPlan + ); - try { - const result = await window.DeepLAPI.testDeepLConnection(apiKey, apiPlan); + if (result.success) { + showTestResult( + t( + 'deeplTestSuccessSimple', + '✅ DeepL API test successful!' + ), + 'success' + ); + } else { + let fallbackMessage; - if (result.success) { - showTestResult( - t('deeplTestSuccessSimple', '✅ DeepL API test successful!'), - 'success' - ); - } else { - let fallbackMessage; + switch (result.error) { + case 'API_KEY_MISSING': + fallbackMessage = t( + 'deeplApiKeyError', + 'Please enter your DeepL API key first.' + ); + break; + case 'UNEXPECTED_FORMAT': + fallbackMessage = t( + 'deeplTestUnexpectedFormat', + '⚠️ DeepL API responded but with unexpected format' + ); + break; + case 'HTTP_403': + fallbackMessage = t( + 'deeplTestInvalidKey', + '❌ DeepL API key is invalid or has been rejected.' + ); + break; + case 'HTTP_456': + fallbackMessage = t( + 'deeplTestQuotaExceeded', + '❌ DeepL API quota exceeded. Please check your usage limits.' + ); + break; + case 'NETWORK_ERROR': + fallbackMessage = t( + 'deeplTestNetworkError', + '❌ Network error: Could not connect to DeepL API. Check your internet connection.' + ); + break; + default: + if (result.error.startsWith('HTTP_')) { + fallbackMessage = t( + 'deeplTestApiError', + '❌ DeepL API error (%d): %s', + result.status, + result.message || 'Unknown error' + ); + } else { + fallbackMessage = t( + 'deeplTestGenericError', + '❌ Test failed: %s', + result.message + ); + } + break; + } - switch (result.error) { - case 'API_KEY_MISSING': - fallbackMessage = t('deeplApiKeyError', 'Please enter your DeepL API key first.'); - break; - case 'UNEXPECTED_FORMAT': - fallbackMessage = t('deeplTestUnexpectedFormat', '⚠️ DeepL API responded but with unexpected format'); - break; - case 'HTTP_403': - fallbackMessage = t('deeplTestInvalidKey', '❌ DeepL API key is invalid or has been rejected.'); - break; - case 'HTTP_456': - fallbackMessage = t('deeplTestQuotaExceeded', '❌ DeepL API quota exceeded. Please check your usage limits.'); - break; - case 'NETWORK_ERROR': - fallbackMessage = t('deeplTestNetworkError', '❌ Network error: Could not connect to DeepL API. Check your internet connection.'); - break; - default: - if (result.error.startsWith('HTTP_')) { - fallbackMessage = t('deeplTestApiError', '❌ DeepL API error (%d): %s', result.status, result.message || 'Unknown error'); - } else { - fallbackMessage = t('deeplTestGenericError', '❌ Test failed: %s', result.message); - } - break; + const errorType = + result.error === 'UNEXPECTED_FORMAT' + ? 'warning' + : 'error'; + showTestResult(fallbackMessage, errorType); } - - const errorType = result.error === 'UNEXPECTED_FORMAT' ? 'warning' : 'error'; - showTestResult(fallbackMessage, errorType); + } catch (error) { + showTestResult( + t( + 'deeplTestGenericError', + '❌ Test failed: %s', + error.message + ), + 'error' + ); + } finally { + setTesting(false); } - } catch (error) { - showTestResult( - t('deeplTestGenericError', '❌ Test failed: %s', error.message), - 'error' - ); - } finally { - setTesting(false); - } - }, [t, showTestResult]); + }, + [t, showTestResult] + ); - const initializeStatus = useCallback((apiKey) => { - if (apiKey) { - showTestResult( - t('deeplTestNeedsTesting', '⚠️ DeepL API key needs testing.'), - 'warning' - ); - } else { - showTestResult( - t('deeplApiKeyError', 'Please enter your DeepL API key first.'), - 'error' - ); - } - }, [t, showTestResult]); + const initializeStatus = useCallback( + (apiKey) => { + if (apiKey) { + showTestResult( + t( + 'deeplTestNeedsTesting', + '⚠️ DeepL API key needs testing.' + ), + 'warning' + ); + } else { + showTestResult( + t( + 'deeplApiKeyError', + 'Please enter your DeepL API key first.' + ), + 'error' + ); + } + }, + [t, showTestResult] + ); return { testResult, diff --git a/options/hooks/useOpenAITest.js b/options/hooks/useOpenAITest.js index 92c837f..2b4c71d 100644 --- a/options/hooks/useOpenAITest.js +++ b/options/hooks/useOpenAITest.js @@ -23,82 +23,105 @@ export function useOpenAITest(t, fetchAvailableModels) { }); }, []); - const testConnection = useCallback(async (apiKey, baseUrl) => { - if (!apiKey) { + const testConnection = useCallback( + async (apiKey, baseUrl) => { + if (!apiKey) { + showTestResult( + t('openaiApiKeyError', 'Please enter an API key first.'), + 'error' + ); + return; + } + + setTesting(true); showTestResult( - t('openaiApiKeyError', 'Please enter an API key first.'), - 'error' + t('openaiTestingConnection', 'Testing connection...'), + 'info' ); - return; - } - setTesting(true); - showTestResult( - t('openaiTestingConnection', 'Testing connection...'), - 'info' - ); + try { + await fetchAvailableModels(apiKey, baseUrl); + showTestResult( + t('openaiConnectionSuccessful', 'Connection successful!'), + 'success' + ); + } catch (error) { + showTestResult( + t( + 'openaiConnectionFailed', + 'Connection failed: %s', + error.message + ), + 'error' + ); + } finally { + setTesting(false); + } + }, + [t, fetchAvailableModels, showTestResult] + ); - try { - await fetchAvailableModels(apiKey, baseUrl); - showTestResult( - t('openaiConnectionSuccessful', 'Connection successful!'), - 'success' - ); - } catch (error) { + const fetchModels = useCallback( + async (apiKey, baseUrl, onModelsLoaded) => { + if (!apiKey) { + return; + } + + setFetchingModels(true); showTestResult( - t('openaiConnectionFailed', 'Connection failed: %s', error.message), - 'error' + t('openaieFetchingModels', 'Fetching models...'), + 'info' ); - } finally { - setTesting(false); - } - }, [t, fetchAvailableModels, showTestResult]); - const fetchModels = useCallback(async (apiKey, baseUrl, onModelsLoaded) => { - if (!apiKey) { - return; - } + try { + const models = await fetchAvailableModels(apiKey, baseUrl); - setFetchingModels(true); - showTestResult( - t('openaieFetchingModels', 'Fetching models...'), - 'info' - ); + if (onModelsLoaded) { + onModelsLoaded(models); + } - try { - const models = await fetchAvailableModels(apiKey, baseUrl); - - if (onModelsLoaded) { - onModelsLoaded(models); + showTestResult( + t( + 'openaiModelsFetchedSuccessfully', + 'Models fetched successfully.' + ), + 'success' + ); + } catch (error) { + showTestResult( + t( + 'openaiFailedToFetchModels', + 'Failed to fetch models: %s', + error.message + ), + 'error' + ); + } finally { + setFetchingModels(false); } + }, + [t, fetchAvailableModels, showTestResult] + ); - showTestResult( - t('openaiModelsFetchedSuccessfully', 'Models fetched successfully.'), - 'success' - ); - } catch (error) { - showTestResult( - t('openaiFailedToFetchModels', 'Failed to fetch models: %s', error.message), - 'error' - ); - } finally { - setFetchingModels(false); - } - }, [t, fetchAvailableModels, showTestResult]); - - const initializeStatus = useCallback((apiKey) => { - if (apiKey) { - showTestResult( - t('openaiTestNeedsTesting', '⚠️ OpenAI-compatible API key needs testing.'), - 'warning' - ); - } else { - showTestResult( - t('openaiApiKeyError', 'Please enter your API key first.'), - 'error' - ); - } - }, [t, showTestResult]); + const initializeStatus = useCallback( + (apiKey) => { + if (apiKey) { + showTestResult( + t( + 'openaiTestNeedsTesting', + '⚠️ OpenAI-compatible API key needs testing.' + ), + 'warning' + ); + } else { + showTestResult( + t('openaiApiKeyError', 'Please enter your API key first.'), + 'error' + ); + } + }, + [t, showTestResult] + ); return { testResult, diff --git a/options/hooks/useVertexTest.js b/options/hooks/useVertexTest.js index 0ed7be7..fbd5ddd 100644 --- a/options/hooks/useVertexTest.js +++ b/options/hooks/useVertexTest.js @@ -1,5 +1,8 @@ import { useState, useCallback } from 'react'; -import { getAccessTokenFromServiceAccount, checkTokenExpiration as checkExpiration } from '../../utils/vertexAuth.js'; +import { + getAccessTokenFromServiceAccount, + checkTokenExpiration as checkExpiration, +} from '../../utils/vertexAuth.js'; /** * Hook for testing Vertex AI and importing service account JSON @@ -9,7 +12,12 @@ import { getAccessTokenFromServiceAccount, checkTokenExpiration as checkExpirati * @param {Function} onProviderChange - Callback to switch provider * @returns {Object} Test functions and state */ -export function useVertexTest(t, onAccessTokenChange, onProjectIdChange, onProviderChange) { +export function useVertexTest( + t, + onAccessTokenChange, + onProjectIdChange, + onProviderChange +) { const [testResult, setTestResult] = useState({ visible: false, message: '', @@ -39,235 +47,307 @@ export function useVertexTest(t, onAccessTokenChange, onProjectIdChange, onProvi }); }, []); - const testConnection = useCallback(async (accessToken, projectId, location, model) => { - if (!accessToken || !projectId) { - showTestResult( - t('vertexMissingConfig', 'Please enter access token and project ID.'), - 'error' - ); - return; - } - - setTesting(true); - showTestResult( - t('openaiTestingConnection', 'Testing connection...'), - 'info' - ); - - try { - const normalizedModel = model.startsWith('models/') ? model.split('/').pop() : model; - const endpoint = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${normalizedModel}:generateContent`; - - const body = { - contents: [{ role: 'user', parts: [{ text: 'ping' }] }], - generationConfig: { temperature: 0 }, - }; - const res = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${accessToken}`, - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`${res.status} ${res.statusText}: ${text}`); + const testConnection = useCallback( + async (accessToken, projectId, location, model) => { + if (!accessToken || !projectId) { + showTestResult( + t( + 'vertexMissingConfig', + 'Please enter access token and project ID.' + ), + 'error' + ); + return; } + setTesting(true); showTestResult( - t('openaiConnectionSuccessful', 'Connection successful!'), - 'success' - ); - } catch (error) { - showTestResult( - t('vertexConnectionFailed', 'Connection failed: %s', error.message), - 'error' - ); - } finally { - setTesting(false); - } - }, [t, showTestResult]); - - const importServiceAccountJson = useCallback(async (file) => { - if (!file) return; - - setImporting(true); - showImportResult( - t('vertexImporting', 'Importing service account...'), - 'info' - ); - - try { - const text = await file.text(); - let sa; - try { - sa = JSON.parse(text); - } catch (e) { - throw new Error('Invalid JSON file.'); - } - - const required = ['type', 'project_id', 'private_key', 'client_email']; - const missing = required.filter((k) => !sa[k] || typeof sa[k] !== 'string' || sa[k].trim() === ''); - if (missing.length > 0) { - throw new Error(`Missing fields: ${missing.join(', ')}`); - } - if (sa.type !== 'service_account') { - throw new Error('JSON is not a service account key.'); - } - - showImportResult( - t('vertexGeneratingToken', 'Generating access token...'), + t('openaiTestingConnection', 'Testing connection...'), 'info' ); - const { accessToken, expiresIn } = await getAccessTokenFromServiceAccount(sa); - - // Calculate token expiration time - const expiresAt = Date.now() + (expiresIn * 1000); - - // Store the service account JSON for auto-refresh - // Security Note: Storing the complete service account (including private_key) in - // chrome.storage.local is a security trade-off to enable automatic token refresh. - // Chrome extension storage is isolated per-extension and encrypted at rest by the OS. - // Alternative approaches (e.g., storing only the token) would require manual - // re-import every hour when tokens expire. Users with high security requirements - // should use short-lived tokens and manual refresh instead of storing credentials. - if (typeof chrome !== 'undefined' && chrome.storage) { - await chrome.storage.local.set({ - vertexServiceAccount: sa, - vertexTokenExpiresAt: expiresAt, - }); - } - // Update settings via callbacks - await onProjectIdChange(sa.project_id); - await onAccessTokenChange(accessToken); + try { + const normalizedModel = model.startsWith('models/') + ? model.split('/').pop() + : model; + const endpoint = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${normalizedModel}:generateContent`; + + const body = { + contents: [{ role: 'user', parts: [{ text: 'ping' }] }], + generationConfig: { temperature: 0 }, + }; + const res = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify(body), + }); - showImportResult( - '✅ ' + t('vertexImportSuccess', 'Service account imported and token generated.'), - 'success' - ); + if (!res.ok) { + const text = await res.text(); + throw new Error(`${res.status} ${res.statusText}: ${text}`); + } - // Switch provider to Vertex - if (onProviderChange) { - await onProviderChange('vertex_gemini'); + showTestResult( + t('openaiConnectionSuccessful', 'Connection successful!'), + 'success' + ); + } catch (error) { + showTestResult( + t( + 'vertexConnectionFailed', + 'Connection failed: %s', + error.message + ), + 'error' + ); + } finally { + setTesting(false); } + }, + [t, showTestResult] + ); + + const importServiceAccountJson = useCallback( + async (file) => { + if (!file) return; - return { projectId: sa.project_id, accessToken, expiresAt }; - } catch (error) { - showImportResult( - t('vertexImportFailed', 'Import failed: %s', error.message), - 'error' - ); - throw error; - } finally { - setImporting(false); - } - }, [t, showImportResult, onAccessTokenChange, onProjectIdChange, onProviderChange]); - - const refreshToken = useCallback(async (silent = false) => { - if (!silent) { setImporting(true); showImportResult( - t('vertexRefreshingToken', 'Refreshing access token...'), + t('vertexImporting', 'Importing service account...'), 'info' ); - } - - try { - // Retrieve stored service account - if (typeof chrome === 'undefined' || !chrome.storage) { - throw new Error('Chrome storage not available'); - } - const result = await chrome.storage.local.get(['vertexServiceAccount']); - const sa = result.vertexServiceAccount; - - if (!sa) { - throw new Error('No stored service account found. Please import the JSON file again.'); - } - - // Generate new token - const { accessToken, expiresIn } = await getAccessTokenFromServiceAccount(sa); + try { + const text = await file.text(); + let sa; + try { + sa = JSON.parse(text); + } catch (e) { + throw new Error('Invalid JSON file.'); + } - // Calculate new expiration time - const expiresAt = Date.now() + (expiresIn * 1000); + const required = [ + 'type', + 'project_id', + 'private_key', + 'client_email', + ]; + const missing = required.filter( + (k) => + !sa[k] || + typeof sa[k] !== 'string' || + sa[k].trim() === '' + ); + if (missing.length > 0) { + throw new Error(`Missing fields: ${missing.join(', ')}`); + } + if (sa.type !== 'service_account') { + throw new Error('JSON is not a service account key.'); + } - // Update expiration time in storage - await chrome.storage.local.set({ - vertexTokenExpiresAt: expiresAt, - }); + showImportResult( + t('vertexGeneratingToken', 'Generating access token...'), + 'info' + ); + const { accessToken, expiresIn } = + await getAccessTokenFromServiceAccount(sa); + + // Calculate token expiration time + const expiresAt = Date.now() + expiresIn * 1000; + + // Store the service account JSON for auto-refresh + // Security Note: Storing the complete service account (including private_key) in + // chrome.storage.local is a security trade-off to enable automatic token refresh. + // Chrome extension storage is isolated per-extension and encrypted at rest by the OS. + // Alternative approaches (e.g., storing only the token) would require manual + // re-import every hour when tokens expire. Users with high security requirements + // should use short-lived tokens and manual refresh instead of storing credentials. + if (typeof chrome !== 'undefined' && chrome.storage) { + await chrome.storage.local.set({ + vertexServiceAccount: sa, + vertexTokenExpiresAt: expiresAt, + }); + } - // Update settings via callback - await onAccessTokenChange(accessToken); + // Update settings via callbacks + await onProjectIdChange(sa.project_id); + await onAccessTokenChange(accessToken); - if (!silent) { showImportResult( - '✅ ' + t('vertexTokenRefreshed', 'Access token refreshed successfully.'), + '✅ ' + + t( + 'vertexImportSuccess', + 'Service account imported and token generated.' + ), 'success' ); - } else { - console.log('[Vertex AI] Access token auto-refreshed successfully'); - } - return { accessToken, expiresAt }; - } catch (error) { - if (!silent) { + // Switch provider to Vertex + if (onProviderChange) { + await onProviderChange('vertex_gemini'); + } + + return { projectId: sa.project_id, accessToken, expiresAt }; + } catch (error) { showImportResult( - t('vertexRefreshFailed', 'Token refresh failed: %s', error.message), + t('vertexImportFailed', 'Import failed: %s', error.message), 'error' ); - } else { - console.error('[Vertex AI] Auto-refresh failed:', error); + throw error; + } finally { + setImporting(false); } - throw error; - } finally { + }, + [ + t, + showImportResult, + onAccessTokenChange, + onProjectIdChange, + onProviderChange, + ] + ); + + const refreshToken = useCallback( + async (silent = false) => { if (!silent) { - setImporting(false); + setImporting(true); + showImportResult( + t('vertexRefreshingToken', 'Refreshing access token...'), + 'info' + ); } - } - }, [t, showImportResult, onAccessTokenChange]); + + try { + // Retrieve stored service account + if (typeof chrome === 'undefined' || !chrome.storage) { + throw new Error('Chrome storage not available'); + } + + const result = await chrome.storage.local.get([ + 'vertexServiceAccount', + ]); + const sa = result.vertexServiceAccount; + + if (!sa) { + throw new Error( + 'No stored service account found. Please import the JSON file again.' + ); + } + + // Generate new token + const { accessToken, expiresIn } = + await getAccessTokenFromServiceAccount(sa); + + // Calculate new expiration time + const expiresAt = Date.now() + expiresIn * 1000; + + // Update expiration time in storage + await chrome.storage.local.set({ + vertexTokenExpiresAt: expiresAt, + }); + + // Update settings via callback + await onAccessTokenChange(accessToken); + + if (!silent) { + showImportResult( + '✅ ' + + t( + 'vertexTokenRefreshed', + 'Access token refreshed successfully.' + ), + 'success' + ); + } else { + console.log( + '[Vertex AI] Access token auto-refreshed successfully' + ); + } + + return { accessToken, expiresAt }; + } catch (error) { + if (!silent) { + showImportResult( + t( + 'vertexRefreshFailed', + 'Token refresh failed: %s', + error.message + ), + 'error' + ); + } else { + console.error('[Vertex AI] Auto-refresh failed:', error); + } + throw error; + } finally { + if (!silent) { + setImporting(false); + } + } + }, + [t, showImportResult, onAccessTokenChange] + ); const checkTokenExpiration = useCallback(async () => { return await checkExpiration(); }, []); - const initializeStatus = useCallback(async (accessToken, projectId) => { - if (accessToken && projectId) { - // Check if token is about to expire - const expirationInfo = await checkTokenExpiration(); - - if (expirationInfo) { - if (expirationInfo.isExpired) { - showTestResult( - t('vertexTokenExpired', '⚠️ Access token expired. Click refresh to renew.'), - 'warning' - ); - } else if (expirationInfo.shouldRefresh) { - showTestResult( - t('vertexTokenExpiringSoon', `⚠️ Token expires in ${expirationInfo.expiresInMinutes} minutes. Consider refreshing.`), - 'warning' - ); + const initializeStatus = useCallback( + async (accessToken, projectId) => { + if (accessToken && projectId) { + // Check if token is about to expire + const expirationInfo = await checkTokenExpiration(); + + if (expirationInfo) { + if (expirationInfo.isExpired) { + showTestResult( + t( + 'vertexTokenExpired', + '⚠️ Access token expired. Click refresh to renew.' + ), + 'warning' + ); + } else if (expirationInfo.shouldRefresh) { + showTestResult( + t( + 'vertexTokenExpiringSoon', + `⚠️ Token expires in ${expirationInfo.expiresInMinutes} minutes. Consider refreshing.` + ), + 'warning' + ); + } else { + showTestResult( + t( + 'vertexConfigured', + '⚠️ Vertex AI configured. Please test connection.' + ), + 'warning' + ); + } } else { showTestResult( - t('vertexConfigured', '⚠️ Vertex AI configured. Please test connection.'), + t( + 'vertexConfigured', + '⚠️ Vertex AI configured. Please test connection.' + ), 'warning' ); } } else { showTestResult( - t('vertexConfigured', '⚠️ Vertex AI configured. Please test connection.'), - 'warning' + t( + 'vertexNotConfigured', + 'Please import service account JSON or enter credentials.' + ), + 'error' ); } - } else { - showTestResult( - t('vertexNotConfigured', 'Please import service account JSON or enter credentials.'), - 'error' - ); - } - }, [t, showTestResult, checkTokenExpiration]); + }, + [t, showTestResult, checkTokenExpiration] + ); return { testResult, @@ -283,4 +363,3 @@ export function useVertexTest(t, onAccessTokenChange, onProjectIdChange, onProvi showImportResult, }; } - diff --git a/options/options.css b/options/options.css index f849b98..f68a84b 100644 --- a/options/options.css +++ b/options/options.css @@ -740,12 +740,14 @@ button#testDeepLButton.btn-sparkle:hover span.text { border-radius: 8px; background: linear-gradient(180deg, #007aff 0%, #0051d5 100%); color: white; - font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, 'SF Pro Text', 'Helvetica Neue', + sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94); - box-shadow: + box-shadow: 0 1px 2px rgba(0, 122, 255, 0.3), 0 2px 4px rgba(0, 0, 0, 0.1); position: relative; @@ -759,13 +761,17 @@ button#testDeepLButton.btn-sparkle:hover span.text { left: 0; right: 0; height: 50%; - background: linear-gradient(180deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0) 100%); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.15) 0%, + rgba(255, 255, 255, 0) 100% + ); pointer-events: none; } .apple-file-btn:hover { background: linear-gradient(180deg, #0077ed 0%, #004fc7 100%); - box-shadow: + box-shadow: 0 2px 4px rgba(0, 122, 255, 0.4), 0 4px 8px rgba(0, 0, 0, 0.15); transform: translateY(-1px); @@ -773,7 +779,7 @@ button#testDeepLButton.btn-sparkle:hover span.text { .apple-file-btn:active { background: linear-gradient(180deg, #0051d5 0%, #003fa8 100%); - box-shadow: + box-shadow: 0 1px 2px rgba(0, 122, 255, 0.3), 0 1px 3px rgba(0, 0, 0, 0.2); transform: translateY(0); @@ -782,8 +788,7 @@ button#testDeepLButton.btn-sparkle:hover span.text { .apple-file-btn:disabled { background: linear-gradient(180deg, #c7c7cc 0%, #aeaeb2 100%); cursor: not-allowed; - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } .apple-file-btn:disabled::before { diff --git a/options/options.html b/options/options.html index 97971c2..3bee63c 100644 --- a/options/options.html +++ b/options/options.html @@ -9,4 +9,4 @@
- \ No newline at end of file + diff --git a/popup/PopupApp.jsx b/popup/PopupApp.jsx index ddaa3c8..6124ab8 100644 --- a/popup/PopupApp.jsx +++ b/popup/PopupApp.jsx @@ -1,5 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useSettings, useTranslation, useChromeMessage, useLogger } from './hooks/index.js'; +import { + useSettings, + useTranslation, + useChromeMessage, + useLogger, +} from './hooks/index.js'; import { Header } from './components/Header.jsx'; import { SettingToggle } from './components/SettingToggle.jsx'; import { LanguageSelector } from './components/LanguageSelector.jsx'; @@ -8,10 +13,12 @@ import { StatusMessage } from './components/StatusMessage.jsx'; export function PopupApp() { const { settings, updateSetting, loading, error } = useSettings(); - const { t, loading: translationsLoading } = useTranslation(settings.uiLanguage || 'en'); + const { t, loading: translationsLoading } = useTranslation( + settings.uiLanguage || 'en' + ); const { sendImmediateConfigUpdate } = useChromeMessage(); const logger = useLogger('Popup'); - + const [statusMessage, setStatusMessage] = useState(''); const statusTimeoutRef = useRef(null); @@ -19,7 +26,7 @@ export function PopupApp() { if (statusTimeoutRef.current) { clearTimeout(statusTimeoutRef.current); } - + setStatusMessage(message); statusTimeoutRef.current = setTimeout(() => { setStatusMessage(''); @@ -41,14 +48,19 @@ export function PopupApp() { logger.error('Error loading settings', error, { component: 'loadSettings', }); - showStatus('Failed to load settings. Please try refreshing the popup.', 5000); + showStatus( + 'Failed to load settings. Please try refreshing the popup.', + 5000 + ); } }, [error, logger]); const handleToggleSubtitles = async (enabled) => { try { await updateSetting('subtitlesEnabled', enabled); - const statusKey = enabled ? 'statusDualEnabled' : 'statusDualDisabled'; + const statusKey = enabled + ? 'statusDualEnabled' + : 'statusDualDisabled'; const statusText = t( statusKey, enabled ? 'Dual subtitles enabled.' : 'Dual subtitles disabled.' @@ -70,7 +82,7 @@ export function PopupApp() { try { await updateSetting('useNativeSubtitles', useOfficial); await updateSetting('useOfficialTranslations', useOfficial); - + const statusKey = useOfficial ? 'statusSmartTranslationEnabled' : 'statusSmartTranslationDisabled'; @@ -81,7 +93,7 @@ export function PopupApp() { : 'Official subtitles disabled.' ); showStatus(statusText); - + sendImmediateConfigUpdate({ useNativeSubtitles: useOfficial, useOfficialTranslations: useOfficial, @@ -93,7 +105,9 @@ export function PopupApp() { component: 'useNativeSubtitlesToggle', }); } - showStatus('Failed to update official subtitles setting. Please try again.'); + showStatus( + 'Failed to update official subtitles setting. Please try again.' + ); } }; @@ -134,7 +148,9 @@ export function PopupApp() { const handleLayoutOrderChange = async (layoutOrder) => { try { await updateSetting('subtitleLayoutOrder', layoutOrder); - showStatus(t('statusDisplayOrderUpdated', 'Display order updated.')); + showStatus( + t('statusDisplayOrderUpdated', 'Display order updated.') + ); sendImmediateConfigUpdate({ subtitleLayoutOrder: layoutOrder }); } catch (error) { if (logger) { @@ -150,8 +166,15 @@ export function PopupApp() { const handleLayoutOrientationChange = async (layoutOrientation) => { try { await updateSetting('subtitleLayoutOrientation', layoutOrientation); - showStatus(t('statusLayoutOrientationUpdated', 'Layout orientation updated.')); - sendImmediateConfigUpdate({ subtitleLayoutOrientation: layoutOrientation }); + showStatus( + t( + 'statusLayoutOrientationUpdated', + 'Layout orientation updated.' + ) + ); + sendImmediateConfigUpdate({ + subtitleLayoutOrientation: layoutOrientation, + }); } catch (error) { if (logger) { logger.error('Error setting layout orientation', error, { @@ -159,7 +182,9 @@ export function PopupApp() { component: 'subtitleLayoutOrientationSelect', }); } - showStatus('Failed to update layout orientation. Please try again.'); + showStatus( + 'Failed to update layout orientation. Please try again.' + ); } }; @@ -171,7 +196,9 @@ export function PopupApp() { const handleFontSizeChangeEnd = async (fontSize) => { try { await updateSetting('subtitleFontSize', fontSize); - showStatus(`${t('statusFontSize', 'Font size: ')}${fontSize.toFixed(1)}vw.`); + showStatus( + `${t('statusFontSize', 'Font size: ')}${fontSize.toFixed(1)}vw.` + ); sendImmediateConfigUpdate({ subtitleFontSize: fontSize }); } catch (error) { if (logger) { @@ -192,7 +219,9 @@ export function PopupApp() { const handleGapChangeEnd = async (gap) => { try { await updateSetting('subtitleGap', gap); - showStatus(`${t('statusVerticalGap', 'Vertical gap: ')}${gap.toFixed(1)}em.`); + showStatus( + `${t('statusVerticalGap', 'Vertical gap: ')}${gap.toFixed(1)}em.` + ); sendImmediateConfigUpdate({ subtitleGap: gap }); } catch (error) { if (logger) { @@ -207,20 +236,30 @@ export function PopupApp() { const handleVerticalPositionChange = (verticalPosition) => { // Real-time update without saving - sendImmediateConfigUpdate({ subtitleVerticalPosition: verticalPosition }); + sendImmediateConfigUpdate({ + subtitleVerticalPosition: verticalPosition, + }); }; const handleVerticalPositionChangeEnd = async (verticalPosition) => { try { await updateSetting('subtitleVerticalPosition', verticalPosition); - showStatus(`${t('statusVerticalPosition', 'Vertical position: ')}${verticalPosition.toFixed(1)}.`); - sendImmediateConfigUpdate({ subtitleVerticalPosition: verticalPosition }); + showStatus( + `${t('statusVerticalPosition', 'Vertical position: ')}${verticalPosition.toFixed(1)}.` + ); + sendImmediateConfigUpdate({ + subtitleVerticalPosition: verticalPosition, + }); } catch (error) { if (logger) { - logger.error('Error setting subtitle vertical position', error, { - verticalPosition, - component: 'subtitleVerticalPositionInput', - }); + logger.error( + 'Error setting subtitle vertical position', + error, + { + verticalPosition, + component: 'subtitleVerticalPositionInput', + } + ); } showStatus('Failed to update vertical position. Please try again.'); } @@ -230,7 +269,9 @@ export function PopupApp() { try { let offset = parseFloat(value); if (isNaN(offset)) { - showStatus(t('statusInvalidOffset', 'Invalid offset, reverting.')); + showStatus( + t('statusInvalidOffset', 'Invalid offset, reverting.') + ); return; } offset = parseFloat(offset.toFixed(2)); @@ -288,9 +329,10 @@ export function PopupApp() { } = settings; // Use useOfficialTranslations if available, fallback to useNativeSubtitles - const useOfficial = useOfficialTranslations !== undefined - ? useOfficialTranslations - : useNativeSubtitles; + const useOfficial = + useOfficialTranslations !== undefined + ? useOfficialTranslations + : useNativeSubtitles; return ( <> @@ -309,7 +351,10 @@ export function PopupApp() { diff --git a/popup/components/SliderSetting.jsx b/popup/components/SliderSetting.jsx index badf65c..8cdc6c8 100644 --- a/popup/components/SliderSetting.jsx +++ b/popup/components/SliderSetting.jsx @@ -12,12 +12,15 @@ export function SliderSetting({ }) { const sliderRef = useRef(null); - const updateSliderProgress = useCallback((sliderElement, val) => { - const minVal = parseFloat(min) || 0; - const maxVal = parseFloat(max) || 100; - const percentage = ((val - minVal) / (maxVal - minVal)) * 100; - sliderElement.style.backgroundSize = `${percentage}% 100%`; - }, [min, max]); + const updateSliderProgress = useCallback( + (sliderElement, val) => { + const minVal = parseFloat(min) || 0; + const maxVal = parseFloat(max) || 100; + const percentage = ((val - minVal) / (maxVal - minVal)) * 100; + sliderElement.style.backgroundSize = `${percentage}% 100%`; + }, + [min, max] + ); useEffect(() => { if (sliderRef.current) { @@ -48,7 +51,9 @@ export function SliderSetting({ step={step} value={value} onInput={handleInput} - onChange={(e) => onChangeEnd && onChangeEnd(parseFloat(e.target.value))} + onChange={(e) => + onChangeEnd && onChangeEnd(parseFloat(e.target.value)) + } /> {formatValue(value)}
diff --git a/popup/hooks/useSettings.js b/popup/hooks/useSettings.js index 004df12..dbad0ef 100644 --- a/popup/hooks/useSettings.js +++ b/popup/hooks/useSettings.js @@ -17,7 +17,7 @@ export function useSettings(keys) { try { setLoading(true); let data; - + if (Array.isArray(keys)) { data = await configService.getMultiple(keys); } else if (keys) { @@ -26,7 +26,7 @@ export function useSettings(keys) { } else { data = await configService.getAll(); } - + setSettings(data); setError(null); } catch (err) { @@ -43,7 +43,7 @@ export function useSettings(keys) { // Listen for setting changes useEffect(() => { const handleChange = (changes) => { - setSettings(prev => ({ ...prev, ...changes })); + setSettings((prev) => ({ ...prev, ...changes })); }; const unsubscribe = configService.onChanged(handleChange); @@ -60,7 +60,7 @@ export function useSettings(keys) { const updateSetting = useCallback(async (key, value) => { try { await configService.set(key, value); - setSettings(prev => ({ ...prev, [key]: value })); + setSettings((prev) => ({ ...prev, [key]: value })); return true; } catch (err) { setError(err); @@ -73,7 +73,7 @@ export function useSettings(keys) { const updateSettings = useCallback(async (updates) => { try { await configService.setMultiple(updates); - setSettings(prev => ({ ...prev, ...updates })); + setSettings((prev) => ({ ...prev, ...updates })); return true; } catch (err) { setError(err); diff --git a/popup/hooks/useTranslation.js b/popup/hooks/useTranslation.js index 2940c83..5c412d1 100644 --- a/popup/hooks/useTranslation.js +++ b/popup/hooks/useTranslation.js @@ -14,7 +14,7 @@ export function useTranslation(locale) { useEffect(() => { const loadTranslations = async () => { const normalizedLangCode = locale.replace('-', '_'); - + // Check cache first if (translationsCache[normalizedLangCode]) { setTranslations(translationsCache[normalizedLangCode]); @@ -29,11 +29,11 @@ export function useTranslation(locale) { try { setLoading(true); const response = await fetch(translationsPath); - + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - + const data = await response.json(); translationsCache[normalizedLangCode] = data; setTranslations(data); @@ -42,7 +42,7 @@ export function useTranslation(locale) { `Could not load '${normalizedLangCode}' translations, falling back to English`, error ); - + // Fallback to English try { const fallbackPath = chrome.runtime.getURL( @@ -70,22 +70,25 @@ export function useTranslation(locale) { }, [locale]); // Translation function - const t = useCallback((key, fallback = '', ...substitutions) => { - let message = translations[key]?.message || fallback || key; - - // Replace %s and %d placeholders with substitutions - if (substitutions.length > 0) { - let substitutionIndex = 0; - message = message.replace(/%[sd]/g, (match) => { - if (substitutionIndex < substitutions.length) { - return substitutions[substitutionIndex++]; - } - return match; - }); - } - - return message; - }, [translations]); + const t = useCallback( + (key, fallback = '', ...substitutions) => { + let message = translations[key]?.message || fallback || key; + + // Replace %s and %d placeholders with substitutions + if (substitutions.length > 0) { + let substitutionIndex = 0; + message = message.replace(/%[sd]/g, (match) => { + if (substitutionIndex < substitutions.length) { + return substitutions[substitutionIndex++]; + } + return match; + }); + } + + return message; + }, + [translations] + ); return { t, loading, translations }; } diff --git a/translation_providers/geminiVertexTranslate.js b/translation_providers/geminiVertexTranslate.js index 74e5871..405630d 100644 --- a/translation_providers/geminiVertexTranslate.js +++ b/translation_providers/geminiVertexTranslate.js @@ -48,20 +48,23 @@ function parsePossiblyJson(responseText) { if (typeof responseText !== 'string' || responseText.trim() === '') { return ''; } - const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```|(\[[\s\S]*\])/); - const jsonString = jsonMatch ? (jsonMatch[1] || jsonMatch[2]) : null; + const jsonMatch = responseText.match( + /```json\s*([\s\S]*?)\s*```|(\[[\s\S]*\])/ + ); + const jsonString = jsonMatch ? jsonMatch[1] || jsonMatch[2] : null; if (!jsonString) { return responseText; } try { return JSON.parse(jsonString); } catch (e) { - logger.warn('Response looked like JSON but failed to parse, using raw text.'); + logger.warn( + 'Response looked like JSON but failed to parse, using raw text.' + ); return responseText; } } - // Ensure model name is in short form (e.g., "gemini-1.5-flash"), removing any leading path like // "models/gemini-1.5-flash" or "publishers/google/models/gemini-1.5-flash". function normalizeModelName(model) { @@ -100,7 +103,9 @@ export async function translate(text, sourceLang, targetLang) { const { accessToken, projectId, location, model } = await getConfig(); if (!accessToken || !projectId || !location || !model) { - throw new Error('Vertex access token, project, location, or model not configured.'); + throw new Error( + 'Vertex access token, project, location, or model not configured.' + ); } const endpoint = buildVertexEndpoint( @@ -125,10 +130,18 @@ export async function translate(text, sourceLang, targetLang) { const requestBody = { contents: [ - { role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}\n\n${text}` }] }, + { + role: 'user', + parts: [ + { text: `${systemPrompt}\n\n${userPrompt}\n\n${text}` }, + ], + }, ], generationConfig: { - maxOutputTokens: Math.max(256, Math.min(2048, Math.ceil(text.length * 3))), + maxOutputTokens: Math.max( + 256, + Math.min(2048, Math.ceil(text.length * 3)) + ), }, }; @@ -150,34 +163,31 @@ export async function translate(text, sourceLang, targetLang) { errorMessage = parsed.error.message; } } catch (e) {} - logger.error( - 'Vertex AI single translation failed', - null, - { - status: response.status, - statusText: response.statusText, - endpoint, - errorMessage, - } + logger.error('Vertex AI single translation failed', null, { + status: response.status, + statusText: response.statusText, + endpoint, + errorMessage, + }); + throw new Error( + `Vertex translation error: ${response.status} ${response.statusText}` ); - throw new Error(`Vertex translation error: ${response.status} ${response.statusText}`); } const data = await response.json(); - const responseText = data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; + const responseText = + data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; if (!responseText) { throw new Error('Empty response from Vertex AI'); } - return typeof responseText === 'string' ? responseText.trim() : String(responseText); + return typeof responseText === 'string' + ? responseText.trim() + : String(responseText); } catch (error) { - logger.error( - 'Fatal error during Vertex AI single translation', - error, - { - sourceLang, - targetLang, - } - ); + logger.error('Fatal error during Vertex AI single translation', error, { + sourceLang, + targetLang, + }); return text; // Fallback to original } } @@ -211,7 +221,9 @@ export async function translateBatch( const { accessToken, projectId, location, model } = await getConfig(); if (!accessToken || !projectId || !location || !model) { - throw new Error('Vertex access token, project, location, or model not configured.'); + throw new Error( + 'Vertex access token, project, location, or model not configured.' + ); } const endpoint = buildVertexEndpoint( @@ -235,10 +247,16 @@ Important: const requestBody = { contents: [ - { role: 'user', parts: [{ text: `${instructions}\n\n${combinedText}` }] }, + { + role: 'user', + parts: [{ text: `${instructions}\n\n${combinedText}` }], + }, ], generationConfig: { - maxOutputTokens: Math.min(4096, Math.max(500, combinedText.length * 3)), + maxOutputTokens: Math.min( + 4096, + Math.max(500, combinedText.length * 3) + ), }, }; @@ -260,21 +278,20 @@ Important: errorMessage = parsed.error.message; } } catch (e) {} - logger.error( - 'Vertex AI batch translation failed', - null, - { - status: response.status, - statusText: response.statusText, - endpoint, - errorMessage, - } + logger.error('Vertex AI batch translation failed', null, { + status: response.status, + statusText: response.statusText, + endpoint, + errorMessage, + }); + throw new Error( + `Vertex batch translation error: ${response.status}` ); - throw new Error(`Vertex batch translation error: ${response.status}`); } const data = await response.json(); - const responseText = data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; + const responseText = + data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; if (!responseText) { throw new Error('Empty response from Vertex AI'); } @@ -284,7 +301,9 @@ Important: if (Array.isArray(parsed)) { // Ensure array length matches input size if (parsed.length !== texts.length) { - throw new Error('Translated array length does not match input array length.'); + throw new Error( + 'Translated array length does not match input array length.' + ); } return parsed.map((s) => (typeof s === 'string' ? s : String(s))); } @@ -301,15 +320,11 @@ Important: } return split.map((s) => s.trim()); } catch (error) { - logger.error( - 'Fatal error during Vertex AI batch translation', - error, - { - sourceLang, - targetLang, - textCount: texts.length, - } - ); + logger.error('Fatal error during Vertex AI batch translation', error, { + sourceLang, + targetLang, + textCount: texts.length, + }); return texts; // Fallback to original array } } diff --git a/utils/vertexAuth.js b/utils/vertexAuth.js index d986f97..b6c630e 100644 --- a/utils/vertexAuth.js +++ b/utils/vertexAuth.js @@ -81,7 +81,8 @@ export async function getAccessTokenFromServiceAccount(serviceAccountJson) { const now = Math.floor(Date.now() / 1000); const iat = now; const exp = now + 3600; // 1 hour - const tokenUri = serviceAccountJson.token_uri || 'https://oauth2.googleapis.com/token'; + const tokenUri = + serviceAccountJson.token_uri || 'https://oauth2.googleapis.com/token'; const scope = 'https://www.googleapis.com/auth/cloud-platform'; const header = { alg: 'RS256', typ: 'JWT' }; @@ -93,7 +94,11 @@ export async function getAccessTokenFromServiceAccount(serviceAccountJson) { exp, }; - const jwt = await signJwtRS256(header, claims, serviceAccountJson.private_key); + const jwt = await signJwtRS256( + header, + claims, + serviceAccountJson.private_key + ); const body = new URLSearchParams(); body.set('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer'); @@ -107,14 +112,19 @@ export async function getAccessTokenFromServiceAccount(serviceAccountJson) { if (!res.ok) { const text = await res.text(); - throw new Error(`Token exchange failed: ${res.status} ${res.statusText} ${text}`); + throw new Error( + `Token exchange failed: ${res.status} ${res.statusText} ${text}` + ); } const data = await res.json(); if (!data.access_token) { throw new Error('Token exchange response missing access_token'); } - return { accessToken: data.access_token, expiresIn: data.expires_in || 3600 }; + return { + accessToken: data.access_token, + expiresIn: data.expires_in || 3600, + }; } /** @@ -170,10 +180,11 @@ export async function refreshAccessToken(updateConfig = true) { } // Generate new token - const { accessToken, expiresIn } = await getAccessTokenFromServiceAccount(sa); + const { accessToken, expiresIn } = + await getAccessTokenFromServiceAccount(sa); // Calculate new expiration time - const expiresAt = Date.now() + (expiresIn * 1000); + const expiresAt = Date.now() + expiresIn * 1000; // Update expiration time in storage await chrome.storage.local.set({ @@ -183,7 +194,9 @@ export async function refreshAccessToken(updateConfig = true) { // Update config if requested if (updateConfig && typeof chrome.storage.sync !== 'undefined') { try { - const { configService } = await import('../services/configService.js'); + const { configService } = await import( + '../services/configService.js' + ); await configService.set('vertexAccessToken', accessToken); } catch (error) { console.error('[VertexAuth] Failed to update config:', error); @@ -199,7 +212,7 @@ export async function refreshAccessToken(updateConfig = true) { */ export async function autoRefreshIfNeeded() { const expirationInfo = await checkTokenExpiration(); - + if (!expirationInfo) { return null; } @@ -217,4 +230,3 @@ export async function autoRefreshIfNeeded() { return null; } - diff --git a/vite.config.js b/vite.config.js index d118b83..5a245ee 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,10 +11,10 @@ export default defineConfig({ closeBundle() { const distDir = 'dist'; mkdirSync(distDir, { recursive: true }); - + // Copy manifest.json copyFileSync('manifest.json', `${distDir}/manifest.json`); - + // Copy all extension files that aren't built by Vite const filesToCopy = [ 'background.js', @@ -30,12 +30,15 @@ export default defineConfig({ 'translation_providers', 'context_providers', ]; - + filesToCopy.forEach((file) => { try { cpSync(file, `${distDir}/${file}`, { recursive: true }); } catch (err) { - console.warn(`Warning: Could not copy ${file}:`, err.message); + console.warn( + `Warning: Could not copy ${file}:`, + err.message + ); } }); }, From 45f67e443be8b9a88050e9dbab7c0a0cf8cfaad7 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Fri, 17 Oct 2025 16:52:43 -0400 Subject: [PATCH 04/23] Implement selection synchronization for side panel integration - Added a new message action for synchronizing word selections between the content script and the side panel. - Enhanced the message handler to process selection sync messages and forward them appropriately. - Updated the side panel service to handle selection sync requests and manage active connections. - Improved content scripts to clear selections on subtitle changes and synchronize state with the side panel. - Refactored word selection handling to accommodate new selection actions and ensure consistent state management. --- background/handlers/messageHandler.js | 39 ++++++ background/services/sidePanelService.js | 119 +++++++++++------- content_scripts/core/BaseContentScript.js | 78 +++++++++++- .../shared/constants/messageActions.js | 1 + content_scripts/shared/subtitleUtilities.js | 45 +++++++ sidepanel/hooks/useWordSelection.js | 44 ++++++- 6 files changed, 274 insertions(+), 52 deletions(-) diff --git a/background/handlers/messageHandler.js b/background/handlers/messageHandler.js index 866ddec..5b89d2f 100644 --- a/background/handlers/messageHandler.js +++ b/background/handlers/messageHandler.js @@ -224,6 +224,9 @@ class MessageHandler { case MessageActions.SIDEPANEL_WORD_SELECTED: return this.handleSidePanelWordSelectedMessage(message, sender, sendResponse); + case MessageActions.SIDEPANEL_SELECTION_SYNC: + return this.handleSidePanelSelectionSyncMessage(message, sender, sendResponse); + case MessageActions.SIDEPANEL_SET_ANALYZING: return this.handleSidePanelSetAnalyzingMessage(message, sender, sendResponse); @@ -1083,6 +1086,42 @@ class MessageHandler { return true; // Async response } + handleSidePanelSelectionSyncMessage(message, sender, sendResponse) { + if (!this.sidePanelService) { + sendResponse({ + success: false, + error: 'Side panel service not available', + }); + return true; + } + + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ + success: false, + error: 'No tab ID available', + }); + return true; + } + + this.sidePanelService + .forwardSelectionSync(tabId, message) + .then(() => { + sendResponse({ success: true }); + }) + .catch((error) => { + this.logger.error('Failed to forward selection sync', error, { + tabId, + }); + sendResponse({ + success: false, + error: error.message || 'Failed to forward selection sync', + }); + }); + + return true; + } + /** * Proxy a side panel or content message to the tab's content script */ diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js index 6892bbc..c5f6510 100644 --- a/background/services/sidePanelService.js +++ b/background/services/sidePanelService.js @@ -66,61 +66,38 @@ class SidePanelService { * Handle new connection from side panel */ handleSidePanelConnection(port) { - const tabId = port.sender?.tab?.id; - if (!tabId) { - this.logger.warn('Side panel connection without tab ID'); - return; - } + let tabId = port.sender?.tab?.id ?? null; - this.logger.info('Side panel connected', { tabId }); - this.activeConnections.set(tabId, port); + if (tabId != null) { + this.logger.info('Side panel connected', { tabId }); + this.activeConnections.set(tabId, port); + } else { + this.logger.warn('Side panel connection without tab ID (awaiting register message)'); + } // Handle messages from side panel port.onMessage.addListener((message) => { + // Update tabId once the side panel sends an explicit register payload + if (message?.action === MessageActions.SIDEPANEL_REGISTER) { + const claimedTabId = message?.data?.tabId; + if (typeof claimedTabId === 'number') { + tabId = claimedTabId; + this.activeConnections.set(tabId, port); + } + } + this.handleSidePanelMessage(message, port, tabId); }); // Handle disconnection port.onDisconnect.addListener(() => { - this.logger.info('Side panel disconnected', { tabId }); - this.activeConnections.delete(tabId); - }); - - // Send current state to newly connected side panel - const state = this.tabStates.get(tabId); - if (state) { - // If there's a pending selection, request AI tab first then forward selection after a short delay - if (state.pendingWordSelection) { - // Request tab switch - port.postMessage({ - action: MessageActions.SIDEPANEL_UPDATE_STATE, - data: { activeTab: 'ai-analysis' }, - }); - - // Forward selection after a small delay so the AI tab can mount its listeners - setTimeout(() => { - try { - port.postMessage({ - action: MessageActions.SIDEPANEL_WORD_SELECTED, - data: state.pendingWordSelection, - }); - - // Clear pending selection - const newState = { ...state }; - delete newState.pendingWordSelection; - newState.activeTab = 'ai-analysis'; - this.tabStates.set(tabId, newState); - } catch (err) { - this.logger.error('Failed to deliver pending selection to side panel', err, { tabId }); - } - }, 60); + if (tabId != null) { + this.logger.info('Side panel disconnected', { tabId }); + this.activeConnections.delete(tabId); } else { - port.postMessage({ - action: MessageActions.SIDEPANEL_UPDATE_STATE, - data: state, - }); + this.logger.info('Side panel disconnected before registration'); } - } + }); } /** @@ -177,13 +154,33 @@ class SidePanelService { const pending = st.pendingWordSelection; setTimeout(() => { try { + const selectedWords = + Array.isArray(pending?.selectedWords) && pending.selectedWords.length > 0 + ? pending.selectedWords + : pending?.word + ? [pending.word] + : []; + port.postMessage({ - action: MessageActions.SIDEPANEL_WORD_SELECTED, - data: pending, + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords, + reason: pending?.reason || 'initial-pending-selection', + }, }); + if (selectedWords.length > 0) { + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: { activeTab: 'ai-analysis' }, + }); + } + // Clear pending selection after delivery const newState = { ...st }; delete newState.pendingWordSelection; + if (selectedWords.length > 0) { + newState.selectedWords = selectedWords; + } this.tabStates.set(claimedTabId, newState); } catch (err) { this.logger.error('Failed to deliver pending selection on register', err, { tabId: claimedTabId }); @@ -352,6 +349,36 @@ class SidePanelService { } } + /** + * Forward selection synchronization (e.g., subtitle change clears selection) + */ + async forwardSelectionSync(tabId, payload = {}) { + const port = this.activeConnections.get(tabId); + if (port) { + try { + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords: payload.selectedWords || [], + reason: payload.reason || 'unknown', + }, + }); + this.logger.debug('Selection sync forwarded to side panel', { + tabId, + count: (payload.selectedWords || []).length, + }); + } catch (err) { + this.logger.error('Failed to forward selection sync', err, { + tabId, + }); + } + } else { + this.logger.debug('Selection sync skipped (no active side panel connection)', { + tabId, + }); + } + } + /** * Update tab state */ diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index 36f2969..325b77f 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -1249,10 +1249,14 @@ export class BaseContentScript { useSidePanel: false, isAnalyzing: false, boundHandler: null, + boundSubtitleChangeHandler: null, + selectedWords: new Set(), async initialize() { if (this.initialized) return; + this.selectedWords = new Set(); + // Prepare logger bridge and messaging wrapper this._log = (level, message, data) => { try { @@ -1296,6 +1300,16 @@ export class BaseContentScript { { capture: true } ); + // Listen for subtitle content changes to clear stale selections + this.boundSubtitleChangeHandler = this.handleSubtitleContentChange.bind( + this + ); + document.addEventListener( + 'dualsub-subtitle-content-changing', + this.boundSubtitleChangeHandler, + { capture: false } + ); + // Listen for storage changes chrome.storage.onChanged.addListener((changes, area) => { if (area === 'sync') { @@ -1359,7 +1373,7 @@ export class BaseContentScript { targetLanguage, context, subtitleType, - action: 'toggle', + selectionAction: 'toggle', timestamp: Date.now(), }); @@ -1371,11 +1385,57 @@ export class BaseContentScript { element.classList.add('dualsub-word-selected'); } } + + const normalizedWord = (word || '').trim(); + if (normalizedWord) { + const isSelectedNow = + element?.classList?.contains('dualsub-word-selected') ?? + !this.selectedWords.has(normalizedWord); + if (isSelectedNow) { + this.selectedWords.add(normalizedWord); + } else { + this.selectedWords.delete(normalizedWord); + } + } } catch (error) { console.error('[SidePanelIntegration] Error forwarding word selection:', error); } }, + handleSubtitleContentChange(event) { + if (!this.sidePanelEnabled || !this.useSidePanel) { + return; + } + + const detail = event?.detail || {}; + if (detail.type && detail.type !== 'original') { + return; + } + + if (!this.selectedWords || this.selectedWords.size === 0) { + return; + } + + try { + document + .querySelectorAll('.dualsub-interactive-word.dualsub-word-selected') + .forEach((el) => el.classList.remove('dualsub-word-selected')); + } catch (_) {} + + this.selectedWords.clear(); + + try { + void this._send({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + selectedWords: [], + timestamp: Date.now(), + reason: 'subtitle-change', + }); + } catch (error) { + console.error('[SidePanelIntegration] Error syncing cleared selection:', error); + } + }, + destroy() { if (!this.initialized) return; if (this.boundHandler) { @@ -1384,7 +1444,17 @@ export class BaseContentScript { this.boundHandler, { capture: true } ); + this.boundHandler = null; } + if (this.boundSubtitleChangeHandler) { + document.removeEventListener( + 'dualsub-subtitle-content-changing', + this.boundSubtitleChangeHandler, + { capture: false } + ); + this.boundSubtitleChangeHandler = null; + } + this.selectedWords = new Set(); this.initialized = false; }, @@ -3278,6 +3348,9 @@ export class BaseContentScript { document .querySelectorAll('.dualsub-interactive-word.dualsub-word-selected') .forEach((el) => el.classList.remove('dualsub-word-selected')); + if (this.sidePanelIntegration && this.sidePanelIntegration.selectedWords) { + this.sidePanelIntegration.selectedWords.clear(); + } } if (Array.isArray(data.selectedWords)) { @@ -3288,6 +3361,9 @@ export class BaseContentScript { ).find((e) => (e.getAttribute('data-word') || '').trim() === word); if (el) el.classList.add('dualsub-word-selected'); }); + if (this.sidePanelIntegration) { + this.sidePanelIntegration.selectedWords = new Set(data.selectedWords); + } } sendResponse({ success: true }); diff --git a/content_scripts/shared/constants/messageActions.js b/content_scripts/shared/constants/messageActions.js index 46d1e13..e420421 100644 --- a/content_scripts/shared/constants/messageActions.js +++ b/content_scripts/shared/constants/messageActions.js @@ -30,4 +30,5 @@ export const MessageActions = { SIDEPANEL_UPDATE_STATE: 'sidePanelUpdateState', SIDEPANEL_REGISTER: 'sidePanelRegister', SIDEPANEL_SET_ANALYZING: 'sidePanelSetAnalyzing', + SIDEPANEL_SELECTION_SYNC: 'sidePanelSelectionSync', }; diff --git a/content_scripts/shared/subtitleUtilities.js b/content_scripts/shared/subtitleUtilities.js index 8912c5b..66d26fb 100644 --- a/content_scripts/shared/subtitleUtilities.js +++ b/content_scripts/shared/subtitleUtilities.js @@ -58,6 +58,37 @@ export function computeTextSignature(textOrHtml) { return s; } +function dispatchContentChangeImmediate(type, oldContent, newContent, element) { + try { + const existingTimeout = contentChangeDebounceTimeouts.get(element); + if (existingTimeout) { + clearTimeout(existingTimeout); + contentChangeDebounceTimeouts.delete(element); + } + + document.dispatchEvent( + new CustomEvent('dualsub-subtitle-content-changing', { + detail: { + type, + oldContent, + newContent, + element, + }, + }) + ); + + logWithFallback('debug', 'Immediate subtitle content change dispatched', { + type, + oldContentLength: oldContent.length, + newContentLength: newContent.length, + }); + } catch (error) { + logWithFallback('error', 'Failed to dispatch immediate subtitle change', { + error: error?.message, + }); + } +} + /** * Dispatch subtitle content change event with debouncing to prevent rapid-fire events * @param {string} type - Subtitle type ('original' or 'translated') @@ -1645,8 +1676,15 @@ export function updateSubtitles( originalSubtitleElement.style.display = 'inline-block'; } else { if (originalSubtitleElement.innerHTML) { + dispatchContentChangeImmediate( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement + ); originalSubtitleElement.innerHTML = ''; originalSubtitleElement.dataset.textSig = ''; + contentChanged = true; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1713,8 +1751,15 @@ export function updateSubtitles( originalSubtitleElement.style.display = 'inline-block'; } else { if (originalSubtitleElement.innerHTML) { + dispatchContentChangeImmediate( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement + ); originalSubtitleElement.innerHTML = ''; originalSubtitleElement.dataset.textSig = ''; + contentChanged = true; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', diff --git a/sidepanel/hooks/useWordSelection.js b/sidepanel/hooks/useWordSelection.js index 05793e4..936f1e2 100644 --- a/sidepanel/hooks/useWordSelection.js +++ b/sidepanel/hooks/useWordSelection.js @@ -29,6 +29,31 @@ export function useWordSelection() { const { onMessage, sendToActiveTab, getActiveTab } = useSidePanelCommunication(); + useEffect(() => { + const unsubscribe = onMessage( + 'sidePanelSelectionSync', + (payload) => { + const incomingWords = Array.isArray(payload?.selectedWords) + ? payload.selectedWords + : []; + const normalized = Array.from( + new Set( + incomingWords + .map((w) => + typeof w === 'string' ? w.trim() : '' + ) + .filter((w) => w.length > 0) + ) + ); + + clearWords(); + normalized.forEach((w) => addWord(w)); + } + ); + + return unsubscribe; + }, [onMessage, addWord, clearWords]); + /** * Handle word selected event from content script */ @@ -39,14 +64,23 @@ export function useWordSelection() { return; } - const { word, sourceLanguage, targetLanguage, subtitleType, action } = data; + const { word, sourceLanguage, targetLanguage } = data; + const selectionAction = data?.selectionAction ?? data?.action; + const normalizedAction = + selectionAction && selectionAction !== 'sidePanelWordSelected' + ? selectionAction + : 'toggle'; - // Toggle/append semantics like modal: default to 'add' if unspecified - if (action === 'remove') { + if (normalizedAction === 'remove') { removeWord(word); - } else if (action === 'toggle') { + } else if (normalizedAction === 'toggle') { if (selectedWords.has(word)) removeWord(word); else addWord(word); + } else if (normalizedAction === 'replace') { + clearWords(); + addWord(word); + } else if (normalizedAction === 'add') { + addWord(word); } else { addWord(word); } @@ -59,7 +93,7 @@ export function useWordSelection() { setTargetLanguage(targetLanguage); } }, - [addWord, removeWord, selectedWords, setSourceLanguage, setTargetLanguage] + [addWord, removeWord, clearWords, selectedWords, setSourceLanguage, setTargetLanguage] ); /** From 58528a9c2f43a274d3d3cf75efc2a351fda89a03 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Tue, 21 Oct 2025 17:53:41 -0400 Subject: [PATCH 05/23] Enhance side panel functionality and improve selection handling - Added a new alias for shared components in vite.config.js. - Implemented selection synchronization in SidePanelService to manage word selections more effectively. - Updated content scripts to handle selection updates and synchronize state with the side panel. - Refactored word selection logic to ensure consistent state management and prevent race conditions. - Introduced a spinner animation in side panel CSS for improved user feedback during loading states. --- background/services/sidePanelService.js | 66 ++++-- content_scripts/core/BaseContentScript.js | 63 ++++-- content_scripts/shared/subtitleUtilities.js | 234 +++++++++++++------- popup/hooks/index.js | 10 +- popup/hooks/useSettings.js | 93 +------- shared/hooks/useSettings.js | 109 +++++++++ sidepanel/SidePanelApp.jsx | 30 +-- sidepanel/components/tabs/AIAnalysisTab.jsx | 31 +-- sidepanel/hooks/SidePanelContext.jsx | 15 +- sidepanel/hooks/useSettings.js | 80 +------ sidepanel/hooks/useWordSelection.js | 134 +++++++---- sidepanel/sidepanel.css | 22 ++ vite.config.js | 1 + 13 files changed, 511 insertions(+), 377 deletions(-) create mode 100644 shared/hooks/useSettings.js diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js index c5f6510..6a1bd9e 100644 --- a/background/services/sidePanelService.js +++ b/background/services/sidePanelService.js @@ -186,6 +186,23 @@ class SidePanelService { this.logger.error('Failed to deliver pending selection on register', err, { tabId: claimedTabId }); } }, 60); + } else { + const selectedWords = Array.isArray(st.selectedWords) + ? st.selectedWords + : []; + setTimeout(() => { + try { + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords, + reason: 'state-sync-on-register', + }, + }); + } catch (err) { + this.logger.error('Failed to deliver stored selection on register', err, { tabId: claimedTabId }); + } + }, 40); } } } catch (err) { @@ -318,12 +335,16 @@ class SidePanelService { */ async forwardWordSelection(tabId, wordData) { const port = this.activeConnections.get(tabId); + + // Do not mutate selection state here; treat selection sync as the single source of truth if (port) { - // Ensure AI Analysis tab is active if selection comes while open - port.postMessage({ - action: MessageActions.SIDEPANEL_UPDATE_STATE, - data: { activeTab: 'ai-analysis' }, - }); + const isUserInitiated = wordData.reason === 'word-click'; + if (isUserInitiated) { + port.postMessage({ + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: { activeTab: 'ai-analysis' }, + }); + } port.postMessage({ action: MessageActions.SIDEPANEL_WORD_SELECTED, @@ -336,15 +357,11 @@ class SidePanelService { }); } else { this.logger.debug('No active side panel connection', { tabId }); - - // Store word selection for when side panel opens + // Store to deliver after connection if needed (UI hint), but do not change selection state order here const state = this.tabStates.get(tabId) || {}; state.pendingWordSelection = wordData; - // Ensure we request AI Analysis tab on open state.activeTab = 'ai-analysis'; this.tabStates.set(tabId, state); - - // Open side panel immediately to preserve user gesture await this.openSidePanelImmediate(tabId, { pauseVideo: true }); } } @@ -354,18 +371,33 @@ class SidePanelService { */ async forwardSelectionSync(tabId, payload = {}) { const port = this.activeConnections.get(tabId); + const incomingWords = (Array.isArray(payload?.selectedWords) ? payload.selectedWords : []) + .map((w) => (typeof w === 'string' ? w.trim() : '')) + .filter((w) => w.length > 0); + + // Deduplicate while preserving order + const normalizedWords = incomingWords.reduce((acc, word) => { + if (!acc.includes(word)) acc.push(word); + return acc; + }, []); + + const state = this.tabStates.get(tabId) || {}; + state.selectedWords = normalizedWords; + delete state.pendingWordSelection; + this.tabStates.set(tabId, state); + if (port) { try { port.postMessage({ action: MessageActions.SIDEPANEL_SELECTION_SYNC, data: { - selectedWords: payload.selectedWords || [], + selectedWords: normalizedWords, reason: payload.reason || 'unknown', }, }); this.logger.debug('Selection sync forwarded to side panel', { tabId, - count: (payload.selectedWords || []).length, + count: normalizedWords.length, }); } catch (err) { this.logger.error('Failed to forward selection sync', err, { @@ -373,9 +405,13 @@ class SidePanelService { }); } } else { - this.logger.debug('Selection sync skipped (no active side panel connection)', { - tabId, - }); + this.logger.debug( + 'Selection sync persisted without active side panel connection', + { + tabId, + count: normalizedWords.length, + } + ); } } diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index 325b77f..8bbb319 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -125,7 +125,10 @@ import { MessageHandlerRegistry, } from './utils.js'; import { COMMON_CONSTANTS } from './constants.js'; -import { getOrCreateUiRoot } from '../shared/subtitleUtilities.js'; +import { + getOrCreateUiRoot, + finalizeExpiredSubtitleIfNeeded, +} from '../shared/subtitleUtilities.js'; import { MessageActions } from '../shared/constants/messageActions.js'; import { NavigationDetectionManager } from '../shared/navigationUtils.js'; @@ -1365,19 +1368,7 @@ export class BaseContentScript { }); } catch (_) {} - // 2) Forward word selection (ok to await) - const resp = await this._send({ - action: MessageActions.SIDEPANEL_WORD_SELECTED, - word, - sourceLanguage, - targetLanguage, - context, - subtitleType, - selectionAction: 'toggle', - timestamp: Date.now(), - }); - - // Visual feedback: toggle selection like legacy modal (do not clear all) + // 2) Toggle visual selection immediately to reflect DOM state if (element) { if (element.classList.contains('dualsub-word-selected')) { element.classList.remove('dualsub-word-selected'); @@ -1397,6 +1388,43 @@ export class BaseContentScript { this.selectedWords.delete(normalizedWord); } } + + // 3) After DOM reflects the new selection, compute canonical ordered list and broadcast + try { + const highlighted = Array.from( + document.querySelectorAll('.dualsub-interactive-word.dualsub-word-selected') + ); + const words = []; + const seen = new Set(); + highlighted.forEach((el) => { + const w = el.getAttribute('data-word') || el.textContent || ''; + const ww = (w || '').trim(); + if (ww && !seen.has(ww)) { + seen.add(ww); + words.push(ww); + } + }); + + void this._send({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + selectedWords: words, + timestamp: Date.now(), + reason: 'word-click', + }); + } catch (_) {} + + // 4) Forward word selection (non-authoritative, kept for compatibility) + void this._send({ + action: MessageActions.SIDEPANEL_WORD_SELECTED, + word, + sourceLanguage, + targetLanguage, + context, + subtitleType, + selectionAction: 'toggle', + reason: 'word-click', + timestamp: Date.now(), + }); } catch (error) { console.error('[SidePanelIntegration] Error forwarding word selection:', error); } @@ -3574,6 +3602,13 @@ export class BaseContentScript { 'debug', 'Page visible, resuming operations' ); + try { + finalizeExpiredSubtitleIfNeeded(); + } catch (err) { + this.logWithFallback('warn', 'Failed to finalize subtitles after visibility restore', { + error: err?.message, + }); + } // Re-check video setup when page becomes visible if ( this.activePlatform && diff --git a/content_scripts/shared/subtitleUtilities.js b/content_scripts/shared/subtitleUtilities.js index 66d26fb..e5885aa 100644 --- a/content_scripts/shared/subtitleUtilities.js +++ b/content_scripts/shared/subtitleUtilities.js @@ -58,85 +58,51 @@ export function computeTextSignature(textOrHtml) { return s; } -function dispatchContentChangeImmediate(type, oldContent, newContent, element) { +function dispatchContentChange(type, oldContent, newContent, element, { immediate = false } = {}) { try { + const oldSig = computeTextSignature(oldContent || ''); + const newSig = computeTextSignature(newContent || ''); + if (!immediate && oldSig === newSig) { + return; + } + const existingTimeout = contentChangeDebounceTimeouts.get(element); if (existingTimeout) { clearTimeout(existingTimeout); contentChangeDebounceTimeouts.delete(element); } - document.dispatchEvent( - new CustomEvent('dualsub-subtitle-content-changing', { - detail: { - type, - oldContent, - newContent, - element, - }, - }) - ); - - logWithFallback('debug', 'Immediate subtitle content change dispatched', { - type, - oldContentLength: oldContent.length, - newContentLength: newContent.length, - }); - } catch (error) { - logWithFallback('error', 'Failed to dispatch immediate subtitle change', { - error: error?.message, - }); - } -} - -/** - * Dispatch subtitle content change event with debouncing to prevent rapid-fire events - * @param {string} type - Subtitle type ('original' or 'translated') - * @param {string} oldContent - Previous content - * @param {string} newContent - New content - * @param {HTMLElement} element - Subtitle element - */ -function dispatchContentChangeDebounced(type, oldContent, newContent, element) { - // Phase 2: Gate dispatching based on normalized signatures - try { - const oldSig = computeTextSignature(oldContent || ''); - const newSig = computeTextSignature(newContent || ''); - if (oldSig === newSig) { - return; // no effective change - } - } catch (_) {} - const existingTimeout = contentChangeDebounceTimeouts.get(element); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - const timeoutId = setTimeout(() => { - document.dispatchEvent( - new CustomEvent('dualsub-subtitle-content-changing', { - detail: { - type, - oldContent, - newContent, - element, - }, - }) - ); + const dispatch = () => { + document.dispatchEvent( + new CustomEvent('dualsub-subtitle-content-changing', { + detail: { + type, + oldContent, + newContent, + element, + }, + }) + ); - logWithFallback( - 'debug', - 'Debounced subtitle content change event dispatched', - { + logWithFallback('debug', `${immediate ? 'Immediate' : 'Debounced'} subtitle content change dispatched`, { type, oldContentLength: oldContent.length, newContentLength: newContent.length, - } - ); + }); + }; - // Clean up the timeout from the map - contentChangeDebounceTimeouts.delete(element); - }, CONTENT_CHANGE_DEBOUNCE_DELAY); + if (immediate) { + dispatch(); + return; + } - // Store the timeout for this element - contentChangeDebounceTimeouts.set(element, timeoutId); + const timeoutId = setTimeout(() => { + dispatch(); + contentChangeDebounceTimeouts.delete(element); + }, CONTENT_CHANGE_DEBOUNCE_DELAY); + + contentChangeDebounceTimeouts.set(element, timeoutId); + } catch (_) {} } // Initialize logger when available @@ -1656,7 +1622,7 @@ export function updateSubtitles( originalSubtitleElement.innerHTML === '' ) { // Notify AI Context modal about subtitle content change (debounced) - dispatchContentChangeDebounced( + dispatchContentChange( 'original', originalSubtitleElement.innerHTML, originalTextFormatted, @@ -1676,15 +1642,21 @@ export function updateSubtitles( originalSubtitleElement.style.display = 'inline-block'; } else { if (originalSubtitleElement.innerHTML) { - dispatchContentChangeImmediate( + dispatchContentChange( 'original', originalSubtitleElement.innerHTML, '', - originalSubtitleElement + originalSubtitleElement, + { immediate: true } ); originalSubtitleElement.innerHTML = ''; originalSubtitleElement.dataset.textSig = ''; contentChanged = true; + lastDisplayedCueWindow = { + start: null, + end: null, + videoId: null, + }; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1718,8 +1690,16 @@ export function updateSubtitles( translatedSubtitleElement.style.display = 'inline-block'; } else { if (translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); translatedSubtitleElement.innerHTML = ''; translatedSubtitleElement.dataset.textSig = ''; + contentChanged = true; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1751,15 +1731,21 @@ export function updateSubtitles( originalSubtitleElement.style.display = 'inline-block'; } else { if (originalSubtitleElement.innerHTML) { - dispatchContentChangeImmediate( + dispatchContentChange( 'original', originalSubtitleElement.innerHTML, '', - originalSubtitleElement + originalSubtitleElement, + { immediate: true } ); originalSubtitleElement.innerHTML = ''; originalSubtitleElement.dataset.textSig = ''; contentChanged = true; + lastDisplayedCueWindow = { + start: null, + end: null, + videoId: null, + }; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1793,8 +1779,16 @@ export function updateSubtitles( translatedSubtitleElement.style.display = 'inline-block'; } else { if (translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); translatedSubtitleElement.innerHTML = ''; translatedSubtitleElement.dataset.textSig = ''; + contentChanged = true; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1883,13 +1877,35 @@ export function updateSubtitles( return; } - if (originalSubtitleElement.innerHTML) + if (originalSubtitleElement && originalSubtitleElement.innerHTML) { + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement, + { immediate: true } + ); originalSubtitleElement.innerHTML = ''; - originalSubtitleElement.style.display = 'none'; + originalSubtitleElement.dataset.textSig = ''; + } + if (originalSubtitleElement) + originalSubtitleElement.style.display = 'none'; - if (translatedSubtitleElement.innerHTML) + if (translatedSubtitleElement && translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); translatedSubtitleElement.innerHTML = ''; - translatedSubtitleElement.style.display = 'none'; + translatedSubtitleElement.dataset.textSig = ''; + } + if (translatedSubtitleElement) + translatedSubtitleElement.style.display = 'none'; + + lastDisplayedCueWindow = { start: null, end: null, videoId: null }; } } @@ -1927,6 +1943,74 @@ export function clearSubtitlesDisplayAndQueue( } } +export function finalizeExpiredSubtitleIfNeeded(thresholdSeconds = 0.1) { + try { + if (!lastDisplayedCueWindow || lastDisplayedCueWindow.end == null) { + return false; + } + + const video = + document.querySelector('video[data-listener-attached="true"]') || + document.querySelector('video'); + const currentTime = + typeof video?.currentTime === 'number' ? video.currentTime : null; + + if ( + currentTime == null || + currentTime <= (lastDisplayedCueWindow.end ?? 0) + thresholdSeconds + ) { + return false; + } + + let cleared = false; + + if (originalSubtitleElement && originalSubtitleElement.innerHTML) { + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement, + { immediate: true } + ); + originalSubtitleElement.innerHTML = ''; + originalSubtitleElement.dataset.textSig = ''; + originalSubtitleElement.style.display = 'none'; + cleared = true; + } + + if (translatedSubtitleElement && translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); + translatedSubtitleElement.innerHTML = ''; + translatedSubtitleElement.dataset.textSig = ''; + translatedSubtitleElement.style.display = 'none'; + cleared = true; + } + + if (cleared) { + document + .querySelectorAll( + '.dualsub-interactive-word.dualsub-word-selected' + ) + .forEach((el) => el.classList.remove('dualsub-word-selected')); + + lastDisplayedCueWindow = { start: null, end: null, videoId: null }; + } + + return cleared; + } catch (error) { + logWithFallback('warn', 'Failed to finalize expired subtitle', { + error: error?.message, + }); + return false; + } +} + export function clearSubtitleDOM() { if (subtitleContainer && subtitleContainer.parentElement) { subtitleContainer.parentElement.removeChild(subtitleContainer); diff --git a/popup/hooks/index.js b/popup/hooks/index.js index 5ac01a6..e31b1f6 100644 --- a/popup/hooks/index.js +++ b/popup/hooks/index.js @@ -1,4 +1,6 @@ -export { useSettings } from './useSettings.js'; -export { useTranslation } from './useTranslation.js'; -export { useChromeMessage } from './useChromeMessage.js'; -export { useLogger } from './useLogger.js'; +import { useSettings } from '@shared/hooks/useSettings.js'; +import { useTranslation } from './useTranslation.js'; +import { useLogger } from './useLogger.js'; +import { useChromeMessage } from './useChromeMessage.js'; + +export { useSettings, useTranslation, useChromeMessage, useLogger }; diff --git a/popup/hooks/useSettings.js b/popup/hooks/useSettings.js index dbad0ef..a1f333f 100644 --- a/popup/hooks/useSettings.js +++ b/popup/hooks/useSettings.js @@ -1,92 +1 @@ -import { useState, useEffect, useCallback } from 'react'; -import { configService } from '../../services/configService.js'; - -/** - * Hook for managing extension settings - * @param {string|string[]} keys - Setting key(s) to watch - * @returns {Object} Settings state and update function - */ -export function useSettings(keys) { - const [settings, setSettings] = useState({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Load initial settings - useEffect(() => { - const loadSettings = async () => { - try { - setLoading(true); - let data; - - if (Array.isArray(keys)) { - data = await configService.getMultiple(keys); - } else if (keys) { - const value = await configService.get(keys); - data = { [keys]: value }; - } else { - data = await configService.getAll(); - } - - setSettings(data); - setError(null); - } catch (err) { - setError(err); - console.error('Error loading settings:', err); - } finally { - setLoading(false); - } - }; - - loadSettings(); - }, [keys]); - - // Listen for setting changes - useEffect(() => { - const handleChange = (changes) => { - setSettings((prev) => ({ ...prev, ...changes })); - }; - - const unsubscribe = configService.onChanged(handleChange); - - return () => { - // Clean up listener - if (typeof unsubscribe === 'function') { - unsubscribe(); - } - }; - }, []); - - // Update a setting - const updateSetting = useCallback(async (key, value) => { - try { - await configService.set(key, value); - setSettings((prev) => ({ ...prev, [key]: value })); - return true; - } catch (err) { - setError(err); - console.error(`Error updating setting ${key}:`, err); - return false; - } - }, []); - - // Update multiple settings at once - const updateSettings = useCallback(async (updates) => { - try { - await configService.setMultiple(updates); - setSettings((prev) => ({ ...prev, ...updates })); - return true; - } catch (err) { - setError(err); - console.error('Error updating settings:', err); - return false; - } - }, []); - - return { - settings, - updateSetting, - updateSettings, - loading, - error, - }; -} +export { useSettings } from '@shared/hooks/useSettings.js'; diff --git a/shared/hooks/useSettings.js b/shared/hooks/useSettings.js new file mode 100644 index 0000000..d962543 --- /dev/null +++ b/shared/hooks/useSettings.js @@ -0,0 +1,109 @@ +import { useState, useEffect, useCallback } from 'react'; +import { configService } from '../../services/configService.js'; + +/** + * Hook for managing extension settings + * @param {string|string[]} keys - Setting key(s) to watch + * @returns {Object} Settings state and update function + */ +export function useSettings(keys) { + const [settings, setSettings] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Load initial settings + useEffect(() => { + const loadSettings = async () => { + try { + setLoading(true); + let data; + + if (Array.isArray(keys)) { + data = await configService.getMultiple(keys); + } else if (keys) { + const value = await configService.get(keys); + data = { [keys]: value }; + } else { + data = await configService.getAll(); + } + + setSettings(data); + setError(null); + } catch (err) { + setError(err); + console.error('Error loading settings:', err); + } finally { + setLoading(false); + } + }; + + loadSettings(); + }, [JSON.stringify(keys)]); + + // Listen for setting changes + useEffect(() => { + const handleChange = (changes) => { + const relevantChanges = {}; + let hasRelevantChange = false; + + if (!keys) { + setSettings((prev) => ({ ...prev, ...changes })); + return; + } + + const watchedKeys = Array.isArray(keys) ? keys : [keys]; + for (const key in changes) { + if (watchedKeys.includes(key)) { + relevantChanges[key] = changes[key]; + hasRelevantChange = true; + } + } + + if (hasRelevantChange) { + setSettings((prev) => ({ ...prev, ...relevantChanges })); + } + }; + + const unsubscribe = configService.onChanged(handleChange); + + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [JSON.stringify(keys)]); + + // Update a setting + const updateSetting = useCallback(async (key, value) => { + try { + await configService.set(key, value); + setSettings((prev) => ({ ...prev, [key]: value })); + return true; + } catch (err) { + setError(err); + console.error(`Error updating setting ${key}:`, err); + return false; + } + }, []); + + // Update multiple settings at once + const updateSettings = useCallback(async (updates) => { + try { + await configService.setMultiple(updates); + setSettings((prev) => ({ ...prev, ...updates })); + return true; + } catch (err) { + setError(err); + console.error('Error updating settings:', err); + return false; + } + }, []); + + return { + settings, + updateSetting, + updateSettings, + loading, + error, + }; +} diff --git a/sidepanel/SidePanelApp.jsx b/sidepanel/SidePanelApp.jsx index 8f1d3aa..6afa53f 100644 --- a/sidepanel/SidePanelApp.jsx +++ b/sidepanel/SidePanelApp.jsx @@ -6,6 +6,7 @@ import { useTheme } from './hooks/useTheme.js'; import { useSettings } from './hooks/useSettings.js'; import { SidePanelProvider } from './hooks/SidePanelContext.jsx'; import { useSidePanelCommunication } from './hooks/useSidePanelCommunication.js'; +import { useCallback } from 'react'; /** * Main Side Panel Application Component @@ -30,6 +31,15 @@ export function SidePanelApp() { const [activeTab, setActiveTab] = useState('ai-analysis'); const { theme, toggleTheme } = useTheme(); const { settings, loading: settingsLoading } = useSettings(); + const { postMessage } = useSidePanelCommunication(); + + const handleTabChange = useCallback( + (tabId) => { + setActiveTab(tabId); + postMessage('sidePanelUpdateState', { activeTab: tabId }); + }, + [postMessage] + ); // Apply theme class to body useEffect(() => { @@ -60,17 +70,7 @@ export function SidePanelApp() { }} >
-
+

Loading...

@@ -85,7 +85,7 @@ export function SidePanelApp() {
@@ -108,12 +108,6 @@ export function SidePanelApp() { overflow-y: auto; overflow-x: hidden; } - - @keyframes spin { - to { - transform: rotate(360deg); - } - } `} ); diff --git a/sidepanel/components/tabs/AIAnalysisTab.jsx b/sidepanel/components/tabs/AIAnalysisTab.jsx index b6a1ede..1a22ed6 100644 --- a/sidepanel/components/tabs/AIAnalysisTab.jsx +++ b/sidepanel/components/tabs/AIAnalysisTab.jsx @@ -21,7 +21,7 @@ export function AIAnalysisTab() { const { toggleWord, clearSelection } = useWordSelection(); const handleAnalyze = () => { - if (selectedWords.size > 0) { + if (selectedWords.length > 0) { analyzeWords(); } }; @@ -38,7 +38,7 @@ export function AIAnalysisTab() {
- - ); } diff --git a/sidepanel/components/tabs/AIAnalysisTab.jsx b/sidepanel/components/tabs/AIAnalysisTab.jsx index 1a22ed6..724185a 100644 --- a/sidepanel/components/tabs/AIAnalysisTab.jsx +++ b/sidepanel/components/tabs/AIAnalysisTab.jsx @@ -35,7 +35,7 @@ export function AIAnalysisTab() {

AI Analysis

-
- - ); } diff --git a/sidepanel/components/tabs/WordsListsTab.jsx b/sidepanel/components/tabs/WordsListsTab.jsx index 542187e..fcf2272 100644 --- a/sidepanel/components/tabs/WordsListsTab.jsx +++ b/sidepanel/components/tabs/WordsListsTab.jsx @@ -90,216 +90,6 @@ export function WordsListsTab() { ))}
- - ); } diff --git a/sidepanel/hooks/SidePanelContext.jsx b/sidepanel/hooks/SidePanelContext.jsx index cc64bb9..7410d8f 100644 --- a/sidepanel/hooks/SidePanelContext.jsx +++ b/sidepanel/hooks/SidePanelContext.jsx @@ -3,7 +3,7 @@ import React, { useContext, useState, useEffect, - useCallback, + useMemo, } from 'react'; import { useSidePanelCommunication } from './useSidePanelCommunication'; @@ -23,18 +23,9 @@ export function SidePanelProvider({ children }) { const [tabState, setTabState] = useState({}); const [activeTabId, setActiveTabId] = useState(null); const { onMessage, getActiveTab, postMessage, getBinding } = useSidePanelCommunication(); - const pendingSelectionRef = React.useRef(null); - // Effect to set the initial active tab and listen for changes + // Initial setup and tab activation listener useEffect(() => { - let followActiveRef = { current: false }; - // Load follow-active-tab behavior - chrome.storage.sync.get(['sidePanelFollowActiveTabInWindow']).then((res) => { - followActiveRef.current = !!res.sidePanelFollowActiveTabInWindow; - }).catch(() => { - followActiveRef.current = false; - }); - const handleTabActivated = (tabId) => { setActiveTabId(tabId); setTabState((prev) => ({ @@ -53,132 +44,75 @@ export function SidePanelProvider({ children }) { // Get initial active tab getActiveTab() .then((tab) => { - if (tab && tab.id) { + if (tab?.id) { handleTabActivated(tab.id); - try { - const binding = getBinding(); - postMessage('sidePanelRegister', { tabId: tab.id, windowId: tab.windowId, panelInstanceId: binding?.panelInstanceId }); - } catch (_) {} - // Apply any pending selection captured before tab ID was known - if (pendingSelectionRef.current) { - const normalized = pendingSelectionRef.current; - setTabState((prev) => ({ - ...prev, - [tab.id]: { - ...(prev[tab.id] || {}), - selectedWords: normalized, - }, - })); - pendingSelectionRef.current = null; - } } }) - .catch(() => {}); + .catch((err) => console.error('Failed to get initial tab:', err)); - // Listen for tab activation changes from the background script - const unsubscribe = onMessage('tabActivated', ({ tabId, windowId }) => { - // Always update the active tab ID to reflect the user's current view + // Listen for tab activation changes from background + const unsubscribe = onMessage('tabActivated', ({ tabId }) => { handleTabActivated(tabId); - - // Notify background that we are now "looking" at this tab - // This triggers the background to send us the latest state for this tab + + // Notify background to register this tab try { const binding = getBinding(); - postMessage('sidePanelRegister', { tabId, windowId, panelInstanceId: binding?.panelInstanceId }); - } catch (_) {} - - // Apply any pending selection for unknown tab now that we have an ID - if (pendingSelectionRef.current) { - const normalized = pendingSelectionRef.current; - setTabState((prev) => ({ - ...prev, - [tabId]: { - ...(prev[tabId] || {}), - selectedWords: normalized, - }, - })); - pendingSelectionRef.current = null; + postMessage('sidePanelRegister', { + tabId, + windowId: binding?.boundWindowId, + panelInstanceId: binding?.panelInstanceId + }); + } catch (e) { + console.error('Failed to register on tab switch:', e); } }); - // Listen for forced tab binding (triggered by explicit user interaction like clicking a word) - const unsubscribeForce = onMessage('sidePanelForceBindTab', ({ tabId, windowId }) => { + // Listen for forced tab binding + const unsubscribeForce = onMessage('sidePanelForceBindTab', ({ tabId }) => { handleTabActivated(tabId); try { const binding = getBinding(); - postMessage('sidePanelRegister', { tabId, windowId, panelInstanceId: binding?.panelInstanceId }); - } catch (_) {} + postMessage('sidePanelRegister', { + tabId, + windowId: binding?.boundWindowId, + panelInstanceId: binding?.panelInstanceId + }); + } catch (e) { + console.error('Failed to register on force bind:', e); + } }); return () => { unsubscribe(); unsubscribeForce(); }; - }, [getActiveTab, onMessage, postMessage]); + }, [getActiveTab, onMessage, postMessage, getBinding]); - // Effect to handle authoritative selection sync from background (e.g., subtitle change clears selection) + // Handle selection sync from background useEffect(() => { - const unsubscribe = onMessage( - 'sidePanelSelectionSync', - ({ selectedWords, tabId }) => { - // Always update the state for the specific tab provided in the message - // This ensures we have the latest data cached even if we aren't looking at it right now - const normalized = Array.isArray(selectedWords) - ? Array.from( - new Set( - selectedWords - .map((w) => - typeof w === 'string' - ? w.trim() - : '' - ) - .filter(Boolean) - ) - ) - : []; - - if (typeof tabId === 'number') { - setTabState((prev) => ({ - ...prev, - [tabId]: { - ...(prev[tabId] || {}), - selectedWords: normalized, - }, - })); - } else if (!activeTabId) { - // Fallback for initialization race conditions where tabId isn't known yet - pendingSelectionRef.current = normalized; - getActiveTab().then(tab => { - if (tab?.id) { - setActiveTabId(tab.id); - setTabState(prev => ({ - ...prev, - [tab.id]: { ...(prev[tab.id] || {}), selectedWords: normalized } - })); - pendingSelectionRef.current = null; - } - }).catch(() => {}); - } else { - // Fallback: if no tabId in message, assume it's for the active tab - setTabState((prev) => ({ - ...prev, - [activeTabId]: { - ...(prev[activeTabId] || {}), - selectedWords: normalized, - }, - })); - } + const unsubscribe = onMessage('sidePanelSelectionSync', ({ selectedWords, tabId }) => { + const normalizedWords = Array.isArray(selectedWords) + ? Array.from(new Set(selectedWords.map(w => w?.trim()).filter(Boolean))) + : []; + + const targetTabId = tabId || activeTabId; + + if (targetTabId) { + setTabState((prev) => ({ + ...prev, + [targetTabId]: { + ...(prev[targetTabId] || {}), + selectedWords: normalizedWords, + }, + })); } - ); - return unsubscribe; - }, [onMessage, activeTabId, getActiveTab]); + }); - // Note: We intentionally ignore 'wordSelectionUpdate' messages here. - // The authoritative selection state is delivered via 'sidePanelSelectionSync', - // which avoids race conditions between toggle and full-list updates. + return unsubscribe; + }, [onMessage, activeTabId]); // Memoized context value - const value = React.useMemo(() => { + const value = useMemo(() => { const activeState = tabState[activeTabId] || { selectedWords: [], analysisResult: null, @@ -200,32 +134,20 @@ export function SidePanelProvider({ children }) { return { ...activeState, activeTabId, - setSelectedWords: (words) => - updateActiveTabState({ selectedWords: words }), - setAnalysisResult: (result) => - updateActiveTabState({ analysisResult: result }), - setIsAnalyzing: (isAnalyzing) => - updateActiveTabState({ isAnalyzing }), + setSelectedWords: (words) => updateActiveTabState({ selectedWords: words }), + setAnalysisResult: (result) => updateActiveTabState({ analysisResult: result }), + setIsAnalyzing: (isAnalyzing) => updateActiveTabState({ isAnalyzing }), setError: (error) => updateActiveTabState({ error }), - setSourceLanguage: (lang) => - updateActiveTabState({ sourceLanguage: lang }), - setTargetLanguage: (lang) => - updateActiveTabState({ targetLanguage: lang }), - clearAnalysis: () => - updateActiveTabState({ analysisResult: null, error: null }), + setSourceLanguage: (lang) => updateActiveTabState({ sourceLanguage: lang }), + setTargetLanguage: (lang) => updateActiveTabState({ targetLanguage: lang }), + clearAnalysis: () => updateActiveTabState({ analysisResult: null, error: null }), clearWords: () => updateActiveTabState({ selectedWords: [] }), - addWord: (word) => - updateActiveTabState({ - selectedWords: [ - ...new Set([...activeState.selectedWords, word]), - ], - }), - removeWord: (word) => - updateActiveTabState({ - selectedWords: activeState.selectedWords.filter( - (w) => w !== word - ), - }), + addWord: (word) => updateActiveTabState({ + selectedWords: [...new Set([...activeState.selectedWords, word])], + }), + removeWord: (word) => updateActiveTabState({ + selectedWords: activeState.selectedWords.filter((w) => w !== word), + }), }; }, [tabState, activeTabId]); @@ -239,9 +161,8 @@ export function SidePanelProvider({ children }) { export function useSidePanelContext() { const context = useContext(SidePanelContext); if (!context) { - throw new Error( - 'useSidePanelContext must be used within SidePanelProvider' - ); + throw new Error('useSidePanelContext must be used within SidePanelProvider'); } return context; } + diff --git a/sidepanel/hooks/useSidePanelCommunication.js b/sidepanel/hooks/useSidePanelCommunication.js index aef8ec6..688952a 100644 --- a/sidepanel/hooks/useSidePanelCommunication.js +++ b/sidepanel/hooks/useSidePanelCommunication.js @@ -16,154 +16,135 @@ export function useSidePanelCommunication() { const messageListeners = useRef(new Map()); const portRef = useRef(null); const reconnectTimerRef = useRef(null); - const reconnectDelayRef = useRef(1000); - const heartbeatTimerRef = useRef(null); - const mountedRef = useRef(false); const bindingRef = useRef({ panelInstanceId: null, boundTabId: null, boundWindowId: null }); + const mountedRef = useRef(false); - const generateInstanceId = () => { - // Simple UUID v4-ish generator - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = (Math.random() * 16) | 0; - const v = c === 'x' ? r : (r & 0x3) | 0x8; - return v.toString(16); - }); - }; - - // Initialize long-lived connection to background with auto-reconnect and heartbeat + // Initialize instance ID once useEffect(() => { + if (!bindingRef.current.panelInstanceId) { + bindingRef.current.panelInstanceId = crypto.randomUUID(); + } mountedRef.current = true; - - const registerWithActiveTab = async () => { - try { - const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); - if (tab && tab.id && portRef.current) { - const windowId = tab.windowId ?? (await chrome.windows.getCurrent({ populate: false }).then(w => w?.id).catch(() => undefined)); - if (!bindingRef.current.panelInstanceId) { - bindingRef.current.panelInstanceId = generateInstanceId(); - } - bindingRef.current.boundTabId = tab.id; - bindingRef.current.boundWindowId = windowId ?? null; - try { - portRef.current.postMessage({ - action: 'sidePanelRegister', - data: { tabId: tab.id, windowId: bindingRef.current.boundWindowId, panelInstanceId: bindingRef.current.panelInstanceId }, - source: 'sidepanel', - timestamp: Date.now(), - }); - // Ask background for a fresh state snapshot for the current tab - portRef.current.postMessage({ - action: 'sidePanelGetState', - data: {}, - source: 'sidepanel', - timestamp: Date.now(), - }); - } catch (e) { - console.warn('Failed to register side panel with background:', e); - } - } - } catch (e) { - console.warn('Failed to query active tab for registration:', e); - } + return () => { + mountedRef.current = false; }; + }, []); - const clearReconnectTimer = () => { - if (reconnectTimerRef.current) { - clearTimeout(reconnectTimerRef.current); - reconnectTimerRef.current = null; - } - }; + /** + * Register the side panel with the active tab and background script. + */ + const registerWithActiveTab = useCallback(async () => { + if (!portRef.current) return; - const startHeartbeat = () => { - if (heartbeatTimerRef.current) return; - heartbeatTimerRef.current = setInterval(async () => { - try { - // Use runtime message for keep-alive; background handles MessageActions.PING - await chrome.runtime.sendMessage({ action: 'ping', source: 'sidepanel', timestamp: Date.now() }); - } catch (e) { - // Likely background asleep or reloading; will trigger reconnect via disconnect path - } - }, 25000); - }; + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.id) { + const windowId = tab.windowId; + bindingRef.current.boundTabId = tab.id; + bindingRef.current.boundWindowId = windowId; - const stopHeartbeat = () => { - if (heartbeatTimerRef.current) { - clearInterval(heartbeatTimerRef.current); - heartbeatTimerRef.current = null; + portRef.current.postMessage({ + action: 'sidePanelRegister', + data: { + tabId: tab.id, + windowId, + panelInstanceId: bindingRef.current.panelInstanceId + }, + source: 'sidepanel', + timestamp: Date.now(), + }); + + // Request fresh state + portRef.current.postMessage({ + action: 'sidePanelGetState', + data: {}, + source: 'sidepanel', + timestamp: Date.now(), + }); } - }; + } catch (e) { + console.error('Failed to register side panel:', e); + } + }, []); + + /** + * Establishes a long-lived connection to the background script. + */ + const connectPort = useCallback(() => { + if (portRef.current) return; - const connectPort = () => { - try { + try { const port = chrome.runtime.connect({ name: 'sidepanel' }); portRef.current = port; + setIsConnected(true); + setError(null); port.onMessage.addListener((message) => { - // Special handling for potential future binding change advisories + // Handle internal binding updates if (message?.action === 'bindingChanged' && message?.data) { const { tabId, windowId } = message.data; - if (typeof tabId === 'number') { - bindingRef.current.boundTabId = tabId; - } - if (typeof windowId === 'number') { - bindingRef.current.boundWindowId = windowId; - } + if (typeof tabId === 'number') bindingRef.current.boundTabId = tabId; + if (typeof windowId === 'number') bindingRef.current.boundWindowId = windowId; } + + // Dispatch to listeners const listeners = messageListeners.current.get(message.action); if (listeners) { - listeners.forEach((callback) => callback(message.data)); + listeners.forEach((callback) => { + try { + callback(message.data); + } catch (err) { + console.error(`Error in listener for ${message.action}:`, err); + } + }); } }); port.onDisconnect.addListener(() => { - console.log('Side panel disconnected from background'); - setIsConnected(false); + console.log('Side panel disconnected'); portRef.current = null; - stopHeartbeat(); - // Exponential backoff reconnect - clearReconnectTimer(); - const delay = Math.min(reconnectDelayRef.current, 30000); - reconnectTimerRef.current = setTimeout(() => { - if (!mountedRef.current) return; - connectPort(); - reconnectDelayRef.current = Math.min(delay * 2, 30000); - }, delay); + setIsConnected(false); + + // Attempt reconnect if still mounted + if (mountedRef.current) { + reconnectTimerRef.current = setTimeout(connectPort, 1000); + } }); - setIsConnected(true); - reconnectDelayRef.current = 1000; - startHeartbeat(); - registerWithActiveTab(); + // Initial registration + registerWithActiveTab(); + } catch (err) { - console.error('Failed to connect to background:', err); + console.error('Connection failed:', err); setError(err); setIsConnected(false); - // Schedule a reconnect attempt - clearReconnectTimer(); - const delay = Math.min(reconnectDelayRef.current, 30000); - reconnectTimerRef.current = setTimeout(() => { - if (!mountedRef.current) return; - connectPort(); - reconnectDelayRef.current = Math.min(delay * 2, 30000); - }, delay); + if (mountedRef.current) { + reconnectTimerRef.current = setTimeout(connectPort, 2000); } - }; + } + }, [registerWithActiveTab]); + // Lifecycle management for connection + useEffect(() => { connectPort(); return () => { - mountedRef.current = false; - clearReconnectTimer(); - stopHeartbeat(); + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + } if (portRef.current) { - try { portRef.current.disconnect(); } catch (_) {} + try { + portRef.current.disconnect(); + } catch (e) { + // Ignore disconnect errors + } portRef.current = null; } }; - }, []); + }, [connectPort]); /** - * Send a message to the background service worker + * Send a one-off message to the background service worker. */ const sendMessage = useCallback(async (action, data = {}) => { try { @@ -174,31 +155,23 @@ export function useSidePanelCommunication() { timestamp: Date.now(), }); - if (response && response.error) { + if (response?.error) { throw new Error(response.error); } - return response; } catch (err) { - console.error(`Failed to send message (${action}):`, err); - setError(err); + console.error(`sendMessage failed (${action}):`, err); throw err; } }, []); /** - * Send a message to the active tab's content script + * Send a message to the active tab's content script. */ const sendToActiveTab = useCallback(async (action, data = {}) => { try { - const [tab] = await chrome.tabs.query({ - active: true, - currentWindow: true, - }); - - if (!tab || !tab.id) { - throw new Error('No active tab found'); - } + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tab?.id) throw new Error('No active tab found'); const response = await chrome.tabs.sendMessage(tab.id, { action, @@ -207,54 +180,47 @@ export function useSidePanelCommunication() { timestamp: Date.now(), }); - if (response && response.error) { - throw new Error(response.error); - } - + if (response?.error) throw new Error(response.error); return response; } catch (err) { - console.error(`Failed to send message to tab (${action}):`, err); - setError(err); + console.error(`sendToActiveTab failed (${action}):`, err); throw err; } }, []); /** - * Send a message to the currently bound tab's content script + * Send a message to the currently bound tab's content script. */ const sendToBoundTab = useCallback(async (action, data = {}) => { + const tabId = bindingRef.current.boundTabId; + if (!tabId) { + return sendToActiveTab(action, data); + } + try { - const tabId = bindingRef.current.boundTabId; - if (!tabId) { - // Fallback to active tab for initialization edge cases - return await sendToActiveTab(action, data); - } const response = await chrome.tabs.sendMessage(tabId, { action, data, source: 'sidepanel', timestamp: Date.now(), }); - if (response && response.error) { - throw new Error(response.error); - } + + if (response?.error) throw new Error(response.error); return response; } catch (err) { - console.error(`Failed to send message to bound tab (${action}):`, err); - setError(err); + console.error(`sendToBoundTab failed (${action}):`, err); throw err; } }, [sendToActiveTab]); /** - * Send a message via long-lived connection + * Send a message via the long-lived port connection. */ const postMessage = useCallback((action, data = {}) => { if (!portRef.current) { - console.error('No active connection to background'); + console.warn('Cannot post message: disconnected'); return; } - try { portRef.current.postMessage({ action, @@ -263,13 +229,12 @@ export function useSidePanelCommunication() { timestamp: Date.now(), }); } catch (err) { - console.error(`Failed to post message (${action}):`, err); - setError(err); + console.error(`postMessage failed (${action}):`, err); } }, []); /** - * Subscribe to messages of a specific action type + * Subscribe to messages of a specific action type. */ const onMessage = useCallback((action, callback) => { if (!messageListeners.current.has(action)) { @@ -277,7 +242,6 @@ export function useSidePanelCommunication() { } messageListeners.current.get(action).add(callback); - // Return unsubscribe function return () => { const listeners = messageListeners.current.get(action); if (listeners) { @@ -289,29 +253,16 @@ export function useSidePanelCommunication() { }; }, []); - /** - * Get the current active tab - */ const getActiveTab = useCallback(async () => { try { - const [tab] = await chrome.tabs.query({ - active: true, - currentWindow: true, - }); + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); return tab; } catch (err) { - console.error('Failed to get active tab:', err); + console.error('getActiveTab failed:', err); return null; } }, []); - /** - * Check if side panel is supported (Chrome 114+) - */ - const isSidePanelSupported = useCallback(() => { - return typeof chrome.sidePanel !== 'undefined'; - }, []); - return { isConnected, error, @@ -321,7 +272,7 @@ export function useSidePanelCommunication() { postMessage, onMessage, getActiveTab, - isSidePanelSupported, getBinding: () => ({ ...bindingRef.current }), }; } + diff --git a/sidepanel/hooks/useWordSelection.js b/sidepanel/hooks/useWordSelection.js index c578fac..e4a0afb 100644 --- a/sidepanel/hooks/useWordSelection.js +++ b/sidepanel/hooks/useWordSelection.js @@ -67,15 +67,9 @@ export function useWordSelection() { } } - try { - postMessage('sidePanelSelectionSync', { - selectedWords: next, - reason: 'panel-toggle', - tabId: activeTabId // Explicitly associate with current view - }); - } catch (err) { - console.warn('Failed to sync toggle to background:', err); - } + // NOTE: We do NOT send sidePanelSelectionSync to background here. + // We wait for the content script to process the update and broadcast the sync back. + // This ensures the content script remains the single source of truth. }, [selectedWords, addWord, removeWord, activeTabId, postMessage] ); @@ -85,7 +79,7 @@ export function useWordSelection() { */ const clearSelection = useCallback(async () => { clearWords(); - + if (activeTabId) { try { await chrome.tabs.sendMessage(activeTabId, { @@ -100,16 +94,6 @@ export function useWordSelection() { console.error('Failed to notify content script of clear:', err); } } - - try { - postMessage('sidePanelSelectionSync', { - selectedWords: [], - reason: 'panel-clear', - tabId: activeTabId - }); - } catch (err) { - console.warn('Failed to sync clear to background:', err); - } }, [clearWords, activeTabId, postMessage]); /** @@ -145,6 +129,6 @@ export function useWordSelection() { removeWord, toggleWord, clearSelection, - syncWithContentScript: async () => {} // No-op stub for compatibility if needed + syncWithContentScript: async () => { } // No-op stub for compatibility if needed }; } diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css index 375fa7b..4155723 100644 --- a/sidepanel/sidepanel.css +++ b/sidepanel/sidepanel.css @@ -242,3 +242,467 @@ body.dark .spinner { transform: rotate(360deg); } } + +/* --- AI Analysis Tab Styles --- */ + +.ai-analysis-tab { + padding: var(--spacing-4); + min-width: 360px; + max-width: 920px; + margin: 0 auto; +} + +.tab-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-4); +} + +.tab-title { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--color-foreground-light); + margin: 0; +} + +body.dark .tab-title { + color: var(--color-foreground-dark); +} + +.analyze-button { + display: flex; + align-items: center; + gap: var(--spacing-2); + background: var(--color-primary); + color: white; + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-lg); + font-weight: 600; + font-size: var(--font-size-sm); + transition: all var(--transition-base); +} + +.analyze-button:hover:not(:disabled) { + background: #1170d8; +} + +.analyze-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.analyze-button .material-symbols-outlined { + font-size: 18px; +} + +.input-container { + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-6); +} + +body.dark .input-container { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); +} + +.input-label { + display: block; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-foreground-light); + margin-bottom: var(--spacing-2); +} + +body.dark .input-label { + color: var(--color-foreground-dark); +} + +.word-input-wrapper { + background: var(--color-background-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3); +} + +body.dark .word-input-wrapper { + background: var(--color-background-dark); + border-color: var(--color-border-dark); +} + +.word-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + min-height: 40px; + align-items: center; +} + +.word-tag { + display: flex; + align-items: center; + gap: var(--spacing-1); + background: var(--color-primary); + color: white; + font-size: var(--font-size-sm); + font-weight: 600; + padding: var(--spacing-1) var(--spacing-2); + border-radius: 6px; +} + +body.dark .word-tag { + background: #0f5fb8; + color: white; +} + +.word-tag-remove { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-left: var(--spacing-1); + background: rgba(255, 255, 255, 0.25); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + font-size: 16px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.word-tag-remove:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.1); +} + +.word-tag-remove:active:not(:disabled) { + transform: scale(0.95); +} + +.word-tag-remove:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.placeholder-text { + color: var(--color-subtle-light); + font-size: var(--font-size-sm); +} + +body.dark .placeholder-text { + color: var(--color-subtle-dark); +} + +.loading-state { + text-align: center; + padding: var(--spacing-8); + color: var(--color-subtle-light); +} + +body.dark .loading-state { + color: var(--color-subtle-dark); +} + +.error-message { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); + padding: var(--spacing-4); + border-radius: var(--radius-lg); + border: 1px solid var(--color-error); + margin-bottom: var(--spacing-4); +} + +.error-message p { + margin: 0 0 var(--spacing-2) 0; +} + +.error-retry { + background: var(--color-error); + color: white; + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-default); + font-size: var(--font-size-sm); + font-weight: 500; + transition: all var(--transition-fast); +} + +.error-retry:hover { + background: #dc2626; +} + +.results-container { + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-4); +} + +body.dark .results-container { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); +} + +.results-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-foreground-light); + margin-bottom: var(--spacing-4); +} + +body.dark .results-title { + color: var(--color-foreground-dark); +} + +.results-sections { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.result-section { + padding: 0; +} + +.result-section-title { + font-size: var(--font-size-base); + font-weight: 600; + color: var(--color-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.result-section-content { + font-size: var(--font-size-sm); + line-height: 1.6; + color: var(--color-subtle-light); + margin: 0; +} + +body.dark .result-section-content { + color: var(--color-subtle-dark); +} + +/* --- Words Lists Tab Styles --- */ + +.words-lists-tab { + padding: var(--spacing-4); + min-width: 360px; + max-width: 920px; + margin: 0 auto; +} + +.controls-row { + display: flex; + gap: var(--spacing-2); + margin-bottom: var(--spacing-6); +} + +.list-selector { + position: relative; + flex: 1; +} + +.list-select { + width: 100%; + appearance: none; + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3) var(--spacing-4); + padding-right: 40px; + font-size: var(--font-size-base); + color: var(--color-foreground-light); + cursor: pointer; +} + +body.dark .list-select { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); + color: var(--color-foreground-dark); +} + +.select-icon { + position: absolute; + right: var(--spacing-2); + top: 50%; + transform: translateY(-50%); + color: var(--color-subtle-light); + pointer-events: none; +} + +body.dark .select-icon { + color: var(--color-subtle-dark); +} + +.filter-button { + flex-shrink: 0; + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3); + color: var(--color-subtle-light); + transition: all var(--transition-base); +} + +body.dark .filter-button { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); + color: var(--color-subtle-dark); +} + +.filter-button:hover { + background: rgba(19, 127, 236, 0.1); + color: var(--color-primary); +} + +body.dark .filter-button:hover { + background: rgba(19, 127, 236, 0.2); +} + +.filter-button .material-symbols-outlined { + font-size: 20px; +} + +.feature-notice { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + background: rgba(19, 127, 236, 0.1); + border: 1px solid var(--color-primary); + border-radius: var(--radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-4); + color: var(--color-foreground-light); +} + +body.dark .feature-notice { + color: var(--color-foreground-dark); +} + +.notice-icon { + color: var(--color-primary); + flex-shrink: 0; + margin-top: 2px; +} + +.feature-notice p { + margin: 0; + font-size: var(--font-size-sm); + line-height: 1.6; +} + +.words-list { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.word-card { + display: flex; + align-items: center; + gap: var(--spacing-4); + background: var(--color-surface-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3); + transition: all var(--transition-base); + cursor: pointer; +} + +body.dark .word-card { + background: var(--color-surface-dark); +} + +.word-card.starred { + background: rgba(19, 127, 236, 0.1); +} + +body.dark .word-card.starred { + background: rgba(19, 127, 236, 0.2); +} + +.word-card:hover { + background: var(--color-background-light); +} + +body.dark .word-card:hover { + background: rgba(25, 38, 51, 0.8); +} + +.word-content { + flex: 1; +} + +.word-text { + font-weight: 600; + color: var(--color-foreground-light); + margin: 0 0 var(--spacing-1) 0; +} + +body.dark .word-text { + color: var(--color-foreground-dark); +} + +.word-translation { + font-size: var(--font-size-sm); + color: var(--color-subtle-light); + margin: 0; +} + +body.dark .word-translation { + color: var(--color-subtle-dark); +} + +.word-actions { + display: flex; + align-items: center; +} + +.star-button { + padding: var(--spacing-2); + border-radius: var(--radius-full); + color: var(--color-subtle-light); + transition: all var(--transition-base); +} + +body.dark .star-button { + color: var(--color-subtle-dark); +} + +.star-button:hover { + background: rgba(19, 127, 236, 0.2); + color: var(--color-star); +} + +body.dark .star-button:hover { + background: rgba(19, 127, 236, 0.3); +} + +.star-button .material-symbols-outlined.filled { + color: var(--color-star); +} + +/* --- SidePanelApp Styles --- */ + +.sidepanel-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; +} + +.sidepanel-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} diff --git a/translation_providers/deeplTranslate.js b/translation_providers/deeplTranslate.js index dcf0d5e..a414118 100644 --- a/translation_providers/deeplTranslate.js +++ b/translation_providers/deeplTranslate.js @@ -61,8 +61,8 @@ function detectEnvironment() { environmentType: isServiceWorker ? 'service-worker' : isBrowser - ? 'browser' - : 'unknown', + ? 'browser' + : 'unknown', }; } catch (error) { logger.warn('Environment detection failed', { From 14d0107feec2dad0baa42325a96c27a254a3c6ef Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Wed, 19 Nov 2025 19:16:43 -0500 Subject: [PATCH 15/23] feat: Update retry configuration to use nullish coalescing operator, allowing zero values for delay, and refactor initialization logging. --- background/services/sidePanelService.js | 3 +- config/configSchema.test.js | 4 +-- content_scripts/core/BaseContentScript.js | 13 ++++---- .../tests/BaseContentScript.test.js | 31 ++++++++++--------- sidepanel/components/tabs/AIAnalysisTab.jsx | 2 +- 5 files changed, 27 insertions(+), 26 deletions(-) diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js index 5b8115f..d941a4e 100644 --- a/background/services/sidePanelService.js +++ b/background/services/sidePanelService.js @@ -174,13 +174,14 @@ class SidePanelService { await this.resumeVideo(tabId); break; - case MessageActions.SIDEPANEL_GET_STATE: + case MessageActions.SIDEPANEL_GET_STATE: { const state = this.tabStates.get(tabId) || {}; port.postMessage({ action: MessageActions.SIDEPANEL_UPDATE_STATE, data: state, }); break; + } case MessageActions.SIDEPANEL_UPDATE_STATE: this.updateTabState(tabId, data); diff --git a/config/configSchema.test.js b/config/configSchema.test.js index 47b6aca..a0a9a67 100644 --- a/config/configSchema.test.js +++ b/config/configSchema.test.js @@ -181,7 +181,7 @@ describe('configSchema', () => { expect(actualSettings).toEqual( expect.arrayContaining(expectedSettings) ); - expect(actualSettings.length).toBe(66); + expect(actualSettings.length).toBe(81); }); it('should have correct scope distribution', () => { @@ -196,7 +196,7 @@ describe('configSchema', () => { 'aiContextDebugMode', ]) ); - expect(localKeys.length).toBe(3); + expect(localKeys.length).toBe(5); // Sync scope should contain all other settings including loggingLevel and OpenAI settings expect(syncKeys.length).toBeGreaterThan(10); diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index aa281fe..903a8e4 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -2016,10 +2016,10 @@ export class BaseContentScript { _getRetryConfiguration() { return { maxRetries: - this.currentConfig?.platformInitMaxRetries || + this.currentConfig?.platformInitMaxRetries ?? COMMON_CONSTANTS.PLATFORM_INIT_MAX_RETRIES, retryDelay: - this.currentConfig?.platformInitRetryDelay || + this.currentConfig?.platformInitRetryDelay ?? COMMON_CONSTANTS.PLATFORM_INIT_RETRY_DELAY, }; } @@ -2043,13 +2043,12 @@ export class BaseContentScript { /** * Log initialization start with attempt information * @private - * @param {number} retryCount - Current retry count - * @param {number} maxRetries - Maximum retries allowed + * @param {Object} context - Initialization context */ - _logInitializationStart(retryCount, maxRetries) { + _logInitializationStart(context) { this.logWithFallback('info', 'Starting platform initialization', { - attempt: retryCount + 1, - maxRetries: maxRetries + 1, + attempt: context.attempt, + maxRetries: context.totalAttempts, }); } diff --git a/content_scripts/tests/BaseContentScript.test.js b/content_scripts/tests/BaseContentScript.test.js index e411fac..971ff66 100644 --- a/content_scripts/tests/BaseContentScript.test.js +++ b/content_scripts/tests/BaseContentScript.test.js @@ -297,7 +297,7 @@ describe('BaseContentScript', () => { return 'partial'; } getPlatformClass() { - return class {}; + return class { }; } // Missing other abstract methods } @@ -1017,6 +1017,7 @@ describe('BaseContentScript', () => { contentScript.configService = mockModules.configService; contentScript.currentConfig = { subtitlesEnabled: true, + platformInitRetryDelay: 0, }; }); @@ -1115,9 +1116,9 @@ describe('BaseContentScript', () => { const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); // Set multiple intervals - intervalManager.set('test1', () => {}, 1000); - intervalManager.set('test2', () => {}, 2000); - intervalManager.set('test3', () => {}, 3000); + intervalManager.set('test1', () => { }, 1000); + intervalManager.set('test2', () => { }, 2000); + intervalManager.set('test3', () => { }, 3000); expect(intervalManager.count()).toBe(3); @@ -1223,7 +1224,7 @@ describe('BaseContentScript', () => { contentScript.currentConfig = { subtitlesEnabled: true, platformInitMaxRetries: 3, - platformInitRetryDelay: 1000, + platformInitRetryDelay: 0, platformInitTimeout: 5000, }; contentScript.contentLogger = mockLogger; @@ -1267,7 +1268,7 @@ describe('BaseContentScript', () => { }); contentScript.currentConfig.platformInitMaxRetries = 3; - contentScript.currentConfig.platformInitRetryDelay = 10; + contentScript.currentConfig.platformInitRetryDelay = 0; contentScript.startVideoElementDetection = jest.fn(); contentScript.processBufferedEvents = jest.fn(); @@ -1285,7 +1286,7 @@ describe('BaseContentScript', () => { }; contentScript.currentConfig.platformInitMaxRetries = 2; - contentScript.currentConfig.platformInitRetryDelay = 10; + contentScript.currentConfig.platformInitRetryDelay = 0; const result = await contentScript.initializePlatform(); @@ -1726,8 +1727,8 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification initialize() { return Promise.resolve(); } - handleNativeSubtitles() {} - cleanup() {} + handleNativeSubtitles() { } + cleanup() { } }; } getInjectScriptConfig() { @@ -1770,8 +1771,8 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification initialize() { return Promise.resolve(); } - handleNativeSubtitles() {} - cleanup() {} + handleNativeSubtitles() { } + cleanup() { } }; } getInjectScriptConfig() { @@ -1862,7 +1863,7 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification return name; } getPlatformClass() { - return class MockPlatform {}; + return class MockPlatform { }; } getInjectScriptConfig() { return { @@ -1871,8 +1872,8 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification eventId, }; } - setupNavigationDetection() {} - checkForUrlChange() {} + setupNavigationDetection() { } + checkForUrlChange() { } handlePlatformSpecificMessage(req, res) { res({ platform: name, @@ -1907,7 +1908,7 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification // Verify handler information const handlers = contentScript.getRegisteredHandlers(); - expect(handlers).toHaveLength(3); + expect(handlers).toHaveLength(8); const toggleHandler = handlers.find( (h) => h.action === 'toggleSubtitles' diff --git a/sidepanel/components/tabs/AIAnalysisTab.jsx b/sidepanel/components/tabs/AIAnalysisTab.jsx index 724185a..e68bd5f 100644 --- a/sidepanel/components/tabs/AIAnalysisTab.jsx +++ b/sidepanel/components/tabs/AIAnalysisTab.jsx @@ -100,7 +100,7 @@ export function AIAnalysisTab() { {analysisResult && !isAnalyzing && (

- Results for "{selectedWords.join('", "')}" + Results for "{selectedWords.join('", "')}"

{/* Definition */} From 73075968efe36149d1b0d6c4d203776a6f313fa2 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Wed, 19 Nov 2025 21:15:50 -0500 Subject: [PATCH 16/23] feat: Integrate AI Context Analysis into a new Side Panel and resolve several synchronization and event handling bugs. --- CHANGELOG.md | 17 ++++++++++ README.md | 8 ++++- README_zh.md | 8 ++++- background/services/sidePanelService.js | 2 +- content_scripts/core/BaseContentScript.js | 2 +- manifest.json | 39 +++++++++++++++++------ package-lock.json | 6 ++-- package.json | 4 +-- 8 files changed, 67 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b79a870..759e959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to DualSub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2025-11-19 + +### ✨ Added + +- **Unified AI Side Panel**: Migrated the AI Context Analysis modal into the new Side Panel interface. This provides a persistent, non-intrusive workspace for exploring cultural and linguistic context without obstructing the video playback. + +### 🐛 Fixed + +- **Side Panel Desync Edge Cases**: + - Fixed an issue where deselecting words in the side panel or switching videos caused synchronization errors. + - Implemented a "Single Source of Truth" architecture where the content script is the authoritative state holder. + - Eliminated race conditions by removing optimistic updates from the side panel to the background. +- **Word Selection Order**: + - Ensured that selected words always maintain their original sentence order (DOM order) in the side panel, even after deselection and re-selection. +- **Duplicate Event Listeners**: + - Fixed a bug where `dualsub-word-selected` listeners were accumulating, causing multiple events for a single click. Added proper cleanup logic. + ## [2.4.0] - 2025-09-30 ### 🎉 Major Changes diff --git a/README.md b/README.md index 553ade4..1cd65c0 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,13 @@ For full license terms, see [LICENSE](LICENSE) file. ## 📋 Changelog -### Version 2.4.0 (Current) +### Version 2.5.0 (Current) + +- 🤖 **Unified AI Experience**: Integrated AI Context Analysis into the Side Panel for a seamless, persistent workspace. +- 🐛 **Stability Improvements**: Fixed desync issues when switching videos or deselecting words in the side panel. +- ✨ **Better UX**: Improved word selection ordering to always match sentence structure. + +### Version 2.4.0 - 🎉 **Full React Migration**: Popup and options pages migrated to React with 100% functional parity - ✨ Modern component-based architecture with custom hooks diff --git a/README_zh.md b/README_zh.md index 1b311dc..17715fc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -259,7 +259,13 @@ npm test -- --coverage ## 📋 更新日志 -### 版本 2.4.0(当前) +### 版本 2.5.0(当前) + +- 🤖 **统一 AI 体验**:将 AI 上下文分析集成到侧边栏中,提供无缝、持久的工作空间。 +- 🐛 **稳定性改进**:修复了切换视频或在侧边栏中取消选择单词时的不同步问题。 +- ✨ **体验优化**:改进了单词选择排序,使其始终与句子结构匹配。 + +### 版本 2.4.0 - ⚛️ **React 迁移**:将弹出窗口和选项页面完全迁移到 React - 🏗️ **现代化构建**:使用 Vite 进行快速开发和优化构建 diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js index d941a4e..783d11e 100644 --- a/background/services/sidePanelService.js +++ b/background/services/sidePanelService.js @@ -5,7 +5,7 @@ * Handles opening/closing the side panel, routing messages, and managing state. * * @author DualSub Extension - * @version 2.0.0 + * @version 2.5.0 */ import Logger from '../../utils/logger.js'; diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index 903a8e4..af9529c 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -69,7 +69,7 @@ * * @abstract * @author DualSub Extension - * @version 1.0.0 + * @version 2.5.0 * @since 1.0.0 * * @example diff --git a/manifest.json b/manifest.json index 1f71c81..a3f0fb2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,14 @@ { "manifest_version": 3, "name": "__MSG_appName__", - "version": "2.4.1", + "version": "2.5.0", "description": "__MSG_appDesc__", "default_locale": "en", - "permissions": ["tabs", "storage", "activeTab", "sidePanel"], + "permissions": [ + "storage", + "activeTab", + "sidePanel" + ], "host_permissions": [ "*://*.disneyplus.com/*", "*://*.netflix.com/*", @@ -29,16 +33,28 @@ }, "content_scripts": [ { - "matches": ["*://*.disneyplus.com/*"], - "js": ["content_scripts/platforms/disneyPlusContent.js"], - "css": ["content_scripts/shared/content.css"], + "matches": [ + "*://*.disneyplus.com/*" + ], + "js": [ + "content_scripts/platforms/disneyPlusContent.js" + ], + "css": [ + "content_scripts/shared/content.css" + ], "run_at": "document_start", "type": "module" }, { - "matches": ["*://*.netflix.com/*"], - "js": ["content_scripts/platforms/netflixContent.js"], - "css": ["content_scripts/shared/content.css"], + "matches": [ + "*://*.netflix.com/*" + ], + "js": [ + "content_scripts/platforms/netflixContent.js" + ], + "css": [ + "content_scripts/shared/content.css" + ], "run_at": "document_start", "type": "module" } @@ -98,7 +114,10 @@ "video_platforms/netflixPlatform.js", "content_scripts/platforms/NetflixContentScript.js" ], - "matches": ["*://*.disneyplus.com/*", "*://*.netflix.com/*"] + "matches": [ + "*://*.disneyplus.com/*", + "*://*.netflix.com/*" + ] } ], "action": { @@ -121,4 +140,4 @@ "side_panel": { "default_path": "sidepanel/sidepanel.html" } -} +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 42adc40..03c1749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dualsub", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dualsub", - "version": "2.4.1", + "version": "2.5.0", "license": "CC-BY-NC-SA-4.0", "dependencies": { "react": "^19.1.1", @@ -8640,4 +8640,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 55748f8..56ddb3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dualsub", - "version": "2.4.1", + "version": "2.5.0", "type": "module", "description": "Displays dual language subtitles on streaming platforms.", "main": "background.js", @@ -49,4 +49,4 @@ "react": "^19.1.1", "react-dom": "^19.1.1" } -} +} \ No newline at end of file From 6430a063fc99bbe711c23fcf36c0cb42637d5e83 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Thu, 20 Nov 2025 17:44:57 -0500 Subject: [PATCH 17/23] feat: implement internationalization for sidepanel and AI context UI elements and messages. --- _locales/en/messages.json | 963 ++++++++++---- _locales/es/messages.json | 889 +++++++++---- _locales/ja/messages.json | 981 ++++++++++---- _locales/ko/messages.json | 1033 +++++++++++---- _locales/zh_CN/messages.json | 1102 ++++++++++++---- _locales/zh_TW/messages.json | 1137 +++++++++++++---- .../aicontext/ui/events/ModalController.js | 68 +- sidepanel/SidePanelApp.jsx | 15 +- sidepanel/components/TabNavigator.jsx | 7 +- sidepanel/components/tabs/AIAnalysisTab.jsx | 22 +- sidepanel/components/tabs/WordsListsTab.jsx | 9 +- sidepanel/hooks/useAIAnalysis.js | 8 +- sidepanel/hooks/useTranslation.js | 78 ++ 13 files changed, 4745 insertions(+), 1567 deletions(-) create mode 100644 sidepanel/hooks/useTranslation.js diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 88fa1d1..ad4abbe 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,34 +1,82 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "Displays dual language subtitles on streaming platforms." }, - "pageTitle": { "message": "DualSub Settings" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "Enable Dual Subtitles:" }, - "useNativeSubtitlesLabel": { "message": "Use Official Subtitles:" }, - "originalLanguageLabel": { "message": "Language Set:" }, - "translationSettingsLegend": { "message": "Translation Settings" }, - "providerLabel": { "message": "Provider:" }, - "targetLanguageLabel": { "message": "Translate to:" }, - "batchSizeLabel": { "message": "Batch Size:" }, - "requestDelayLabel": { "message": "Request Delay (ms):" }, + "pageTitle": { + "message": "DualSub Settings" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "Enable Dual Subtitles:" + }, + "useNativeSubtitlesLabel": { + "message": "Use Official Subtitles:" + }, + "originalLanguageLabel": { + "message": "Language Set:" + }, + "translationSettingsLegend": { + "message": "Translation Settings" + }, + "providerLabel": { + "message": "Provider:" + }, + "targetLanguageLabel": { + "message": "Translate to:" + }, + "batchSizeLabel": { + "message": "Batch Size:" + }, + "requestDelayLabel": { + "message": "Request Delay (ms):" + }, "subtitleAppearanceTimingLegend": { "message": "Subtitle Appearance & Timing" }, - "displayOrderLabel": { "message": "Display Order:" }, - "layoutLabel": { "message": "Layout:" }, - "fontSizeLabel": { "message": "Font Size:" }, - "verticalGapLabel": { "message": "Vertical Gap:" }, - "subtitleVerticalPositionLabel": { "message": "Vertical Position:" }, - "timeOffsetLabel": { "message": "Time Offset(s):" }, - "displayOrderOriginalFirst": { "message": "Original First" }, - "displayOrderTranslationFirst": { "message": "Translation First" }, - "layoutTopBottom": { "message": "Top / Bottom" }, - "layoutLeftRight": { "message": "Left / Right" }, - "uiLanguageLabel": { "message": "Language:" }, - "openOptionsButton": { "message": "Advanced Settings" }, - "statusLanguageSetTo": { "message": "Language Set (Refresh Page): " }, + "displayOrderLabel": { + "message": "Display Order:" + }, + "layoutLabel": { + "message": "Layout:" + }, + "fontSizeLabel": { + "message": "Font Size:" + }, + "verticalGapLabel": { + "message": "Vertical Gap:" + }, + "subtitleVerticalPositionLabel": { + "message": "Vertical Position:" + }, + "timeOffsetLabel": { + "message": "Time Offset(s):" + }, + "displayOrderOriginalFirst": { + "message": "Original First" + }, + "displayOrderTranslationFirst": { + "message": "Translation First" + }, + "layoutTopBottom": { + "message": "Top / Bottom" + }, + "layoutLeftRight": { + "message": "Left / Right" + }, + "uiLanguageLabel": { + "message": "Language:" + }, + "openOptionsButton": { + "message": "Advanced Settings" + }, + "statusLanguageSetTo": { + "message": "Language Set (Refresh Page): " + }, "statusDualEnabled": { "message": "Dual Subtitles Enabled. (Refresh Page)" }, @@ -41,81 +89,174 @@ "statusSmartTranslationDisabled": { "message": "Smart Translation Disabled. (Refresh Page)" }, - "statusOriginalLanguage": { "message": "Language Set (Refresh Page): " }, - "statusTimeOffset": { "message": "Time offset: " }, - "statusDisplayOrderUpdated": { "message": "Display order updated." }, + "statusOriginalLanguage": { + "message": "Language Set (Refresh Page): " + }, + "statusTimeOffset": { + "message": "Time offset: " + }, + "statusDisplayOrderUpdated": { + "message": "Display order updated." + }, "statusLayoutOrientationUpdated": { "message": "Layout orientation updated." }, - "statusFontSize": { "message": "Font size: " }, - "statusVerticalGap": { "message": "Vertical gap: " }, - "statusVerticalPosition": { "message": "Vertical position: " }, - "statusInvalidOffset": { "message": "Invalid offset, reverting." }, + "statusFontSize": { + "message": "Font size: " + }, + "statusVerticalGap": { + "message": "Vertical gap: " + }, + "statusVerticalPosition": { + "message": "Vertical position: " + }, + "statusInvalidOffset": { + "message": "Invalid offset, reverting." + }, "statusSettingNotApplied": { "message": "Setting not applied. Refresh page." }, - - "optionsPageTitle": { "message": "DualSub Options" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "General" }, - "navTranslation": { "message": "Translation" }, - "navProviders": { "message": "Providers" }, - "navAbout": { "message": "About" }, - "sectionGeneral": { "message": "General" }, - "cardUILanguageTitle": { "message": "UI Language" }, + "optionsPageTitle": { + "message": "DualSub Options" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "General" + }, + "navTranslation": { + "message": "Translation" + }, + "navProviders": { + "message": "Providers" + }, + "navAbout": { + "message": "About" + }, + "sectionGeneral": { + "message": "General" + }, + "cardUILanguageTitle": { + "message": "UI Language" + }, "cardUILanguageDesc": { "message": "Choose the display language for the extension's interface." }, - "cardHideOfficialSubtitlesTitle": { "message": "Hide Official Subtitles" }, + "cardHideOfficialSubtitlesTitle": { + "message": "Hide Official Subtitles" + }, "cardHideOfficialSubtitlesDesc": { "message": "Hide the official subtitles from the video platform when DualSub is active." }, - "hideOfficialSubtitlesLabel": { "message": "Hide official subtitles:" }, - "sectionTranslation": { "message": "Translation" }, - "cardTranslationEngineTitle": { "message": "Translation Engine" }, + "hideOfficialSubtitlesLabel": { + "message": "Hide official subtitles:" + }, + "sectionTranslation": { + "message": "Translation" + }, + "cardTranslationEngineTitle": { + "message": "Translation Engine" + }, "cardTranslationEngineDesc": { "message": "Select your preferred translation service." }, - "cardPerformanceTitle": { "message": "Performance" }, + "cardPerformanceTitle": { + "message": "Performance" + }, "cardPerformanceDesc": { "message": "Adjust how the extension handles translation requests to balance speed and stability." }, - "sectionProviders": { "message": "Provider Settings" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "Provider Settings" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "Enter your API key for DeepL Translate. Choose between Free and Pro plans." }, - "apiKeyLabel": { "message": "API Key:" }, - "apiPlanLabel": { "message": "API Plan:" }, - "apiPlanFree": { "message": "DeepL API Free" }, - "apiPlanPro": { "message": "DeepL API Pro" }, - "sectionAbout": { "message": "About" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "Version" }, + "apiKeyLabel": { + "message": "API Key:" + }, + "apiPlanLabel": { + "message": "API Plan:" + }, + "apiPlanFree": { + "message": "DeepL API Free" + }, + "apiPlanPro": { + "message": "DeepL API Pro" + }, + "sectionAbout": { + "message": "About" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "Version" + }, "aboutDescription": { "message": "This extension helps you watch videos with dual language subtitles on various platforms." }, - "aboutDevelopment": { "message": "Developed by QuellaMC & 1jifang." }, - - "lang_en": { "message": "English" }, - "lang_es": { "message": "Spanish" }, - "lang_fr": { "message": "French" }, - "lang_de": { "message": "German" }, - "lang_it": { "message": "Italian" }, - "lang_pt": { "message": "Portuguese" }, - "lang_ja": { "message": "Japanese" }, - "lang_ko": { "message": "Korean" }, - "lang_zh_CN": { "message": "Chinese (Simp)" }, - "lang_zh_TW": { "message": "Chinese (Trad)" }, - "lang_ru": { "message": "Russian" }, - "lang_ar": { "message": "Arabic" }, - "lang_hi": { "message": "Hindi" }, - - "testDeepLButton": { "message": "Test DeepL Connection" }, - "deeplApiKeyError": { "message": "Please enter your DeepL API key first." }, - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API key needs testing." }, - "testingButton": { "message": "Testing..." }, - "testingConnection": { "message": "Testing DeepL connection..." }, + "aboutDevelopment": { + "message": "Developed by QuellaMC & 1jifang." + }, + "lang_en": { + "message": "English" + }, + "lang_es": { + "message": "Spanish" + }, + "lang_fr": { + "message": "French" + }, + "lang_de": { + "message": "German" + }, + "lang_it": { + "message": "Italian" + }, + "lang_pt": { + "message": "Portuguese" + }, + "lang_ja": { + "message": "Japanese" + }, + "lang_ko": { + "message": "Korean" + }, + "lang_zh_CN": { + "message": "Chinese (Simp)" + }, + "lang_zh_TW": { + "message": "Chinese (Trad)" + }, + "lang_ru": { + "message": "Russian" + }, + "lang_ar": { + "message": "Arabic" + }, + "lang_hi": { + "message": "Hindi" + }, + "testDeepLButton": { + "message": "Test DeepL Connection" + }, + "deeplApiKeyError": { + "message": "Please enter your DeepL API key first." + }, + "deeplTestNeedsTesting": { + "message": "⚠️ DeepL API key needs testing." + }, + "testingButton": { + "message": "Testing..." + }, + "testingConnection": { + "message": "Testing DeepL connection..." + }, "deeplTestSuccess": { "message": "✅ DeepL API test successful!" }, @@ -128,58 +269,108 @@ "deeplTestQuotaExceeded": { "message": "❌ DeepL API quota exceeded. Please check your usage limits." }, - "deeplTestApiError": { "message": "❌ DeepL API error (%d): %s" }, + "deeplTestApiError": { + "message": "❌ DeepL API error (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ Network error: Could not connect to DeepL API. Check your internet connection." }, - "deeplTestGenericError": { "message": "❌ Test failed: %s" }, - "deepLApiUnavailable": { "message": "DeepL API Unavailable" }, + "deeplTestGenericError": { + "message": "❌ Test failed: %s" + }, + "deepLApiUnavailable": { + "message": "DeepL API Unavailable" + }, "deepLApiUnavailableTooltip": { "message": "DeepL API script failed to load" }, "deeplApiNotLoadedError": { "message": "❌ DeepL API script is not available. Please refresh the page." }, - "cardGoogleTitle": { "message": "Google Translate" }, + "cardGoogleTitle": { + "message": "Google Translate" + }, "cardGoogleDesc": { "message": "Free translation service provided by Google. No additional configuration required." }, - "cardMicrosoftTitle": { "message": "Microsoft Translate" }, + "cardMicrosoftTitle": { + "message": "Microsoft Translate" + }, "cardMicrosoftDesc": { "message": "Free translation service provided by Microsoft Edge. No additional configuration required." }, - "cardDeepLFreeTitle": { "message": "DeepL Translate (Free)" }, + "cardDeepLFreeTitle": { + "message": "DeepL Translate (Free)" + }, "cardDeepLFreeDesc": { "message": "Free DeepL translation service with high quality results. No API key required - uses DeepL's web interface." }, - "providerStatus": { "message": "Status:" }, - "statusReady": { "message": "Ready to use" }, - "providerFeatures": { "message": "Features:" }, - "featureFree": { "message": "Free to use" }, - "featureNoApiKey": { "message": "No API key required" }, - "featureWideLanguageSupport": { "message": "Wide language support" }, - "featureFastTranslation": { "message": "Fast translation" }, - "featureHighQuality": { "message": "High quality translation" }, - "featureGoodPerformance": { "message": "Good performance" }, - "featureHighestQuality": { "message": "Highest quality translation" }, - "featureApiKeyRequired": { "message": "API key required" }, - "featureLimitedLanguages": { "message": "Limited language support" }, - "featureUsageLimits": { "message": "Usage limits apply" }, - "featureMultipleBackups": { "message": "Multiple backup methods" }, - - "providerNotes": { "message": "Notes:" }, + "providerStatus": { + "message": "Status:" + }, + "statusReady": { + "message": "Ready to use" + }, + "providerFeatures": { + "message": "Features:" + }, + "featureFree": { + "message": "Free to use" + }, + "featureNoApiKey": { + "message": "No API key required" + }, + "featureWideLanguageSupport": { + "message": "Wide language support" + }, + "featureFastTranslation": { + "message": "Fast translation" + }, + "featureHighQuality": { + "message": "High quality translation" + }, + "featureGoodPerformance": { + "message": "Good performance" + }, + "featureHighestQuality": { + "message": "Highest quality translation" + }, + "featureApiKeyRequired": { + "message": "API key required" + }, + "featureLimitedLanguages": { + "message": "Limited language support" + }, + "featureUsageLimits": { + "message": "Usage limits apply" + }, + "featureMultipleBackups": { + "message": "Multiple backup methods" + }, + "providerNotes": { + "message": "Notes:" + }, "noteSlowForSecurity": { "message": "Slightly slower due to security measures" }, "noteAutoFallback": { "message": "Automatic fallback to alternative services" }, - "noteRecommendedDefault": { "message": "Recommended as default provider" }, - - "providerGoogleName": { "message": "Google Translate (Free)" }, - "providerMicrosoftName": { "message": "Microsoft Translate (Free)" }, - "providerDeepLName": { "message": "DeepL (API Key Required)" }, - "providerDeepLFreeName": { "message": "DeepL Translate (Free)" }, + "noteRecommendedDefault": { + "message": "Recommended as default provider" + }, + "providerGoogleName": { + "message": "Google Translate (Free)" + }, + "providerMicrosoftName": { + "message": "Microsoft Translate (Free)" + }, + "providerDeepLName": { + "message": "DeepL (API Key Required)" + }, + "providerDeepLFreeName": { + "message": "DeepL Translate (Free)" + }, "providerOpenAICompatibleName": { "message": "OpenAI Compatible (API Key Required)" }, @@ -198,29 +389,57 @@ "cardVertexGeminiDesc": { "message": "Enter your access token and Vertex project settings." }, - "vertexAccessTokenLabel": { "message": "Access Token:" }, - "vertexProjectIdLabel": { "message": "Project ID:" }, - "vertexLocationLabel": { "message": "Location:" }, - "vertexModelLabel": { "message": "Model:" }, + "vertexAccessTokenLabel": { + "message": "Access Token:" + }, + "vertexProjectIdLabel": { + "message": "Project ID:" + }, + "vertexLocationLabel": { + "message": "Location:" + }, + "vertexModelLabel": { + "message": "Model:" + }, "vertexMissingConfig": { "message": "Please enter access token and project ID." }, - "vertexConnectionFailed": { "message": "Connection failed: %s" }, - "vertexServiceAccountLabel": { "message": "Service Account JSON:" }, - "vertexImportButton": { "message": "Import JSON File" }, - "vertexRefreshButton": { "message": "🔄 Refresh Token" }, - "vertexImportHint": { "message": "Auto-fills credentials below" }, - "vertexImporting": { "message": "Importing..." }, - "vertexRefreshingToken": { "message": "Refreshing access token..." }, - "vertexGeneratingToken": { "message": "Generating access token..." }, + "vertexConnectionFailed": { + "message": "Connection failed: %s" + }, + "vertexServiceAccountLabel": { + "message": "Service Account JSON:" + }, + "vertexImportButton": { + "message": "Import JSON File" + }, + "vertexRefreshButton": { + "message": "🔄 Refresh Token" + }, + "vertexImportHint": { + "message": "Auto-fills credentials below" + }, + "vertexImporting": { + "message": "Importing..." + }, + "vertexRefreshingToken": { + "message": "Refreshing access token..." + }, + "vertexGeneratingToken": { + "message": "Generating access token..." + }, "vertexImportSuccess": { "message": "Service account imported and token generated." }, - "vertexImportFailed": { "message": "Import failed: %s" }, + "vertexImportFailed": { + "message": "Import failed: %s" + }, "vertexTokenRefreshed": { "message": "Access token refreshed successfully." }, - "vertexRefreshFailed": { "message": "Token refresh failed: %s" }, + "vertexRefreshFailed": { + "message": "Token refresh failed: %s" + }, "vertexTokenExpired": { "message": "⚠️ Access token expired. Click refresh to renew." }, @@ -233,54 +452,102 @@ "vertexNotConfigured": { "message": "Please import service account JSON or enter credentials." }, - "featureVertexServiceAccount": { "message": "Service account JSON import" }, - "featureVertexAutoToken": { "message": "Automatic token generation" }, - "featureVertexGemini": { "message": "Google Gemini models via Vertex AI" }, - "providerNote": { "message": "Note:" }, + "featureVertexServiceAccount": { + "message": "Service account JSON import" + }, + "featureVertexAutoToken": { + "message": "Automatic token generation" + }, + "featureVertexGemini": { + "message": "Google Gemini models via Vertex AI" + }, + "providerNote": { + "message": "Note:" + }, "vertexNote": { "message": "Access tokens expire after 1 hour. Your service account is securely stored for easy token refresh - just click the Refresh Token button when needed." }, - "baseUrlLabel": { "message": "Base URL:" }, - "modelLabel": { "message": "Model:" }, - "featureCustomizable": { "message": "Customizable endpoint and model" }, - "fetchModelsButton": { "message": "Fetch Models" }, - "testConnectionButton": { "message": "Test Connection" }, + "baseUrlLabel": { + "message": "Base URL:" + }, + "modelLabel": { + "message": "Model:" + }, + "featureCustomizable": { + "message": "Customizable endpoint and model" + }, + "fetchModelsButton": { + "message": "Fetch Models" + }, + "testConnectionButton": { + "message": "Test Connection" + }, "openaiApiKeyPlaceholder": { "message": "Enter your OpenAI-compatible API key" }, "openaiBaseUrlPlaceholder": { "message": "e.g., https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "Please enter your API key first." }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API key needs testing." }, + "openaiApiKeyError": { + "message": "Please enter your API key first." + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API key needs testing." + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI-compatible API key needs testing." }, - "openaiTestingConnection": { "message": "Testing connection..." }, - "openaiConnectionSuccessful": { "message": "Connection successful!" }, - "openaiConnectionFailed": { "message": "Connection failed: %s" }, - "openaieFetchingModels": { "message": "Fetching models..." }, + "openaiTestingConnection": { + "message": "Testing connection..." + }, + "openaiConnectionSuccessful": { + "message": "Connection successful!" + }, + "openaiConnectionFailed": { + "message": "Connection failed: %s" + }, + "openaieFetchingModels": { + "message": "Fetching models..." + }, "openaiModelsFetchedSuccessfully": { "message": "Models fetched successfully." }, - "openaiFailedToFetchModels": { "message": "Failed to fetch models: %s" }, - - "cardLoggingLevelTitle": { "message": "Logging Level" }, + "openaiFailedToFetchModels": { + "message": "Failed to fetch models: %s" + }, + "cardLoggingLevelTitle": { + "message": "Logging Level" + }, "cardLoggingLevelDesc": { "message": "Control the amount of debug information displayed in browser console. Higher levels include all lower level messages." }, - "loggingLevelLabel": { "message": "Logging Level:" }, - "loggingLevelOff": { "message": "Off" }, - "loggingLevelError": { "message": "Error Only" }, - "loggingLevelWarn": { "message": "Warnings & Errors" }, - "loggingLevelInfo": { "message": "Info & Above" }, - "loggingLevelDebug": { "message": "Debug (All)" }, - - "cardBatchTranslationTitle": { "message": "Batch Translation" }, + "loggingLevelLabel": { + "message": "Logging Level:" + }, + "loggingLevelOff": { + "message": "Off" + }, + "loggingLevelError": { + "message": "Error Only" + }, + "loggingLevelWarn": { + "message": "Warnings & Errors" + }, + "loggingLevelInfo": { + "message": "Info & Above" + }, + "loggingLevelDebug": { + "message": "Debug (All)" + }, + "cardBatchTranslationTitle": { + "message": "Batch Translation" + }, "cardBatchTranslationDesc": { "message": "Batch translation processes multiple subtitle segments together, reducing API calls by 80-90% and improving performance. Configure optimal settings for your preferred translation provider." }, - "batchingEnabledLabel": { "message": "Enable Batch Translation:" }, + "batchingEnabledLabel": { + "message": "Enable Batch Translation:" + }, "batchingEnabledHelp": { "message": "Groups multiple subtitle segments into single translation requests" }, @@ -290,61 +557,87 @@ "useProviderDefaultsHelp": { "message": "Automatically use optimal batch sizes for each translation provider" }, - "globalBatchSizeLabel": { "message": "Global Batch Size:" }, + "globalBatchSizeLabel": { + "message": "Global Batch Size:" + }, "globalBatchSizeHelp": { "message": "Number of subtitle segments to process together (1-15)" }, - "smartBatchingLabel": { "message": "Smart Batch Optimization:" }, + "smartBatchingLabel": { + "message": "Smart Batch Optimization:" + }, "smartBatchingHelp": { "message": "Prioritizes subtitle segments based on playback position" }, - "maxConcurrentBatchesLabel": { "message": "Maximum Concurrent Batches:" }, + "maxConcurrentBatchesLabel": { + "message": "Maximum Concurrent Batches:" + }, "maxConcurrentBatchesHelp": { "message": "Number of translation batches to process simultaneously" }, - - "cardProviderBatchTitle": { "message": "Provider-Specific Batch Sizes" }, + "cardProviderBatchTitle": { + "message": "Provider-Specific Batch Sizes" + }, "cardProviderBatchDesc": { "message": "Configure optimal batch sizes for each translation provider. These settings are used when \"Use Provider-Optimized Settings\" is enabled." }, - "openaieBatchSizeLabel": { "message": "OpenAI Batch Size:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI Batch Size:" + }, "openaieBatchSizeHelp": { "message": "Recommended: 5-10 segments (default: 8)" }, - "googleBatchSizeLabel": { "message": "Google Translate Batch Size:" }, + "googleBatchSizeLabel": { + "message": "Google Translate Batch Size:" + }, "googleBatchSizeHelp": { "message": "Recommended: 3-5 segments (default: 4)" }, - "deeplBatchSizeLabel": { "message": "DeepL Batch Size:" }, + "deeplBatchSizeLabel": { + "message": "DeepL Batch Size:" + }, "deeplBatchSizeHelp": { "message": "Recommended: 2-3 segments (default: 3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft Translate Batch Size:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft Translate Batch Size:" + }, "microsoftBatchSizeHelp": { "message": "Recommended: 3-5 segments (default: 4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI Batch Size:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI Batch Size:" + }, "vertexBatchSizeHelp": { "message": "Recommended: 5-10 segments (default: 8)" }, - - "cardProviderDelayTitle": { "message": "Provider-Specific Request Delays" }, + "cardProviderDelayTitle": { + "message": "Provider-Specific Request Delays" + }, "cardProviderDelayDesc": { "message": "Configure mandatory delays between translation requests to prevent account lockouts. These delays are applied even when batch processing is enabled." }, - "openaieDelayLabel": { "message": "OpenAI Request Delay (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI Request Delay (ms):" + }, "openaieDelayHelp": { "message": "Minimum delay between requests (default: 100ms)" }, - "googleDelayLabel": { "message": "Google Translate Request Delay (ms):" }, + "googleDelayLabel": { + "message": "Google Translate Request Delay (ms):" + }, "googleDelayHelp": { "message": "Required delay to prevent temporary lockouts (default: 1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API Request Delay (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API Request Delay (ms):" + }, "deeplDelayHelp": { "message": "Delay for DeepL API requests (default: 500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL Free Request Delay (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL Free Request Delay (ms):" + }, "deeplFreeDelayHelp": { "message": "Conservative delay for free tier (default: 2000ms)" }, @@ -360,117 +653,246 @@ "vertexDelayHelp": { "message": "Minimum delay between requests (default: 100ms)" }, - - "aiContextModalTitle": { "message": "AI Context Analysis" }, - "aiContextSelectedWords": { "message": "Selected Words" }, - "aiContextNoWordsSelected": { "message": "No words selected" }, - "aiContextClickHint": { "message": "💡 Click a word to add or remove it." }, - "aiContextStartAnalysis": { "message": "Start Analysis" }, - "aiContextPauseAnalysis": { "message": "⏸ Pause" }, - "aiContextPauseAnalysisTitle": { "message": "Pause Analysis" }, + "aiContextModalTitle": { + "message": "AI Context Analysis" + }, + "aiContextSelectedWords": { + "message": "Selected Words" + }, + "aiContextNoWordsSelected": { + "message": "No words selected" + }, + "aiContextClickHint": { + "message": "💡 Click a word to add or remove it." + }, + "aiContextStartAnalysis": { + "message": "Start Analysis" + }, + "aiContextPauseAnalysis": { + "message": "⏸ Pause" + }, + "aiContextPauseAnalysisTitle": { + "message": "Pause Analysis" + }, "aiContextInitialMessage": { "message": "Select words from the subtitles to begin analysis." }, - "aiContextAnalyzing": { "message": "Analyzing context..." }, - "aiContextPauseNote": { "message": "Click ⏸ to pause analysis" }, - "aiContextAnalysisFailed": { "message": "Analysis Failed" }, - "aiContextNoContent": { "message": "No Analysis Content" }, + "aiContextAnalyzing": { + "message": "Analyzing context..." + }, + "aiContextPauseNote": { + "message": "Click ⏸ to pause analysis" + }, + "aiContextAnalysisFailed": { + "message": "Analysis Failed" + }, + "aiContextNoContent": { + "message": "No Analysis Content" + }, "aiContextNoContentMessage": { "message": "Analysis completed but no content was returned." }, - "aiContextDefinition": { "message": "📖 Definition" }, - "aiContextCultural": { "message": "🌍 Cultural Context" }, - "aiContextCulturalSignificance": { "message": "⭐ Cultural Significance" }, - "aiContextHistorical": { "message": "📜 Historical Context" }, + "aiContextDefinition": { + "message": "📖 Definition" + }, + "aiContextCultural": { + "message": "🌍 Cultural Context" + }, + "aiContextCulturalSignificance": { + "message": "⭐ Cultural Significance" + }, + "aiContextHistorical": { + "message": "📜 Historical Context" + }, "aiContextHistoricalSignificance": { "message": "📜 Historical Significance" }, - "aiContextEvolution": { "message": "🔄 Evolution Over Time" }, - "aiContextLinguistic": { "message": "🔤 Linguistic Analysis" }, - "aiContextGrammar": { "message": "📝 Grammar & Semantics" }, - "aiContextUsage": { "message": "💡 Usage & Examples" }, - "aiContextExamples": { "message": "Examples:" }, - "aiContextLearningTips": { "message": "🎯 Learning Tips" }, - "aiContextRelatedExpressions": { "message": "🔗 Related Expressions" }, - "aiContextKeyInsights": { "message": "🔑 Key Insights" }, - "aiContextTypeCultural": { "message": "Cultural" }, - "aiContextTypeHistorical": { "message": "Historical" }, - "aiContextTypeLinguistic": { "message": "Linguistic" }, - "aiContextTypeComprehensive": { "message": "Comprehensive" }, - "aiContextTypeGeneric": { "message": "Context" }, - "aiContextClose": { "message": "Close" }, - "aiContextAnalysisResults": { "message": "Analysis Results" }, - "aiContextRetrying": { "message": "Analysis failed, regenerating..." }, - "aiContextRetryNotification": { "message": "Analysis failed, retrying..." }, - "aiContextRetryButton": { "message": "Try Again" }, + "aiContextEvolution": { + "message": "🔄 Evolution Over Time" + }, + "aiContextLinguistic": { + "message": "🔤 Linguistic Analysis" + }, + "aiContextGrammar": { + "message": "📝 Grammar & Semantics" + }, + "aiContextUsage": { + "message": "💡 Usage & Examples" + }, + "aiContextExamples": { + "message": "Examples:" + }, + "aiContextLearningTips": { + "message": "🎯 Learning Tips" + }, + "aiContextRelatedExpressions": { + "message": "🔗 Related Expressions" + }, + "aiContextKeyInsights": { + "message": "🔑 Key Insights" + }, + "aiContextTypeCultural": { + "message": "Cultural" + }, + "aiContextTypeHistorical": { + "message": "Historical" + }, + "aiContextTypeLinguistic": { + "message": "Linguistic" + }, + "aiContextTypeComprehensive": { + "message": "Comprehensive" + }, + "aiContextTypeGeneric": { + "message": "Context" + }, + "aiContextClose": { + "message": "Close" + }, + "aiContextAnalysisResults": { + "message": "Analysis Results" + }, + "aiContextRetrying": { + "message": "Analysis failed, regenerating..." + }, + "aiContextRetryNotification": { + "message": "Analysis failed, retrying..." + }, + "aiContextRetryButton": { + "message": "Try Again" + }, "aiContextMalformedResponse": { "message": "The AI service returned an invalid response format. This may be due to temporary service issues." }, "aiContextJsonCodeBlock": { "message": "The AI service returned unprocessed JSON code instead of structured data. This indicates a formatting error in the response." }, - "aiContextCulturalContext": { "message": "Cultural Context:" }, - "aiContextSocialUsage": { "message": "Social Usage:" }, - "aiContextRegionalNotes": { "message": "Regional Notes:" }, - "aiContextOrigins": { "message": "Origins:" }, - "aiContextHistoricalContext": { "message": "Historical Context:" }, - "aiContextHistoricalSignificance": { + "aiContextCulturalContext": { + "message": "Cultural Context:" + }, + "aiContextSocialUsage": { + "message": "Social Usage:" + }, + "aiContextRegionalNotes": { + "message": "Regional Notes:" + }, + "aiContextOrigins": { + "message": "Origins:" + }, + "aiContextHistoricalContext": { + "message": "Historical Context:" + }, + "aiContextHistoricalSignificanceLabel": { "message": "Historical Significance:" }, - "aiContextEvolution": { "message": "Evolution:" }, - "aiContextEtymology": { "message": "Etymology:" }, - "aiContextGrammarNotes": { "message": "Grammar Notes:" }, - "aiContextTranslationNotes": { "message": "Translation Notes:" }, - "aiContextLinguisticAnalysis": { "message": "Linguistic Analysis:" }, - "aiContextGrammarSemantics": { "message": "Grammar & Semantics:" }, - "aiContextUsageExamples": { "message": "Usage & Examples:" }, - "aiContextLearningTips": { "message": "Learning Tips:" }, - "aiContextRelatedExpressions": { "message": "Related Expressions:" }, - "aiContextKeyInsights": { "message": "Key Insights:" }, - - "navAIContext": { "message": "AI Context" }, - "sectionAIContext": { "message": "AI Context Assistant" }, - "cardAIContextToggleTitle": { "message": "Enable AI Context Analysis" }, + "aiContextEvolutionLabel": { + "message": "Evolution:" + }, + "aiContextEtymology": { + "message": "Etymology:" + }, + "aiContextGrammarNotes": { + "message": "Grammar Notes:" + }, + "aiContextTranslationNotes": { + "message": "Translation Notes:" + }, + "aiContextLinguisticAnalysis": { + "message": "Linguistic Analysis:" + }, + "aiContextGrammarSemantics": { + "message": "Grammar & Semantics:" + }, + "aiContextUsageExamples": { + "message": "Usage & Examples:" + }, + "aiContextLearningTipsLabel": { + "message": "Learning Tips:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "Related Expressions:" + }, + "aiContextKeyInsightsLabel": { + "message": "Key Insights:" + }, + "navAIContext": { + "message": "AI Context" + }, + "sectionAIContext": { + "message": "AI Context Assistant" + }, + "cardAIContextToggleTitle": { + "message": "Enable AI Context Analysis" + }, "cardAIContextToggleDesc": { "message": "Enable AI-powered cultural, historical, and linguistic context analysis for subtitle text. Click on words or phrases in subtitles to get detailed explanations." }, - "aiContextEnabledLabel": { "message": "Enable AI Context:" }, - "cardAIContextProviderTitle": { "message": "AI Provider" }, + "aiContextEnabledLabel": { + "message": "Enable AI Context:" + }, + "cardAIContextProviderTitle": { + "message": "AI Provider" + }, "cardAIContextProviderDesc": { "message": "Choose the AI service provider for context analysis. Different providers may offer varying quality and response times." }, - "aiContextProviderLabel": { "message": "Provider:" }, - "cardOpenAIContextTitle": { "message": "OpenAI Configuration" }, + "aiContextProviderLabel": { + "message": "Provider:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI Configuration" + }, "cardOpenAIContextDesc": { "message": "Configure your OpenAI API settings for context analysis. You need a valid OpenAI API key." }, - "openaiApiKeyLabel": { "message": "API Key:" }, - "openaiBaseUrlLabel": { "message": "Base URL:" }, - "openaiModelLabel": { "message": "Model:" }, - "cardGeminiContextTitle": { "message": "Google Gemini Configuration" }, + "openaiApiKeyLabel": { + "message": "API Key:" + }, + "openaiBaseUrlLabel": { + "message": "Base URL:" + }, + "openaiModelLabel": { + "message": "Model:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini Configuration" + }, "cardGeminiContextDesc": { "message": "Configure your Google Gemini API settings for context analysis. You need a valid Gemini API key." }, - "geminiApiKeyLabel": { "message": "API Key:" }, - "geminiModelLabel": { "message": "Model:" }, - "cardAIContextTypesTitle": { "message": "Context Types" }, + "geminiApiKeyLabel": { + "message": "API Key:" + }, + "geminiModelLabel": { + "message": "Model:" + }, + "cardAIContextTypesTitle": { + "message": "Context Types" + }, "cardAIContextTypesDesc": { "message": "Enable the types of context analysis you want to use. You can enable multiple types." }, - "contextTypeCulturalLabel": { "message": "Cultural Context:" }, + "contextTypeCulturalLabel": { + "message": "Cultural Context:" + }, "contextTypeCulturalHelp": { "message": "Analyze cultural references, idioms, and social context" }, - "contextTypeHistoricalLabel": { "message": "Historical Context:" }, + "contextTypeHistoricalLabel": { + "message": "Historical Context:" + }, "contextTypeHistoricalHelp": { "message": "Provide historical background and time period context" }, - "contextTypeLinguisticLabel": { "message": "Linguistic Analysis:" }, + "contextTypeLinguisticLabel": { + "message": "Linguistic Analysis:" + }, "contextTypeLinguisticHelp": { "message": "Explain grammar, etymology, and language structure" }, - - "cardAIContextPrivacyTitle": { "message": "Privacy & Data" }, + "cardAIContextPrivacyTitle": { + "message": "Privacy & Data" + }, "cardAIContextPrivacyDesc": { "message": "Control how your data is handled during context analysis." }, @@ -486,26 +908,91 @@ "aiContextDataSharingHelp": { "message": "Help improve the service by sharing anonymous usage data" }, - "cardAIContextAdvancedTitle": { "message": "Advanced Settings" }, + "cardAIContextAdvancedTitle": { + "message": "Advanced Settings" + }, "cardAIContextAdvancedDesc": { "message": "Configure advanced options for AI context analysis behavior." }, - "aiContextTimeoutLabel": { "message": "Request Timeout (ms):" }, + "aiContextTimeoutLabel": { + "message": "Request Timeout (ms):" + }, "aiContextTimeoutHelp": { "message": "Maximum time to wait for AI response" }, - "aiContextRateLimitLabel": { "message": "Rate Limit (requests/min):" }, + "aiContextRateLimitLabel": { + "message": "Rate Limit (requests/min):" + }, "aiContextRateLimitHelp": { "message": "Maximum number of requests per minute" }, - "aiContextCacheEnabledLabel": { "message": "Enable Caching:" }, + "aiContextCacheEnabledLabel": { + "message": "Enable Caching:" + }, "aiContextCacheEnabledHelp": { "message": "Cache analysis results to reduce API calls" }, - "aiContextRetryAttemptsLabel": { "message": "Retry Attempts:" }, + "aiContextRetryAttemptsLabel": { + "message": "Retry Attempts:" + }, "aiContextRetryAttemptsHelp": { "message": "Number of times to retry failed requests" }, - "showAdvancedSettings": { "message": "Show Advanced Settings" }, - "hideAdvancedSettings": { "message": "Hide Advanced Settings" } -} + "sidepanelLoading": { + "message": "Loading..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI Analysis" + }, + "sidepanelTabWordsLists": { + "message": "Words Lists" + }, + "sidepanelAnalyzeButton": { + "message": "Analyze" + }, + "sidepanelAnalyzing": { + "message": "Analyzing..." + }, + "sidepanelWordsToAnalyze": { + "message": "Words to Analyze" + }, + "sidepanelWordInputPlaceholder": { + "message": "Click on subtitle words to add them for analysis..." + }, + "sidepanelErrorRetry": { + "message": "Retry" + }, + "sidepanelResultsTitle": { + "message": "Results for \"%s\"" + }, + "sidepanelSectionDefinition": { + "message": "Definition" + }, + "sidepanelSectionCultural": { + "message": "Cultural Context" + }, + "sidepanelSectionHistorical": { + "message": "Historical Context" + }, + "sidepanelSectionLinguistic": { + "message": "Linguistic Analysis" + }, + "sidepanelMyWordsTitle": { + "message": "My Words" + }, + "sidepanelFeatureComingSoon": { + "message": "Words Lists feature coming soon!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "This feature is currently in development. Enable it in Settings to try the preview." + }, + "sidepanelErrorNoWords": { + "message": "No words selected for analysis" + }, + "sidepanelErrorDisabled": { + "message": "AI Context analysis is disabled. Enable it in settings." + }, + "sidepanelErrorGeneric": { + "message": "An error occurred during analysis." + } +} \ No newline at end of file diff --git a/_locales/es/messages.json b/_locales/es/messages.json index af2f976..cc752d2 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -1,33 +1,79 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "Muestra subtítulos en dos idiomas en plataformas de streaming." }, - "pageTitle": { "message": "Configuración de DualSub" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "Habilitar Subtítulos Duales:" }, - "useNativeSubtitlesLabel": { "message": "Usar Subtítulos Oficiales:" }, - "originalLanguageLabel": { "message": "Idioma Original:" }, - "translationSettingsLegend": { "message": "Configuración de Traducción" }, - "providerLabel": { "message": "Proveedor:" }, - "targetLanguageLabel": { "message": "Traducir a:" }, - "batchSizeLabel": { "message": "Tamaño de Lote:" }, - "requestDelayLabel": { "message": "Retraso de Solicitud (ms):" }, + "pageTitle": { + "message": "Configuración de DualSub" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "Habilitar Subtítulos Duales:" + }, + "useNativeSubtitlesLabel": { + "message": "Usar Subtítulos Oficiales:" + }, + "originalLanguageLabel": { + "message": "Idioma Original:" + }, + "translationSettingsLegend": { + "message": "Configuración de Traducción" + }, + "providerLabel": { + "message": "Proveedor:" + }, + "targetLanguageLabel": { + "message": "Traducir a:" + }, + "batchSizeLabel": { + "message": "Tamaño de Lote:" + }, + "requestDelayLabel": { + "message": "Retraso de Solicitud (ms):" + }, "subtitleAppearanceTimingLegend": { "message": "Apariencia y Sincronización de Subtítulos" }, - "displayOrderLabel": { "message": "Orden de Visualización:" }, - "layoutLabel": { "message": "Diseño:" }, - "fontSizeLabel": { "message": "Tamaño de Fuente:" }, - "verticalGapLabel": { "message": "Espaciado Vertical:" }, - "subtitleVerticalPositionLabel": { "message": "Posición Vertical:" }, - "timeOffsetLabel": { "message": "Desfase de Tiempo(s):" }, - "displayOrderOriginalFirst": { "message": "Original Primero" }, - "displayOrderTranslationFirst": { "message": "Traducción Primero" }, - "layoutTopBottom": { "message": "Arriba / Abajo" }, - "layoutLeftRight": { "message": "Izquierda / Derecha" }, - "uiLanguageLabel": { "message": "Idioma:" }, - "openOptionsButton": { "message": "Configuración Avanzada" }, + "displayOrderLabel": { + "message": "Orden de Visualización:" + }, + "layoutLabel": { + "message": "Diseño:" + }, + "fontSizeLabel": { + "message": "Tamaño de Fuente:" + }, + "verticalGapLabel": { + "message": "Espaciado Vertical:" + }, + "subtitleVerticalPositionLabel": { + "message": "Posición Vertical:" + }, + "timeOffsetLabel": { + "message": "Desfase de Tiempo(s):" + }, + "displayOrderOriginalFirst": { + "message": "Original Primero" + }, + "displayOrderTranslationFirst": { + "message": "Traducción Primero" + }, + "layoutTopBottom": { + "message": "Arriba / Abajo" + }, + "layoutLeftRight": { + "message": "Izquierda / Derecha" + }, + "uiLanguageLabel": { + "message": "Idioma:" + }, + "openOptionsButton": { + "message": "Configuración Avanzada" + }, "statusLanguageSetTo": { "message": "Idioma Configurado (Recargar Página): " }, @@ -46,29 +92,54 @@ "statusOriginalLanguage": { "message": "Idioma Configurado (Recargar Página): " }, - "statusTimeOffset": { "message": "Desfase de tiempo: " }, + "statusTimeOffset": { + "message": "Desfase de tiempo: " + }, "statusDisplayOrderUpdated": { "message": "Orden de visualización actualizado." }, "statusLayoutOrientationUpdated": { "message": "Orientación del diseño actualizada." }, - "statusFontSize": { "message": "Tamaño de fuente: " }, - "statusVerticalGap": { "message": "Espaciado vertical: " }, - "statusVerticalPosition": { "message": "Posición vertical: " }, - "statusInvalidOffset": { "message": "Desfase inválido, revirtiendo." }, + "statusFontSize": { + "message": "Tamaño de fuente: " + }, + "statusVerticalGap": { + "message": "Espaciado vertical: " + }, + "statusVerticalPosition": { + "message": "Posición vertical: " + }, + "statusInvalidOffset": { + "message": "Desfase inválido, revirtiendo." + }, "statusSettingNotApplied": { "message": "Configuración no aplicada. Recargar página." }, - - "optionsPageTitle": { "message": "Opciones de DualSub" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "General" }, - "navTranslation": { "message": "Traducción" }, - "navProviders": { "message": "Proveedores" }, - "navAbout": { "message": "Acerca de" }, - "sectionGeneral": { "message": "General" }, - "cardUILanguageTitle": { "message": "Idioma de la Interfaz" }, + "optionsPageTitle": { + "message": "Opciones de DualSub" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "General" + }, + "navTranslation": { + "message": "Traducción" + }, + "navProviders": { + "message": "Proveedores" + }, + "navAbout": { + "message": "Acerca de" + }, + "sectionGeneral": { + "message": "General" + }, + "cardUILanguageTitle": { + "message": "Idioma de la Interfaz" + }, "cardUILanguageDesc": { "message": "Elige el idioma de visualización para la interfaz de la extensión." }, @@ -81,56 +152,114 @@ "hideOfficialSubtitlesLabel": { "message": "Ocultar subtítulos oficiales:" }, - "sectionTranslation": { "message": "Traducción" }, - "cardTranslationEngineTitle": { "message": "Motor de Traducción" }, + "sectionTranslation": { + "message": "Traducción" + }, + "cardTranslationEngineTitle": { + "message": "Motor de Traducción" + }, "cardTranslationEngineDesc": { "message": "Selecciona tu servicio de traducción preferido." }, - "cardPerformanceTitle": { "message": "Rendimiento" }, + "cardPerformanceTitle": { + "message": "Rendimiento" + }, "cardPerformanceDesc": { "message": "Ajusta cómo la extensión maneja las solicitudes de traducción para equilibrar velocidad y estabilidad." }, - "sectionProviders": { "message": "Configuración de Proveedores" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "Configuración de Proveedores" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "Ingresa tu clave API para DeepL Translate. Elige entre planes Gratuito y Pro." }, - "apiKeyLabel": { "message": "Clave API:" }, - "apiPlanLabel": { "message": "Plan API:" }, - "apiPlanFree": { "message": "DeepL API Gratuito" }, - "apiPlanPro": { "message": "DeepL API Pro" }, - "sectionAbout": { "message": "Acerca de" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "Versión" }, + "apiKeyLabel": { + "message": "Clave API:" + }, + "apiPlanLabel": { + "message": "Plan API:" + }, + "apiPlanFree": { + "message": "DeepL API Gratuito" + }, + "apiPlanPro": { + "message": "DeepL API Pro" + }, + "sectionAbout": { + "message": "Acerca de" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "Versión" + }, "aboutDescription": { "message": "Esta extensión te ayuda a ver videos con subtítulos en dos idiomas en varias plataformas." }, - "aboutDevelopment": { "message": "Desarrollado por QuellaMC & 1jifang." }, - - "lang_en": { "message": "Inglés" }, - "lang_es": { "message": "Español" }, - "lang_fr": { "message": "Francés" }, - "lang_de": { "message": "Alemán" }, - "lang_it": { "message": "Italiano" }, - "lang_pt": { "message": "Portugués" }, - "lang_ja": { "message": "Japonés" }, - "lang_ko": { "message": "Coreano" }, - "lang_zh_CN": { "message": "Chino (Simp)" }, - "lang_zh_TW": { "message": "Chino (Trad)" }, - "lang_ru": { "message": "Ruso" }, - "lang_ar": { "message": "Árabe" }, - "lang_hi": { "message": "Hindi" }, - - "testDeepLButton": { "message": "Probar Conexión DeepL" }, + "aboutDevelopment": { + "message": "Desarrollado por QuellaMC & 1jifang." + }, + "lang_en": { + "message": "Inglés" + }, + "lang_es": { + "message": "Español" + }, + "lang_fr": { + "message": "Francés" + }, + "lang_de": { + "message": "Alemán" + }, + "lang_it": { + "message": "Italiano" + }, + "lang_pt": { + "message": "Portugués" + }, + "lang_ja": { + "message": "Japonés" + }, + "lang_ko": { + "message": "Coreano" + }, + "lang_zh_CN": { + "message": "Chino (Simp)" + }, + "lang_zh_TW": { + "message": "Chino (Trad)" + }, + "lang_ru": { + "message": "Ruso" + }, + "lang_ar": { + "message": "Árabe" + }, + "lang_hi": { + "message": "Hindi" + }, + "testDeepLButton": { + "message": "Probar Conexión DeepL" + }, "deeplApiKeyError": { "message": "Por favor ingresa tu clave API de DeepL primero." }, - "testingButton": { "message": "Probando..." }, - "testingConnection": { "message": "Probando conexión DeepL..." }, + "testingButton": { + "message": "Probando..." + }, + "testingConnection": { + "message": "Probando conexión DeepL..." + }, "deeplTestSuccess": { "message": "✅ ¡Prueba de API DeepL exitosa! Tradujo \"Hello\" a \"%s\"" }, - "deeplTestSuccessSimple": { "message": "✅ ¡Prueba de API DeepL exitosa!" }, + "deeplTestSuccessSimple": { + "message": "✅ ¡Prueba de API DeepL exitosa!" + }, "deeplTestUnexpectedFormat": { "message": "⚠️ DeepL API respondió pero con formato inesperado" }, @@ -140,47 +269,87 @@ "deeplTestQuotaExceeded": { "message": "❌ Cuota de API DeepL excedida. Por favor verifica tus límites de uso." }, - "deeplTestApiError": { "message": "❌ Error de API DeepL (%d): %s" }, + "deeplTestApiError": { + "message": "❌ Error de API DeepL (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ Error de red: No se pudo conectar a la API de DeepL. Verifica tu conexión a internet." }, - "deeplTestGenericError": { "message": "❌ Prueba fallida: %s" }, - "deepLApiUnavailable": { "message": "API DeepL No Disponible" }, + "deeplTestGenericError": { + "message": "❌ Prueba fallida: %s" + }, + "deepLApiUnavailable": { + "message": "API DeepL No Disponible" + }, "deepLApiUnavailableTooltip": { "message": "El script de la API DeepL falló al cargar" }, "deeplApiNotLoadedError": { "message": "❌ El script de la API DeepL no está disponible. Por favor recarga la página." }, - - "cardGoogleTitle": { "message": "Google Translate" }, + "cardGoogleTitle": { + "message": "Google Translate" + }, "cardGoogleDesc": { "message": "Servicio de traducción gratuito proporcionado por Google. No requiere configuración adicional." }, - "cardMicrosoftTitle": { "message": "Microsoft Translate" }, + "cardMicrosoftTitle": { + "message": "Microsoft Translate" + }, "cardMicrosoftDesc": { "message": "Servicio de traducción gratuito proporcionado por Microsoft Edge. No requiere configuración adicional." }, - "cardDeepLFreeTitle": { "message": "DeepL Translate (Gratuito)" }, + "cardDeepLFreeTitle": { + "message": "DeepL Translate (Gratuito)" + }, "cardDeepLFreeDesc": { "message": "Servicio de traducción DeepL gratuito con resultados de alta calidad. No requiere clave API - usa la interfaz web de DeepL." }, - "providerStatus": { "message": "Estado:" }, - "statusReady": { "message": "Listo para usar" }, - "providerFeatures": { "message": "Características:" }, - "featureFree": { "message": "Gratis para usar" }, - "featureNoApiKey": { "message": "No requiere clave API" }, - "featureWideLanguageSupport": { "message": "Amplio soporte de idiomas" }, - "featureFastTranslation": { "message": "Traducción rápida" }, - "featureHighQuality": { "message": "Traducción de alta calidad" }, - "featureGoodPerformance": { "message": "Buen rendimiento" }, - "featureHighestQuality": { "message": "Traducción de la más alta calidad" }, - "featureApiKeyRequired": { "message": "Requiere clave API" }, - "featureLimitedLanguages": { "message": "Soporte limitado de idiomas" }, - "featureUsageLimits": { "message": "Se aplican límites de uso" }, - "featureMultipleBackups": { "message": "Múltiples métodos de respaldo" }, - - "providerNotes": { "message": "Notas:" }, + "providerStatus": { + "message": "Estado:" + }, + "statusReady": { + "message": "Listo para usar" + }, + "providerFeatures": { + "message": "Características:" + }, + "featureFree": { + "message": "Gratis para usar" + }, + "featureNoApiKey": { + "message": "No requiere clave API" + }, + "featureWideLanguageSupport": { + "message": "Amplio soporte de idiomas" + }, + "featureFastTranslation": { + "message": "Traducción rápida" + }, + "featureHighQuality": { + "message": "Traducción de alta calidad" + }, + "featureGoodPerformance": { + "message": "Buen rendimiento" + }, + "featureHighestQuality": { + "message": "Traducción de la más alta calidad" + }, + "featureApiKeyRequired": { + "message": "Requiere clave API" + }, + "featureLimitedLanguages": { + "message": "Soporte limitado de idiomas" + }, + "featureUsageLimits": { + "message": "Se aplican límites de uso" + }, + "featureMultipleBackups": { + "message": "Múltiples métodos de respaldo" + }, + "providerNotes": { + "message": "Notas:" + }, "noteSlowForSecurity": { "message": "Ligeramente más lento debido a medidas de seguridad" }, @@ -190,11 +359,18 @@ "noteRecommendedDefault": { "message": "Recomendado como proveedor predeterminado" }, - - "providerGoogleName": { "message": "Google Translate (Gratuito)" }, - "providerMicrosoftName": { "message": "Microsoft Translate (Gratuito)" }, - "providerDeepLName": { "message": "DeepL (Requiere Clave API)" }, - "providerDeepLFreeName": { "message": "DeepL Translate (Gratuito)" }, + "providerGoogleName": { + "message": "Google Translate (Gratuito)" + }, + "providerMicrosoftName": { + "message": "Microsoft Translate (Gratuito)" + }, + "providerDeepLName": { + "message": "DeepL (Requiere Clave API)" + }, + "providerDeepLFreeName": { + "message": "DeepL Translate (Gratuito)" + }, "providerOpenAICompatibleName": { "message": "Compatible con OpenAI (Requiere Clave API)" }, @@ -213,31 +389,57 @@ "cardVertexGeminiDesc": { "message": "Ingresa tu token de acceso y configuraciones del proyecto Vertex, o importa un archivo JSON de cuenta de servicio." }, - "vertexAccessTokenLabel": { "message": "Token de Acceso:" }, - "vertexProjectIdLabel": { "message": "ID del Proyecto:" }, - "vertexLocationLabel": { "message": "Ubicación:" }, - "vertexModelLabel": { "message": "Modelo:" }, + "vertexAccessTokenLabel": { + "message": "Token de Acceso:" + }, + "vertexProjectIdLabel": { + "message": "ID del Proyecto:" + }, + "vertexLocationLabel": { + "message": "Ubicación:" + }, + "vertexModelLabel": { + "message": "Modelo:" + }, "vertexMissingConfig": { "message": "Por favor ingresa el token de acceso y el ID del proyecto." }, - "vertexConnectionFailed": { "message": "Conexión fallida: %s" }, - "vertexServiceAccountLabel": { "message": "JSON de Cuenta de Servicio:" }, - "vertexImportButton": { "message": "Importar Archivo JSON" }, - "vertexRefreshButton": { "message": "🔄 Actualizar Token" }, + "vertexConnectionFailed": { + "message": "Conexión fallida: %s" + }, + "vertexServiceAccountLabel": { + "message": "JSON de Cuenta de Servicio:" + }, + "vertexImportButton": { + "message": "Importar Archivo JSON" + }, + "vertexRefreshButton": { + "message": "🔄 Actualizar Token" + }, "vertexImportHint": { "message": "Rellena automáticamente las credenciales a continuación" }, - "vertexImporting": { "message": "Importando..." }, - "vertexRefreshingToken": { "message": "Actualizando token de acceso..." }, - "vertexGeneratingToken": { "message": "Generando token de acceso..." }, + "vertexImporting": { + "message": "Importando..." + }, + "vertexRefreshingToken": { + "message": "Actualizando token de acceso..." + }, + "vertexGeneratingToken": { + "message": "Generando token de acceso..." + }, "vertexImportSuccess": { "message": "Cuenta de servicio importada y token generado." }, - "vertexImportFailed": { "message": "Importación fallida: %s" }, + "vertexImportFailed": { + "message": "Importación fallida: %s" + }, "vertexTokenRefreshed": { "message": "Token de acceso actualizado exitosamente." }, - "vertexRefreshFailed": { "message": "Actualización de token fallida: %s" }, + "vertexRefreshFailed": { + "message": "Actualización de token fallida: %s" + }, "vertexTokenExpired": { "message": "⚠️ Token de acceso expirado. Haz clic en actualizar para renovar." }, @@ -253,23 +455,36 @@ "featureVertexServiceAccount": { "message": "Importación de JSON de cuenta de servicio" }, - "featureVertexAutoToken": { "message": "Generación automática de tokens" }, + "featureVertexAutoToken": { + "message": "Generación automática de tokens" + }, "featureVertexGemini": { "message": "Modelos Google Gemini a través de Vertex AI" }, "vertexNote": { "message": "Los tokens de acceso expiran después de 1 hora. Tu cuenta de servicio está almacenada de forma segura para facilitar la actualización del token - solo haz clic en el botón Actualizar Token cuando sea necesario." }, - "baseUrlLabel": { "message": "URL Base:" }, - "modelLabel": { "message": "Modelo:" }, - "featureCustomizable": { "message": "Endpoint y modelo personalizables" }, - "fetchModelsButton": { "message": "Obtener Modelos" }, - "testConnectionButton": { "message": "Probar Conexión" }, - + "baseUrlLabel": { + "message": "URL Base:" + }, + "modelLabel": { + "message": "Modelo:" + }, + "featureCustomizable": { + "message": "Endpoint y modelo personalizables" + }, + "fetchModelsButton": { + "message": "Obtener Modelos" + }, + "testConnectionButton": { + "message": "Probar Conexión" + }, "openaiApiKeyPlaceholder": { "message": "Ingresa tu clave API compatible con OpenAI" }, - "openaiBaseUrlPlaceholder": { "message": "ej., https://api.openai.com/v1" }, + "openaiBaseUrlPlaceholder": { + "message": "ej., https://api.openai.com/v1" + }, "openaiApiKeyError": { "message": "Por favor ingresa tu clave API primero." }, @@ -279,31 +494,57 @@ "openaiTestNeedsTesting": { "message": "⚠️ La clave API compatible con OpenAI necesita ser probada." }, - "openaiTestingConnection": { "message": "Probando conexión..." }, - "openaiConnectionSuccessful": { "message": "¡Conexión exitosa!" }, - "openaiConnectionFailed": { "message": "Conexión falló: %s" }, - "openaieFetchingModels": { "message": "Obteniendo modelos..." }, + "openaiTestingConnection": { + "message": "Probando conexión..." + }, + "openaiConnectionSuccessful": { + "message": "¡Conexión exitosa!" + }, + "openaiConnectionFailed": { + "message": "Conexión falló: %s" + }, + "openaieFetchingModels": { + "message": "Obteniendo modelos..." + }, "openaiModelsFetchedSuccessfully": { "message": "Modelos obtenidos exitosamente." }, - "openaiFailedToFetchModels": { "message": "Error al obtener modelos: %s" }, - - "cardLoggingLevelTitle": { "message": "Nivel de Registro" }, + "openaiFailedToFetchModels": { + "message": "Error al obtener modelos: %s" + }, + "cardLoggingLevelTitle": { + "message": "Nivel de Registro" + }, "cardLoggingLevelDesc": { "message": "Controla la cantidad de información de depuración mostrada en la consola del navegador. Los niveles más altos incluyen todos los mensajes de niveles inferiores." }, - "loggingLevelLabel": { "message": "Nivel de Registro:" }, - "loggingLevelOff": { "message": "Desactivado" }, - "loggingLevelError": { "message": "Solo Errores" }, - "loggingLevelWarn": { "message": "Advertencias y Errores" }, - "loggingLevelInfo": { "message": "Info y Superior" }, - "loggingLevelDebug": { "message": "Debug (Todo)" }, - - "cardBatchTranslationTitle": { "message": "Traducción por Lotes" }, + "loggingLevelLabel": { + "message": "Nivel de Registro:" + }, + "loggingLevelOff": { + "message": "Desactivado" + }, + "loggingLevelError": { + "message": "Solo Errores" + }, + "loggingLevelWarn": { + "message": "Advertencias y Errores" + }, + "loggingLevelInfo": { + "message": "Info y Superior" + }, + "loggingLevelDebug": { + "message": "Debug (Todo)" + }, + "cardBatchTranslationTitle": { + "message": "Traducción por Lotes" + }, "cardBatchTranslationDesc": { "message": "La traducción por lotes procesa múltiples segmentos de subtítulos juntos, reduciendo las llamadas a la API en un 80-90% y mejorando el rendimiento. Configure los ajustes óptimos para su proveedor de traducción preferido." }, - "batchingEnabledLabel": { "message": "Habilitar Traducción por Lotes:" }, + "batchingEnabledLabel": { + "message": "Habilitar Traducción por Lotes:" + }, "batchingEnabledHelp": { "message": "Agrupa múltiples segmentos de subtítulos en solicitudes de traducción únicas" }, @@ -313,34 +554,45 @@ "useProviderDefaultsHelp": { "message": "Usar automáticamente tamaños de lote óptimos para cada proveedor de traducción" }, - "globalBatchSizeLabel": { "message": "Tamaño de Lote Global:" }, + "globalBatchSizeLabel": { + "message": "Tamaño de Lote Global:" + }, "globalBatchSizeHelp": { "message": "Número de segmentos de subtítulos a procesar juntos (1-15)" }, - "smartBatchingLabel": { "message": "Optimización Inteligente de Lotes:" }, + "smartBatchingLabel": { + "message": "Optimización Inteligente de Lotes:" + }, "smartBatchingHelp": { "message": "Prioriza segmentos de subtítulos basándose en la posición de reproducción" }, - "maxConcurrentBatchesLabel": { "message": "Máximo de Lotes Concurrentes:" }, + "maxConcurrentBatchesLabel": { + "message": "Máximo de Lotes Concurrentes:" + }, "maxConcurrentBatchesHelp": { "message": "Número de lotes de traducción a procesar simultáneamente" }, - "cardProviderBatchTitle": { "message": "Tamaños de Lote Específicos del Proveedor" }, "cardProviderBatchDesc": { "message": "Configure tamaños de lote óptimos para cada proveedor de traducción. Estas configuraciones se usan cuando \"Usar Configuración Optimizada del Proveedor\" está habilitado." }, - "openaieBatchSizeLabel": { "message": "Tamaño de Lote OpenAI:" }, + "openaieBatchSizeLabel": { + "message": "Tamaño de Lote OpenAI:" + }, "openaieBatchSizeHelp": { "message": "Recomendado: 5-10 segmentos (predeterminado: 8)" }, - "googleBatchSizeLabel": { "message": "Tamaño de Lote Google Translate:" }, + "googleBatchSizeLabel": { + "message": "Tamaño de Lote Google Translate:" + }, "googleBatchSizeHelp": { "message": "Recomendado: 3-5 segmentos (predeterminado: 4)" }, - "deeplBatchSizeLabel": { "message": "Tamaño de Lote DeepL:" }, + "deeplBatchSizeLabel": { + "message": "Tamaño de Lote DeepL:" + }, "deeplBatchSizeHelp": { "message": "Recomendado: 2-3 segmentos (predeterminado: 3)" }, @@ -350,22 +602,24 @@ "microsoftBatchSizeHelp": { "message": "Recomendado: 3-5 segmentos (predeterminado: 4)" }, - "vertexBatchSizeLabel": { "message": "Tamaño de Lote Vertex AI:" }, + "vertexBatchSizeLabel": { + "message": "Tamaño de Lote Vertex AI:" + }, "vertexBatchSizeHelp": { "message": "Recomendado: 5-10 segmentos (predeterminado: 8)" }, - "deeplTestNeedsTesting": { "message": "⚠️ La clave API de DeepL necesita ser probada." }, - "cardProviderDelayTitle": { "message": "Retrasos de Solicitud Específicos del Proveedor" }, "cardProviderDelayDesc": { "message": "Configure retrasos obligatorios entre solicitudes de traducción para prevenir bloqueos de cuenta. Estos retrasos se aplican incluso cuando el procesamiento por lotes está habilitado." }, - "openaieDelayLabel": { "message": "Retraso de Solicitud OpenAI (ms):" }, + "openaieDelayLabel": { + "message": "Retraso de Solicitud OpenAI (ms):" + }, "openaieDelayHelp": { "message": "Retraso mínimo entre solicitudes (predeterminado: 100ms)" }, @@ -375,7 +629,9 @@ "googleDelayHelp": { "message": "Retraso requerido para prevenir bloqueos temporales (predeterminado: 1500ms)" }, - "deeplDelayLabel": { "message": "Retraso de Solicitud API DeepL (ms):" }, + "deeplDelayLabel": { + "message": "Retraso de Solicitud API DeepL (ms):" + }, "deeplDelayHelp": { "message": "Retraso para solicitudes de API DeepL (predeterminado: 500ms)" }, @@ -397,118 +653,240 @@ "vertexDelayHelp": { "message": "Retraso mínimo entre solicitudes (predeterminado: 100ms)" }, - - "aiContextModalTitle": { "message": "Análisis de Contexto IA" }, - "aiContextSelectedWords": { "message": "Palabras Seleccionadas" }, - "aiContextNoWordsSelected": { "message": "No hay palabras seleccionadas" }, + "aiContextModalTitle": { + "message": "Análisis de Contexto IA" + }, + "aiContextSelectedWords": { + "message": "Palabras Seleccionadas" + }, + "aiContextNoWordsSelected": { + "message": "No hay palabras seleccionadas" + }, "aiContextClickHint": { "message": "💡 Haz clic en una palabra para agregarla o quitarla." }, - "aiContextStartAnalysis": { "message": "Iniciar Análisis" }, - "aiContextPauseAnalysis": { "message": "⏸ Pausar" }, - "aiContextPauseAnalysisTitle": { "message": "Pausar Análisis" }, + "aiContextStartAnalysis": { + "message": "Iniciar Análisis" + }, + "aiContextPauseAnalysis": { + "message": "⏸ Pausar" + }, + "aiContextPauseAnalysisTitle": { + "message": "Pausar Análisis" + }, "aiContextInitialMessage": { "message": "Selecciona palabras de los subtítulos para comenzar el análisis." }, - "aiContextAnalyzing": { "message": "Analizando contexto..." }, + "aiContextAnalyzing": { + "message": "Analizando contexto..." + }, "aiContextPauseNote": { "message": "Haz clic en ⏸ para pausar el análisis" }, - "aiContextAnalysisFailed": { "message": "Análisis Fallido" }, - "aiContextNoContent": { "message": "Sin Contenido de Análisis" }, + "aiContextAnalysisFailed": { + "message": "Análisis Fallido" + }, + "aiContextNoContent": { + "message": "Sin Contenido de Análisis" + }, "aiContextNoContentMessage": { "message": "El análisis se completó pero no se devolvió contenido." }, - "aiContextDefinition": { "message": "📖 Definición" }, - "aiContextCultural": { "message": "🌍 Contexto Cultural" }, - "aiContextCulturalSignificance": { "message": "⭐ Significado Cultural" }, - "aiContextHistorical": { "message": "📜 Contexto Histórico" }, + "aiContextDefinition": { + "message": "📖 Definición" + }, + "aiContextCultural": { + "message": "🌍 Contexto Cultural" + }, + "aiContextCulturalSignificance": { + "message": "⭐ Significado Cultural" + }, + "aiContextHistorical": { + "message": "📜 Contexto Histórico" + }, "aiContextHistoricalSignificance": { "message": "📜 Significado Histórico" }, - "aiContextEvolution": { "message": "🔄 Evolución en el Tiempo" }, - "aiContextLinguistic": { "message": "🔤 Análisis Lingüístico" }, - "aiContextGrammar": { "message": "📝 Gramática y Semántica" }, - "aiContextUsage": { "message": "💡 Uso y Ejemplos" }, - "aiContextExamples": { "message": "Ejemplos:" }, - "aiContextLearningTips": { "message": "🎯 Consejos de Aprendizaje" }, - "aiContextRelatedExpressions": { "message": "🔗 Expresiones Relacionadas" }, - "aiContextKeyInsights": { "message": "🔑 Perspectivas Clave" }, - "aiContextTypeCultural": { "message": "Cultural" }, - "aiContextTypeHistorical": { "message": "Histórico" }, - "aiContextTypeLinguistic": { "message": "Lingüístico" }, - "aiContextTypeComprehensive": { "message": "Integral" }, - "aiContextTypeGeneric": { "message": "Contexto" }, - "aiContextClose": { "message": "Cerrar" }, - "aiContextAnalysisResults": { "message": "Resultados del Análisis" }, - "aiContextRetrying": { "message": "Análisis falló, regenerando..." }, + "aiContextEvolution": { + "message": "🔄 Evolución en el Tiempo" + }, + "aiContextLinguistic": { + "message": "🔤 Análisis Lingüístico" + }, + "aiContextGrammar": { + "message": "📝 Gramática y Semántica" + }, + "aiContextUsage": { + "message": "💡 Uso y Ejemplos" + }, + "aiContextExamples": { + "message": "Ejemplos:" + }, + "aiContextLearningTips": { + "message": "🎯 Consejos de Aprendizaje" + }, + "aiContextRelatedExpressions": { + "message": "🔗 Expresiones Relacionadas" + }, + "aiContextKeyInsights": { + "message": "🔑 Perspectivas Clave" + }, + "aiContextTypeCultural": { + "message": "Cultural" + }, + "aiContextTypeHistorical": { + "message": "Histórico" + }, + "aiContextTypeLinguistic": { + "message": "Lingüístico" + }, + "aiContextTypeComprehensive": { + "message": "Integral" + }, + "aiContextTypeGeneric": { + "message": "Contexto" + }, + "aiContextClose": { + "message": "Cerrar" + }, + "aiContextAnalysisResults": { + "message": "Resultados del Análisis" + }, + "aiContextRetrying": { + "message": "Análisis falló, regenerando..." + }, "aiContextRetryNotification": { "message": "Análisis falló, reintentando..." }, - "aiContextRetryButton": { "message": "Intentar de Nuevo" }, + "aiContextRetryButton": { + "message": "Intentar de Nuevo" + }, "aiContextMalformedResponse": { "message": "El servicio de IA devolvió un formato de respuesta inválido. Esto puede deberse a problemas temporales del servicio." }, "aiContextJsonCodeBlock": { "message": "El servicio de IA devolvió código JSON sin procesar en lugar de datos estructurados. Esto indica un error de formato en la respuesta." }, - "aiContextCulturalContext": { "message": "Contexto Cultural:" }, - "aiContextSocialUsage": { "message": "Uso Social:" }, - "aiContextRegionalNotes": { "message": "Notas Regionales:" }, - "aiContextOrigins": { "message": "Orígenes:" }, - "aiContextHistoricalContext": { "message": "Contexto Histórico:" }, - "aiContextHistoricalSignificance": { "message": "Significado Histórico:" }, - "aiContextEvolution": { "message": "Evolución:" }, - "aiContextEtymology": { "message": "Etimología:" }, - "aiContextGrammarNotes": { "message": "Notas Gramaticales:" }, - "aiContextTranslationNotes": { "message": "Notas de Traducción:" }, - "aiContextLinguisticAnalysis": { "message": "Análisis Lingüístico:" }, - "aiContextGrammarSemantics": { "message": "Gramática y Semántica:" }, - "aiContextUsageExamples": { "message": "Uso y Ejemplos:" }, - "aiContextLearningTips": { "message": "Consejos de Aprendizaje:" }, - "aiContextRelatedExpressions": { "message": "Expresiones Relacionadas:" }, - "aiContextKeyInsights": { "message": "Puntos Clave:" }, - - "navAIContext": { "message": "Contexto IA" }, - "sectionAIContext": { "message": "Asistente de Contexto IA" }, + "aiContextCulturalContext": { + "message": "Contexto Cultural:" + }, + "aiContextSocialUsage": { + "message": "Uso Social:" + }, + "aiContextRegionalNotes": { + "message": "Notas Regionales:" + }, + "aiContextOrigins": { + "message": "Orígenes:" + }, + "aiContextHistoricalContext": { + "message": "Contexto Histórico:" + }, + "aiContextHistoricalSignificanceLabel": { + "message": "Significado Histórico:" + }, + "aiContextEvolutionLabel": { + "message": "Evolución:" + }, + "aiContextEtymology": { + "message": "Etimología:" + }, + "aiContextGrammarNotesLabel": { + "message": "Notas Gramaticales:" + }, + "aiContextTranslationNotesLabel": { + "message": "Notas de Traducción:" + }, + "aiContextLinguisticAnalysisLabel": { + "message": "Análisis Lingüístico:" + }, + "aiContextGrammarSemanticsLabel": { + "message": "Gramática y Semántica:" + }, + "aiContextUsageExamplesLabel": { + "message": "Uso y Ejemplos:" + }, + "aiContextLearningTipsLabel": { + "message": "Consejos de Aprendizaje:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "Expresiones Relacionadas:" + }, + "aiContextKeyInsightsLabel": { + "message": "Puntos Clave:" + }, + "navAIContext": { + "message": "Contexto IA" + }, + "sectionAIContext": { + "message": "Asistente de Contexto IA" + }, "cardAIContextToggleTitle": { "message": "Habilitar Análisis de Contexto IA" }, "cardAIContextToggleDesc": { "message": "Habilita el análisis de contexto cultural, histórico y lingüístico impulsado por IA para el texto de los subtítulos. Haz clic en palabras o frases en los subtítulos para obtener explicaciones detalladas." }, - "aiContextEnabledLabel": { "message": "Habilitar Contexto IA:" }, - "cardAIContextProviderTitle": { "message": "Proveedor de IA" }, + "aiContextEnabledLabel": { + "message": "Habilitar Contexto IA:" + }, + "cardAIContextProviderTitle": { + "message": "Proveedor de IA" + }, "cardAIContextProviderDesc": { "message": "Elige el proveedor de servicios de IA para el análisis de contexto. Diferentes proveedores pueden ofrecer calidad y tiempos de respuesta variables." }, - "aiContextProviderLabel": { "message": "Proveedor:" }, - "cardOpenAIContextTitle": { "message": "Configuración de OpenAI" }, + "aiContextProviderLabel": { + "message": "Proveedor:" + }, + "cardOpenAIContextTitle": { + "message": "Configuración de OpenAI" + }, "cardOpenAIContextDesc": { "message": "Configura tus ajustes de API de OpenAI para el análisis de contexto. Necesitas una clave API válida de OpenAI." }, - "openaiApiKeyLabel": { "message": "Clave API:" }, - "openaiBaseUrlLabel": { "message": "URL Base:" }, - "openaiModelLabel": { "message": "Modelo:" }, - "cardGeminiContextTitle": { "message": "Configuración de Google Gemini" }, + "openaiApiKeyLabel": { + "message": "Clave API:" + }, + "openaiBaseUrlLabel": { + "message": "URL Base:" + }, + "openaiModelLabel": { + "message": "Modelo:" + }, + "cardGeminiContextTitle": { + "message": "Configuración de Google Gemini" + }, "cardGeminiContextDesc": { "message": "Configura tus ajustes de API de Google Gemini para el análisis de contexto. Necesitas una clave API válida de Gemini." }, - "geminiApiKeyLabel": { "message": "Clave API:" }, - "geminiModelLabel": { "message": "Modelo:" }, - "cardAIContextTypesTitle": { "message": "Tipos de Contexto" }, + "geminiApiKeyLabel": { + "message": "Clave API:" + }, + "geminiModelLabel": { + "message": "Modelo:" + }, + "cardAIContextTypesTitle": { + "message": "Tipos de Contexto" + }, "cardAIContextTypesDesc": { "message": "Habilita los tipos de análisis de contexto que quieres usar. Puedes habilitar múltiples tipos." }, - "contextTypeCulturalLabel": { "message": "Contexto Cultural:" }, + "contextTypeCulturalLabel": { + "message": "Contexto Cultural:" + }, "contextTypeCulturalHelp": { "message": "Analizar referencias culturales, modismos y contexto social" }, - "contextTypeHistoricalLabel": { "message": "Contexto Histórico:" }, + "contextTypeHistoricalLabel": { + "message": "Contexto Histórico:" + }, "contextTypeHistoricalHelp": { "message": "Proporcionar antecedentes históricos y contexto del período de tiempo" }, - "contextTypeLinguisticLabel": { "message": "Análisis Lingüístico:" }, + "contextTypeLinguisticLabel": { + "message": "Análisis Lingüístico:" + }, "contextTypeLinguisticHelp": { "message": "Explicar gramática, etimología y estructura del lenguaje" }, @@ -524,15 +902,21 @@ "interactiveSubtitlesEnabledHelp": { "message": "Hacer que las palabras de los subtítulos sean clicables para el análisis de contexto" }, - "contextOnClickLabel": { "message": "Contexto al Hacer Clic:" }, + "contextOnClickLabel": { + "message": "Contexto al Hacer Clic:" + }, "contextOnClickHelp": { "message": "Mostrar análisis de contexto al hacer clic en palabras" }, - "contextOnSelectionLabel": { "message": "Contexto en Selección:" }, + "contextOnSelectionLabel": { + "message": "Contexto en Selección:" + }, "contextOnSelectionHelp": { "message": "Mostrar análisis de contexto al seleccionar texto" }, - "cardAIContextPrivacyTitle": { "message": "Privacidad y Datos" }, + "cardAIContextPrivacyTitle": { + "message": "Privacidad y Datos" + }, "cardAIContextPrivacyDesc": { "message": "Controla cómo se manejan tus datos durante el análisis de contexto." }, @@ -548,7 +932,9 @@ "aiContextDataSharingHelp": { "message": "Ayuda a mejorar el servicio compartiendo datos de uso anónimos" }, - "cardAIContextAdvancedTitle": { "message": "Configuración Avanzada" }, + "cardAIContextAdvancedTitle": { + "message": "Configuración Avanzada" + }, "cardAIContextAdvancedDesc": { "message": "Configure opciones avanzadas para el comportamiento del análisis de contexto de IA." }, @@ -564,14 +950,79 @@ "aiContextRateLimitHelp": { "message": "Número máximo de solicitudes por minuto" }, - "aiContextCacheEnabledLabel": { "message": "Habilitar Caché:" }, + "aiContextCacheEnabledLabel": { + "message": "Habilitar Caché:" + }, "aiContextCacheEnabledHelp": { "message": "Almacenar en caché los resultados del análisis para reducir las llamadas a la API" }, - "aiContextRetryAttemptsLabel": { "message": "Intentos de Reintento:" }, + "aiContextRetryAttemptsLabel": { + "message": "Intentos de Reintento:" + }, "aiContextRetryAttemptsHelp": { "message": "Número de veces para reintentar solicitudes fallidas" }, - "showAdvancedSettings": { "message": "Mostrar Configuración Avanzada" }, - "hideAdvancedSettings": { "message": "Ocultar Configuración Avanzada" } -} + "showAdvancedSettings": { + "message": "Mostrar Configuración Avanzada" + }, + "hideAdvancedSettings": { + "message": "Ocultar Configuración Avanzada" + }, + "sidepanelLoading": { + "message": "Cargando..." + }, + "sidepanelTabAIAnalysis": { + "message": "Análisis IA" + }, + "sidepanelTabWordsLists": { + "message": "Listas de Palabras" + }, + "sidepanelAnalyzeButton": { + "message": "Analizar" + }, + "sidepanelAnalyzing": { + "message": "Analizando..." + }, + "sidepanelWordsToAnalyze": { + "message": "Palabras a Analizar" + }, + "sidepanelWordInputPlaceholder": { + "message": "Haz clic en las palabras de los subtítulos para añadirlas al análisis..." + }, + "sidepanelErrorRetry": { + "message": "Reintentar" + }, + "sidepanelResultsTitle": { + "message": "Resultados para \"%s\"" + }, + "sidepanelSectionDefinition": { + "message": "Definición" + }, + "sidepanelSectionCultural": { + "message": "Contexto Cultural" + }, + "sidepanelSectionHistorical": { + "message": "Contexto Histórico" + }, + "sidepanelSectionLinguistic": { + "message": "Análisis Lingüístico" + }, + "sidepanelMyWordsTitle": { + "message": "Mis Palabras" + }, + "sidepanelFeatureComingSoon": { + "message": "¡La función de Listas de Palabras llegará pronto!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "Esta función está actualmente en desarrollo. Habilítala en Configuración para probar la vista previa." + }, + "sidepanelErrorNoWords": { + "message": "No hay palabras seleccionadas para el análisis" + }, + "sidepanelErrorDisabled": { + "message": "El análisis de contexto IA está deshabilitado. Habilítalo en la configuración." + }, + "sidepanelErrorGeneric": { + "message": "Ocurrió un error durante el análisis." + } +} \ No newline at end of file diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index d3c64ed..a0e0efd 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -1,31 +1,79 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "ストリーミングプラットフォームで二言語字幕を表示します。" }, - "pageTitle": { "message": "DualSub 設定" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "二言語字幕を有効にする:" }, - "useNativeSubtitlesLabel": { "message": "公式字幕を使用:" }, - "originalLanguageLabel": { "message": "言語設定:" }, - "translationSettingsLegend": { "message": "翻訳設定" }, - "providerLabel": { "message": "プロバイダー:" }, - "targetLanguageLabel": { "message": "翻訳先:" }, - "batchSizeLabel": { "message": "バッチサイズ:" }, - "requestDelayLabel": { "message": "リクエスト遅延 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "字幕の外観とタイミング" }, - "displayOrderLabel": { "message": "表示順序:" }, - "layoutLabel": { "message": "レイアウト:" }, - "fontSizeLabel": { "message": "フォントサイズ:" }, - "verticalGapLabel": { "message": "垂直間隔:" }, - "subtitleVerticalPositionLabel": { "message": "垂直位置:" }, - "timeOffsetLabel": { "message": "時間オフセット(秒):" }, - "displayOrderOriginalFirst": { "message": "原文を上に" }, - "displayOrderTranslationFirst": { "message": "翻訳を上に" }, - "layoutTopBottom": { "message": "上下配置" }, - "layoutLeftRight": { "message": "左右配置" }, - "uiLanguageLabel": { "message": "言語:" }, - "openOptionsButton": { "message": "詳細設定" }, + "pageTitle": { + "message": "DualSub 設定" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "二言語字幕を有効にする:" + }, + "useNativeSubtitlesLabel": { + "message": "公式字幕を使用:" + }, + "originalLanguageLabel": { + "message": "言語設定:" + }, + "translationSettingsLegend": { + "message": "翻訳設定" + }, + "providerLabel": { + "message": "プロバイダー:" + }, + "targetLanguageLabel": { + "message": "翻訳先:" + }, + "batchSizeLabel": { + "message": "バッチサイズ:" + }, + "requestDelayLabel": { + "message": "リクエスト遅延 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "字幕の外観とタイミング" + }, + "displayOrderLabel": { + "message": "表示順序:" + }, + "layoutLabel": { + "message": "レイアウト:" + }, + "fontSizeLabel": { + "message": "フォントサイズ:" + }, + "verticalGapLabel": { + "message": "垂直間隔:" + }, + "subtitleVerticalPositionLabel": { + "message": "垂直位置:" + }, + "timeOffsetLabel": { + "message": "時間オフセット(秒):" + }, + "displayOrderOriginalFirst": { + "message": "原文を上に" + }, + "displayOrderTranslationFirst": { + "message": "翻訳を上に" + }, + "layoutTopBottom": { + "message": "上下配置" + }, + "layoutLeftRight": { + "message": "左右配置" + }, + "uiLanguageLabel": { + "message": "言語:" + }, + "openOptionsButton": { + "message": "詳細設定" + }, "statusLanguageSetTo": { "message": "言語設定完了(ページを更新してください):" }, @@ -44,83 +92,168 @@ "statusOriginalLanguage": { "message": "言語設定完了(ページを更新してください):" }, - "statusTimeOffset": { "message": "時間オフセット:" }, - "statusDisplayOrderUpdated": { "message": "表示順序が更新されました。" }, + "statusTimeOffset": { + "message": "時間オフセット:" + }, + "statusDisplayOrderUpdated": { + "message": "表示順序が更新されました。" + }, "statusLayoutOrientationUpdated": { "message": "レイアウト方向が更新されました。" }, - "statusFontSize": { "message": "フォントサイズ:" }, - "statusVerticalGap": { "message": "垂直間隔:" }, - "statusVerticalPosition": { "message": "垂直位置:" }, + "statusFontSize": { + "message": "フォントサイズ:" + }, + "statusVerticalGap": { + "message": "垂直間隔:" + }, + "statusVerticalPosition": { + "message": "垂直位置:" + }, "statusInvalidOffset": { "message": "無効なオフセットです。元に戻します。" }, "statusSettingNotApplied": { "message": "設定が適用されませんでした。ページを更新してください。" }, - - "optionsPageTitle": { "message": "DualSub オプション" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "一般" }, - "navTranslation": { "message": "翻訳" }, - "navProviders": { "message": "プロバイダー" }, - "navAbout": { "message": "について" }, - "sectionGeneral": { "message": "一般" }, - "cardUILanguageTitle": { "message": "UI言語" }, + "optionsPageTitle": { + "message": "DualSub オプション" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "一般" + }, + "navTranslation": { + "message": "翻訳" + }, + "navProviders": { + "message": "プロバイダー" + }, + "navAbout": { + "message": "について" + }, + "sectionGeneral": { + "message": "一般" + }, + "cardUILanguageTitle": { + "message": "UI言語" + }, "cardUILanguageDesc": { "message": "拡張機能のインターフェースの表示言語を選択します。" }, - "cardHideOfficialSubtitlesTitle": { "message": "公式字幕を非表示" }, + "cardHideOfficialSubtitlesTitle": { + "message": "公式字幕を非表示" + }, "cardHideOfficialSubtitlesDesc": { "message": "DualSubがアクティブな時、動画プラットフォームの公式字幕を非表示にします。" }, - "hideOfficialSubtitlesLabel": { "message": "公式字幕を非表示:" }, - "sectionTranslation": { "message": "翻訳" }, - "cardTranslationEngineTitle": { "message": "翻訳エンジン" }, + "hideOfficialSubtitlesLabel": { + "message": "公式字幕を非表示:" + }, + "sectionTranslation": { + "message": "翻訳" + }, + "cardTranslationEngineTitle": { + "message": "翻訳エンジン" + }, "cardTranslationEngineDesc": { "message": "お好みの翻訳サービスを選択してください。" }, - "cardPerformanceTitle": { "message": "パフォーマンス" }, + "cardPerformanceTitle": { + "message": "パフォーマンス" + }, "cardPerformanceDesc": { "message": "拡張機能が翻訳リクエストを処理する方法を調整して、速度と安定性のバランスを取ります。" }, - "sectionProviders": { "message": "プロバイダー設定" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "プロバイダー設定" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "DeepL TranslateのAPIキーを入力してください。FreeプランまたはProプランから選択できます。" }, - "apiKeyLabel": { "message": "APIキー:" }, - "apiPlanLabel": { "message": "APIプラン:" }, - "apiPlanFree": { "message": "DeepL API Free" }, - "apiPlanPro": { "message": "DeepL API Pro" }, - "sectionAbout": { "message": "について" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "バージョン" }, + "apiKeyLabel": { + "message": "APIキー:" + }, + "apiPlanLabel": { + "message": "APIプラン:" + }, + "apiPlanFree": { + "message": "DeepL API Free" + }, + "apiPlanPro": { + "message": "DeepL API Pro" + }, + "sectionAbout": { + "message": "について" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "バージョン" + }, "aboutDescription": { "message": "この拡張機能は、様々なプラットフォームで二言語字幕付きの動画を視聴するのに役立ちます。" }, "aboutDevelopment": { "message": "QuellaMC & 1jifang によって開発されました。" }, - - "lang_en": { "message": "英語" }, - "lang_es": { "message": "スペイン語" }, - "lang_fr": { "message": "フランス語" }, - "lang_de": { "message": "ドイツ語" }, - "lang_it": { "message": "イタリア語" }, - "lang_pt": { "message": "ポルトガル語" }, - "lang_ja": { "message": "日本語" }, - "lang_ko": { "message": "韓国語" }, - "lang_zh_CN": { "message": "中国語(簡体)" }, - "lang_zh_TW": { "message": "中国語(繁体)" }, - "lang_ru": { "message": "ロシア語" }, - "lang_ar": { "message": "アラビア語" }, - "lang_hi": { "message": "ヒンディー語" }, - - "testDeepLButton": { "message": "DeepL接続をテスト" }, - "deeplApiKeyError": { "message": "まずDeepL APIキーを入力してください。" }, - "testingButton": { "message": "テスト中..." }, - "testingConnection": { "message": "DeepL接続をテストしています..." }, + "lang_en": { + "message": "英語" + }, + "lang_es": { + "message": "スペイン語" + }, + "lang_fr": { + "message": "フランス語" + }, + "lang_de": { + "message": "ドイツ語" + }, + "lang_it": { + "message": "イタリア語" + }, + "lang_pt": { + "message": "ポルトガル語" + }, + "lang_ja": { + "message": "日本語" + }, + "lang_ko": { + "message": "韓国語" + }, + "lang_zh_CN": { + "message": "中国語(簡体)" + }, + "lang_zh_TW": { + "message": "中国語(繁体)" + }, + "lang_ru": { + "message": "ロシア語" + }, + "lang_ar": { + "message": "アラビア語" + }, + "lang_hi": { + "message": "ヒンディー語" + }, + "testDeepLButton": { + "message": "DeepL接続をテスト" + }, + "deeplApiKeyError": { + "message": "まずDeepL APIキーを入力してください。" + }, + "testingButton": { + "message": "テスト中..." + }, + "testingConnection": { + "message": "DeepL接続をテストしています..." + }, "deeplTestSuccess": { "message": "✅ DeepL APIテストが成功しました!\"Hello\"を\"%s\"に翻訳しました" }, @@ -136,90 +269,177 @@ "deeplTestQuotaExceeded": { "message": "❌ DeepL APIの使用量制限に達しました。使用制限を確認してください。" }, - "deeplTestApiError": { "message": "❌ DeepL APIエラー (%d): %s" }, + "deeplTestApiError": { + "message": "❌ DeepL APIエラー (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ ネットワークエラー:DeepL APIに接続できませんでした。インターネット接続を確認してください。" }, - "deeplTestGenericError": { "message": "❌ テストに失敗しました:%s" }, - "deepLApiUnavailable": { "message": "DeepL API利用不可" }, + "deeplTestGenericError": { + "message": "❌ テストに失敗しました:%s" + }, + "deepLApiUnavailable": { + "message": "DeepL API利用不可" + }, "deepLApiUnavailableTooltip": { "message": "DeepL APIスクリプトの読み込みに失敗しました" }, "deeplApiNotLoadedError": { "message": "❌ DeepL APIスクリプトが利用できません。ページを更新してください。" }, - - "cardGoogleTitle": { "message": "Google翻訳" }, + "cardGoogleTitle": { + "message": "Google翻訳" + }, "cardGoogleDesc": { "message": "Googleが提供する無料の翻訳サービスです。追加の設定は必要ありません。" }, - "cardMicrosoftTitle": { "message": "Microsoft翻訳" }, + "cardMicrosoftTitle": { + "message": "Microsoft翻訳" + }, "cardMicrosoftDesc": { "message": "Microsoft Edgeが提供する無料の翻訳サービスです。追加の設定は必要ありません。" }, - "cardDeepLFreeTitle": { "message": "DeepL翻訳(無料)" }, + "cardDeepLFreeTitle": { + "message": "DeepL翻訳(無料)" + }, "cardDeepLFreeDesc": { "message": "高品質な結果を提供する無料のDeepL翻訳サービス。APIキー不要 - DeepLのWebインターフェースを使用します。" }, - "providerStatus": { "message": "ステータス:" }, - "statusReady": { "message": "使用可能" }, - "providerFeatures": { "message": "機能:" }, - "featureFree": { "message": "無料で使用可能" }, - "featureNoApiKey": { "message": "APIキー不要" }, - "featureWideLanguageSupport": { "message": "幅広い言語サポート" }, - "featureFastTranslation": { "message": "高速翻訳" }, - "featureHighQuality": { "message": "高品質翻訳" }, - "featureGoodPerformance": { "message": "良好なパフォーマンス" }, - "featureHighestQuality": { "message": "最高品質翻訳" }, - "featureApiKeyRequired": { "message": "APIキーが必要" }, - "featureLimitedLanguages": { "message": "限定的な言語サポート" }, - "featureUsageLimits": { "message": "使用制限が適用されます" }, - "featureMultipleBackups": { "message": "複数のバックアップ方法" }, - - "providerNotes": { "message": "注意事項:" }, - "noteSlowForSecurity": { "message": "セキュリティ対策のため若干低速" }, - "noteAutoFallback": { "message": "代替サービスへの自動フォールバック" }, - "noteRecommendedDefault": { "message": "デフォルトプロバイダーとして推奨" }, - - "providerGoogleName": { "message": "Google翻訳(無料)" }, - "providerMicrosoftName": { "message": "Microsoft翻訳(無料)" }, - "providerDeepLName": { "message": "DeepL(APIキー必須)" }, - "providerDeepLFreeName": { "message": "DeepL翻訳(無料)" }, - "providerOpenAICompatibleName": { "message": "OpenAI互換(APIキー必須)" }, + "providerStatus": { + "message": "ステータス:" + }, + "statusReady": { + "message": "使用可能" + }, + "providerFeatures": { + "message": "機能:" + }, + "featureFree": { + "message": "無料で使用可能" + }, + "featureNoApiKey": { + "message": "APIキー不要" + }, + "featureWideLanguageSupport": { + "message": "幅広い言語サポート" + }, + "featureFastTranslation": { + "message": "高速翻訳" + }, + "featureHighQuality": { + "message": "高品質翻訳" + }, + "featureGoodPerformance": { + "message": "良好なパフォーマンス" + }, + "featureHighestQuality": { + "message": "最高品質翻訳" + }, + "featureApiKeyRequired": { + "message": "APIキーが必要" + }, + "featureLimitedLanguages": { + "message": "限定的な言語サポート" + }, + "featureUsageLimits": { + "message": "使用制限が適用されます" + }, + "featureMultipleBackups": { + "message": "複数のバックアップ方法" + }, + "providerNotes": { + "message": "注意事項:" + }, + "noteSlowForSecurity": { + "message": "セキュリティ対策のため若干低速" + }, + "noteAutoFallback": { + "message": "代替サービスへの自動フォールバック" + }, + "noteRecommendedDefault": { + "message": "デフォルトプロバイダーとして推奨" + }, + "providerGoogleName": { + "message": "Google翻訳(無料)" + }, + "providerMicrosoftName": { + "message": "Microsoft翻訳(無料)" + }, + "providerDeepLName": { + "message": "DeepL(APIキー必須)" + }, + "providerDeepLFreeName": { + "message": "DeepL翻訳(無料)" + }, + "providerOpenAICompatibleName": { + "message": "OpenAI互換(APIキー必須)" + }, "providerVertexGeminiName": { "message": "Vertex AI Gemini(APIキー必須)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI互換(APIキー必須)" }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI互換(APIキー必須)" + }, "cardOpenAICompatibleDesc": { "message": "GeminiなどのOpenAI互換サービス用のAPIキーと設定を入力してください。" }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(APIキー必須)" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini(APIキー必須)" + }, "cardVertexGeminiDesc": { "message": "アクセストークンとVertex プロジェクト設定を入力するか、サービスアカウントJSONファイルをインポートしてください。" }, - "vertexAccessTokenLabel": { "message": "アクセストークン:" }, - "vertexProjectIdLabel": { "message": "プロジェクトID:" }, - "vertexLocationLabel": { "message": "ロケーション:" }, - "vertexModelLabel": { "message": "モデル:" }, + "vertexAccessTokenLabel": { + "message": "アクセストークン:" + }, + "vertexProjectIdLabel": { + "message": "プロジェクトID:" + }, + "vertexLocationLabel": { + "message": "ロケーション:" + }, + "vertexModelLabel": { + "message": "モデル:" + }, "vertexMissingConfig": { "message": "アクセストークンとプロジェクトIDを入力してください。" }, - "vertexConnectionFailed": { "message": "接続に失敗しました:%s" }, - "vertexServiceAccountLabel": { "message": "サービスアカウントJSON:" }, - "vertexImportButton": { "message": "JSONファイルをインポート" }, - "vertexRefreshButton": { "message": "🔄 トークンを更新" }, - "vertexImportHint": { "message": "以下の認証情報を自動入力" }, - "vertexImporting": { "message": "インポート中..." }, - "vertexRefreshingToken": { "message": "アクセストークンを更新中..." }, - "vertexGeneratingToken": { "message": "アクセストークンを生成中..." }, + "vertexConnectionFailed": { + "message": "接続に失敗しました:%s" + }, + "vertexServiceAccountLabel": { + "message": "サービスアカウントJSON:" + }, + "vertexImportButton": { + "message": "JSONファイルをインポート" + }, + "vertexRefreshButton": { + "message": "🔄 トークンを更新" + }, + "vertexImportHint": { + "message": "以下の認証情報を自動入力" + }, + "vertexImporting": { + "message": "インポート中..." + }, + "vertexRefreshingToken": { + "message": "アクセストークンを更新中..." + }, + "vertexGeneratingToken": { + "message": "アクセストークンを生成中..." + }, "vertexImportSuccess": { "message": "サービスアカウントがインポートされ、トークンが生成されました。" }, - "vertexImportFailed": { "message": "インポートに失敗しました:%s" }, + "vertexImportFailed": { + "message": "インポートに失敗しました:%s" + }, "vertexTokenRefreshed": { "message": "アクセストークンが正常に更新されました。" }, - "vertexRefreshFailed": { "message": "トークンの更新に失敗しました:%s" }, + "vertexRefreshFailed": { + "message": "トークンの更新に失敗しました:%s" + }, "vertexTokenExpired": { "message": "⚠️ アクセストークンが期限切れです。更新をクリックして更新してください。" }, @@ -235,235 +455,429 @@ "featureVertexServiceAccount": { "message": "サービスアカウントJSONのインポート" }, - "featureVertexAutoToken": { "message": "自動トークン生成" }, - "featureVertexGemini": { "message": "Vertex AI経由のGoogle Geminiモデル" }, + "featureVertexAutoToken": { + "message": "自動トークン生成" + }, + "featureVertexGemini": { + "message": "Vertex AI経由のGoogle Geminiモデル" + }, "vertexNote": { "message": "アクセストークンは1時間後に期限切れになります。サービスアカウントは安全に保存されており、簡単にトークンを更新できます - 必要に応じてトークン更新ボタンをクリックしてください。" }, - "baseUrlLabel": { "message": "ベースURL:" }, - "modelLabel": { "message": "モデル:" }, + "baseUrlLabel": { + "message": "ベースURL:" + }, + "modelLabel": { + "message": "モデル:" + }, "featureCustomizable": { "message": "カスタマイズ可能なエンドポイントとモデル" }, - "fetchModelsButton": { "message": "モデルを取得" }, - "testConnectionButton": { "message": "接続をテスト" }, - + "fetchModelsButton": { + "message": "モデルを取得" + }, + "testConnectionButton": { + "message": "接続をテスト" + }, "openaiApiKeyPlaceholder": { "message": "OpenAI互換APIキーを入力してください" }, - "openaiBaseUrlPlaceholder": { "message": "例:https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "まずAPIキーを入力してください。" }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ APIキーのテストが必要です。" }, + "openaiBaseUrlPlaceholder": { + "message": "例:https://api.openai.com/v1" + }, + "openaiApiKeyError": { + "message": "まずAPIキーを入力してください。" + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ APIキーのテストが必要です。" + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI互換APIキーのテストが必要です。" }, - "openaiTestingConnection": { "message": "接続をテスト中..." }, - "openaiConnectionSuccessful": { "message": "接続成功!" }, - "openaiConnectionFailed": { "message": "接続失敗:%s" }, - "openaieFetchingModels": { "message": "モデルを取得中..." }, + "openaiTestingConnection": { + "message": "接続をテスト中..." + }, + "openaiConnectionSuccessful": { + "message": "接続成功!" + }, + "openaiConnectionFailed": { + "message": "接続失敗:%s" + }, + "openaieFetchingModels": { + "message": "モデルを取得中..." + }, "openaiModelsFetchedSuccessfully": { "message": "モデルの取得に成功しました。" }, "openaiFailedToFetchModels": { "message": "モデルの取得に失敗しました:%s" }, - - "cardLoggingLevelTitle": { "message": "ログレベル" }, + "cardLoggingLevelTitle": { + "message": "ログレベル" + }, "cardLoggingLevelDesc": { "message": "ブラウザコンソールに表示されるデバッグ情報の量を制御します。高いレベルには低いレベルのメッセージも含まれます。" }, - "loggingLevelLabel": { "message": "ログレベル:" }, - "loggingLevelOff": { "message": "オフ" }, - "loggingLevelError": { "message": "エラーのみ" }, - "loggingLevelWarn": { "message": "警告とエラー" }, - "loggingLevelInfo": { "message": "情報以上" }, - "loggingLevelDebug": { "message": "デバッグ(すべて)" }, - - "cardBatchTranslationTitle": { "message": "バッチ翻訳" }, + "loggingLevelLabel": { + "message": "ログレベル:" + }, + "loggingLevelOff": { + "message": "オフ" + }, + "loggingLevelError": { + "message": "エラーのみ" + }, + "loggingLevelWarn": { + "message": "警告とエラー" + }, + "loggingLevelInfo": { + "message": "情報以上" + }, + "loggingLevelDebug": { + "message": "デバッグ(すべて)" + }, + "cardBatchTranslationTitle": { + "message": "バッチ翻訳" + }, "cardBatchTranslationDesc": { "message": "バッチ翻訳は複数の字幕セグメントを一緒に処理し、API呼び出しを80-90%削減してパフォーマンスを向上させます。お好みの翻訳プロバイダーに最適な設定を構成してください。" }, - "batchingEnabledLabel": { "message": "バッチ翻訳を有効にする:" }, + "batchingEnabledLabel": { + "message": "バッチ翻訳を有効にする:" + }, "batchingEnabledHelp": { "message": "複数の字幕セグメントを単一の翻訳リクエストにグループ化します" }, - "useProviderDefaultsLabel": { "message": "プロバイダー最適化設定を使用:" }, + "useProviderDefaultsLabel": { + "message": "プロバイダー最適化設定を使用:" + }, "useProviderDefaultsHelp": { "message": "各翻訳プロバイダーに最適なバッチサイズを自動的に使用します" }, - "globalBatchSizeLabel": { "message": "グローバルバッチサイズ:" }, + "globalBatchSizeLabel": { + "message": "グローバルバッチサイズ:" + }, "globalBatchSizeHelp": { "message": "一緒に処理する字幕セグメントの数(1-15)" }, - "smartBatchingLabel": { "message": "スマートバッチ最適化:" }, + "smartBatchingLabel": { + "message": "スマートバッチ最適化:" + }, "smartBatchingHelp": { "message": "再生位置に基づいて字幕セグメントを優先処理します" }, - "maxConcurrentBatchesLabel": { "message": "最大同時バッチ数:" }, + "maxConcurrentBatchesLabel": { + "message": "最大同時バッチ数:" + }, "maxConcurrentBatchesHelp": { "message": "同時に処理する翻訳バッチの数" }, - - "cardProviderBatchTitle": { "message": "プロバイダー固有のバッチサイズ" }, + "cardProviderBatchTitle": { + "message": "プロバイダー固有のバッチサイズ" + }, "cardProviderBatchDesc": { "message": "各翻訳プロバイダーに最適なバッチサイズを設定します。これらの設定は「プロバイダー最適化設定を使用」が有効な場合に使用されます。" }, - "openaieBatchSizeLabel": { "message": "OpenAIバッチサイズ:" }, + "openaieBatchSizeLabel": { + "message": "OpenAIバッチサイズ:" + }, "openaieBatchSizeHelp": { "message": "推奨:5-10セグメント(デフォルト:8)" }, - "googleBatchSizeLabel": { "message": "Google翻訳バッチサイズ:" }, + "googleBatchSizeLabel": { + "message": "Google翻訳バッチサイズ:" + }, "googleBatchSizeHelp": { "message": "推奨:3-5セグメント(デフォルト:4)" }, - "deeplBatchSizeLabel": { "message": "DeepLバッチサイズ:" }, + "deeplBatchSizeLabel": { + "message": "DeepLバッチサイズ:" + }, "deeplBatchSizeHelp": { "message": "推奨:2-3セグメント(デフォルト:3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft翻訳バッチサイズ:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft翻訳バッチサイズ:" + }, "microsoftBatchSizeHelp": { "message": "推奨:3-5セグメント(デフォルト:4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AIバッチサイズ:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AIバッチサイズ:" + }, "vertexBatchSizeHelp": { "message": "推奨:5-10セグメント(デフォルト:8)" }, - "deeplTestNeedsTesting": { "message": "⚠️ DeepL APIキーのテストが必要です。" }, - - "cardProviderDelayTitle": { "message": "プロバイダー固有のリクエスト遅延" }, + "cardProviderDelayTitle": { + "message": "プロバイダー固有のリクエスト遅延" + }, "cardProviderDelayDesc": { "message": "アカウントロックアウトを防ぐため、翻訳リクエスト間の必須遅延を設定します。これらの遅延はバッチ処理が有効な場合でも適用されます。" }, - "openaieDelayLabel": { "message": "OpenAIリクエスト遅延 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAIリクエスト遅延 (ms):" + }, "openaieDelayHelp": { "message": "リクエスト間の最小遅延(デフォルト:100ms)" }, - "googleDelayLabel": { "message": "Google翻訳リクエスト遅延 (ms):" }, + "googleDelayLabel": { + "message": "Google翻訳リクエスト遅延 (ms):" + }, "googleDelayHelp": { "message": "一時的なロックアウトを防ぐために必要な遅延(デフォルト:1500ms)" }, - "deeplDelayLabel": { "message": "DeepL APIリクエスト遅延 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL APIリクエスト遅延 (ms):" + }, "deeplDelayHelp": { "message": "DeepL APIリクエストの遅延(デフォルト:500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL無料リクエスト遅延 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL無料リクエスト遅延 (ms):" + }, "deeplFreeDelayHelp": { "message": "無料プランの保守的な遅延(デフォルト:2000ms)" }, - "microsoftDelayLabel": { "message": "Microsoft翻訳リクエスト遅延 (ms):" }, + "microsoftDelayLabel": { + "message": "Microsoft翻訳リクエスト遅延 (ms):" + }, "microsoftDelayHelp": { "message": "文字制限を尊重するための遅延(デフォルト:800ms)" }, - "vertexDelayLabel": { "message": "Vertex AIリクエスト遅延 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AIリクエスト遅延 (ms):" + }, "vertexDelayHelp": { "message": "リクエスト間の最小遅延(デフォルト:100ms)" }, - - "aiContextModalTitle": { "message": "AIコンテキスト分析" }, - "aiContextSelectedWords": { "message": "選択された単語" }, - "aiContextNoWordsSelected": { "message": "単語が選択されていません" }, + "aiContextModalTitle": { + "message": "AIコンテキスト分析" + }, + "aiContextSelectedWords": { + "message": "選択された単語" + }, + "aiContextNoWordsSelected": { + "message": "単語が選択されていません" + }, "aiContextClickHint": { "message": "💡 単語をクリックして追加または削除します。" }, - "aiContextStartAnalysis": { "message": "分析開始" }, - "aiContextPauseAnalysis": { "message": "⏸ 一時停止" }, - "aiContextPauseAnalysisTitle": { "message": "分析を一時停止" }, + "aiContextStartAnalysis": { + "message": "分析開始" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 一時停止" + }, + "aiContextPauseAnalysisTitle": { + "message": "分析を一時停止" + }, "aiContextInitialMessage": { "message": "字幕から単語を選択して分析を開始してください。" }, - "aiContextAnalyzing": { "message": "コンテキストを分析中..." }, - "aiContextPauseNote": { "message": "⏸ をクリックして分析を一時停止" }, - "aiContextAnalysisFailed": { "message": "分析失敗" }, - "aiContextNoContent": { "message": "分析コンテンツなし" }, + "aiContextAnalyzing": { + "message": "コンテキストを分析中..." + }, + "aiContextPauseNote": { + "message": "⏸ をクリックして分析を一時停止" + }, + "aiContextAnalysisFailed": { + "message": "分析失敗" + }, + "aiContextNoContent": { + "message": "分析コンテンツなし" + }, "aiContextNoContentMessage": { "message": "分析は完了しましたが、コンテンツが返されませんでした。" }, - "aiContextDefinition": { "message": "📖 定義" }, - "aiContextCultural": { "message": "🌍 文化的コンテキスト" }, - "aiContextCulturalSignificance": { "message": "⭐ 文化的意義" }, - "aiContextHistorical": { "message": "📜 歴史的コンテキスト" }, - "aiContextHistoricalSignificance": { "message": "📜 歴史的意義" }, - "aiContextEvolution": { "message": "🔄 時代による変遷" }, - "aiContextLinguistic": { "message": "🔤 言語学的分析" }, - "aiContextGrammar": { "message": "📝 文法と意味論" }, - "aiContextUsage": { "message": "💡 使用法と例" }, - "aiContextExamples": { "message": "例:" }, - "aiContextLearningTips": { "message": "🎯 学習のヒント" }, - "aiContextRelatedExpressions": { "message": "🔗 関連表現" }, - "aiContextKeyInsights": { "message": "🔑 重要な洞察" }, - "aiContextTypeCultural": { "message": "文化的" }, - "aiContextTypeHistorical": { "message": "歴史的" }, - "aiContextTypeLinguistic": { "message": "言語学的" }, - "aiContextTypeComprehensive": { "message": "包括的" }, - "aiContextTypeGeneric": { "message": "コンテキスト" }, - "aiContextClose": { "message": "閉じる" }, - "aiContextAnalysisResults": { "message": "分析結果" }, - "aiContextRetrying": { "message": "分析に失敗しました、再生成中..." }, + "aiContextDefinition": { + "message": "📖 定義" + }, + "aiContextCultural": { + "message": "🌍 文化的コンテキスト" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 文化的意義" + }, + "aiContextHistorical": { + "message": "📜 歴史的コンテキスト" + }, + "aiContextHistoricalSignificance": { + "message": "📜 歴史的意義" + }, + "aiContextEvolution": { + "message": "🔄 時代による変遷" + }, + "aiContextLinguistic": { + "message": "🔤 言語学的分析" + }, + "aiContextGrammar": { + "message": "📝 文法と意味論" + }, + "aiContextUsage": { + "message": "💡 使用法と例" + }, + "aiContextExamples": { + "message": "例:" + }, + "aiContextLearningTips": { + "message": "🎯 学習のヒント" + }, + "aiContextRelatedExpressions": { + "message": "🔗 関連表現" + }, + "aiContextKeyInsights": { + "message": "🔑 重要な洞察" + }, + "aiContextTypeCultural": { + "message": "文化的" + }, + "aiContextTypeHistorical": { + "message": "歴史的" + }, + "aiContextTypeLinguistic": { + "message": "言語学的" + }, + "aiContextTypeComprehensive": { + "message": "包括的" + }, + "aiContextTypeGeneric": { + "message": "コンテキスト" + }, + "aiContextClose": { + "message": "閉じる" + }, + "aiContextAnalysisResults": { + "message": "分析結果" + }, + "aiContextRetrying": { + "message": "分析に失敗しました、再生成中..." + }, "aiContextRetryNotification": { "message": "分析に失敗しました、再試行中..." }, - "aiContextRetryButton": { "message": "再試行" }, + "aiContextRetryButton": { + "message": "再試行" + }, "aiContextMalformedResponse": { "message": "AIサービスが無効な応答形式を返しました。これは一時的なサービスの問題が原因である可能性があります。" }, "aiContextJsonCodeBlock": { "message": "AIサービスが構造化データではなく未処理のJSONコードを返しました。これは応答の形式エラーを示しています。" }, - "aiContextCulturalContext": { "message": "文化的背景:" }, - "aiContextSocialUsage": { "message": "社会的用法:" }, - "aiContextRegionalNotes": { "message": "地域的特徴:" }, - "aiContextOrigins": { "message": "語源:" }, - "aiContextHistoricalContext": { "message": "歴史的背景:" }, - "aiContextEtymology": { "message": "語源学:" }, - "aiContextGrammarNotes": { "message": "文法注記:" }, - "aiContextTranslationNotes": { "message": "翻訳注記:" }, - "aiContextLinguisticAnalysis": { "message": "言語学的分析:" }, - "aiContextGrammarSemantics": { "message": "文法と意味論:" }, - "aiContextUsageExamples": { "message": "用法と例:" }, - - "navAIContext": { "message": "AIコンテキスト" }, - "sectionAIContext": { "message": "AIコンテキストアシスタント" }, - "cardAIContextToggleTitle": { "message": "AIコンテキスト分析を有効にする" }, + "aiContextCulturalContext": { + "message": "文化的背景:" + }, + "aiContextSocialUsage": { + "message": "社会的用法:" + }, + "aiContextRegionalNotes": { + "message": "地域的特徴:" + }, + "aiContextOrigins": { + "message": "語源:" + }, + "aiContextHistoricalContext": { + "message": "歴史的背景:" + }, + "aiContextEtymology": { + "message": "語源学:" + }, + "aiContextGrammarNotes": { + "message": "文法注記:" + }, + "aiContextTranslationNotes": { + "message": "翻訳注記:" + }, + "aiContextLinguisticAnalysis": { + "message": "言語学的分析:" + }, + "aiContextGrammarSemantics": { + "message": "文法と意味論:" + }, + "aiContextUsageExamples": { + "message": "用法と例:" + }, + "navAIContext": { + "message": "AIコンテキスト" + }, + "sectionAIContext": { + "message": "AIコンテキストアシスタント" + }, + "cardAIContextToggleTitle": { + "message": "AIコンテキスト分析を有効にする" + }, "cardAIContextToggleDesc": { "message": "字幕テキストのAI駆動による文化的、歴史的、言語学的コンテキスト分析を有効にします。字幕の単語やフレーズをクリックして詳細な説明を取得できます。" }, - "aiContextEnabledLabel": { "message": "AIコンテキストを有効にする:" }, - "cardAIContextProviderTitle": { "message": "AIプロバイダー" }, + "aiContextEnabledLabel": { + "message": "AIコンテキストを有効にする:" + }, + "cardAIContextProviderTitle": { + "message": "AIプロバイダー" + }, "cardAIContextProviderDesc": { "message": "コンテキスト分析用のAIサービスプロバイダーを選択します。プロバイダーによって品質や応答時間が異なる場合があります。" }, - "aiContextProviderLabel": { "message": "プロバイダー:" }, - "cardOpenAIContextTitle": { "message": "OpenAI設定" }, + "aiContextProviderLabel": { + "message": "プロバイダー:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI設定" + }, "cardOpenAIContextDesc": { "message": "コンテキスト分析用のOpenAI API設定を構成します。有効なOpenAI APIキーが必要です。" }, - "openaiApiKeyLabel": { "message": "APIキー:" }, - "openaiBaseUrlLabel": { "message": "ベースURL:" }, - "openaiModelLabel": { "message": "モデル:" }, - "cardGeminiContextTitle": { "message": "Google Gemini設定" }, + "openaiApiKeyLabel": { + "message": "APIキー:" + }, + "openaiBaseUrlLabel": { + "message": "ベースURL:" + }, + "openaiModelLabel": { + "message": "モデル:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini設定" + }, "cardGeminiContextDesc": { "message": "コンテキスト分析用のGoogle Gemini API設定を構成します。有効なGemini APIキーが必要です。" }, - "geminiApiKeyLabel": { "message": "APIキー:" }, - "geminiModelLabel": { "message": "モデル:" }, - "cardAIContextTypesTitle": { "message": "コンテキストタイプ" }, + "geminiApiKeyLabel": { + "message": "APIキー:" + }, + "geminiModelLabel": { + "message": "モデル:" + }, + "cardAIContextTypesTitle": { + "message": "コンテキストタイプ" + }, "cardAIContextTypesDesc": { "message": "使用したいコンテキスト分析のタイプを有効にします。複数のタイプを有効にできます。" }, - "contextTypeCulturalLabel": { "message": "文化的コンテキスト:" }, + "contextTypeCulturalLabel": { + "message": "文化的コンテキスト:" + }, "contextTypeCulturalHelp": { "message": "文化的参照、慣用句、社会的コンテキストを分析" }, - "contextTypeHistoricalLabel": { "message": "歴史的コンテキスト:" }, + "contextTypeHistoricalLabel": { + "message": "歴史的コンテキスト:" + }, "contextTypeHistoricalHelp": { "message": "歴史的背景と時代のコンテキストを提供" }, - "contextTypeLinguisticLabel": { "message": "言語学的分析:" }, - "contextTypeLinguisticHelp": { "message": "文法、語源、言語構造を説明" }, - "cardAIContextInteractiveTitle": { "message": "インタラクティブ機能" }, + "contextTypeLinguisticLabel": { + "message": "言語学的分析:" + }, + "contextTypeLinguisticHelp": { + "message": "文法、語源、言語構造を説明" + }, + "cardAIContextInteractiveTitle": { + "message": "インタラクティブ機能" + }, "cardAIContextInteractiveDesc": { "message": "コンテキスト分析をトリガーするために字幕とどのように相互作用するかを設定します。" }, @@ -473,15 +887,21 @@ "interactiveSubtitlesEnabledHelp": { "message": "コンテキスト分析のために字幕の単語をクリック可能にする" }, - "contextOnClickLabel": { "message": "クリック時のコンテキスト:" }, + "contextOnClickLabel": { + "message": "クリック時のコンテキスト:" + }, "contextOnClickHelp": { "message": "単語をクリックしたときにコンテキスト分析を表示" }, - "contextOnSelectionLabel": { "message": "選択時のコンテキスト:" }, + "contextOnSelectionLabel": { + "message": "選択時のコンテキスト:" + }, "contextOnSelectionHelp": { "message": "テキストを選択したときにコンテキスト分析を表示" }, - "cardAIContextPrivacyTitle": { "message": "プライバシーとデータ" }, + "cardAIContextPrivacyTitle": { + "message": "プライバシーとデータ" + }, "cardAIContextPrivacyDesc": { "message": "コンテキスト分析中のデータの処理方法を制御します。" }, @@ -491,26 +911,103 @@ "aiContextUserConsentHelp": { "message": "AIコンテキスト分析が機能するために必要" }, - "aiContextDataSharingLabel": { "message": "匿名使用分析を許可:" }, + "aiContextDataSharingLabel": { + "message": "匿名使用分析を許可:" + }, "aiContextDataSharingHelp": { "message": "匿名使用データを共有してサービスの改善に協力" }, - "cardAIContextAdvancedTitle": { "message": "詳細設定" }, + "cardAIContextAdvancedTitle": { + "message": "詳細設定" + }, "cardAIContextAdvancedDesc": { "message": "AIコンテキスト分析の動作に関する詳細オプションを設定します。" }, - "aiContextTimeoutLabel": { "message": "リクエストタイムアウト(ms):" }, - "aiContextTimeoutHelp": { "message": "AI応答を待つ最大時間" }, - "aiContextRateLimitLabel": { "message": "レート制限(リクエスト/分):" }, - "aiContextRateLimitHelp": { "message": "1分あたりの最大リクエスト数" }, - "aiContextCacheEnabledLabel": { "message": "キャッシュを有効にする:" }, + "aiContextTimeoutLabel": { + "message": "リクエストタイムアウト(ms):" + }, + "aiContextTimeoutHelp": { + "message": "AI応答を待つ最大時間" + }, + "aiContextRateLimitLabel": { + "message": "レート制限(リクエスト/分):" + }, + "aiContextRateLimitHelp": { + "message": "1分あたりの最大リクエスト数" + }, + "aiContextCacheEnabledLabel": { + "message": "キャッシュを有効にする:" + }, "aiContextCacheEnabledHelp": { "message": "API呼び出しを減らすために分析結果をキャッシュ" }, - "aiContextRetryAttemptsLabel": { "message": "再試行回数:" }, + "aiContextRetryAttemptsLabel": { + "message": "再試行回数:" + }, "aiContextRetryAttemptsHelp": { "message": "失敗したリクエストを再試行する回数" }, - "showAdvancedSettings": { "message": "詳細設定を表示" }, - "hideAdvancedSettings": { "message": "詳細設定を非表示" } -} + "showAdvancedSettings": { + "message": "詳細設定を表示" + }, + "hideAdvancedSettings": { + "message": "詳細設定を非表示" + }, + "sidepanelLoading": { + "message": "読み込み中..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI分析" + }, + "sidepanelTabWordsLists": { + "message": "単語リスト" + }, + "sidepanelAnalyzeButton": { + "message": "分析" + }, + "sidepanelAnalyzing": { + "message": "分析中..." + }, + "sidepanelWordsToAnalyze": { + "message": "分析する単語" + }, + "sidepanelWordInputPlaceholder": { + "message": "字幕の単語をクリックして分析に追加..." + }, + "sidepanelErrorRetry": { + "message": "再試行" + }, + "sidepanelResultsTitle": { + "message": "\"%s\" の結果" + }, + "sidepanelSectionDefinition": { + "message": "定義" + }, + "sidepanelSectionCultural": { + "message": "文化的コンテキスト" + }, + "sidepanelSectionHistorical": { + "message": "歴史的コンテキスト" + }, + "sidepanelSectionLinguistic": { + "message": "言語学的分析" + }, + "sidepanelMyWordsTitle": { + "message": "マイ単語" + }, + "sidepanelFeatureComingSoon": { + "message": "単語リスト機能は近日公開予定!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "この機能は現在開発中です。設定で有効にしてプレビューをお試しください。" + }, + "sidepanelErrorNoWords": { + "message": "分析する単語が選択されていません" + }, + "sidepanelErrorDisabled": { + "message": "AIコンテキスト分析は無効になっています。設定で有効にしてください。" + }, + "sidepanelErrorGeneric": { + "message": "分析中にエラーが発生しました。" + } +} \ No newline at end of file diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index 8102b13..2e9d217 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -1,31 +1,79 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "스트리밍 플랫폼에서 이중 언어 자막을 표시합니다." }, - "pageTitle": { "message": "DualSub 설정" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "이중 자막 활성화:" }, - "useNativeSubtitlesLabel": { "message": "공식 자막 사용:" }, - "originalLanguageLabel": { "message": "언어 설정:" }, - "translationSettingsLegend": { "message": "번역 설정" }, - "providerLabel": { "message": "제공업체:" }, - "targetLanguageLabel": { "message": "번역 대상:" }, - "batchSizeLabel": { "message": "배치 크기:" }, - "requestDelayLabel": { "message": "요청 지연 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "자막 외관 및 타이밍" }, - "displayOrderLabel": { "message": "표시 순서:" }, - "layoutLabel": { "message": "레이아웃:" }, - "fontSizeLabel": { "message": "글꼴 크기:" }, - "verticalGapLabel": { "message": "세로 간격:" }, - "subtitleVerticalPositionLabel": { "message": "세로 위치:" }, - "timeOffsetLabel": { "message": "시간 오프셋(초):" }, - "displayOrderOriginalFirst": { "message": "원문 먼저" }, - "displayOrderTranslationFirst": { "message": "번역 먼저" }, - "layoutTopBottom": { "message": "위아래 배치" }, - "layoutLeftRight": { "message": "좌우 배치" }, - "uiLanguageLabel": { "message": "언어:" }, - "openOptionsButton": { "message": "고급 설정" }, + "pageTitle": { + "message": "DualSub 설정" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "이중 자막 활성화:" + }, + "useNativeSubtitlesLabel": { + "message": "공식 자막 사용:" + }, + "originalLanguageLabel": { + "message": "언어 설정:" + }, + "translationSettingsLegend": { + "message": "번역 설정" + }, + "providerLabel": { + "message": "제공업체:" + }, + "targetLanguageLabel": { + "message": "번역 대상:" + }, + "batchSizeLabel": { + "message": "배치 크기:" + }, + "requestDelayLabel": { + "message": "요청 지연 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "자막 외관 및 타이밍" + }, + "displayOrderLabel": { + "message": "표시 순서:" + }, + "layoutLabel": { + "message": "레이아웃:" + }, + "fontSizeLabel": { + "message": "글꼴 크기:" + }, + "verticalGapLabel": { + "message": "세로 간격:" + }, + "subtitleVerticalPositionLabel": { + "message": "세로 위치:" + }, + "timeOffsetLabel": { + "message": "시간 오프셋(초):" + }, + "displayOrderOriginalFirst": { + "message": "원문 먼저" + }, + "displayOrderTranslationFirst": { + "message": "번역 먼저" + }, + "layoutTopBottom": { + "message": "위아래 배치" + }, + "layoutLeftRight": { + "message": "좌우 배치" + }, + "uiLanguageLabel": { + "message": "언어:" + }, + "openOptionsButton": { + "message": "고급 설정" + }, "statusLanguageSetTo": { "message": "언어 설정 완료 (페이지를 새로고침하세요): " }, @@ -44,81 +92,168 @@ "statusOriginalLanguage": { "message": "언어 설정 완료 (페이지를 새로고침하세요): " }, - "statusTimeOffset": { "message": "시간 오프셋: " }, + "statusTimeOffset": { + "message": "시간 오프셋: " + }, "statusDisplayOrderUpdated": { "message": "표시 순서가 업데이트되었습니다." }, "statusLayoutOrientationUpdated": { "message": "레이아웃 방향이 업데이트되었습니다." }, - "statusFontSize": { "message": "글꼴 크기: " }, - "statusVerticalGap": { "message": "세로 간격: " }, - "statusVerticalPosition": { "message": "세로 위치: " }, - "statusInvalidOffset": { "message": "잘못된 오프셋입니다. 되돌립니다." }, + "statusFontSize": { + "message": "글꼴 크기: " + }, + "statusVerticalGap": { + "message": "세로 간격: " + }, + "statusVerticalPosition": { + "message": "세로 위치: " + }, + "statusInvalidOffset": { + "message": "잘못된 오프셋입니다. 되돌립니다." + }, "statusSettingNotApplied": { "message": "설정이 적용되지 않았습니다. 페이지를 새로고침하세요." }, - - "optionsPageTitle": { "message": "DualSub 옵션" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "일반" }, - "navTranslation": { "message": "번역" }, - "navProviders": { "message": "제공업체" }, - "navAbout": { "message": "정보" }, - "sectionGeneral": { "message": "일반" }, - "cardUILanguageTitle": { "message": "UI 언어" }, + "optionsPageTitle": { + "message": "DualSub 옵션" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "일반" + }, + "navTranslation": { + "message": "번역" + }, + "navProviders": { + "message": "제공업체" + }, + "navAbout": { + "message": "정보" + }, + "sectionGeneral": { + "message": "일반" + }, + "cardUILanguageTitle": { + "message": "UI 언어" + }, "cardUILanguageDesc": { "message": "확장 프로그램 인터페이스의 표시 언어를 선택하세요." }, - "cardHideOfficialSubtitlesTitle": { "message": "공식 자막 숨기기" }, + "cardHideOfficialSubtitlesTitle": { + "message": "공식 자막 숨기기" + }, "cardHideOfficialSubtitlesDesc": { "message": "DualSub이 활성화된 상태에서 비디오 플랫폼의 공식 자막을 숨깁니다." }, - "hideOfficialSubtitlesLabel": { "message": "공식 자막 숨기기:" }, - "sectionTranslation": { "message": "번역" }, - "cardTranslationEngineTitle": { "message": "번역 엔진" }, + "hideOfficialSubtitlesLabel": { + "message": "공식 자막 숨기기:" + }, + "sectionTranslation": { + "message": "번역" + }, + "cardTranslationEngineTitle": { + "message": "번역 엔진" + }, "cardTranslationEngineDesc": { "message": "선호하는 번역 서비스를 선택하세요." }, - "cardPerformanceTitle": { "message": "성능" }, + "cardPerformanceTitle": { + "message": "성능" + }, "cardPerformanceDesc": { "message": "확장 프로그램이 번역 요청을 처리하는 방식을 조정하여 속도와 안정성의 균형을 맞춥니다." }, - "sectionProviders": { "message": "제공업체 설정" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "제공업체 설정" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "DeepL Translate의 API 키를 입력하세요. 무료 플랜 또는 프로 플랜 중에서 선택할 수 있습니다." }, - "apiKeyLabel": { "message": "API 키:" }, - "apiPlanLabel": { "message": "API 플랜:" }, - "apiPlanFree": { "message": "DeepL API 무료" }, - "apiPlanPro": { "message": "DeepL API 프로" }, - "sectionAbout": { "message": "정보" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "버전" }, + "apiKeyLabel": { + "message": "API 키:" + }, + "apiPlanLabel": { + "message": "API 플랜:" + }, + "apiPlanFree": { + "message": "DeepL API 무료" + }, + "apiPlanPro": { + "message": "DeepL API 프로" + }, + "sectionAbout": { + "message": "정보" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "버전" + }, "aboutDescription": { "message": "이 확장 프로그램은 다양한 플랫폼에서 이중 언어 자막으로 비디오를 시청하는 데 도움이 됩니다." }, - "aboutDevelopment": { "message": "QuellaMC & 1jifang이 개발했습니다." }, - - "lang_en": { "message": "영어" }, - "lang_es": { "message": "스페인어" }, - "lang_fr": { "message": "프랑스어" }, - "lang_de": { "message": "독일어" }, - "lang_it": { "message": "이탈리아어" }, - "lang_pt": { "message": "포르투갈어" }, - "lang_ja": { "message": "일본어" }, - "lang_ko": { "message": "한국어" }, - "lang_zh_CN": { "message": "중국어(간체)" }, - "lang_zh_TW": { "message": "중국어(번체)" }, - "lang_ru": { "message": "러시아어" }, - "lang_ar": { "message": "아랍어" }, - "lang_hi": { "message": "힌디어" }, - - "testDeepLButton": { "message": "DeepL 연결 테스트" }, - "deeplApiKeyError": { "message": "먼저 DeepL API 키를 입력해주세요." }, - "testingButton": { "message": "테스트 중..." }, - "testingConnection": { "message": "DeepL 연결을 테스트하고 있습니다..." }, + "aboutDevelopment": { + "message": "QuellaMC & 1jifang이 개발했습니다." + }, + "lang_en": { + "message": "영어" + }, + "lang_es": { + "message": "스페인어" + }, + "lang_fr": { + "message": "프랑스어" + }, + "lang_de": { + "message": "독일어" + }, + "lang_it": { + "message": "이탈리아어" + }, + "lang_pt": { + "message": "포르투갈어" + }, + "lang_ja": { + "message": "일본어" + }, + "lang_ko": { + "message": "한국어" + }, + "lang_zh_CN": { + "message": "중국어(간체)" + }, + "lang_zh_TW": { + "message": "중국어(번체)" + }, + "lang_ru": { + "message": "러시아어" + }, + "lang_ar": { + "message": "아랍어" + }, + "lang_hi": { + "message": "힌디어" + }, + "testDeepLButton": { + "message": "DeepL 연결 테스트" + }, + "deeplApiKeyError": { + "message": "먼저 DeepL API 키를 입력해주세요." + }, + "testingButton": { + "message": "테스트 중..." + }, + "testingConnection": { + "message": "DeepL 연결을 테스트하고 있습니다..." + }, "deeplTestSuccess": { "message": "✅ DeepL API 테스트가 성공했습니다! \"Hello\"를 \"%s\"로 번역했습니다" }, @@ -134,88 +269,177 @@ "deeplTestQuotaExceeded": { "message": "❌ DeepL API 사용량 한도에 도달했습니다. 사용 제한을 확인해주세요." }, - "deeplTestApiError": { "message": "❌ DeepL API 오류 (%d): %s" }, + "deeplTestApiError": { + "message": "❌ DeepL API 오류 (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ 네트워크 오류: DeepL API에 연결할 수 없습니다. 인터넷 연결을 확인해주세요." }, - "deeplTestGenericError": { "message": "❌ 테스트에 실패했습니다: %s" }, - "deepLApiUnavailable": { "message": "DeepL API 사용 불가" }, + "deeplTestGenericError": { + "message": "❌ 테스트에 실패했습니다: %s" + }, + "deepLApiUnavailable": { + "message": "DeepL API 사용 불가" + }, "deepLApiUnavailableTooltip": { "message": "DeepL API 스크립트 로드에 실패했습니다" }, "deeplApiNotLoadedError": { "message": "❌ DeepL API 스크립트를 사용할 수 없습니다. 페이지를 새로고침해주세요." }, - - "cardGoogleTitle": { "message": "Google 번역" }, + "cardGoogleTitle": { + "message": "Google 번역" + }, "cardGoogleDesc": { "message": "Google에서 제공하는 무료 번역 서비스입니다. 추가 설정이 필요하지 않습니다." }, - "cardMicrosoftTitle": { "message": "Microsoft 번역" }, + "cardMicrosoftTitle": { + "message": "Microsoft 번역" + }, "cardMicrosoftDesc": { "message": "Microsoft Edge에서 제공하는 무료 번역 서비스입니다. 추가 설정이 필요하지 않습니다." }, - "cardDeepLFreeTitle": { "message": "DeepL 번역 (무료)" }, + "cardDeepLFreeTitle": { + "message": "DeepL 번역 (무료)" + }, "cardDeepLFreeDesc": { "message": "고품질 결과를 제공하는 무료 DeepL 번역 서비스입니다. API 키 불필요 - DeepL 웹 인터페이스를 사용합니다." }, - "providerStatus": { "message": "상태:" }, - "statusReady": { "message": "사용 가능" }, - "providerFeatures": { "message": "기능:" }, - "featureFree": { "message": "무료 사용" }, - "featureNoApiKey": { "message": "API 키 불필요" }, - "featureWideLanguageSupport": { "message": "광범위한 언어 지원" }, - "featureFastTranslation": { "message": "빠른 번역" }, - "featureHighQuality": { "message": "고품질 번역" }, - "featureGoodPerformance": { "message": "우수한 성능" }, - "featureHighestQuality": { "message": "최고품질 번역" }, - "featureApiKeyRequired": { "message": "API 키 필요" }, - "featureLimitedLanguages": { "message": "제한적인 언어 지원" }, - "featureUsageLimits": { "message": "사용 제한 적용" }, - "featureMultipleBackups": { "message": "다중 백업 방법" }, - - "providerNotes": { "message": "참고사항:" }, - "noteSlowForSecurity": { "message": "보안 조치로 인해 약간 느림" }, - "noteAutoFallback": { "message": "대체 서비스로 자동 전환" }, - "noteRecommendedDefault": { "message": "기본 제공업체로 권장" }, - - "providerGoogleName": { "message": "Google 번역 (무료)" }, - "providerMicrosoftName": { "message": "Microsoft 번역 (무료)" }, - "providerDeepLName": { "message": "DeepL (API 키 필요)" }, - "providerDeepLFreeName": { "message": "DeepL 번역 (무료)" }, - "providerOpenAICompatibleName": { "message": "OpenAI 호환 (API 키 필요)" }, - "providerVertexGeminiName": { "message": "Vertex AI Gemini (API 키 필요)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI 호환 (API 키 필요)" }, + "providerStatus": { + "message": "상태:" + }, + "statusReady": { + "message": "사용 가능" + }, + "providerFeatures": { + "message": "기능:" + }, + "featureFree": { + "message": "무료 사용" + }, + "featureNoApiKey": { + "message": "API 키 불필요" + }, + "featureWideLanguageSupport": { + "message": "광범위한 언어 지원" + }, + "featureFastTranslation": { + "message": "빠른 번역" + }, + "featureHighQuality": { + "message": "고품질 번역" + }, + "featureGoodPerformance": { + "message": "우수한 성능" + }, + "featureHighestQuality": { + "message": "최고품질 번역" + }, + "featureApiKeyRequired": { + "message": "API 키 필요" + }, + "featureLimitedLanguages": { + "message": "제한적인 언어 지원" + }, + "featureUsageLimits": { + "message": "사용 제한 적용" + }, + "featureMultipleBackups": { + "message": "다중 백업 방법" + }, + "providerNotes": { + "message": "참고사항:" + }, + "noteSlowForSecurity": { + "message": "보안 조치로 인해 약간 느림" + }, + "noteAutoFallback": { + "message": "대체 서비스로 자동 전환" + }, + "noteRecommendedDefault": { + "message": "기본 제공업체로 권장" + }, + "providerGoogleName": { + "message": "Google 번역 (무료)" + }, + "providerMicrosoftName": { + "message": "Microsoft 번역 (무료)" + }, + "providerDeepLName": { + "message": "DeepL (API 키 필요)" + }, + "providerDeepLFreeName": { + "message": "DeepL 번역 (무료)" + }, + "providerOpenAICompatibleName": { + "message": "OpenAI 호환 (API 키 필요)" + }, + "providerVertexGeminiName": { + "message": "Vertex AI Gemini (API 키 필요)" + }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI 호환 (API 키 필요)" + }, "cardOpenAICompatibleDesc": { "message": "Gemini와 같은 OpenAI 호환 서비스를 위한 API 키와 설정을 입력하세요." }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini (API 키 필요)" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini (API 키 필요)" + }, "cardVertexGeminiDesc": { "message": "액세스 토큰과 Vertex 프로젝트 설정을 입력하거나 서비스 계정 JSON 파일을 가져오세요." }, - "vertexAccessTokenLabel": { "message": "액세스 토큰:" }, - "vertexProjectIdLabel": { "message": "프로젝트 ID:" }, - "vertexLocationLabel": { "message": "위치:" }, - "vertexModelLabel": { "message": "모델:" }, + "vertexAccessTokenLabel": { + "message": "액세스 토큰:" + }, + "vertexProjectIdLabel": { + "message": "프로젝트 ID:" + }, + "vertexLocationLabel": { + "message": "위치:" + }, + "vertexModelLabel": { + "message": "모델:" + }, "vertexMissingConfig": { "message": "액세스 토큰과 프로젝트 ID를 입력하세요." }, - "vertexConnectionFailed": { "message": "연결 실패: %s" }, - "vertexServiceAccountLabel": { "message": "서비스 계정 JSON:" }, - "vertexImportButton": { "message": "JSON 파일 가져오기" }, - "vertexRefreshButton": { "message": "🔄 토큰 새로고침" }, - "vertexImportHint": { "message": "아래 자격 증명을 자동으로 입력합니다" }, - "vertexImporting": { "message": "가져오는 중..." }, - "vertexRefreshingToken": { "message": "액세스 토큰 새로고침 중..." }, - "vertexGeneratingToken": { "message": "액세스 토큰 생성 중..." }, + "vertexConnectionFailed": { + "message": "연결 실패: %s" + }, + "vertexServiceAccountLabel": { + "message": "서비스 계정 JSON:" + }, + "vertexImportButton": { + "message": "JSON 파일 가져오기" + }, + "vertexRefreshButton": { + "message": "🔄 토큰 새로고침" + }, + "vertexImportHint": { + "message": "아래 자격 증명을 자동으로 입력합니다" + }, + "vertexImporting": { + "message": "가져오는 중..." + }, + "vertexRefreshingToken": { + "message": "액세스 토큰 새로고침 중..." + }, + "vertexGeneratingToken": { + "message": "액세스 토큰 생성 중..." + }, "vertexImportSuccess": { "message": "서비스 계정을 가져오고 토큰을 생성했습니다." }, - "vertexImportFailed": { "message": "가져오기 실패: %s" }, + "vertexImportFailed": { + "message": "가져오기 실패: %s" + }, "vertexTokenRefreshed": { "message": "액세스 토큰이 성공적으로 새로고침되었습니다." }, - "vertexRefreshFailed": { "message": "토큰 새로고침 실패: %s" }, + "vertexRefreshFailed": { + "message": "토큰 새로고침 실패: %s" + }, "vertexTokenExpired": { "message": "⚠️ 액세스 토큰이 만료되었습니다. 새로고침을 클릭하여 갱신하세요." }, @@ -228,249 +452,471 @@ "vertexNotConfigured": { "message": "서비스 계정 JSON을 가져오거나 자격 증명을 입력하세요." }, - "featureVertexServiceAccount": { "message": "서비스 계정 JSON 가져오기" }, - "featureVertexAutoToken": { "message": "자동 토큰 생성" }, - "featureVertexGemini": { "message": "Vertex AI를 통한 Google Gemini 모델" }, + "featureVertexServiceAccount": { + "message": "서비스 계정 JSON 가져오기" + }, + "featureVertexAutoToken": { + "message": "자동 토큰 생성" + }, + "featureVertexGemini": { + "message": "Vertex AI를 통한 Google Gemini 모델" + }, "vertexNote": { "message": "액세스 토큰은 1시간 후에 만료됩니다. 서비스 계정은 안전하게 저장되어 쉽게 토큰을 새로고침할 수 있습니다 - 필요할 때 토큰 새로고침 버튼을 클릭하세요." }, - "baseUrlLabel": { "message": "기본 URL:" }, - "modelLabel": { "message": "모델:" }, + "baseUrlLabel": { + "message": "기본 URL:" + }, + "modelLabel": { + "message": "모델:" + }, "featureCustomizable": { "message": "사용자 정의 가능한 엔드포인트 및 모델" }, - "fetchModelsButton": { "message": "모델 가져오기" }, - "testConnectionButton": { "message": "연결 테스트" }, - - "openaiApiKeyPlaceholder": { "message": "OpenAI 호환 API 키를 입력하세요" }, - "openaiBaseUrlPlaceholder": { "message": "예: https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "먼저 API 키를 입력해주세요." }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API 키 테스트가 필요합니다." }, + "fetchModelsButton": { + "message": "모델 가져오기" + }, + "testConnectionButton": { + "message": "연결 테스트" + }, + "openaiApiKeyPlaceholder": { + "message": "OpenAI 호환 API 키를 입력하세요" + }, + "openaiBaseUrlPlaceholder": { + "message": "예: https://api.openai.com/v1" + }, + "openaiApiKeyError": { + "message": "먼저 API 키를 입력해주세요." + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API 키 테스트가 필요합니다." + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI 호환 API 키 테스트가 필요합니다." }, - "openaiTestingConnection": { "message": "연결 테스트 중..." }, - "openaiConnectionSuccessful": { "message": "연결 성공!" }, - "openaiConnectionFailed": { "message": "연결 실패: %s" }, - "openaieFetchingModels": { "message": "모델 가져오는 중..." }, + "openaiTestingConnection": { + "message": "연결 테스트 중..." + }, + "openaiConnectionSuccessful": { + "message": "연결 성공!" + }, + "openaiConnectionFailed": { + "message": "연결 실패: %s" + }, + "openaieFetchingModels": { + "message": "모델 가져오는 중..." + }, "openaiModelsFetchedSuccessfully": { "message": "모델을 성공적으로 가져왔습니다." }, - "openaiFailedToFetchModels": { "message": "모델 가져오기 실패: %s" }, - - "cardLoggingLevelTitle": { "message": "로깅 레벨" }, + "openaiFailedToFetchModels": { + "message": "모델 가져오기 실패: %s" + }, + "cardLoggingLevelTitle": { + "message": "로깅 레벨" + }, "cardLoggingLevelDesc": { "message": "브라우저 콘솔에 표시되는 디버그 정보의 양을 제어합니다. 높은 레벨에는 낮은 레벨의 모든 메시지가 포함됩니다." }, - "loggingLevelLabel": { "message": "로깅 레벨:" }, - "loggingLevelOff": { "message": "끄기" }, - "loggingLevelError": { "message": "오류만" }, - "loggingLevelWarn": { "message": "경고 및 오류" }, - "loggingLevelInfo": { "message": "정보 이상" }, - "loggingLevelDebug": { "message": "디버그 (모두)" }, - - "cardBatchTranslationTitle": { "message": "배치 번역" }, + "loggingLevelLabel": { + "message": "로깅 레벨:" + }, + "loggingLevelOff": { + "message": "끄기" + }, + "loggingLevelError": { + "message": "오류만" + }, + "loggingLevelWarn": { + "message": "경고 및 오류" + }, + "loggingLevelInfo": { + "message": "정보 이상" + }, + "loggingLevelDebug": { + "message": "디버그 (모두)" + }, + "cardBatchTranslationTitle": { + "message": "배치 번역" + }, "cardBatchTranslationDesc": { "message": "배치 번역은 여러 자막 세그먼트를 함께 처리하여 API 호출을 80-90% 줄이고 성능을 향상시킵니다. 선호하는 번역 제공업체에 대한 최적 설정을 구성하세요." }, - "batchingEnabledLabel": { "message": "배치 번역 활성화:" }, + "batchingEnabledLabel": { + "message": "배치 번역 활성화:" + }, "batchingEnabledHelp": { "message": "여러 자막 세그먼트를 단일 번역 요청으로 그룹화합니다" }, - "useProviderDefaultsLabel": { "message": "제공업체 최적화 설정 사용:" }, + "useProviderDefaultsLabel": { + "message": "제공업체 최적화 설정 사용:" + }, "useProviderDefaultsHelp": { "message": "각 번역 제공업체에 대해 최적의 배치 크기를 자동으로 사용합니다" }, - "globalBatchSizeLabel": { "message": "전역 배치 크기:" }, + "globalBatchSizeLabel": { + "message": "전역 배치 크기:" + }, "globalBatchSizeHelp": { "message": "함께 처리할 자막 세그먼트 수 (1-15)" }, - "smartBatchingLabel": { "message": "스마트 배치 최적화:" }, + "smartBatchingLabel": { + "message": "스마트 배치 최적화:" + }, "smartBatchingHelp": { "message": "재생 위치를 기반으로 자막 세그먼트의 우선순위를 정합니다" }, - "maxConcurrentBatchesLabel": { "message": "최대 동시 배치 수:" }, + "maxConcurrentBatchesLabel": { + "message": "최대 동시 배치 수:" + }, "maxConcurrentBatchesHelp": { "message": "동시에 처리할 번역 배치 수" }, - - "cardProviderBatchTitle": { "message": "제공업체별 배치 크기" }, + "cardProviderBatchTitle": { + "message": "제공업체별 배치 크기" + }, "cardProviderBatchDesc": { "message": "각 번역 제공업체에 대한 최적 배치 크기를 구성합니다. 이 설정은 \"제공업체 최적화 설정 사용\"이 활성화된 경우에 사용됩니다." }, - "openaieBatchSizeLabel": { "message": "OpenAI 배치 크기:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI 배치 크기:" + }, "openaieBatchSizeHelp": { "message": "권장: 5-10개 세그먼트 (기본값: 8)" }, - "googleBatchSizeLabel": { "message": "Google 번역 배치 크기:" }, + "googleBatchSizeLabel": { + "message": "Google 번역 배치 크기:" + }, "googleBatchSizeHelp": { "message": "권장: 3-5개 세그먼트 (기본값: 4)" }, - "deeplBatchSizeLabel": { "message": "DeepL 배치 크기:" }, + "deeplBatchSizeLabel": { + "message": "DeepL 배치 크기:" + }, "deeplBatchSizeHelp": { "message": "권장: 2-3개 세그먼트 (기본값: 3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft 번역 배치 크기:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft 번역 배치 크기:" + }, "microsoftBatchSizeHelp": { "message": "권장: 3-5개 세그먼트 (기본값: 4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI 배치 크기:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI 배치 크기:" + }, "vertexBatchSizeHelp": { "message": "권장: 5-10개 세그먼트 (기본값: 8)" }, - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API 키 테스트가 필요합니다." }, - - "cardProviderDelayTitle": { "message": "제공업체별 요청 지연" }, + "cardProviderDelayTitle": { + "message": "제공업체별 요청 지연" + }, "cardProviderDelayDesc": { "message": "계정 잠금을 방지하기 위해 번역 요청 간의 필수 지연을 구성합니다. 이러한 지연은 배치 처리가 활성화된 경우에도 적용됩니다." }, - "openaieDelayLabel": { "message": "OpenAI 요청 지연 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI 요청 지연 (ms):" + }, "openaieDelayHelp": { "message": "요청 간 최소 지연 (기본값: 100ms)" }, - "googleDelayLabel": { "message": "Google 번역 요청 지연 (ms):" }, + "googleDelayLabel": { + "message": "Google 번역 요청 지연 (ms):" + }, "googleDelayHelp": { "message": "임시 잠금을 방지하기 위한 필수 지연 (기본값: 1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API 요청 지연 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API 요청 지연 (ms):" + }, "deeplDelayHelp": { "message": "DeepL API 요청 지연 (기본값: 500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL 무료 요청 지연 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL 무료 요청 지연 (ms):" + }, "deeplFreeDelayHelp": { "message": "무료 계층을 위한 보수적 지연 (기본값: 2000ms)" }, - "microsoftDelayLabel": { "message": "Microsoft 번역 요청 지연 (ms):" }, + "microsoftDelayLabel": { + "message": "Microsoft 번역 요청 지연 (ms):" + }, "microsoftDelayHelp": { "message": "문자 제한을 준수하기 위한 지연 (기본값: 800ms)" }, - "vertexDelayLabel": { "message": "Vertex AI 요청 지연 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AI 요청 지연 (ms):" + }, "vertexDelayHelp": { "message": "요청 사이의 최소 지연 (기본값: 100ms)" }, - - "aiContextModalTitle": { "message": "AI 컨텍스트 분석" }, - "aiContextSelectedWords": { "message": "선택된 단어" }, - "aiContextNoWordsSelected": { "message": "선택된 단어가 없습니다" }, + "aiContextModalTitle": { + "message": "AI 컨텍스트 분석" + }, + "aiContextSelectedWords": { + "message": "선택된 단어" + }, + "aiContextNoWordsSelected": { + "message": "선택된 단어가 없습니다" + }, "aiContextClickHint": { "message": "💡 단어를 클릭하여 추가하거나 제거하세요." }, - "aiContextStartAnalysis": { "message": "분석 시작" }, - "aiContextPauseAnalysis": { "message": "⏸ 일시정지" }, - "aiContextPauseAnalysisTitle": { "message": "분석 일시정지" }, + "aiContextStartAnalysis": { + "message": "분석 시작" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 일시정지" + }, + "aiContextPauseAnalysisTitle": { + "message": "분석 일시정지" + }, "aiContextInitialMessage": { "message": "자막에서 단어를 선택하여 분석을 시작하세요." }, - "aiContextAnalyzing": { "message": "컨텍스트 분석 중..." }, - "aiContextPauseNote": { "message": "⏸ 를 클릭하여 분석 일시정지" }, - "aiContextAnalysisFailed": { "message": "분석 실패" }, - "aiContextNoContent": { "message": "분석 콘텐츠 없음" }, + "aiContextAnalyzing": { + "message": "컨텍스트 분석 중..." + }, + "aiContextPauseNote": { + "message": "⏸ 를 클릭하여 분석 일시정지" + }, + "aiContextAnalysisFailed": { + "message": "분석 실패" + }, + "aiContextNoContent": { + "message": "분석 콘텐츠 없음" + }, "aiContextNoContentMessage": { "message": "분석이 완료되었지만 콘텐츠가 반환되지 않았습니다." }, - "aiContextDefinition": { "message": "📖 정의" }, - "aiContextCultural": { "message": "🌍 문화적 맥락" }, - "aiContextCulturalSignificance": { "message": "⭐ 문화적 의미" }, - "aiContextHistorical": { "message": "📜 역사적 맥락" }, - "aiContextHistoricalSignificance": { "message": "📜 역사적 의미" }, - "aiContextEvolution": { "message": "🔄 시대별 변화" }, - "aiContextLinguistic": { "message": "🔤 언어학적 분석" }, - "aiContextGrammar": { "message": "📝 문법 및 의미론" }, - "aiContextUsage": { "message": "💡 사용법 및 예시" }, - "aiContextExamples": { "message": "예시:" }, - "aiContextLearningTips": { "message": "🎯 학습 팁" }, - "aiContextRelatedExpressions": { "message": "🔗 관련 표현" }, - "aiContextKeyInsights": { "message": "🔑 핵심 통찰" }, - "aiContextTypeCultural": { "message": "문화적" }, - "aiContextTypeHistorical": { "message": "역사적" }, - "aiContextTypeLinguistic": { "message": "언어학적" }, - "aiContextTypeComprehensive": { "message": "포괄적" }, - "aiContextTypeGeneric": { "message": "맥락" }, - "aiContextClose": { "message": "닫기" }, - "aiContextAnalysisResults": { "message": "분석 결과" }, - "aiContextRetrying": { "message": "분석 실패, 재생성 중..." }, - "aiContextRetryNotification": { "message": "분석 실패, 재시도 중..." }, - "aiContextRetryButton": { "message": "다시 시도" }, + "aiContextDefinition": { + "message": "📖 정의" + }, + "aiContextCultural": { + "message": "🌍 문화적 맥락" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 문화적 의미" + }, + "aiContextHistorical": { + "message": "📜 역사적 맥락" + }, + "aiContextHistoricalSignificance": { + "message": "📜 역사적 의미" + }, + "aiContextEvolution": { + "message": "🔄 시대별 변화" + }, + "aiContextLinguistic": { + "message": "🔤 언어학적 분석" + }, + "aiContextGrammar": { + "message": "📝 문법 및 의미론" + }, + "aiContextUsage": { + "message": "💡 사용법 및 예시" + }, + "aiContextExamples": { + "message": "예시:" + }, + "aiContextLearningTips": { + "message": "🎯 학습 팁" + }, + "aiContextRelatedExpressions": { + "message": "🔗 관련 표현" + }, + "aiContextKeyInsights": { + "message": "🔑 핵심 통찰" + }, + "aiContextTypeCultural": { + "message": "문화적" + }, + "aiContextTypeHistorical": { + "message": "역사적" + }, + "aiContextTypeLinguistic": { + "message": "언어학적" + }, + "aiContextTypeComprehensive": { + "message": "포괄적" + }, + "aiContextTypeGeneric": { + "message": "맥락" + }, + "aiContextClose": { + "message": "닫기" + }, + "aiContextAnalysisResults": { + "message": "분석 결과" + }, + "aiContextRetrying": { + "message": "분석 실패, 재생성 중..." + }, + "aiContextRetryNotification": { + "message": "분석 실패, 재시도 중..." + }, + "aiContextRetryButton": { + "message": "다시 시도" + }, "aiContextMalformedResponse": { "message": "AI 서비스가 잘못된 응답 형식을 반환했습니다. 이는 일시적인 서비스 문제로 인한 것일 수 있습니다." }, "aiContextJsonCodeBlock": { "message": "AI 서비스가 구조화된 데이터 대신 처리되지 않은 JSON 코드를 반환했습니다. 이는 응답의 형식 오류를 나타냅니다." }, - "aiContextCulturalContext": { "message": "문화적 맥락:" }, - "aiContextSocialUsage": { "message": "사회적 용법:" }, - "aiContextRegionalNotes": { "message": "지역적 특징:" }, - "aiContextOrigins": { "message": "어원:" }, - "aiContextHistoricalContext": { "message": "역사적 배경:" }, - "aiContextHistoricalSignificance": { "message": "역사적 의미:" }, - "aiContextEvolution": { "message": "변화 과정:" }, - "aiContextEtymology": { "message": "어원학:" }, - "aiContextGrammarNotes": { "message": "문법 주석:" }, - "aiContextTranslationNotes": { "message": "번역 주석:" }, - "aiContextLinguisticAnalysis": { "message": "언어학적 분석:" }, - "aiContextGrammarSemantics": { "message": "문법과 의미론:" }, - "aiContextUsageExamples": { "message": "용법과 예시:" }, - "aiContextLearningTips": { "message": "학습 팁:" }, - "aiContextRelatedExpressions": { "message": "관련 표현:" }, - "aiContextKeyInsights": { "message": "핵심 통찰:" }, - - "navAIContext": { "message": "AI 컨텍스트" }, - "sectionAIContext": { "message": "AI 컨텍스트 어시스턴트" }, - "cardAIContextToggleTitle": { "message": "AI 컨텍스트 분석 활성화" }, + "aiContextCulturalContext": { + "message": "문화적 맥락:" + }, + "aiContextSocialUsage": { + "message": "사회적 용법:" + }, + "aiContextRegionalNotes": { + "message": "지역적 특징:" + }, + "aiContextOrigins": { + "message": "어원:" + }, + "aiContextHistoricalContext": { + "message": "역사적 배경:" + }, + "aiContextHistoricalSignificanceLabel": { + "message": "역사적 의미:" + }, + "aiContextEvolutionLabel": { + "message": "변화 과정:" + }, + "aiContextEtymology": { + "message": "어원학:" + }, + "aiContextGrammarNotesLabel": { + "message": "문법 주석:" + }, + "aiContextTranslationNotesLabel": { + "message": "번역 주석:" + }, + "aiContextLinguisticAnalysisLabel": { + "message": "언어학적 분석:" + }, + "aiContextGrammarSemanticsLabel": { + "message": "문법과 의미론:" + }, + "aiContextUsageExamplesLabel": { + "message": "용법과 예시:" + }, + "aiContextLearningTipsLabel": { + "message": "학습 팁:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "관련 표현:" + }, + "aiContextKeyInsightsLabel": { + "message": "핵심 통찰:" + }, + "navAIContext": { + "message": "AI 컨텍스트" + }, + "sectionAIContext": { + "message": "AI 컨텍스트 어시스턴트" + }, + "cardAIContextToggleTitle": { + "message": "AI 컨텍스트 분석 활성화" + }, "cardAIContextToggleDesc": { "message": "자막 텍스트에 대한 AI 기반 문화적, 역사적, 언어학적 컨텍스트 분석을 활성화합니다. 자막의 단어나 구문을 클릭하여 자세한 설명을 얻을 수 있습니다." }, - "aiContextEnabledLabel": { "message": "AI 컨텍스트 활성화:" }, - "cardAIContextProviderTitle": { "message": "AI 제공업체" }, + "aiContextEnabledLabel": { + "message": "AI 컨텍스트 활성화:" + }, + "cardAIContextProviderTitle": { + "message": "AI 제공업체" + }, "cardAIContextProviderDesc": { "message": "컨텍스트 분석을 위한 AI 서비스 제공업체를 선택합니다. 제공업체에 따라 품질과 응답 시간이 다를 수 있습니다." }, - "aiContextProviderLabel": { "message": "제공업체:" }, - "cardOpenAIContextTitle": { "message": "OpenAI 구성" }, + "aiContextProviderLabel": { + "message": "제공업체:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI 구성" + }, "cardOpenAIContextDesc": { "message": "컨텍스트 분석을 위한 OpenAI API 설정을 구성합니다. 유효한 OpenAI API 키가 필요합니다." }, - "openaiApiKeyLabel": { "message": "API 키:" }, - "openaiBaseUrlLabel": { "message": "기본 URL:" }, - "openaiModelLabel": { "message": "모델:" }, - "cardGeminiContextTitle": { "message": "Google Gemini 구성" }, + "openaiApiKeyLabel": { + "message": "API 키:" + }, + "openaiBaseUrlLabel": { + "message": "기본 URL:" + }, + "openaiModelLabel": { + "message": "모델:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini 구성" + }, "cardGeminiContextDesc": { "message": "컨텍스트 분석을 위한 Google Gemini API 설정을 구성합니다. 유효한 Gemini API 키가 필요합니다." }, - "geminiApiKeyLabel": { "message": "API 키:" }, - "geminiModelLabel": { "message": "모델:" }, - "cardAIContextTypesTitle": { "message": "컨텍스트 유형" }, + "geminiApiKeyLabel": { + "message": "API 키:" + }, + "geminiModelLabel": { + "message": "모델:" + }, + "cardAIContextTypesTitle": { + "message": "컨텍스트 유형" + }, "cardAIContextTypesDesc": { "message": "사용하려는 컨텍스트 분석 유형을 활성화합니다. 여러 유형을 활성화할 수 있습니다." }, - "contextTypeCulturalLabel": { "message": "문화적 컨텍스트:" }, + "contextTypeCulturalLabel": { + "message": "문화적 컨텍스트:" + }, "contextTypeCulturalHelp": { "message": "문화적 참조, 관용구, 사회적 맥락 분석" }, - "contextTypeHistoricalLabel": { "message": "역사적 컨텍스트:" }, + "contextTypeHistoricalLabel": { + "message": "역사적 컨텍스트:" + }, "contextTypeHistoricalHelp": { "message": "역사적 배경과 시대적 맥락 제공" }, - "contextTypeLinguisticLabel": { "message": "언어학적 분석:" }, - "contextTypeLinguisticHelp": { "message": "문법, 어원, 언어 구조 설명" }, - "cardAIContextInteractiveTitle": { "message": "상호작용 기능" }, + "contextTypeLinguisticLabel": { + "message": "언어학적 분석:" + }, + "contextTypeLinguisticHelp": { + "message": "문법, 어원, 언어 구조 설명" + }, + "cardAIContextInteractiveTitle": { + "message": "상호작용 기능" + }, "cardAIContextInteractiveDesc": { "message": "컨텍스트 분석을 트리거하기 위해 자막과 상호작용하는 방법을 구성합니다." }, - "interactiveSubtitlesEnabledLabel": { "message": "상호작용 자막 활성화:" }, + "interactiveSubtitlesEnabledLabel": { + "message": "상호작용 자막 활성화:" + }, "interactiveSubtitlesEnabledHelp": { "message": "컨텍스트 분석을 위해 자막 단어를 클릭 가능하게 만들기" }, - "contextOnClickLabel": { "message": "클릭 시 컨텍스트:" }, - "contextOnClickHelp": { "message": "단어를 클릭할 때 컨텍스트 분석 표시" }, - "contextOnSelectionLabel": { "message": "선택 시 컨텍스트:" }, + "contextOnClickLabel": { + "message": "클릭 시 컨텍스트:" + }, + "contextOnClickHelp": { + "message": "단어를 클릭할 때 컨텍스트 분석 표시" + }, + "contextOnSelectionLabel": { + "message": "선택 시 컨텍스트:" + }, "contextOnSelectionHelp": { "message": "텍스트를 선택할 때 컨텍스트 분석 표시" }, - "cardAIContextPrivacyTitle": { "message": "개인정보 및 데이터" }, + "cardAIContextPrivacyTitle": { + "message": "개인정보 및 데이터" + }, "cardAIContextPrivacyDesc": { "message": "컨텍스트 분석 중 데이터 처리 방법을 제어합니다." }, @@ -480,26 +926,103 @@ "aiContextUserConsentHelp": { "message": "AI 컨텍스트 분석이 작동하기 위해 필요" }, - "aiContextDataSharingLabel": { "message": "익명 사용 분석 허용:" }, + "aiContextDataSharingLabel": { + "message": "익명 사용 분석 허용:" + }, "aiContextDataSharingHelp": { "message": "익명 사용 데이터를 공유하여 서비스 개선에 도움" }, - "cardAIContextAdvancedTitle": { "message": "고급 설정" }, + "cardAIContextAdvancedTitle": { + "message": "고급 설정" + }, "cardAIContextAdvancedDesc": { "message": "AI 컨텍스트 분석 동작에 대한 고급 옵션을 구성합니다." }, - "aiContextTimeoutLabel": { "message": "요청 타임아웃 (ms):" }, - "aiContextTimeoutHelp": { "message": "AI 응답을 기다리는 최대 시간" }, - "aiContextRateLimitLabel": { "message": "속도 제한 (요청/분):" }, - "aiContextRateLimitHelp": { "message": "분당 최대 요청 수" }, - "aiContextCacheEnabledLabel": { "message": "캐싱 활성화:" }, + "aiContextTimeoutLabel": { + "message": "요청 타임아웃 (ms):" + }, + "aiContextTimeoutHelp": { + "message": "AI 응답을 기다리는 최대 시간" + }, + "aiContextRateLimitLabel": { + "message": "속도 제한 (요청/분):" + }, + "aiContextRateLimitHelp": { + "message": "분당 최대 요청 수" + }, + "aiContextCacheEnabledLabel": { + "message": "캐싱 활성화:" + }, "aiContextCacheEnabledHelp": { "message": "API 호출을 줄이기 위해 분석 결과 캐시" }, - "aiContextRetryAttemptsLabel": { "message": "재시도 횟수:" }, + "aiContextRetryAttemptsLabel": { + "message": "재시도 횟수:" + }, "aiContextRetryAttemptsHelp": { "message": "실패한 요청을 재시도하는 횟수" }, - "showAdvancedSettings": { "message": "고급 설정 표시" }, - "hideAdvancedSettings": { "message": "고급 설정 숨기기" } -} + "showAdvancedSettings": { + "message": "고급 설정 표시" + }, + "hideAdvancedSettings": { + "message": "고급 설정 숨기기" + }, + "sidepanelLoading": { + "message": "로딩 중..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI 분석" + }, + "sidepanelTabWordsLists": { + "message": "단어장" + }, + "sidepanelAnalyzeButton": { + "message": "분석" + }, + "sidepanelAnalyzing": { + "message": "분석 중..." + }, + "sidepanelWordsToAnalyze": { + "message": "분석할 단어" + }, + "sidepanelWordInputPlaceholder": { + "message": "자막의 단어를 클릭하여 분석에 추가하세요..." + }, + "sidepanelErrorRetry": { + "message": "재시도" + }, + "sidepanelResultsTitle": { + "message": "\"%s\"에 대한 결과" + }, + "sidepanelSectionDefinition": { + "message": "정의" + }, + "sidepanelSectionCultural": { + "message": "문화적 맥락" + }, + "sidepanelSectionHistorical": { + "message": "역사적 맥락" + }, + "sidepanelSectionLinguistic": { + "message": "언어학적 분석" + }, + "sidepanelMyWordsTitle": { + "message": "내 단어" + }, + "sidepanelFeatureComingSoon": { + "message": "단어장 기능이 곧 제공됩니다!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "이 기능은 현재 개발 중입니다. 설정에서 활성화하여 미리보기를 사용해보세요." + }, + "sidepanelErrorNoWords": { + "message": "분석할 단어가 선택되지 않았습니다" + }, + "sidepanelErrorDisabled": { + "message": "AI 컨텍스트 분석이 비활성화되어 있습니다. 설정에서 활성화하세요." + }, + "sidepanelErrorGeneric": { + "message": "분석 중 오류가 발생했습니다." + } +} \ No newline at end of file diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 440b560..67b5b03 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -1,408 +1,883 @@ { - "appName": { "message": "DualSub" }, - "appDesc": { "message": "在流媒体平台上显示双语字幕。" }, - "pageTitle": { "message": "DualSub 设置" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "启用双语字幕:" }, - "useNativeSubtitlesLabel": { "message": "使用官方字幕:" }, - "originalLanguageLabel": { "message": "原始语言:" }, - "translationSettingsLegend": { "message": "翻译设置" }, - "providerLabel": { "message": "提供商:" }, - "targetLanguageLabel": { "message": "翻译为:" }, - "batchSizeLabel": { "message": "批量大小:" }, - "requestDelayLabel": { "message": "请求延迟 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "字幕外观和时序" }, - "displayOrderLabel": { "message": "显示顺序:" }, - "layoutLabel": { "message": "布局:" }, - "fontSizeLabel": { "message": "字体大小:" }, - "verticalGapLabel": { "message": "垂直间距:" }, - "subtitleVerticalPositionLabel": { "message": "垂直位置:" }, - "timeOffsetLabel": { "message": "时间偏移(秒):" }, - "displayOrderOriginalFirst": { "message": "原文在上" }, - "displayOrderTranslationFirst": { "message": "译文在上" }, - "layoutTopBottom": { "message": "上下排列" }, - "layoutLeftRight": { "message": "左右排列" }, - "uiLanguageLabel": { "message": "语言:" }, - "openOptionsButton": { "message": "高级设置" }, - "statusLanguageSetTo": { "message": "刷新页面后生效:" }, - "statusDualEnabled": { "message": "双语字幕已启用。(刷新页面)" }, - "statusDualDisabled": { "message": "双语字幕已禁用。(刷新页面)" }, + "appName": { + "message": "DualSub" + }, + "appDesc": { + "message": "在流媒体平台上显示双语字幕。" + }, + "pageTitle": { + "message": "DualSub 设置" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "启用双语字幕:" + }, + "useNativeSubtitlesLabel": { + "message": "使用官方字幕:" + }, + "originalLanguageLabel": { + "message": "原始语言:" + }, + "translationSettingsLegend": { + "message": "翻译设置" + }, + "providerLabel": { + "message": "提供商:" + }, + "targetLanguageLabel": { + "message": "翻译为:" + }, + "batchSizeLabel": { + "message": "批量大小:" + }, + "requestDelayLabel": { + "message": "请求延迟 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "字幕外观和时序" + }, + "displayOrderLabel": { + "message": "显示顺序:" + }, + "layoutLabel": { + "message": "布局:" + }, + "fontSizeLabel": { + "message": "字体大小:" + }, + "verticalGapLabel": { + "message": "垂直间距:" + }, + "subtitleVerticalPositionLabel": { + "message": "垂直位置:" + }, + "timeOffsetLabel": { + "message": "时间偏移(秒):" + }, + "displayOrderOriginalFirst": { + "message": "原文在上" + }, + "displayOrderTranslationFirst": { + "message": "译文在上" + }, + "layoutTopBottom": { + "message": "上下排列" + }, + "layoutLeftRight": { + "message": "左右排列" + }, + "uiLanguageLabel": { + "message": "语言:" + }, + "openOptionsButton": { + "message": "高级设置" + }, + "statusLanguageSetTo": { + "message": "刷新页面后生效:" + }, + "statusDualEnabled": { + "message": "双语字幕已启用。(刷新页面)" + }, + "statusDualDisabled": { + "message": "双语字幕已禁用。(刷新页面)" + }, "statusSmartTranslationEnabled": { "message": "智能翻译已启用。(刷新页面)" }, "statusSmartTranslationDisabled": { "message": "智能翻译已禁用。(刷新页面)" }, - "statusOriginalLanguage": { "message": "刷新页面后生效:" }, - "statusTimeOffset": { "message": "时间偏移:" }, - "statusDisplayOrderUpdated": { "message": "显示顺序已更新。" }, - "statusLayoutOrientationUpdated": { "message": "布局方向已更新。" }, - "statusFontSize": { "message": "字体大小:" }, - "statusVerticalGap": { "message": "垂直间距:" }, - "statusVerticalPosition": { "message": "垂直位置:" }, - "statusInvalidOffset": { "message": "无效偏移,已还原。" }, - "statusSettingNotApplied": { "message": "设置未应用。请刷新页面。" }, - - "optionsPageTitle": { "message": "DualSub 选项" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "常规" }, - "navTranslation": { "message": "翻译" }, - "navProviders": { "message": "提供商" }, - "navAbout": { "message": "关于" }, - "sectionGeneral": { "message": "常规" }, - "cardUILanguageTitle": { "message": "界面语言" }, - "cardUILanguageDesc": { "message": "选择扩展界面的显示语言。" }, - "cardHideOfficialSubtitlesTitle": { "message": "隐藏官方字幕" }, + "statusOriginalLanguage": { + "message": "刷新页面后生效:" + }, + "statusTimeOffset": { + "message": "时间偏移:" + }, + "statusDisplayOrderUpdated": { + "message": "显示顺序已更新。" + }, + "statusLayoutOrientationUpdated": { + "message": "布局方向已更新。" + }, + "statusFontSize": { + "message": "字体大小:" + }, + "statusVerticalGap": { + "message": "垂直间距:" + }, + "statusVerticalPosition": { + "message": "垂直位置:" + }, + "statusInvalidOffset": { + "message": "无效偏移,已还原。" + }, + "statusSettingNotApplied": { + "message": "设置未应用。请刷新页面。" + }, + "optionsPageTitle": { + "message": "DualSub 选项" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "常规" + }, + "navTranslation": { + "message": "翻译" + }, + "navProviders": { + "message": "提供商" + }, + "navAbout": { + "message": "关于" + }, + "sectionGeneral": { + "message": "常规" + }, + "cardUILanguageTitle": { + "message": "界面语言" + }, + "cardUILanguageDesc": { + "message": "选择扩展界面的显示语言。" + }, + "cardHideOfficialSubtitlesTitle": { + "message": "隐藏官方字幕" + }, "cardHideOfficialSubtitlesDesc": { "message": "当 DualSub 激活时,隐藏视频平台的官方字幕。" }, - "hideOfficialSubtitlesLabel": { "message": "隐藏官方字幕:" }, - "sectionTranslation": { "message": "翻译" }, - "cardTranslationEngineTitle": { "message": "翻译引擎" }, - "cardTranslationEngineDesc": { "message": "选择您喜欢的翻译服务。" }, - "cardPerformanceTitle": { "message": "性能" }, + "hideOfficialSubtitlesLabel": { + "message": "隐藏官方字幕:" + }, + "sectionTranslation": { + "message": "翻译" + }, + "cardTranslationEngineTitle": { + "message": "翻译引擎" + }, + "cardTranslationEngineDesc": { + "message": "选择您喜欢的翻译服务。" + }, + "cardPerformanceTitle": { + "message": "性能" + }, "cardPerformanceDesc": { "message": "调整扩展处理翻译请求的方式,以平衡速度和稳定性。" }, - "sectionProviders": { "message": "提供商设置" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "提供商设置" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "输入您的 DeepL 翻译 API 密钥。选择免费或专业版计划。" }, - "apiKeyLabel": { "message": "API 密钥:" }, - "apiPlanLabel": { "message": "API 计划:" }, - "apiPlanFree": { "message": "DeepL API 免费版" }, - "apiPlanPro": { "message": "DeepL API 专业版" }, - "sectionAbout": { "message": "关于" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "版本" }, + "apiKeyLabel": { + "message": "API 密钥:" + }, + "apiPlanLabel": { + "message": "API 计划:" + }, + "apiPlanFree": { + "message": "DeepL API 免费版" + }, + "apiPlanPro": { + "message": "DeepL API 专业版" + }, + "sectionAbout": { + "message": "关于" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "版本" + }, "aboutDescription": { "message": "此扩展帮助您在各种平台上观看双语字幕视频。" }, - "aboutDevelopment": { "message": "由 QuellaMC & 1jifang 开发。" }, - - "lang_en": { "message": "英语" }, - "lang_es": { "message": "西班牙语" }, - "lang_fr": { "message": "法语" }, - "lang_de": { "message": "德语" }, - "lang_it": { "message": "意大利语" }, - "lang_pt": { "message": "葡萄牙语" }, - "lang_ja": { "message": "日语" }, - "lang_ko": { "message": "韩语" }, - "lang_zh_CN": { "message": "中文 (简体)" }, - "lang_zh_TW": { "message": "中文 (繁体)" }, - "lang_ru": { "message": "俄语" }, - "lang_ar": { "message": "阿拉伯语" }, - "lang_hi": { "message": "印地语" }, - - "testDeepLButton": { "message": "测试 DeepL 连接" }, - "deeplApiKeyError": { "message": "请先输入您的 DeepL API 密钥。" }, - "testingButton": { "message": "测试中..." }, - "testingConnection": { "message": "正在测试 DeepL 连接..." }, + "aboutDevelopment": { + "message": "由 QuellaMC & 1jifang 开发。" + }, + "lang_en": { + "message": "英语" + }, + "lang_es": { + "message": "西班牙语" + }, + "lang_fr": { + "message": "法语" + }, + "lang_de": { + "message": "德语" + }, + "lang_it": { + "message": "意大利语" + }, + "lang_pt": { + "message": "葡萄牙语" + }, + "lang_ja": { + "message": "日语" + }, + "lang_ko": { + "message": "韩语" + }, + "lang_zh_CN": { + "message": "中文 (简体)" + }, + "lang_zh_TW": { + "message": "中文 (繁体)" + }, + "lang_ru": { + "message": "俄语" + }, + "lang_ar": { + "message": "阿拉伯语" + }, + "lang_hi": { + "message": "印地语" + }, + "testDeepLButton": { + "message": "测试 DeepL 连接" + }, + "deeplApiKeyError": { + "message": "请先输入您的 DeepL API 密钥。" + }, + "testingButton": { + "message": "测试中..." + }, + "testingConnection": { + "message": "正在测试 DeepL 连接..." + }, "deeplTestSuccess": { "message": "✅ DeepL API 测试成功" }, - "deeplTestUnexpectedFormat": { "message": "⚠️ DeepL API 响应但格式异常" }, - "deeplTestInvalidKey": { "message": "❌ DeepL API 密钥无效或被拒绝。" }, + "deeplTestUnexpectedFormat": { + "message": "⚠️ DeepL API 响应但格式异常" + }, + "deeplTestInvalidKey": { + "message": "❌ DeepL API 密钥无效或被拒绝。" + }, "deeplTestQuotaExceeded": { "message": "❌ DeepL API 配额已超限。请检查您的使用限制。" }, - "deeplTestApiError": { "message": "❌ DeepL API 错误 (%d):%s" }, + "deeplTestApiError": { + "message": "❌ DeepL API 错误 (%d):%s" + }, "deeplTestNetworkError": { "message": "❌ 网络错误:无法连接到 DeepL API。请检查您的网络连接。" }, - "deeplTestGenericError": { "message": "❌ 测试失败:%s" }, - "deepLApiUnavailable": { "message": "DeepL API 不可用" }, - "deepLApiUnavailableTooltip": { "message": "DeepL API 脚本加载失败" }, + "deeplTestGenericError": { + "message": "❌ 测试失败:%s" + }, + "deepLApiUnavailable": { + "message": "DeepL API 不可用" + }, + "deepLApiUnavailableTooltip": { + "message": "DeepL API 脚本加载失败" + }, "deeplApiNotLoadedError": { "message": "❌ DeepL API 脚本不可用。请刷新页面。" }, - - "cardGoogleTitle": { "message": "谷歌翻译" }, - "cardGoogleDesc": { "message": "由谷歌提供的免费翻译服务。无需额外配置。" }, - "cardMicrosoftTitle": { "message": "微软翻译" }, + "cardGoogleTitle": { + "message": "谷歌翻译" + }, + "cardGoogleDesc": { + "message": "由谷歌提供的免费翻译服务。无需额外配置。" + }, + "cardMicrosoftTitle": { + "message": "微软翻译" + }, "cardMicrosoftDesc": { "message": "由微软 Edge 提供的免费翻译服务。无需额外配置。" }, - "cardDeepLFreeTitle": { "message": "DeepL 翻译(免费)" }, + "cardDeepLFreeTitle": { + "message": "DeepL 翻译(免费)" + }, "cardDeepLFreeDesc": { "message": "免费的 DeepL 翻译服务,提供高质量的翻译结果。无需 API 密钥 - 使用 DeepL 的网页接口。" }, - "providerStatus": { "message": "状态:" }, - "statusReady": { "message": "可以使用" }, - "providerFeatures": { "message": "特性:" }, - "featureFree": { "message": "免费使用" }, - "featureNoApiKey": { "message": "无需 API 密钥" }, - "featureWideLanguageSupport": { "message": "广泛的语言支持" }, - "featureFastTranslation": { "message": "快速翻译" }, - "featureHighQuality": { "message": "高质量翻译" }, - "featureGoodPerformance": { "message": "良好的性能" }, - "featureHighestQuality": { "message": "最高质量翻译" }, - "featureApiKeyRequired": { "message": "需要 API 密钥" }, - "featureLimitedLanguages": { "message": "有限的语言支持" }, - "featureUsageLimits": { "message": "使用限制适用" }, - "featureMultipleBackups": { "message": "多种备用方法" }, - - "providerNotes": { "message": "注意事项:" }, - "noteSlowForSecurity": { "message": "由于安全措施,速度稍慢" }, - "noteAutoFallback": { "message": "自动回退到其他服务" }, - "noteRecommendedDefault": { "message": "推荐作为默认提供商" }, - - "providerGoogleName": { "message": "谷歌翻译(免费)" }, - "providerMicrosoftName": { "message": "微软翻译(免费)" }, - "providerDeepLName": { "message": "DeepL(需要 API 密钥)" }, - "providerDeepLFreeName": { "message": "DeepL 翻译(免费)" }, + "providerStatus": { + "message": "状态:" + }, + "statusReady": { + "message": "可以使用" + }, + "providerFeatures": { + "message": "特性:" + }, + "featureFree": { + "message": "免费使用" + }, + "featureNoApiKey": { + "message": "无需 API 密钥" + }, + "featureWideLanguageSupport": { + "message": "广泛的语言支持" + }, + "featureFastTranslation": { + "message": "快速翻译" + }, + "featureHighQuality": { + "message": "高质量翻译" + }, + "featureGoodPerformance": { + "message": "良好的性能" + }, + "featureHighestQuality": { + "message": "最高质量翻译" + }, + "featureApiKeyRequired": { + "message": "需要 API 密钥" + }, + "featureLimitedLanguages": { + "message": "有限的语言支持" + }, + "featureUsageLimits": { + "message": "使用限制适用" + }, + "featureMultipleBackups": { + "message": "多种备用方法" + }, + "providerNotes": { + "message": "注意事项:" + }, + "noteSlowForSecurity": { + "message": "由于安全措施,速度稍慢" + }, + "noteAutoFallback": { + "message": "自动回退到其他服务" + }, + "noteRecommendedDefault": { + "message": "推荐作为默认提供商" + }, + "providerGoogleName": { + "message": "谷歌翻译(免费)" + }, + "providerMicrosoftName": { + "message": "微软翻译(免费)" + }, + "providerDeepLName": { + "message": "DeepL(需要 API 密钥)" + }, + "providerDeepLFreeName": { + "message": "DeepL 翻译(免费)" + }, "providerOpenAICompatibleName": { "message": "OpenAI 兼容(需要 API 密钥)" }, "providerVertexGeminiName": { "message": "Vertex AI Gemini(需要 API 密钥)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI 兼容(需要 API 密钥)" }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI 兼容(需要 API 密钥)" + }, "cardOpenAICompatibleDesc": { "message": "输入您的 API 密钥和设置,用于 Gemini 等 OpenAI 兼容服务。" }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(需要 API 密钥)" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini(需要 API 密钥)" + }, "cardVertexGeminiDesc": { "message": "输入您的访问令牌和 Vertex 项目设置,或导入服务账号 JSON 文件。" }, - "vertexAccessTokenLabel": { "message": "访问令牌:" }, - "vertexProjectIdLabel": { "message": "项目 ID:" }, - "vertexLocationLabel": { "message": "位置:" }, - "vertexModelLabel": { "message": "模型:" }, - "vertexMissingConfig": { "message": "请输入访问令牌和项目 ID。" }, - "vertexConnectionFailed": { "message": "连接失败:%s" }, - "vertexServiceAccountLabel": { "message": "服务账号 JSON:" }, - "vertexImportButton": { "message": "导入 JSON 文件" }, - "vertexRefreshButton": { "message": "🔄 刷新令牌" }, - "vertexImportHint": { "message": "自动填充下方凭据" }, - "vertexImporting": { "message": "导入中..." }, - "vertexRefreshingToken": { "message": "正在刷新访问令牌..." }, - "vertexGeneratingToken": { "message": "正在生成访问令牌..." }, - "vertexImportSuccess": { "message": "服务账号已导入并生成令牌。" }, - "vertexImportFailed": { "message": "导入失败:%s" }, - "vertexTokenRefreshed": { "message": "访问令牌刷新成功。" }, - "vertexRefreshFailed": { "message": "令牌刷新失败:%s" }, - "vertexTokenExpired": { "message": "⚠️ 访问令牌已过期。点击刷新以续期。" }, + "vertexAccessTokenLabel": { + "message": "访问令牌:" + }, + "vertexProjectIdLabel": { + "message": "项目 ID:" + }, + "vertexLocationLabel": { + "message": "位置:" + }, + "vertexModelLabel": { + "message": "模型:" + }, + "vertexMissingConfig": { + "message": "请输入访问令牌和项目 ID。" + }, + "vertexConnectionFailed": { + "message": "连接失败:%s" + }, + "vertexServiceAccountLabel": { + "message": "服务账号 JSON:" + }, + "vertexImportButton": { + "message": "导入 JSON 文件" + }, + "vertexRefreshButton": { + "message": "🔄 刷新令牌" + }, + "vertexImportHint": { + "message": "自动填充下方凭据" + }, + "vertexImporting": { + "message": "导入中..." + }, + "vertexRefreshingToken": { + "message": "正在刷新访问令牌..." + }, + "vertexGeneratingToken": { + "message": "正在生成访问令牌..." + }, + "vertexImportSuccess": { + "message": "服务账号已导入并生成令牌。" + }, + "vertexImportFailed": { + "message": "导入失败:%s" + }, + "vertexTokenRefreshed": { + "message": "访问令牌刷新成功。" + }, + "vertexRefreshFailed": { + "message": "令牌刷新失败:%s" + }, + "vertexTokenExpired": { + "message": "⚠️ 访问令牌已过期。点击刷新以续期。" + }, "vertexTokenExpiringSoon": { "message": "⚠️ 令牌将在 %s 分钟后过期。建议刷新。" }, - "vertexConfigured": { "message": "⚠️ Vertex AI 已配置。请测试连接。" }, - "vertexNotConfigured": { "message": "请导入服务账号 JSON 或输入凭据。" }, - "featureVertexServiceAccount": { "message": "服务账号 JSON 导入" }, - "featureVertexAutoToken": { "message": "自动生成令牌" }, + "vertexConfigured": { + "message": "⚠️ Vertex AI 已配置。请测试连接。" + }, + "vertexNotConfigured": { + "message": "请导入服务账号 JSON 或输入凭据。" + }, + "featureVertexServiceAccount": { + "message": "服务账号 JSON 导入" + }, + "featureVertexAutoToken": { + "message": "自动生成令牌" + }, "featureVertexGemini": { "message": "通过 Vertex AI 使用 Google Gemini 模型" }, - "providerNote": { "message": "注意:" }, + "providerNote": { + "message": "注意:" + }, "vertexNote": { "message": "访问令牌在 1 小时后过期。您的服务账号已安全存储,需要时只需点击刷新令牌按钮即可。" }, - "baseUrlLabel": { "message": "基础 URL:" }, - "modelLabel": { "message": "模型:" }, - "featureCustomizable": { "message": "可自定义端点和模型" }, - "fetchModelsButton": { "message": "获取模型" }, - "testConnectionButton": { "message": "测试连接" }, - - "openaiApiKeyPlaceholder": { "message": "输入您的 OpenAI 兼容 API 密钥" }, + "baseUrlLabel": { + "message": "基础 URL:" + }, + "modelLabel": { + "message": "模型:" + }, + "featureCustomizable": { + "message": "可自定义端点和模型" + }, + "fetchModelsButton": { + "message": "获取模型" + }, + "testConnectionButton": { + "message": "测试连接" + }, + "openaiApiKeyPlaceholder": { + "message": "输入您的 OpenAI 兼容 API 密钥" + }, "openaiBaseUrlPlaceholder": { "message": "例如:https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "请先输入您的 API 密钥。" }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API 密钥需要测试。" }, + "openaiApiKeyError": { + "message": "请先输入您的 API 密钥。" + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API 密钥需要测试。" + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI 兼容 API 密钥需要测试。" }, - "openaiTestingConnection": { "message": "正在测试连接..." }, - "openaiConnectionSuccessful": { "message": "连接成功!" }, - "openaiConnectionFailed": { "message": "连接失败:%s" }, - "openaieFetchingModels": { "message": "正在获取模型..." }, - "openaiModelsFetchedSuccessfully": { "message": "模型获取成功。" }, - "openaiFailedToFetchModels": { "message": "获取模型失败:%s" }, - - "cardLoggingLevelTitle": { "message": "日志级别" }, + "openaiTestingConnection": { + "message": "正在测试连接..." + }, + "openaiConnectionSuccessful": { + "message": "连接成功!" + }, + "openaiConnectionFailed": { + "message": "连接失败:%s" + }, + "openaieFetchingModels": { + "message": "正在获取模型..." + }, + "openaiModelsFetchedSuccessfully": { + "message": "模型获取成功。" + }, + "openaiFailedToFetchModels": { + "message": "获取模型失败:%s" + }, + "cardLoggingLevelTitle": { + "message": "日志级别" + }, "cardLoggingLevelDesc": { "message": "控制浏览器控制台中显示的调试信息量。较高级别包含所有较低级别的消息。" }, - "loggingLevelLabel": { "message": "日志级别:" }, - "loggingLevelOff": { "message": "关闭" }, - "loggingLevelError": { "message": "仅错误" }, - "loggingLevelWarn": { "message": "警告和错误" }, - "loggingLevelInfo": { "message": "信息及以上" }, - "loggingLevelDebug": { "message": "调试(全部)" }, - - "cardBatchTranslationTitle": { "message": "批量翻译" }, + "loggingLevelLabel": { + "message": "日志级别:" + }, + "loggingLevelOff": { + "message": "关闭" + }, + "loggingLevelError": { + "message": "仅错误" + }, + "loggingLevelWarn": { + "message": "警告和错误" + }, + "loggingLevelInfo": { + "message": "信息及以上" + }, + "loggingLevelDebug": { + "message": "调试(全部)" + }, + "cardBatchTranslationTitle": { + "message": "批量翻译" + }, "cardBatchTranslationDesc": { "message": "批量翻译将多个字幕片段一起处理,减少 80-90% 的 API 调用并提高性能。为您首选的翻译提供商配置最佳设置。" }, - "batchingEnabledLabel": { "message": "启用批量翻译:" }, + "batchingEnabledLabel": { + "message": "启用批量翻译:" + }, "batchingEnabledHelp": { "message": "将多个字幕片段组合成单个翻译请求" }, - "useProviderDefaultsLabel": { "message": "使用提供商优化设置:" }, + "useProviderDefaultsLabel": { + "message": "使用提供商优化设置:" + }, "useProviderDefaultsHelp": { "message": "自动为每个翻译提供商使用最佳批量大小" }, - "globalBatchSizeLabel": { "message": "全局批量大小:" }, + "globalBatchSizeLabel": { + "message": "全局批量大小:" + }, "globalBatchSizeHelp": { "message": "一起处理的字幕片段数量(1-15)" }, - "smartBatchingLabel": { "message": "智能批量优化:" }, + "smartBatchingLabel": { + "message": "智能批量优化:" + }, "smartBatchingHelp": { "message": "根据播放位置优先处理字幕片段" }, - "maxConcurrentBatchesLabel": { "message": "最大并发批次:" }, + "maxConcurrentBatchesLabel": { + "message": "最大并发批次:" + }, "maxConcurrentBatchesHelp": { "message": "同时处理的翻译批次数量" }, - - "cardProviderBatchTitle": { "message": "提供商特定批量大小" }, + "cardProviderBatchTitle": { + "message": "提供商特定批量大小" + }, "cardProviderBatchDesc": { "message": "为每个翻译提供商配置最佳批量大小。这些设置在启用\"使用提供商优化设置\"时使用。" }, - "openaieBatchSizeLabel": { "message": "OpenAI 批量大小:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI 批量大小:" + }, "openaieBatchSizeHelp": { "message": "推荐:5-10 个片段(默认:8)" }, - "googleBatchSizeLabel": { "message": "谷歌翻译批量大小:" }, + "googleBatchSizeLabel": { + "message": "谷歌翻译批量大小:" + }, "googleBatchSizeHelp": { "message": "推荐:3-5 个片段(默认:4)" }, - "deeplBatchSizeLabel": { "message": "DeepL 批量大小:" }, + "deeplBatchSizeLabel": { + "message": "DeepL 批量大小:" + }, "deeplBatchSizeHelp": { "message": "推荐:2-3 个片段(默认:3)" }, - "microsoftBatchSizeLabel": { "message": "微软翻译批量大小:" }, + "microsoftBatchSizeLabel": { + "message": "微软翻译批量大小:" + }, "microsoftBatchSizeHelp": { "message": "推荐:3-5 个片段(默认:4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI 批量大小:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI 批量大小:" + }, "vertexBatchSizeHelp": { "message": "推荐:5-10 个片段(默认:8)" }, - - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API 密钥需要测试。" }, - - "cardProviderDelayTitle": { "message": "提供商特定请求延迟" }, + "deeplTestNeedsTesting": { + "message": "⚠️ DeepL API 密钥需要测试。" + }, + "cardProviderDelayTitle": { + "message": "提供商特定请求延迟" + }, "cardProviderDelayDesc": { "message": "配置翻译请求之间的强制延迟以防止账户锁定。即使启用批量处理,这些延迟也会应用。" }, - "openaieDelayLabel": { "message": "OpenAI 请求延迟 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI 请求延迟 (ms):" + }, "openaieDelayHelp": { "message": "请求之间的最小延迟(默认:100ms)" }, - "googleDelayLabel": { "message": "谷歌翻译请求延迟 (ms):" }, + "googleDelayLabel": { + "message": "谷歌翻译请求延迟 (ms):" + }, "googleDelayHelp": { "message": "防止临时锁定所需的延迟(默认:1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API 请求延迟 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API 请求延迟 (ms):" + }, "deeplDelayHelp": { "message": "DeepL API 请求的延迟(默认:500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL 免费请求延迟 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL 免费请求延迟 (ms):" + }, "deeplFreeDelayHelp": { "message": "免费层的保守延迟(默认:2000ms)" }, - "microsoftDelayLabel": { "message": "微软翻译请求延迟 (ms):" }, + "microsoftDelayLabel": { + "message": "微软翻译请求延迟 (ms):" + }, "microsoftDelayHelp": { "message": "尊重字符限制的延迟(默认:800ms)" }, - "vertexDelayLabel": { "message": "Vertex AI 请求延迟 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AI 请求延迟 (ms):" + }, "vertexDelayHelp": { "message": "请求之间的最小延迟(默认:100ms)" }, - - "aiContextModalTitle": { "message": "AI 上下文分析" }, - "aiContextSelectedWords": { "message": "选中的词语" }, - "aiContextNoWordsSelected": { "message": "未选择词语" }, - "aiContextClickHint": { "message": "💡 点击词语来添加或移除。" }, - "aiContextStartAnalysis": { "message": "开始分析" }, - "aiContextPauseAnalysis": { "message": "⏸ 暂停" }, - "aiContextPauseAnalysisTitle": { "message": "暂停分析" }, - "aiContextInitialMessage": { "message": "从字幕中选择词语开始分析。" }, - "aiContextAnalyzing": { "message": "正在分析上下文..." }, - "aiContextPauseNote": { "message": "点击 ⏸ 暂停分析" }, - "aiContextAnalysisFailed": { "message": "分析失败" }, - "aiContextNoContent": { "message": "无分析内容" }, - "aiContextNoContentMessage": { "message": "分析已完成但未返回内容。" }, - "aiContextDefinition": { "message": "📖 定义" }, - "aiContextCultural": { "message": "🌍 文化背景" }, - "aiContextCulturalSignificance": { "message": "⭐ 文化意义" }, - "aiContextHistorical": { "message": "📜 历史背景" }, - "aiContextHistoricalSignificance": { "message": "📜 历史意义" }, - "aiContextEvolution": { "message": "🔄 历史演变" }, - "aiContextLinguistic": { "message": "🔤 语言学分析" }, - "aiContextGrammar": { "message": "📝 语法与语义" }, - "aiContextUsage": { "message": "💡 用法与例句" }, - "aiContextExamples": { "message": "例句:" }, - "aiContextLearningTips": { "message": "🎯 学习提示" }, - "aiContextRelatedExpressions": { "message": "🔗 相关表达" }, - "aiContextKeyInsights": { "message": "🔑 关键见解" }, - "aiContextTypeCultural": { "message": "文化" }, - "aiContextTypeHistorical": { "message": "历史" }, - "aiContextTypeLinguistic": { "message": "语言学" }, - "aiContextTypeComprehensive": { "message": "综合" }, - "aiContextTypeGeneric": { "message": "上下文" }, - "aiContextClose": { "message": "关闭" }, - "aiContextAnalysisResults": { "message": "分析结果" }, - "aiContextRetrying": { "message": "分析失败,正在重新生成..." }, - "aiContextRetryNotification": { "message": "分析失败,正在重试..." }, - "aiContextRetryButton": { "message": "重试" }, + "aiContextModalTitle": { + "message": "AI 上下文分析" + }, + "aiContextSelectedWords": { + "message": "选中的词语" + }, + "aiContextNoWordsSelected": { + "message": "未选择词语" + }, + "aiContextClickHint": { + "message": "💡 点击词语来添加或移除。" + }, + "aiContextStartAnalysis": { + "message": "开始分析" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 暂停" + }, + "aiContextPauseAnalysisTitle": { + "message": "暂停分析" + }, + "aiContextInitialMessage": { + "message": "从字幕中选择词语开始分析。" + }, + "aiContextAnalyzing": { + "message": "正在分析上下文..." + }, + "aiContextPauseNote": { + "message": "点击 ⏸ 暂停分析" + }, + "aiContextAnalysisFailed": { + "message": "分析失败" + }, + "aiContextNoContent": { + "message": "无分析内容" + }, + "aiContextNoContentMessage": { + "message": "分析已完成但未返回内容。" + }, + "aiContextDefinition": { + "message": "📖 定义" + }, + "aiContextCultural": { + "message": "🌍 文化背景" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 文化意义" + }, + "aiContextHistorical": { + "message": "📜 历史背景" + }, + "aiContextHistoricalSignificance": { + "message": "📜 历史意义" + }, + "aiContextEvolution": { + "message": "🔄 历史演变" + }, + "aiContextLinguistic": { + "message": "🔤 语言学分析" + }, + "aiContextGrammar": { + "message": "📝 语法与语义" + }, + "aiContextUsage": { + "message": "💡 用法与例句" + }, + "aiContextExamples": { + "message": "例句:" + }, + "aiContextLearningTips": { + "message": "🎯 学习提示" + }, + "aiContextRelatedExpressions": { + "message": "🔗 相关表达" + }, + "aiContextKeyInsights": { + "message": "🔑 关键见解" + }, + "aiContextTypeCultural": { + "message": "文化" + }, + "aiContextTypeHistorical": { + "message": "历史" + }, + "aiContextTypeLinguistic": { + "message": "语言学" + }, + "aiContextTypeComprehensive": { + "message": "综合" + }, + "aiContextTypeGeneric": { + "message": "上下文" + }, + "aiContextClose": { + "message": "关闭" + }, + "aiContextAnalysisResults": { + "message": "分析结果" + }, + "aiContextRetrying": { + "message": "分析失败,正在重新生成..." + }, + "aiContextRetryNotification": { + "message": "分析失败,正在重试..." + }, + "aiContextRetryButton": { + "message": "重试" + }, "aiContextMalformedResponse": { "message": "AI 服务返回了无效的响应格式。这可能是由于临时服务问题。" }, "aiContextJsonCodeBlock": { "message": "AI 服务返回了未处理的 JSON 代码而不是结构化数据。这表明响应中存在格式错误。" }, - "aiContextCulturalContext": { "message": "文化背景:" }, - "aiContextSocialUsage": { "message": "社会用法:" }, - "aiContextRegionalNotes": { "message": "地区特色:" }, - "aiContextOrigins": { "message": "词源:" }, - "aiContextHistoricalContext": { "message": "历史背景:" }, - "aiContextEtymology": { "message": "词源学:" }, - "aiContextGrammarNotes": { "message": "语法注释:" }, - "aiContextTranslationNotes": { "message": "翻译注释:" }, - "aiContextLinguisticAnalysis": { "message": "语言学分析:" }, - "aiContextGrammarSemantics": { "message": "语法与语义:" }, - "aiContextUsageExamples": { "message": "用法与例句:" }, - - "navAIContext": { "message": "AI 上下文" }, - "sectionAIContext": { "message": "AI 上下文助手" }, - "cardAIContextToggleTitle": { "message": "启用 AI 上下文分析" }, + "aiContextCulturalContext": { + "message": "文化背景:" + }, + "aiContextSocialUsage": { + "message": "社会用法:" + }, + "aiContextRegionalNotes": { + "message": "地区特色:" + }, + "aiContextOrigins": { + "message": "词源:" + }, + "aiContextHistoricalContext": { + "message": "历史背景:" + }, + "aiContextEtymology": { + "message": "词源学:" + }, + "aiContextGrammarNotes": { + "message": "语法注释:" + }, + "aiContextTranslationNotes": { + "message": "翻译注释:" + }, + "aiContextLinguisticAnalysis": { + "message": "语言学分析:" + }, + "aiContextGrammarSemantics": { + "message": "语法与语义:" + }, + "aiContextUsageExamples": { + "message": "用法与例句:" + }, + "navAIContext": { + "message": "AI 上下文" + }, + "sectionAIContext": { + "message": "AI 上下文助手" + }, + "cardAIContextToggleTitle": { + "message": "启用 AI 上下文分析" + }, "cardAIContextToggleDesc": { "message": "为字幕文本启用 AI 驱动的文化、历史和语言学上下文分析。点击字幕中的单词或短语以获得详细解释。" }, - "aiContextEnabledLabel": { "message": "启用 AI 上下文:" }, - "cardAIContextProviderTitle": { "message": "AI 提供商" }, + "aiContextEnabledLabel": { + "message": "启用 AI 上下文:" + }, + "cardAIContextProviderTitle": { + "message": "AI 提供商" + }, "cardAIContextProviderDesc": { "message": "选择用于上下文分析的 AI 服务提供商。不同的提供商可能提供不同的质量和响应时间。" }, - "aiContextProviderLabel": { "message": "提供商:" }, - "cardOpenAIContextTitle": { "message": "OpenAI 配置" }, + "aiContextProviderLabel": { + "message": "提供商:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI 配置" + }, "cardOpenAIContextDesc": { "message": "配置用于上下文分析的 OpenAI API 设置。您需要一个有效的 OpenAI API 密钥。" }, - "openaiApiKeyLabel": { "message": "API 密钥:" }, - "openaiBaseUrlLabel": { "message": "基础 URL:" }, - "openaiModelLabel": { "message": "模型:" }, - "cardGeminiContextTitle": { "message": "Google Gemini 配置" }, + "openaiApiKeyLabel": { + "message": "API 密钥:" + }, + "openaiBaseUrlLabel": { + "message": "基础 URL:" + }, + "openaiModelLabel": { + "message": "模型:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini 配置" + }, "cardGeminiContextDesc": { "message": "配置用于上下文分析的 Google Gemini API 设置。您需要一个有效的 Gemini API 密钥。" }, - "geminiApiKeyLabel": { "message": "API 密钥:" }, - "geminiModelLabel": { "message": "模型:" }, - "cardAIContextTypesTitle": { "message": "上下文类型" }, + "geminiApiKeyLabel": { + "message": "API 密钥:" + }, + "geminiModelLabel": { + "message": "模型:" + }, + "cardAIContextTypesTitle": { + "message": "上下文类型" + }, "cardAIContextTypesDesc": { "message": "启用您想要使用的上下文分析类型。您可以启用多种类型。" }, - "contextTypeCulturalLabel": { "message": "文化上下文:" }, - "contextTypeCulturalHelp": { "message": "分析文化参考、习语和社会背景" }, - "contextTypeHistoricalLabel": { "message": "历史上下文:" }, - "contextTypeHistoricalHelp": { "message": "提供历史背景和时代背景" }, - "contextTypeLinguisticLabel": { "message": "语言学分析:" }, - "contextTypeLinguisticHelp": { "message": "解释语法、词源和语言结构" }, - - "cardAIContextPrivacyTitle": { "message": "隐私和数据" }, + "contextTypeCulturalLabel": { + "message": "文化上下文:" + }, + "contextTypeCulturalHelp": { + "message": "分析文化参考、习语和社会背景" + }, + "contextTypeHistoricalLabel": { + "message": "历史上下文:" + }, + "contextTypeHistoricalHelp": { + "message": "提供历史背景和时代背景" + }, + "contextTypeLinguisticLabel": { + "message": "语言学分析:" + }, + "contextTypeLinguisticHelp": { + "message": "解释语法、词源和语言结构" + }, + "cardAIContextPrivacyTitle": { + "message": "隐私和数据" + }, "cardAIContextPrivacyDesc": { "message": "控制在上下文分析期间如何处理您的数据。" }, @@ -412,22 +887,103 @@ "aiContextUserConsentHelp": { "message": "AI 上下文分析功能正常运行所必需" }, - "aiContextDataSharingLabel": { "message": "允许匿名使用分析:" }, + "aiContextDataSharingLabel": { + "message": "允许匿名使用分析:" + }, "aiContextDataSharingHelp": { "message": "通过分享匿名使用数据帮助改进服务" }, - "cardAIContextAdvancedTitle": { "message": "高级设置" }, + "cardAIContextAdvancedTitle": { + "message": "高级设置" + }, "cardAIContextAdvancedDesc": { "message": "配置 AI 上下文分析行为的高级选项。" }, - "aiContextTimeoutLabel": { "message": "请求超时 (ms):" }, - "aiContextTimeoutHelp": { "message": "等待 AI 响应的最长时间" }, - "aiContextRateLimitLabel": { "message": "速率限制 (请求/分钟):" }, - "aiContextRateLimitHelp": { "message": "每分钟最大请求数" }, - "aiContextCacheEnabledLabel": { "message": "启用缓存:" }, - "aiContextCacheEnabledHelp": { "message": "缓存分析结果以减少 API 调用" }, - "aiContextRetryAttemptsLabel": { "message": "重试次数:" }, - "aiContextRetryAttemptsHelp": { "message": "重试失败请求的次数" }, - "showAdvancedSettings": { "message": "显示高级设置" }, - "hideAdvancedSettings": { "message": "隐藏高级设置" } -} + "aiContextTimeoutLabel": { + "message": "请求超时 (ms):" + }, + "aiContextTimeoutHelp": { + "message": "等待 AI 响应的最长时间" + }, + "aiContextRateLimitLabel": { + "message": "速率限制 (请求/分钟):" + }, + "aiContextRateLimitHelp": { + "message": "每分钟最大请求数" + }, + "aiContextCacheEnabledLabel": { + "message": "启用缓存:" + }, + "aiContextCacheEnabledHelp": { + "message": "缓存分析结果以减少 API 调用" + }, + "aiContextRetryAttemptsLabel": { + "message": "重试次数:" + }, + "aiContextRetryAttemptsHelp": { + "message": "重试失败请求的次数" + }, + "showAdvancedSettings": { + "message": "显示高级设置" + }, + "hideAdvancedSettings": { + "message": "隐藏高级设置" + }, + "sidepanelLoading": { + "message": "加载中..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI 分析" + }, + "sidepanelTabWordsLists": { + "message": "单词列表" + }, + "sidepanelAnalyzeButton": { + "message": "分析" + }, + "sidepanelAnalyzing": { + "message": "分析中..." + }, + "sidepanelWordsToAnalyze": { + "message": "待分析单词" + }, + "sidepanelWordInputPlaceholder": { + "message": "点击字幕中的单词以添加分析..." + }, + "sidepanelErrorRetry": { + "message": "重试" + }, + "sidepanelResultsTitle": { + "message": "“%s”的结果" + }, + "sidepanelSectionDefinition": { + "message": "定义" + }, + "sidepanelSectionCultural": { + "message": "文化背景" + }, + "sidepanelSectionHistorical": { + "message": "历史背景" + }, + "sidepanelSectionLinguistic": { + "message": "语言学分析" + }, + "sidepanelMyWordsTitle": { + "message": "我的单词" + }, + "sidepanelFeatureComingSoon": { + "message": "单词列表功能即将推出!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "此功能目前正在开发中。在设置中启用以尝试预览。" + }, + "sidepanelErrorNoWords": { + "message": "未选择要分析的单词" + }, + "sidepanelErrorDisabled": { + "message": "AI 上下文分析已禁用。请在设置中启用。" + }, + "sidepanelErrorGeneric": { + "message": "分析过程中发生错误。" + } +} \ No newline at end of file diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index 4fe520b..7cbb0f1 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -1,428 +1,922 @@ { - "appName": { "message": "DualSub" }, - "appDesc": { "message": "在串流平台上顯示雙語字幕。" }, - "pageTitle": { "message": "DualSub 設定" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "啟用雙語字幕:" }, - "useNativeSubtitlesLabel": { "message": "使用官方字幕:" }, - "originalLanguageLabel": { "message": "原始語言:" }, - "translationSettingsLegend": { "message": "翻譯設定" }, - "providerLabel": { "message": "提供商:" }, - "targetLanguageLabel": { "message": "翻譯為:" }, - "batchSizeLabel": { "message": "批次大小:" }, - "requestDelayLabel": { "message": "請求延遲 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "字幕外觀和時序" }, - "displayOrderLabel": { "message": "顯示順序:" }, - "layoutLabel": { "message": "佈局:" }, - "fontSizeLabel": { "message": "字體大小:" }, - "verticalGapLabel": { "message": "垂直間距:" }, - "subtitleVerticalPositionLabel": { "message": "垂直位置:" }, - "timeOffsetLabel": { "message": "時間偏移(秒):" }, - "displayOrderOriginalFirst": { "message": "原文在上" }, - "displayOrderTranslationFirst": { "message": "譯文在上" }, - "layoutTopBottom": { "message": "上下排列" }, - "layoutLeftRight": { "message": "左右排列" }, - "uiLanguageLabel": { "message": "語言:" }, - "openOptionsButton": { "message": "進階設定" }, - "statusLanguageSetTo": { "message": "重新整理頁面後生效:" }, - "statusDualEnabled": { "message": "雙語字幕已啟用。(重新整理頁面)" }, - "statusDualDisabled": { "message": "雙語字幕已停用。(重新整理頁面)" }, + "appName": { + "message": "DualSub" + }, + "appDesc": { + "message": "在串流平台上顯示雙語字幕。" + }, + "pageTitle": { + "message": "DualSub 設定" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "啟用雙語字幕:" + }, + "useNativeSubtitlesLabel": { + "message": "使用官方字幕:" + }, + "originalLanguageLabel": { + "message": "原始語言:" + }, + "translationSettingsLegend": { + "message": "翻譯設定" + }, + "providerLabel": { + "message": "提供商:" + }, + "targetLanguageLabel": { + "message": "翻譯為:" + }, + "batchSizeLabel": { + "message": "批次大小:" + }, + "requestDelayLabel": { + "message": "請求延遲 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "字幕外觀和時序" + }, + "displayOrderLabel": { + "message": "顯示順序:" + }, + "layoutLabel": { + "message": "佈局:" + }, + "fontSizeLabel": { + "message": "字體大小:" + }, + "verticalGapLabel": { + "message": "垂直間距:" + }, + "subtitleVerticalPositionLabel": { + "message": "垂直位置:" + }, + "timeOffsetLabel": { + "message": "時間偏移(秒):" + }, + "displayOrderOriginalFirst": { + "message": "原文在上" + }, + "displayOrderTranslationFirst": { + "message": "譯文在上" + }, + "layoutTopBottom": { + "message": "上下排列" + }, + "layoutLeftRight": { + "message": "左右排列" + }, + "uiLanguageLabel": { + "message": "語言:" + }, + "openOptionsButton": { + "message": "進階設定" + }, + "statusLanguageSetTo": { + "message": "重新整理頁面後生效:" + }, + "statusDualEnabled": { + "message": "雙語字幕已啟用。(重新整理頁面)" + }, + "statusDualDisabled": { + "message": "雙語字幕已停用。(重新整理頁面)" + }, "statusSmartTranslationEnabled": { "message": "智慧翻譯已啟用。(重新整理頁面)" }, "statusSmartTranslationDisabled": { "message": "智慧翻譯已停用。(重新整理頁面)" }, - "statusOriginalLanguage": { "message": "重新整理頁面後生效:" }, - "statusTimeOffset": { "message": "時間偏移:" }, - "statusDisplayOrderUpdated": { "message": "顯示順序已更新。" }, - "statusLayoutOrientationUpdated": { "message": "佈局方向已更新。" }, - "statusFontSize": { "message": "字體大小:" }, - "statusVerticalGap": { "message": "垂直間距:" }, - "statusVerticalPosition": { "message": "垂直位置:" }, - "statusInvalidOffset": { "message": "無效偏移,已還原。" }, - "statusSettingNotApplied": { "message": "設定未套用。請重新整理頁面。" }, - - "optionsPageTitle": { "message": "DualSub 選項" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "一般" }, - "navTranslation": { "message": "翻譯" }, - "navProviders": { "message": "提供商" }, - "navAbout": { "message": "關於" }, - "sectionGeneral": { "message": "一般" }, - "cardUILanguageTitle": { "message": "介面語言" }, - "cardUILanguageDesc": { "message": "選擇擴充功能介面的顯示語言。" }, - "cardHideOfficialSubtitlesTitle": { "message": "隱藏官方字幕" }, + "statusOriginalLanguage": { + "message": "重新整理頁面後生效:" + }, + "statusTimeOffset": { + "message": "時間偏移:" + }, + "statusDisplayOrderUpdated": { + "message": "顯示順序已更新。" + }, + "statusLayoutOrientationUpdated": { + "message": "佈局方向已更新。" + }, + "statusFontSize": { + "message": "字體大小:" + }, + "statusVerticalGap": { + "message": "垂直間距:" + }, + "statusVerticalPosition": { + "message": "垂直位置:" + }, + "statusInvalidOffset": { + "message": "無效偏移,已還原。" + }, + "statusSettingNotApplied": { + "message": "設定未套用。請重新整理頁面。" + }, + "optionsPageTitle": { + "message": "DualSub 選項" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "一般" + }, + "navTranslation": { + "message": "翻譯" + }, + "navProviders": { + "message": "提供商" + }, + "navAbout": { + "message": "關於" + }, + "sectionGeneral": { + "message": "一般" + }, + "cardUILanguageTitle": { + "message": "介面語言" + }, + "cardUILanguageDesc": { + "message": "選擇擴充功能介面的顯示語言。" + }, + "cardHideOfficialSubtitlesTitle": { + "message": "隱藏官方字幕" + }, "cardHideOfficialSubtitlesDesc": { "message": "當 DualSub 啟用時,隱藏影片平台的官方字幕。" }, - "hideOfficialSubtitlesLabel": { "message": "隱藏官方字幕:" }, - "sectionTranslation": { "message": "翻譯" }, - "cardTranslationEngineTitle": { "message": "翻譯引擎" }, - "cardTranslationEngineDesc": { "message": "選擇您偏好的翻譯服務。" }, - "cardPerformanceTitle": { "message": "效能" }, + "hideOfficialSubtitlesLabel": { + "message": "隱藏官方字幕:" + }, + "sectionTranslation": { + "message": "翻譯" + }, + "cardTranslationEngineTitle": { + "message": "翻譯引擎" + }, + "cardTranslationEngineDesc": { + "message": "選擇您偏好的翻譯服務。" + }, + "cardPerformanceTitle": { + "message": "效能" + }, "cardPerformanceDesc": { "message": "調整擴充功能處理翻譯請求的方式,以平衡速度和穩定性。" }, - "sectionProviders": { "message": "提供商設定" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "提供商設定" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "輸入您的 DeepL 翻譯 API 金鑰。選擇免費或專業版方案。" }, - "apiKeyLabel": { "message": "API 金鑰:" }, - "apiPlanLabel": { "message": "API 方案:" }, - "apiPlanFree": { "message": "DeepL API 免費版" }, - "apiPlanPro": { "message": "DeepL API 專業版" }, - "sectionAbout": { "message": "關於" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "版本" }, + "apiKeyLabel": { + "message": "API 金鑰:" + }, + "apiPlanLabel": { + "message": "API 方案:" + }, + "apiPlanFree": { + "message": "DeepL API 免費版" + }, + "apiPlanPro": { + "message": "DeepL API 專業版" + }, + "sectionAbout": { + "message": "關於" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "版本" + }, "aboutDescription": { "message": "此擴充功能幫助您在各種平台上觀看雙語字幕影片。" }, - "aboutDevelopment": { "message": "由 QuellaMC & 1jifang 開發。" }, - - "lang_en": { "message": "英語" }, - "lang_es": { "message": "西班牙語" }, - "lang_fr": { "message": "法語" }, - "lang_de": { "message": "德語" }, - "lang_it": { "message": "義大利語" }, - "lang_pt": { "message": "葡萄牙語" }, - "lang_ja": { "message": "日語" }, - "lang_ko": { "message": "韓語" }, - "lang_zh_CN": { "message": "中文 (簡體)" }, - "lang_zh_TW": { "message": "中文 (繁體)" }, - "lang_ru": { "message": "俄語" }, - "lang_ar": { "message": "阿拉伯語" }, - "lang_hi": { "message": "印地語" }, - - "testDeepLButton": { "message": "測試 DeepL 連線" }, - "deeplApiKeyError": { "message": "請先輸入您的 DeepL API 金鑰。" }, - "testingButton": { "message": "測試中..." }, - "testingConnection": { "message": "正在測試 DeepL 連線..." }, + "aboutDevelopment": { + "message": "由 QuellaMC & 1jifang 開發。" + }, + "lang_en": { + "message": "英語" + }, + "lang_es": { + "message": "西班牙語" + }, + "lang_fr": { + "message": "法語" + }, + "lang_de": { + "message": "德語" + }, + "lang_it": { + "message": "義大利語" + }, + "lang_pt": { + "message": "葡萄牙語" + }, + "lang_ja": { + "message": "日語" + }, + "lang_ko": { + "message": "韓語" + }, + "lang_zh_CN": { + "message": "中文 (簡體)" + }, + "lang_zh_TW": { + "message": "中文 (繁體)" + }, + "lang_ru": { + "message": "俄語" + }, + "lang_ar": { + "message": "阿拉伯語" + }, + "lang_hi": { + "message": "印地語" + }, + "testDeepLButton": { + "message": "測試 DeepL 連線" + }, + "deeplApiKeyError": { + "message": "請先輸入您的 DeepL API 金鑰。" + }, + "testingButton": { + "message": "測試中..." + }, + "testingConnection": { + "message": "正在測試 DeepL 連線..." + }, "deeplTestSuccess": { "message": "✅ DeepL API 測試成功!將 \"Hello\" 翻譯為 \"%s\"" }, - "deeplTestSuccessSimple": { "message": "✅ DeepL API 測試成功!" }, - "deeplTestUnexpectedFormat": { "message": "⚠️ DeepL API 回應但格式異常" }, - "deeplTestInvalidKey": { "message": "❌ DeepL API 金鑰無效或被拒絕。" }, + "deeplTestSuccessSimple": { + "message": "✅ DeepL API 測試成功!" + }, + "deeplTestUnexpectedFormat": { + "message": "⚠️ DeepL API 回應但格式異常" + }, + "deeplTestInvalidKey": { + "message": "❌ DeepL API 金鑰無效或被拒絕。" + }, "deeplTestQuotaExceeded": { "message": "❌ DeepL API 配額已超限。請檢查您的使用限制。" }, - "deeplTestApiError": { "message": "❌ DeepL API 錯誤 (%d):%s" }, + "deeplTestApiError": { + "message": "❌ DeepL API 錯誤 (%d):%s" + }, "deeplTestNetworkError": { "message": "❌ 網路錯誤:無法連線到 DeepL API。請檢查您的網路連線。" }, - "deeplTestGenericError": { "message": "❌ 測試失敗:%s" }, - "deepLApiUnavailable": { "message": "DeepL API 不可用" }, - "deepLApiUnavailableTooltip": { "message": "DeepL API 指令碼載入失敗" }, + "deeplTestGenericError": { + "message": "❌ 測試失敗:%s" + }, + "deepLApiUnavailable": { + "message": "DeepL API 不可用" + }, + "deepLApiUnavailableTooltip": { + "message": "DeepL API 指令碼載入失敗" + }, "deeplApiNotLoadedError": { "message": "❌ DeepL API 指令碼不可用。請重新整理頁面。" }, - - "cardGoogleTitle": { "message": "Google 翻譯" }, + "cardGoogleTitle": { + "message": "Google 翻譯" + }, "cardGoogleDesc": { "message": "由 Google 提供的免費翻譯服務。無需額外設定。" }, - "cardMicrosoftTitle": { "message": "Microsoft 翻譯" }, + "cardMicrosoftTitle": { + "message": "Microsoft 翻譯" + }, "cardMicrosoftDesc": { "message": "由 Microsoft Edge 提供的免費翻譯服務。無需額外設定。" }, - "cardDeepLFreeTitle": { "message": "DeepL 翻譯(免費)" }, + "cardDeepLFreeTitle": { + "message": "DeepL 翻譯(免費)" + }, "cardDeepLFreeDesc": { "message": "免費的 DeepL 翻譯服務,提供高品質的翻譯結果。無需 API 金鑰 - 使用 DeepL 的網頁介面。" }, - "providerStatus": { "message": "狀態:" }, - "statusReady": { "message": "可以使用" }, - "providerFeatures": { "message": "特性:" }, - "featureFree": { "message": "免費使用" }, - "featureNoApiKey": { "message": "無需 API 金鑰" }, - "featureWideLanguageSupport": { "message": "廣泛的語言支援" }, - "featureFastTranslation": { "message": "快速翻譯" }, - "featureHighQuality": { "message": "高品質翻譯" }, - "featureGoodPerformance": { "message": "良好的效能" }, - "featureHighestQuality": { "message": "最高品質翻譯" }, - "featureApiKeyRequired": { "message": "需要 API 金鑰" }, - "featureLimitedLanguages": { "message": "有限的語言支援" }, - "featureUsageLimits": { "message": "使用限制適用" }, - "featureMultipleBackups": { "message": "多種備用方法" }, - - "providerNotes": { "message": "注意事項:" }, - "noteSlowForSecurity": { "message": "由於安全措施,速度稍慢" }, - "noteAutoFallback": { "message": "自動回退到其他服務" }, - "noteRecommendedDefault": { "message": "推薦作為預設提供商" }, - - "providerGoogleName": { "message": "Google 翻譯(免費)" }, - "providerMicrosoftName": { "message": "Microsoft 翻譯(免費)" }, - "providerDeepLName": { "message": "DeepL(需要 API 金鑰)" }, - "providerDeepLFreeName": { "message": "DeepL 翻譯(免費)" }, + "providerStatus": { + "message": "狀態:" + }, + "statusReady": { + "message": "可以使用" + }, + "providerFeatures": { + "message": "特性:" + }, + "featureFree": { + "message": "免費使用" + }, + "featureNoApiKey": { + "message": "無需 API 金鑰" + }, + "featureWideLanguageSupport": { + "message": "廣泛的語言支援" + }, + "featureFastTranslation": { + "message": "快速翻譯" + }, + "featureHighQuality": { + "message": "高品質翻譯" + }, + "featureGoodPerformance": { + "message": "良好的效能" + }, + "featureHighestQuality": { + "message": "最高品質翻譯" + }, + "featureApiKeyRequired": { + "message": "需要 API 金鑰" + }, + "featureLimitedLanguages": { + "message": "有限的語言支援" + }, + "featureUsageLimits": { + "message": "使用限制適用" + }, + "featureMultipleBackups": { + "message": "多種備用方法" + }, + "providerNotes": { + "message": "注意事項:" + }, + "noteSlowForSecurity": { + "message": "由於安全措施,速度稍慢" + }, + "noteAutoFallback": { + "message": "自動回退到其他服務" + }, + "noteRecommendedDefault": { + "message": "推薦作為預設提供商" + }, + "providerGoogleName": { + "message": "Google 翻譯(免費)" + }, + "providerMicrosoftName": { + "message": "Microsoft 翻譯(免費)" + }, + "providerDeepLName": { + "message": "DeepL(需要 API 金鑰)" + }, + "providerDeepLFreeName": { + "message": "DeepL 翻譯(免費)" + }, "providerOpenAICompatibleName": { "message": "OpenAI 相容(需要 API 金鑰)" }, "providerVertexGeminiName": { "message": "Vertex AI Gemini(需要 API 金鑰)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI 相容(需要 API 金鑰)" }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI 相容(需要 API 金鑰)" + }, "cardOpenAICompatibleDesc": { "message": "輸入您的 API 金鑰和設定,用於 Gemini 等 OpenAI 相容服務。" }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(需要 API 金鑰)" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini(需要 API 金鑰)" + }, "cardVertexGeminiDesc": { "message": "輸入您的存取權杖和 Vertex 專案設定,或匯入服務帳戶 JSON 檔案。" }, - "vertexAccessTokenLabel": { "message": "存取權杖:" }, - "vertexProjectIdLabel": { "message": "專案 ID:" }, - "vertexLocationLabel": { "message": "位置:" }, - "vertexModelLabel": { "message": "模型:" }, - "vertexMissingConfig": { "message": "請輸入存取權杖和專案 ID。" }, - "vertexConnectionFailed": { "message": "連線失敗:%s" }, - "vertexServiceAccountLabel": { "message": "服務帳戶 JSON:" }, - "vertexImportButton": { "message": "匯入 JSON 檔案" }, - "vertexRefreshButton": { "message": "🔄 重新整理權杖" }, - "vertexImportHint": { "message": "自動填入下方憑證" }, - "vertexImporting": { "message": "匯入中..." }, - "vertexRefreshingToken": { "message": "正在重新整理存取權杖..." }, - "vertexGeneratingToken": { "message": "正在產生存取權杖..." }, - "vertexImportSuccess": { "message": "服務帳戶已匯入並產生權杖。" }, - "vertexImportFailed": { "message": "匯入失敗:%s" }, - "vertexTokenRefreshed": { "message": "存取權杖已成功重新整理。" }, - "vertexRefreshFailed": { "message": "權杖重新整理失敗:%s" }, + "vertexAccessTokenLabel": { + "message": "存取權杖:" + }, + "vertexProjectIdLabel": { + "message": "專案 ID:" + }, + "vertexLocationLabel": { + "message": "位置:" + }, + "vertexModelLabel": { + "message": "模型:" + }, + "vertexMissingConfig": { + "message": "請輸入存取權杖和專案 ID。" + }, + "vertexConnectionFailed": { + "message": "連線失敗:%s" + }, + "vertexServiceAccountLabel": { + "message": "服務帳戶 JSON:" + }, + "vertexImportButton": { + "message": "匯入 JSON 檔案" + }, + "vertexRefreshButton": { + "message": "🔄 重新整理權杖" + }, + "vertexImportHint": { + "message": "自動填入下方憑證" + }, + "vertexImporting": { + "message": "匯入中..." + }, + "vertexRefreshingToken": { + "message": "正在重新整理存取權杖..." + }, + "vertexGeneratingToken": { + "message": "正在產生存取權杖..." + }, + "vertexImportSuccess": { + "message": "服務帳戶已匯入並產生權杖。" + }, + "vertexImportFailed": { + "message": "匯入失敗:%s" + }, + "vertexTokenRefreshed": { + "message": "存取權杖已成功重新整理。" + }, + "vertexRefreshFailed": { + "message": "權杖重新整理失敗:%s" + }, "vertexTokenExpired": { "message": "⚠️ 存取權杖已過期。點擊重新整理以更新。" }, "vertexTokenExpiringSoon": { "message": "⚠️ 權杖將在 %s 分鐘後過期。建議重新整理。" }, - "vertexConfigured": { "message": "⚠️ Vertex AI 已設定。請測試連線。" }, - "vertexNotConfigured": { "message": "請匯入服務帳戶 JSON 或輸入憑證。" }, - "featureVertexServiceAccount": { "message": "服務帳戶 JSON 匯入" }, - "featureVertexAutoToken": { "message": "自動產生權杖" }, + "vertexConfigured": { + "message": "⚠️ Vertex AI 已設定。請測試連線。" + }, + "vertexNotConfigured": { + "message": "請匯入服務帳戶 JSON 或輸入憑證。" + }, + "featureVertexServiceAccount": { + "message": "服務帳戶 JSON 匯入" + }, + "featureVertexAutoToken": { + "message": "自動產生權杖" + }, "featureVertexGemini": { "message": "透過 Vertex AI 使用 Google Gemini 模型" }, "vertexNote": { "message": "存取權杖在 1 小時後過期。您的服務帳戶已安全儲存,需要時只需點擊重新整理權杖按鈕即可。" }, - "baseUrlLabel": { "message": "基礎 URL:" }, - "modelLabel": { "message": "模型:" }, - "featureCustomizable": { "message": "可自訂端點和模型" }, - "fetchModelsButton": { "message": "取得模型" }, - "testConnectionButton": { "message": "測試連線" }, - - "openaiApiKeyPlaceholder": { "message": "輸入您的 OpenAI 相容 API 金鑰" }, + "baseUrlLabel": { + "message": "基礎 URL:" + }, + "modelLabel": { + "message": "模型:" + }, + "featureCustomizable": { + "message": "可自訂端點和模型" + }, + "fetchModelsButton": { + "message": "取得模型" + }, + "testConnectionButton": { + "message": "測試連線" + }, + "openaiApiKeyPlaceholder": { + "message": "輸入您的 OpenAI 相容 API 金鑰" + }, "openaiBaseUrlPlaceholder": { "message": "例如:https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "請先輸入您的 API 金鑰。" }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API 金鑰需要測試。" }, + "openaiApiKeyError": { + "message": "請先輸入您的 API 金鑰。" + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API 金鑰需要測試。" + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI 相容 API 金鑰需要測試。" }, - "openaiTestingConnection": { "message": "正在測試連線..." }, - "openaiConnectionSuccessful": { "message": "連線成功!" }, - "openaiConnectionFailed": { "message": "連線失敗:%s" }, - "openaieFetchingModels": { "message": "正在取得模型..." }, - "openaiModelsFetchedSuccessfully": { "message": "模型取得成功。" }, - "openaiFailedToFetchModels": { "message": "取得模型失敗:%s" }, - - "cardLoggingLevelTitle": { "message": "日誌級別" }, + "openaiTestingConnection": { + "message": "正在測試連線..." + }, + "openaiConnectionSuccessful": { + "message": "連線成功!" + }, + "openaiConnectionFailed": { + "message": "連線失敗:%s" + }, + "openaieFetchingModels": { + "message": "正在取得模型..." + }, + "openaiModelsFetchedSuccessfully": { + "message": "模型取得成功。" + }, + "openaiFailedToFetchModels": { + "message": "取得模型失敗:%s" + }, + "cardLoggingLevelTitle": { + "message": "日誌級別" + }, "cardLoggingLevelDesc": { "message": "控制瀏覽器控制台中顯示的除錯資訊量。較高級別包含所有較低級別的訊息。" }, - "loggingLevelLabel": { "message": "日誌級別:" }, - "loggingLevelOff": { "message": "關閉" }, - "loggingLevelError": { "message": "僅錯誤" }, - "loggingLevelWarn": { "message": "警告和錯誤" }, - "loggingLevelInfo": { "message": "資訊及以上" }, - "loggingLevelDebug": { "message": "除錯(全部)" }, - - "cardBatchTranslationTitle": { "message": "批次翻譯" }, + "loggingLevelLabel": { + "message": "日誌級別:" + }, + "loggingLevelOff": { + "message": "關閉" + }, + "loggingLevelError": { + "message": "僅錯誤" + }, + "loggingLevelWarn": { + "message": "警告和錯誤" + }, + "loggingLevelInfo": { + "message": "資訊及以上" + }, + "loggingLevelDebug": { + "message": "除錯(全部)" + }, + "cardBatchTranslationTitle": { + "message": "批次翻譯" + }, "cardBatchTranslationDesc": { "message": "批次翻譯將多個字幕片段一起處理,減少 80-90% 的 API 呼叫並提高效能。為您偏好的翻譯提供商設定最佳設定。" }, - "batchingEnabledLabel": { "message": "啟用批次翻譯:" }, + "batchingEnabledLabel": { + "message": "啟用批次翻譯:" + }, "batchingEnabledHelp": { "message": "將多個字幕片段組合成單一翻譯請求" }, - "useProviderDefaultsLabel": { "message": "使用提供商最佳化設定:" }, + "useProviderDefaultsLabel": { + "message": "使用提供商最佳化設定:" + }, "useProviderDefaultsHelp": { "message": "自動為每個翻譯提供商使用最佳批次大小" }, - "globalBatchSizeLabel": { "message": "全域批次大小:" }, + "globalBatchSizeLabel": { + "message": "全域批次大小:" + }, "globalBatchSizeHelp": { "message": "一起處理的字幕片段數量(1-15)" }, - "smartBatchingLabel": { "message": "智慧批次最佳化:" }, + "smartBatchingLabel": { + "message": "智慧批次最佳化:" + }, "smartBatchingHelp": { "message": "根據播放位置優先處理字幕片段" }, - "maxConcurrentBatchesLabel": { "message": "最大並行批次:" }, + "maxConcurrentBatchesLabel": { + "message": "最大並行批次:" + }, "maxConcurrentBatchesHelp": { "message": "同時處理的翻譯批次數量" }, - - "cardProviderBatchTitle": { "message": "提供商特定批次大小" }, + "cardProviderBatchTitle": { + "message": "提供商特定批次大小" + }, "cardProviderBatchDesc": { "message": "為每個翻譯提供商設定最佳批次大小。這些設定在啟用「使用提供商最佳化設定」時使用。" }, - "openaieBatchSizeLabel": { "message": "OpenAI 批次大小:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI 批次大小:" + }, "openaieBatchSizeHelp": { "message": "建議:5-10 個片段(預設:8)" }, - "googleBatchSizeLabel": { "message": "Google 翻譯批次大小:" }, + "googleBatchSizeLabel": { + "message": "Google 翻譯批次大小:" + }, "googleBatchSizeHelp": { "message": "建議:3-5 個片段(預設:4)" }, - "deeplBatchSizeLabel": { "message": "DeepL 批次大小:" }, + "deeplBatchSizeLabel": { + "message": "DeepL 批次大小:" + }, "deeplBatchSizeHelp": { "message": "建議:2-3 個片段(預設:3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft 翻譯批次大小:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft 翻譯批次大小:" + }, "microsoftBatchSizeHelp": { "message": "建議:3-5 個片段(預設:4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI 批次大小:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI 批次大小:" + }, "vertexBatchSizeHelp": { "message": "建議:5-10 個片段(預設:8)" }, - - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API 金鑰需要測試。" }, - - "cardProviderDelayTitle": { "message": "提供商特定請求延遲" }, + "deeplTestNeedsTesting": { + "message": "⚠️ DeepL API 金鑰需要測試。" + }, + "cardProviderDelayTitle": { + "message": "提供商特定請求延遲" + }, "cardProviderDelayDesc": { "message": "設定翻譯請求之間的強制延遲以防止帳戶鎖定。即使啟用批次處理,這些延遲也會套用。" }, - "openaieDelayLabel": { "message": "OpenAI 請求延遲 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI 請求延遲 (ms):" + }, "openaieDelayHelp": { "message": "請求之間的最小延遲(預設:100ms)" }, - "googleDelayLabel": { "message": "Google 翻譯請求延遲 (ms):" }, + "googleDelayLabel": { + "message": "Google 翻譯請求延遲 (ms):" + }, "googleDelayHelp": { "message": "防止臨時鎖定所需的延遲(預設:1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API 請求延遲 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API 請求延遲 (ms):" + }, "deeplDelayHelp": { "message": "DeepL API 請求的延遲(預設:500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL 免費請求延遲 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL 免費請求延遲 (ms):" + }, "deeplFreeDelayHelp": { "message": "免費層的保守延遲(預設:2000ms)" }, - "microsoftDelayLabel": { "message": "Microsoft 翻譯請求延遲 (ms):" }, + "microsoftDelayLabel": { + "message": "Microsoft 翻譯請求延遲 (ms):" + }, "microsoftDelayHelp": { "message": "尊重字元限制的延遲(預設:800ms)" }, - "vertexDelayLabel": { "message": "Vertex AI 請求延遲 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AI 請求延遲 (ms):" + }, "vertexDelayHelp": { "message": "請求之間的最小延遲(預設:100ms)" }, - - "aiContextModalTitle": { "message": "AI 上下文分析" }, - "aiContextSelectedWords": { "message": "選中的詞語" }, - "aiContextNoWordsSelected": { "message": "未選擇詞語" }, - "aiContextClickHint": { "message": "💡 點擊詞語來新增或移除。" }, - "aiContextStartAnalysis": { "message": "開始分析" }, - "aiContextPauseAnalysis": { "message": "⏸ 暫停" }, - "aiContextPauseAnalysisTitle": { "message": "暫停分析" }, - "aiContextInitialMessage": { "message": "從字幕中選擇詞語開始分析。" }, - "aiContextAnalyzing": { "message": "正在分析上下文..." }, - "aiContextPauseNote": { "message": "點擊 ⏸ 暫停分析" }, - "aiContextAnalysisFailed": { "message": "分析失敗" }, - "aiContextNoContent": { "message": "無分析內容" }, - "aiContextNoContentMessage": { "message": "分析已完成但未返回內容。" }, - "aiContextDefinition": { "message": "📖 定義" }, - "aiContextCultural": { "message": "🌍 文化背景" }, - "aiContextCulturalSignificance": { "message": "⭐ 文化意義" }, - "aiContextHistorical": { "message": "📜 歷史背景" }, - "aiContextHistoricalSignificance": { "message": "📜 歷史意義" }, - "aiContextEvolution": { "message": "🔄 歷史演變" }, - "aiContextLinguistic": { "message": "🔤 語言學分析" }, - "aiContextGrammar": { "message": "📝 語法與語義" }, - "aiContextUsage": { "message": "💡 用法與例句" }, - "aiContextExamples": { "message": "例句:" }, - "aiContextLearningTips": { "message": "🎯 學習提示" }, - "aiContextRelatedExpressions": { "message": "🔗 相關表達" }, - "aiContextKeyInsights": { "message": "🔑 關鍵見解" }, - "aiContextTypeCultural": { "message": "文化" }, - "aiContextTypeHistorical": { "message": "歷史" }, - "aiContextTypeLinguistic": { "message": "語言學" }, - "aiContextTypeComprehensive": { "message": "綜合" }, - "aiContextTypeGeneric": { "message": "上下文" }, - "aiContextClose": { "message": "關閉" }, - "aiContextAnalysisResults": { "message": "分析結果" }, - "aiContextRetrying": { "message": "分析失敗,正在重新生成..." }, - "aiContextRetryNotification": { "message": "分析失敗,正在重試..." }, - "aiContextRetryButton": { "message": "重試" }, + "aiContextModalTitle": { + "message": "AI 上下文分析" + }, + "aiContextSelectedWords": { + "message": "選中的詞語" + }, + "aiContextNoWordsSelected": { + "message": "未選擇詞語" + }, + "aiContextClickHint": { + "message": "💡 點擊詞語來新增或移除。" + }, + "aiContextStartAnalysis": { + "message": "開始分析" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 暫停" + }, + "aiContextPauseAnalysisTitle": { + "message": "暫停分析" + }, + "aiContextInitialMessage": { + "message": "從字幕中選擇詞語開始分析。" + }, + "aiContextAnalyzing": { + "message": "正在分析上下文..." + }, + "aiContextPauseNote": { + "message": "點擊 ⏸ 暫停分析" + }, + "aiContextAnalysisFailed": { + "message": "分析失敗" + }, + "aiContextNoContent": { + "message": "無分析內容" + }, + "aiContextNoContentMessage": { + "message": "分析已完成但未返回內容。" + }, + "aiContextDefinition": { + "message": "📖 定義" + }, + "aiContextCultural": { + "message": "🌍 文化背景" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 文化意義" + }, + "aiContextHistorical": { + "message": "📜 歷史背景" + }, + "aiContextHistoricalSignificance": { + "message": "📜 歷史意義" + }, + "aiContextEvolution": { + "message": "🔄 歷史演變" + }, + "aiContextLinguistic": { + "message": "🔤 語言學分析" + }, + "aiContextGrammar": { + "message": "📝 語法與語義" + }, + "aiContextUsage": { + "message": "💡 用法與例句" + }, + "aiContextExamples": { + "message": "例句:" + }, + "aiContextLearningTips": { + "message": "🎯 學習提示" + }, + "aiContextRelatedExpressions": { + "message": "🔗 相關表達" + }, + "aiContextKeyInsights": { + "message": "🔑 關鍵見解" + }, + "aiContextTypeCultural": { + "message": "文化" + }, + "aiContextTypeHistorical": { + "message": "歷史" + }, + "aiContextTypeLinguistic": { + "message": "語言學" + }, + "aiContextTypeComprehensive": { + "message": "綜合" + }, + "aiContextTypeGeneric": { + "message": "上下文" + }, + "aiContextClose": { + "message": "關閉" + }, + "aiContextAnalysisResults": { + "message": "分析結果" + }, + "aiContextRetrying": { + "message": "分析失敗,正在重新生成..." + }, + "aiContextRetryNotification": { + "message": "分析失敗,正在重試..." + }, + "aiContextRetryButton": { + "message": "重試" + }, "aiContextMalformedResponse": { "message": "AI 服務返回了無效的回應格式。這可能是由於臨時服務問題。" }, "aiContextJsonCodeBlock": { "message": "AI 服務返回了未處理的 JSON 代碼而不是結構化數據。這表明回應中存在格式錯誤。" }, - "aiContextCulturalContext": { "message": "文化背景:" }, - "aiContextSocialUsage": { "message": "社會用法:" }, - "aiContextRegionalNotes": { "message": "地區特色:" }, - "aiContextOrigins": { "message": "詞源:" }, - "aiContextHistoricalContext": { "message": "歷史背景:" }, - "aiContextHistoricalSignificance": { "message": "歷史意義:" }, - "aiContextEvolution": { "message": "演變過程:" }, - "aiContextEtymology": { "message": "詞源學:" }, - "aiContextGrammarNotes": { "message": "語法註釋:" }, - "aiContextTranslationNotes": { "message": "翻譯註釋:" }, - "aiContextLinguisticAnalysis": { "message": "語言學分析:" }, - "aiContextGrammarSemantics": { "message": "語法與語義:" }, - "aiContextUsageExamples": { "message": "用法與例句:" }, - "aiContextLearningTips": { "message": "學習技巧:" }, - "aiContextRelatedExpressions": { "message": "相關表達:" }, - "aiContextKeyInsights": { "message": "關鍵要點:" }, - - "navAIContext": { "message": "AI 上下文" }, - "sectionAIContext": { "message": "AI 上下文助手" }, - "cardAIContextToggleTitle": { "message": "啟用 AI 上下文分析" }, + "aiContextCulturalContext": { + "message": "文化背景:" + }, + "aiContextSocialUsage": { + "message": "社會用法:" + }, + "aiContextRegionalNotes": { + "message": "地區特色:" + }, + "aiContextOrigins": { + "message": "詞源:" + }, + "aiContextHistoricalContext": { + "message": "歷史背景:" + }, + "aiContextHistoricalSignificanceLabel": { + "message": "歷史意義:" + }, + "aiContextEvolutionLabel": { + "message": "演變過程:" + }, + "aiContextEtymology": { + "message": "詞源學:" + }, + "aiContextGrammarNotesLabel": { + "message": "語法註釋:" + }, + "aiContextTranslationNotesLabel": { + "message": "翻譯註釋:" + }, + "aiContextLinguisticAnalysisLabel": { + "message": "語言學分析:" + }, + "aiContextGrammarSemanticsLabel": { + "message": "語法與語義:" + }, + "aiContextUsageExamplesLabel": { + "message": "用法與例句:" + }, + "aiContextLearningTipsLabel": { + "message": "學習技巧:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "相關表達:" + }, + "aiContextKeyInsightsLabel": { + "message": "關鍵要點:" + }, + "navAIContext": { + "message": "AI 上下文" + }, + "sectionAIContext": { + "message": "AI 上下文助手" + }, + "cardAIContextToggleTitle": { + "message": "啟用 AI 上下文分析" + }, "cardAIContextToggleDesc": { "message": "為字幕文字啟用 AI 驅動的文化、歷史和語言學上下文分析。點擊字幕中的單詞或短語以獲得詳細解釋。" }, - "aiContextEnabledLabel": { "message": "啟用 AI 上下文:" }, - "cardAIContextProviderTitle": { "message": "AI 提供商" }, + "aiContextEnabledLabel": { + "message": "啟用 AI 上下文:" + }, + "cardAIContextProviderTitle": { + "message": "AI 提供商" + }, "cardAIContextProviderDesc": { "message": "選擇用於上下文分析的 AI 服務提供商。不同的提供商可能提供不同的品質和回應時間。" }, - "aiContextProviderLabel": { "message": "提供商:" }, - "cardOpenAIContextTitle": { "message": "OpenAI 設定" }, + "aiContextProviderLabel": { + "message": "提供商:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI 設定" + }, "cardOpenAIContextDesc": { "message": "設定用於上下文分析的 OpenAI API 設定。您需要一個有效的 OpenAI API 金鑰。" }, - "openaiApiKeyLabel": { "message": "API 金鑰:" }, - "openaiBaseUrlLabel": { "message": "基礎 URL:" }, - "openaiModelLabel": { "message": "模型:" }, - "cardGeminiContextTitle": { "message": "Google Gemini 設定" }, + "openaiApiKeyLabel": { + "message": "API 金鑰:" + }, + "openaiBaseUrlLabel": { + "message": "基礎 URL:" + }, + "openaiModelLabel": { + "message": "模型:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini 設定" + }, "cardGeminiContextDesc": { "message": "設定用於上下文分析的 Google Gemini API 設定。您需要一個有效的 Gemini API 金鑰。" }, - "geminiApiKeyLabel": { "message": "API 金鑰:" }, - "geminiModelLabel": { "message": "模型:" }, - "cardAIContextTypesTitle": { "message": "上下文類型" }, + "geminiApiKeyLabel": { + "message": "API 金鑰:" + }, + "geminiModelLabel": { + "message": "模型:" + }, + "cardAIContextTypesTitle": { + "message": "上下文類型" + }, "cardAIContextTypesDesc": { "message": "啟用您想要使用的上下文分析類型。您可以啟用多種類型。" }, - "contextTypeCulturalLabel": { "message": "文化上下文:" }, - "contextTypeCulturalHelp": { "message": "分析文化參考、習語和社會背景" }, - "contextTypeHistoricalLabel": { "message": "歷史上下文:" }, - "contextTypeHistoricalHelp": { "message": "提供歷史背景和時代背景" }, - "contextTypeLinguisticLabel": { "message": "語言學分析:" }, - "contextTypeLinguisticHelp": { "message": "解釋語法、詞源和語言結構" }, - "cardAIContextInteractiveTitle": { "message": "互動功能" }, + "contextTypeCulturalLabel": { + "message": "文化上下文:" + }, + "contextTypeCulturalHelp": { + "message": "分析文化參考、習語和社會背景" + }, + "contextTypeHistoricalLabel": { + "message": "歷史上下文:" + }, + "contextTypeHistoricalHelp": { + "message": "提供歷史背景和時代背景" + }, + "contextTypeLinguisticLabel": { + "message": "語言學分析:" + }, + "contextTypeLinguisticHelp": { + "message": "解釋語法、詞源和語言結構" + }, + "cardAIContextInteractiveTitle": { + "message": "互動功能" + }, "cardAIContextInteractiveDesc": { "message": "設定如何與字幕互動以觸發上下文分析。" }, - "interactiveSubtitlesEnabledLabel": { "message": "啟用互動式字幕:" }, + "interactiveSubtitlesEnabledLabel": { + "message": "啟用互動式字幕:" + }, "interactiveSubtitlesEnabledHelp": { "message": "使字幕單詞可點擊以進行上下文分析" }, - "contextOnClickLabel": { "message": "點擊時顯示上下文:" }, - "contextOnClickHelp": { "message": "點擊單詞時顯示上下文分析" }, - "contextOnSelectionLabel": { "message": "選擇時顯示上下文:" }, - "contextOnSelectionHelp": { "message": "選擇文字時顯示上下文分析" }, - "cardAIContextPrivacyTitle": { "message": "隱私和資料" }, + "contextOnClickLabel": { + "message": "點擊時顯示上下文:" + }, + "contextOnClickHelp": { + "message": "點擊單詞時顯示上下文分析" + }, + "contextOnSelectionLabel": { + "message": "選擇時顯示上下文:" + }, + "contextOnSelectionHelp": { + "message": "選擇文字時顯示上下文分析" + }, + "cardAIContextPrivacyTitle": { + "message": "隱私和資料" + }, "cardAIContextPrivacyDesc": { "message": "控制在上下文分析期間如何處理您的資料。" }, @@ -432,22 +926,103 @@ "aiContextUserConsentHelp": { "message": "AI 上下文分析功能正常運作所必需" }, - "aiContextDataSharingLabel": { "message": "允許匿名使用分析:" }, + "aiContextDataSharingLabel": { + "message": "允許匿名使用分析:" + }, "aiContextDataSharingHelp": { "message": "透過分享匿名使用資料幫助改進服務" }, - "cardAIContextAdvancedTitle": { "message": "進階設定" }, + "cardAIContextAdvancedTitle": { + "message": "進階設定" + }, "cardAIContextAdvancedDesc": { "message": "設定 AI 上下文分析行為的進階選項。" }, - "aiContextTimeoutLabel": { "message": "請求逾時 (ms):" }, - "aiContextTimeoutHelp": { "message": "等待 AI 回應的最長時間" }, - "aiContextRateLimitLabel": { "message": "速率限制 (請求/分鐘):" }, - "aiContextRateLimitHelp": { "message": "每分鐘最大請求數" }, - "aiContextCacheEnabledLabel": { "message": "啟用快取:" }, - "aiContextCacheEnabledHelp": { "message": "快取分析結果以減少 API 呼叫" }, - "aiContextRetryAttemptsLabel": { "message": "重試次數:" }, - "aiContextRetryAttemptsHelp": { "message": "重試失敗請求的次數" }, - "showAdvancedSettings": { "message": "顯示進階設定" }, - "hideAdvancedSettings": { "message": "隱藏進階設定" } -} + "aiContextTimeoutLabel": { + "message": "請求逾時 (ms):" + }, + "aiContextTimeoutHelp": { + "message": "等待 AI 回應的最長時間" + }, + "aiContextRateLimitLabel": { + "message": "速率限制 (請求/分鐘):" + }, + "aiContextRateLimitHelp": { + "message": "每分鐘最大請求數" + }, + "aiContextCacheEnabledLabel": { + "message": "啟用快取:" + }, + "aiContextCacheEnabledHelp": { + "message": "快取分析結果以減少 API 呼叫" + }, + "aiContextRetryAttemptsLabel": { + "message": "重試次數:" + }, + "aiContextRetryAttemptsHelp": { + "message": "重試失敗請求的次數" + }, + "showAdvancedSettings": { + "message": "顯示進階設定" + }, + "hideAdvancedSettings": { + "message": "隱藏進階設定" + }, + "sidepanelLoading": { + "message": "載入中..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI 分析" + }, + "sidepanelTabWordsLists": { + "message": "單字列表" + }, + "sidepanelAnalyzeButton": { + "message": "分析" + }, + "sidepanelAnalyzing": { + "message": "分析中..." + }, + "sidepanelWordsToAnalyze": { + "message": "待分析單字" + }, + "sidepanelWordInputPlaceholder": { + "message": "點擊字幕中的單字以新增至分析..." + }, + "sidepanelErrorRetry": { + "message": "重試" + }, + "sidepanelResultsTitle": { + "message": "「%s」的結果" + }, + "sidepanelSectionDefinition": { + "message": "定義" + }, + "sidepanelSectionCultural": { + "message": "文化背景" + }, + "sidepanelSectionHistorical": { + "message": "歷史背景" + }, + "sidepanelSectionLinguistic": { + "message": "語言學分析" + }, + "sidepanelMyWordsTitle": { + "message": "我的單字" + }, + "sidepanelFeatureComingSoon": { + "message": "單字列表功能即將推出!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "此功能目前正在開發中。在設定中啟用以嘗試預覽。" + }, + "sidepanelErrorNoWords": { + "message": "未選擇要分析的單字" + }, + "sidepanelErrorDisabled": { + "message": "AI 上下文分析已停用。請在設定中啟用。" + }, + "sidepanelErrorGeneric": { + "message": "分析過程中發生錯誤。" + } +} \ No newline at end of file diff --git a/content_scripts/aicontext/ui/events/ModalController.js b/content_scripts/aicontext/ui/events/ModalController.js index 513320a..ecf63e8 100644 --- a/content_scripts/aicontext/ui/events/ModalController.js +++ b/content_scripts/aicontext/ui/events/ModalController.js @@ -50,7 +50,7 @@ export class ModalController { ); if (original) original.classList.add('dualsub-subtitles-disabled'); - } catch (_) {} + } catch (_) { } // Force-hide remove buttons immediately for robustness try { const selected = document.getElementById( @@ -61,21 +61,21 @@ export class ModalController { .forEach((el) => { el.style.display = 'none'; }); - } catch (_) {} - } catch (_) {} + } catch (_) { } + } catch (_) { } // Freeze selection persistence and suppress immediate restorations try { this.core.selectionPersistence.lastManualSelectionTs = Date.now(); - } catch (_) {} + } catch (_) { } // Ensure UI reflects disabled removal (hide X icons) and keep highlights visible try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } try { this.core.syncSelectionHighlights(); - } catch (_) {} + } catch (_) { } // Switch button to pause state try { @@ -99,7 +99,7 @@ export class ModalController { this.pauseAnalysis(); }); } - } catch (_) {} + } catch (_) { } // Resolve language prefs let targetLanguage = 'en'; @@ -126,7 +126,7 @@ export class ModalController { if (result.originalLanguage) sourceLanguage = result.originalLanguage; } - } catch (_) {} + } catch (_) { } // Dispatch analysis request const requestId = `analysis-${Date.now()}`; @@ -163,7 +163,7 @@ export class ModalController { detail: { requestId: this.core.currentRequest }, }) ); - } catch (_) {} + } catch (_) { } this.core.isAnalyzing = false; this.core.currentRequest = null; @@ -176,13 +176,13 @@ export class ModalController { selectedWordsElement?.classList.remove( 'dualsub-processing-disabled' ); - } catch (_) {} + } catch (_) { } // Reset state back to selection this.core.setState(MODAL_STATES.SELECTION); this.ui.showInitialState(); try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } // Ensure processing classes cleared try { const content = @@ -204,7 +204,7 @@ export class ModalController { ); if (original) original.classList.remove('dualsub-subtitles-disabled'); - } catch (_) {} + } catch (_) { } // Ensure chips show remove buttons again after unfreezing try { const selected = document.getElementById( @@ -215,8 +215,8 @@ export class ModalController { .forEach((el) => { el.style.removeProperty('display'); }); - } catch (_) {} - } catch (_) {} + } catch (_) { } + } catch (_) { } // Reset Start button this.resetAnalysisButton(); } @@ -244,7 +244,7 @@ export class ModalController { this.core.selectedText = ''; try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } // Clear visual highlights on subtitles when closing try { const original = document.getElementById( @@ -259,7 +259,7 @@ export class ModalController { el.classList.remove('dualsub-word-selected') ); } - } catch (_) {} + } catch (_) { } // Hide modal via animations if available if ( this.animations && @@ -304,7 +304,7 @@ export class ModalController { // Store raw result for observability try { this.core.setAnalysisResult(result); - } catch (_) {} + } catch (_) { } const html = this._buildResultsHtml(result); if ( @@ -326,10 +326,10 @@ export class ModalController { 'dualsub-processing-disabled' ); } - } catch (_) {} + } catch (_) { } try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } // Re-enable subtitles interaction visuals try { const original = document.getElementById( @@ -337,7 +337,7 @@ export class ModalController { ); if (original) original.classList.remove('dualsub-subtitles-disabled'); - } catch (_) {} + } catch (_) { } // Ensure chips show remove buttons again after results try { const selected = document.getElementById( @@ -348,7 +348,7 @@ export class ModalController { .forEach((el) => { el.style.removeProperty('display'); }); - } catch (_) {} + } catch (_) { } this.resetAnalysisButton(); return; } @@ -639,8 +639,8 @@ export class ModalController { origin: 'aiContextOrigins', historicalcontext: 'aiContextHistoricalContext', historical: 'aiContextHistoricalContext', - historicalsignificance: 'aiContextHistoricalSignificance', - evolution: 'aiContextEvolution', + historicalsignificance: 'aiContextHistoricalSignificanceLabel', + evolution: 'aiContextEvolutionLabel', linguisticanalysis: 'aiContextLinguisticAnalysis', linguistic: 'aiContextLinguisticAnalysis', etymology: 'aiContextEtymology', @@ -652,15 +652,15 @@ export class ModalController { usageexamples: 'aiContextUsageExamples', usage: 'aiContextUsageExamples', examples: 'aiContextUsageExamples', - learningtips: 'aiContextLearningTips', - learning: 'aiContextLearningTips', - tips: 'aiContextLearningTips', - relatedexpressions: 'aiContextRelatedExpressions', - related: 'aiContextRelatedExpressions', - expressions: 'aiContextRelatedExpressions', - keyinsights: 'aiContextKeyInsights', - insights: 'aiContextKeyInsights', - key: 'aiContextKeyInsights', + learningtips: 'aiContextLearningTipsLabel', + learning: 'aiContextLearningTipsLabel', + tips: 'aiContextLearningTipsLabel', + relatedexpressions: 'aiContextRelatedExpressionsLabel', + related: 'aiContextRelatedExpressionsLabel', + expressions: 'aiContextRelatedExpressionsLabel', + keyinsights: 'aiContextKeyInsightsLabel', + insights: 'aiContextKeyInsightsLabel', + key: 'aiContextKeyInsightsLabel', }; const messageKey = fieldMappings[normalizedField]; @@ -668,7 +668,7 @@ export class ModalController { try { const msg = this.ui._getLocalizedMessage(messageKey); if (msg) return msg; - } catch (_) {} + } catch (_) { } } // Fallback: Capitalize and append colon return ( @@ -753,7 +753,7 @@ export class ModalController { this._getLocalizedMessage( 'aiContextRetryNotification' ) || 'Analysis failed, retrying...'; - } catch (_) {} + } catch (_) { } const newRequestId = `analysis-${Date.now()}`; this.core.currentRequest = newRequestId; diff --git a/sidepanel/SidePanelApp.jsx b/sidepanel/SidePanelApp.jsx index 0a0a736..d004aea 100644 --- a/sidepanel/SidePanelApp.jsx +++ b/sidepanel/SidePanelApp.jsx @@ -6,6 +6,7 @@ import { useTheme } from './hooks/useTheme.js'; import { useSettings } from './hooks/useSettings.js'; import { SidePanelProvider } from './hooks/SidePanelContext.jsx'; import { useSidePanelCommunication } from './hooks/useSidePanelCommunication.js'; +import { useTranslation } from './hooks/useTranslation.js'; /** * Main Side Panel Application Component @@ -16,15 +17,19 @@ import { useSidePanelCommunication } from './hooks/useSidePanelCommunication.js' export function SidePanelApp() { const [activeTab, setActiveTab] = useState('ai-analysis'); const { theme } = useTheme(); - const { settings, loading: settingsLoading } = useSettings(); - const { postMessage } = useSidePanelCommunication(); + const { settings, loading: settingsLoading } = useSettings([ + 'sidePanelTheme', + 'sidePanelWordsListsEnabled', + 'uiLanguage', + ]); + const { t } = useTranslation(); const handleTabChange = useCallback( (tabId) => { setActiveTab(tabId); postMessage('sidePanelUpdateState', { activeTab: tabId }); }, - [postMessage] + [] ); // Apply theme class to body @@ -58,7 +63,7 @@ export function SidePanelApp() {

- Loading... + {t('sidepanelLoading')}

@@ -70,7 +75,7 @@ export function SidePanelApp() {
diff --git a/sidepanel/components/TabNavigator.jsx b/sidepanel/components/TabNavigator.jsx index 987b8c7..0e93a8a 100644 --- a/sidepanel/components/TabNavigator.jsx +++ b/sidepanel/components/TabNavigator.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from '../hooks/useTranslation.js'; /** * Tab Navigator Component @@ -7,15 +8,17 @@ import React from 'react'; * Supports sticky positioning with backdrop blur effect. */ export function TabNavigator({ activeTab, onTabChange, settings }) { + const { t } = useTranslation(); + const tabs = [ { id: 'ai-analysis', - label: 'AI Analysis', + label: t('sidepanelTabAIAnalysis'), enabled: true, }, { id: 'words-lists', - label: 'Words Lists', + label: t('sidepanelTabWordsLists'), enabled: settings.sidePanelWordsListsEnabled || false, }, ]; diff --git a/sidepanel/components/tabs/AIAnalysisTab.jsx b/sidepanel/components/tabs/AIAnalysisTab.jsx index e68bd5f..faef46e 100644 --- a/sidepanel/components/tabs/AIAnalysisTab.jsx +++ b/sidepanel/components/tabs/AIAnalysisTab.jsx @@ -2,6 +2,7 @@ import React from 'react'; import { useSidePanelContext } from '../../hooks/SidePanelContext.jsx'; import { useAIAnalysis } from '../../hooks/useAIAnalysis.js'; import { useWordSelection } from '../../hooks/useWordSelection.js'; +import { useTranslation } from '../../hooks/useTranslation.js'; /** * AI Analysis Tab @@ -19,6 +20,7 @@ export function AIAnalysisTab() { const { analyzeWords, retryAnalysis, settings } = useAIAnalysis(); const { toggleWord, clearSelection } = useWordSelection(); + const { t } = useTranslation(); const handleAnalyze = () => { if (selectedWords.length > 0) { @@ -43,13 +45,13 @@ export function AIAnalysisTab() { auto_awesome - {isAnalyzing ? 'Analyzing...' : 'Analyze'} + {isAnalyzing ? t('sidepanelAnalyzing') : t('sidepanelAnalyzeButton')}
@@ -68,7 +70,7 @@ export function AIAnalysisTab() { ))} {selectedWords.length === 0 && ( - Click on subtitle words to add them for analysis... + {t('sidepanelWordInputPlaceholder')} )}
@@ -78,7 +80,7 @@ export function AIAnalysisTab() { {isAnalyzing && (
-

Analyzing...

+

{t('sidepanelAnalyzing')}

)} @@ -91,7 +93,7 @@ export function AIAnalysisTab() { className="error-retry" onClick={retryAnalysis} > - Retry + {t('sidepanelErrorRetry')}
@@ -100,13 +102,13 @@ export function AIAnalysisTab() { {analysisResult && !isAnalyzing && (

- Results for "{selectedWords.join('", "')}" + {t('sidepanelResultsTitle').replace('%s', selectedWords.join(', '))}

{/* Definition */} {analysisResult?.definition && (
-

Definition

+

{t('sidepanelSectionDefinition')}

{analysisResult.definition}

@@ -116,7 +118,7 @@ export function AIAnalysisTab() { {/* Cultural */} {(analysisResult?.cultural_analysis || analysisResult?.culturalContext) && (
-

Cultural Context

+

{t('sidepanelSectionCultural')}

{analysisResult?.culturalContext || analysisResult?.cultural_analysis?.cultural_context || analysisResult?.cultural_analysis}

@@ -126,7 +128,7 @@ export function AIAnalysisTab() { {/* Historical */} {(analysisResult?.historical_analysis || analysisResult?.historicalContext) && (
-

Historical Context

+

{t('sidepanelSectionHistorical')}

{analysisResult?.historicalContext || analysisResult?.historical_analysis?.historical_significance || analysisResult?.historical_analysis}

@@ -136,7 +138,7 @@ export function AIAnalysisTab() { {/* Linguistic */} {(analysisResult?.linguistic_analysis || analysisResult?.linguisticAnalysis) && (
-

Linguistic Analysis

+

{t('sidepanelSectionLinguistic')}

{analysisResult?.linguisticAnalysis || analysisResult?.linguistic_analysis?.translation_notes || analysisResult?.linguistic_analysis}

diff --git a/sidepanel/components/tabs/WordsListsTab.jsx b/sidepanel/components/tabs/WordsListsTab.jsx index fcf2272..3da7d84 100644 --- a/sidepanel/components/tabs/WordsListsTab.jsx +++ b/sidepanel/components/tabs/WordsListsTab.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { useTranslation } from '../../hooks/useTranslation.js'; /** * Words Lists Tab @@ -7,6 +8,7 @@ import React from 'react'; * Currently shows placeholder UI as feature is disabled by default. */ export function WordsListsTab() { + const { t } = useTranslation(); // Sample data for UI demonstration const sampleWords = [ { @@ -31,7 +33,7 @@ export function WordsListsTab() { return ( <>
-

My Words

+

{t('sidepanelMyWordsTitle')}

@@ -56,10 +58,9 @@ export function WordsListsTab() { info

- Words Lists feature coming soon! + {t('sidepanelFeatureComingSoon')}
- This feature is currently in development. Enable it in - Settings to try the preview. + {t('sidepanelFeatureComingSoonDesc')}

diff --git a/sidepanel/hooks/useAIAnalysis.js b/sidepanel/hooks/useAIAnalysis.js index ad9ef41..7ab29ca 100644 --- a/sidepanel/hooks/useAIAnalysis.js +++ b/sidepanel/hooks/useAIAnalysis.js @@ -115,12 +115,12 @@ export function useAIAnalysis() { const wordsToAnalyze = customWords || selectedWords; if (!wordsToAnalyze || wordsToAnalyze.size === 0) { - setError('No words selected for analysis'); + setError(chrome.i18n.getMessage('sidepanelErrorNoWords')); return null; } if (!settings?.aiContextEnabled) { - setError('AI Context analysis is disabled. Enable it in settings.'); + setError(chrome.i18n.getMessage('sidepanelErrorDisabled')); return null; } @@ -195,7 +195,7 @@ export function useAIAnalysis() { return normalized; } else { const errorMsg = - response?.error || 'Analysis failed. Please try again.'; + response?.error || chrome.i18n.getMessage('sidepanelErrorGeneric'); setError(errorMsg); return null; } @@ -207,7 +207,7 @@ export function useAIAnalysis() { console.error('AI analysis error:', err); const errorMsg = - err.message || 'An error occurred during analysis.'; + err.message || chrome.i18n.getMessage('sidepanelErrorGeneric'); setError(errorMsg); return null; } finally { diff --git a/sidepanel/hooks/useTranslation.js b/sidepanel/hooks/useTranslation.js new file mode 100644 index 0000000..9e3c206 --- /dev/null +++ b/sidepanel/hooks/useTranslation.js @@ -0,0 +1,78 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useSettings } from './useSettings.js'; + +/** + * Custom hook for handling translations + * Wraps chrome.i18n.getMessage but supports dynamic language switching via settings + */ +export function useTranslation() { + const { settings } = useSettings(['uiLanguage']); + const [messages, setMessages] = useState(null); + const [currentLang, setCurrentLang] = useState(null); + + useEffect(() => { + const loadMessages = async () => { + // Default to 'en' if not set, or fallback to browser language if we could detect it easily mapping to our supported locales + // For now, we rely on the setting. If not set, we might want to let chrome.i18n handle it (which uses browser locale) + // But to ensure consistency if the user *explicitly* sets it, we load it. + const lang = settings?.uiLanguage; + + if (!lang || lang === currentLang) return; + + try { + // Chrome locales use underscores (e.g., zh_CN) but settings might use hyphens (e.g., zh-CN) + const normalizedLang = lang.replace('-', '_'); + const url = chrome.runtime.getURL(`_locales/${normalizedLang}/messages.json`); + const response = await fetch(url); + const data = await response.json(); + setMessages(data); + setCurrentLang(lang); + } catch (error) { + console.error(`Failed to load messages for ${lang}`, error); + // Fallback to null so we use chrome.i18n + setMessages(null); + setCurrentLang(null); + } + }; + + loadMessages(); + }, [settings?.uiLanguage, currentLang]); + + const t = useCallback((key, substitutions) => { + // If we have loaded messages for the selected language, use them + if (messages && messages[key]) { + let message = messages[key].message; + + // Handle substitutions (simple %s replacement to match existing keys) + if (substitutions) { + const subs = Array.isArray(substitutions) ? substitutions : [substitutions]; + subs.forEach((sub) => { + message = message.replace('%s', sub); + }); + } + return message; + } + + // Fallback to chrome.i18n (uses browser locale) + // Note: chrome.i18n.getMessage does NOT automatically replace %s. + // It expects $PLACEHOLDERS$. If our keys use %s, we must handle it manually even for chrome.i18n result if we want it to work. + // However, existing code might rely on chrome.i18n behavior. + // If we want to fix the %s issue globally, we should do it here too. + let nativeMessage = chrome.i18n.getMessage(key, substitutions); + + // If chrome.i18n returned a message and we have substitutions, try %s replacement if it wasn't handled + if (nativeMessage && substitutions) { + const subs = Array.isArray(substitutions) ? substitutions : [substitutions]; + // Only replace if it looks like it needs it (contains %s) + if (nativeMessage.includes('%s')) { + subs.forEach((sub) => { + nativeMessage = nativeMessage.replace('%s', sub); + }); + } + } + + return nativeMessage || key; + }, [messages]); + + return { t }; +} From 7faf93885a8eaea732909a2f99cbea3fa8216d83 Mon Sep 17 00:00:00 2001 From: Jiaying Wang <35688096+QuellaMC@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:53:11 -0500 Subject: [PATCH 18/23] Update background/services/sidePanelService.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- background/services/sidePanelService.js | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js index 783d11e..de007a2 100644 --- a/background/services/sidePanelService.js +++ b/background/services/sidePanelService.js @@ -80,16 +80,6 @@ class SidePanelService { this.logger.warn('Side panel connection without tab ID (awaiting register message)'); } - const postToTab = (tid, message) => { - try { - const p = this.activeConnections.get(tid); - if (!p) return; - p.postMessage(message); - } catch (err) { - this.logger.error('Failed to post to side panel', err, { tabId: tid, action: message?.action }); - } - }; - // Handle messages from side panel port.onMessage.addListener((message) => { // Update tabId once the side panel sends an explicit register payload From 3049a7a9f3d89cc9dd9030fa28909644978da491 Mon Sep 17 00:00:00 2001 From: Jiaying Wang <35688096+QuellaMC@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:54:11 -0500 Subject: [PATCH 19/23] Update background/services/sidePanelService.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- background/services/sidePanelService.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js index de007a2..47bc9d3 100644 --- a/background/services/sidePanelService.js +++ b/background/services/sidePanelService.js @@ -527,7 +527,7 @@ class SidePanelService { } else { // Broadcast fallback with tabId so the side panel can self-filter try { - for (const [tid, p] of this.activeConnections.entries()) { + for (const p of this.activeConnections.values()) { try { p.postMessage({ action: MessageActions.SIDEPANEL_SELECTION_SYNC, From 0ea8eefa9a52c780c140d6d8ab4bbda274dc1d5e Mon Sep 17 00:00:00 2001 From: Jiaying Wang <35688096+QuellaMC@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:54:45 -0500 Subject: [PATCH 20/23] Update content_scripts/shared/interactiveSubtitleFormatter.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- content_scripts/shared/interactiveSubtitleFormatter.js | 1 - 1 file changed, 1 deletion(-) diff --git a/content_scripts/shared/interactiveSubtitleFormatter.js b/content_scripts/shared/interactiveSubtitleFormatter.js index c7afc4c..7c954f4 100644 --- a/content_scripts/shared/interactiveSubtitleFormatter.js +++ b/content_scripts/shared/interactiveSubtitleFormatter.js @@ -8,7 +8,6 @@ * @version 1.0.0 */ -import { PlatformDetector } from './platformConfig.js'; // Robust logging function that's always available const logWithFallback = (() => { From 82364c6d68c66d9d492a2f48d2fa8877e5b5b6fc Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Thu, 20 Nov 2025 17:55:56 -0500 Subject: [PATCH 21/23] refactor: Remove unused setters from useWordSelection hook. --- sidepanel/hooks/useWordSelection.js | 3 --- 1 file changed, 3 deletions(-) diff --git a/sidepanel/hooks/useWordSelection.js b/sidepanel/hooks/useWordSelection.js index e4a0afb..953445c 100644 --- a/sidepanel/hooks/useWordSelection.js +++ b/sidepanel/hooks/useWordSelection.js @@ -18,9 +18,6 @@ export function useWordSelection() { addWord, removeWord, clearWords, - setSelectedWords, - setSourceLanguage, - setTargetLanguage, sourceLanguage, targetLanguage, activeTabId From a0867ab1025d9d738f99ea52831d422f0fa65579 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Fri, 5 Dec 2025 08:59:50 -0500 Subject: [PATCH 22/23] feat: Remove explicit source language parameter and update context prompts to allow LLM to infer source language. --- context_providers/geminiContextProvider.js | 136 ++++++++++----------- context_providers/openaiContextProvider.js | 136 ++++++++++----------- 2 files changed, 134 insertions(+), 138 deletions(-) diff --git a/context_providers/geminiContextProvider.js b/context_providers/geminiContextProvider.js index caab919..34f2513 100644 --- a/context_providers/geminiContextProvider.js +++ b/context_providers/geminiContextProvider.js @@ -79,7 +79,6 @@ export function getDefaultModel() { */ function createContextPrompt(text, contextType, metadata = {}) { const { - sourceLanguage = 'unknown', targetLanguage = 'unknown', surroundingContext = '', } = metadata; @@ -120,24 +119,23 @@ function createContextPrompt(text, contextType, metadata = {}) { }; const targetLanguageName = getLanguageName(targetLanguage); - const sourceLanguageName = getLanguageName(sourceLanguage); const baseContext = ` -Analyze this ${sourceLanguageName} text for ${contextType} context: +Analyze this text for ${contextType} context: Text to analyze: "${text}" -Source language: ${sourceLanguage} (${sourceLanguageName}) Target language for response: ${targetLanguage} (${targetLanguageName}) ${surroundingContext ? `Context: "${surroundingContext}"` : ''} CRITICAL INSTRUCTIONS: -1. Write your ENTIRE response in ${targetLanguageName} language -2. Analyze and discuss the ${sourceLanguageName} language content, culture, and context -3. Explain ${sourceLanguageName} cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker -4. Do NOT analyze ${targetLanguageName} language or culture - focus on the ${sourceLanguageName} source material -5. Help ${targetLanguageName} speakers understand this ${sourceLanguageName} text better - -Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this ${sourceLanguageName} content. +1. First, IDENTIFY the language of the "Text to analyze" +2. Write your ENTIRE response in ${targetLanguageName} language +3. Analyze and discuss the content, culture, and context of the identified source language +4. Explain cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker +5. Do NOT analyze ${targetLanguageName} language or culture - focus on the source material +6. Help ${targetLanguageName} speakers understand this text better + +Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this content. `; switch (contextType) { @@ -145,116 +143,116 @@ Provide a clear, educational explanation that helps ${targetLanguageName} speake return ( baseContext + ` -Provide a comprehensive cultural analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a comprehensive cultural analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_context": { - "origins": "${sourceLanguageName} cultural origins and background of this expression", - "social_context": "How this is used in ${sourceLanguageName} society and conversational context", - "regional_variations": "How this ${sourceLanguageName} expression varies across different ${sourceLanguageName}-speaking regions" + "origins": "Cultural origins and background of this expression", + "social_context": "How this is used in the source culture and conversational context", + "regional_variations": "How this expression varies across different regions speaking the source language" }, "usage": { - "examples": ["${sourceLanguageName} usage example 1", "${sourceLanguageName} usage example 2", "${sourceLanguageName} usage example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality_level": "Formality level in ${sourceLanguageName} culture" + "examples": ["Usage example 1", "Usage example 2", "Usage example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality_level": "Formality level in the source culture" }, - "cultural_significance": "Why this expression is culturally important in ${sourceLanguageName} culture", - "learning_tips": "Practical advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Similar ${sourceLanguageName} expression 1", "Similar ${sourceLanguageName} expression 2"], - "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this ${sourceLanguageName} expression" + "cultural_significance": "Why this expression is culturally important in the source culture", + "learning_tips": "Practical advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Similar expression 1", "Similar expression 2"], + "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); case 'historical': return ( baseContext + ` -Provide a detailed historical analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a detailed historical analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "historical_context": { - "time_period": "Historical period relevant to this ${sourceLanguageName} expression", - "historical_figures": "Important ${sourceLanguageName} historical figures connected to this expression", - "events": "${sourceLanguageName} historical events that shaped this expression" + "time_period": "Historical period relevant to this expression", + "historical_figures": "Important historical figures connected to this expression", + "events": "Historical events that shaped this expression" }, "evolution": { - "original_meaning": "How this ${sourceLanguageName} expression was originally used", - "changes_over_time": "How this ${sourceLanguageName} expression's meaning evolved", - "modern_usage": "How this ${sourceLanguageName} expression is used today" + "original_meaning": "How this expression was originally used", + "changes_over_time": "How this expression's meaning evolved", + "modern_usage": "How this expression is used today" }, - "historical_significance": "Why this expression is historically important in ${sourceLanguageName} culture/history", - "examples": ["${sourceLanguageName} historical usage example 1", "${sourceLanguageName} historical usage example 2"], - "related_terms": ["Related ${sourceLanguageName} historical term 1", "Related ${sourceLanguageName} historical term 2"], - "learning_context": "How understanding ${sourceLanguageName} history helps ${targetLanguageName} speakers learn this expression" + "historical_significance": "Why this expression is historically important in the source culture/history", + "examples": ["Historical usage example 1", "Historical usage example 2"], + "related_terms": ["Related historical term 1", "Related historical term 2"], + "learning_context": "How understanding the source history helps ${targetLanguageName} speakers learn this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} historical context.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the historical context of the source.` ); case 'linguistic': return ( baseContext + ` -Provide an in-depth linguistic analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide an in-depth linguistic analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "etymology": { - "word_origins": "${sourceLanguageName} language family and root origins of this expression", - "historical_development": "How this ${sourceLanguageName} word/phrase developed linguistically" + "word_origins": "Language family and root origins of this expression", + "historical_development": "How this word/phrase developed linguistically" }, "grammar": { - "structure": "${sourceLanguageName} grammatical structure and patterns of this expression", - "usage_rules": "${sourceLanguageName} grammar rules for proper usage" + "structure": "Grammatical structure and patterns of this expression", + "usage_rules": "Grammar rules for proper usage" }, "semantics": { - "literal_meaning": "Literal ${sourceLanguageName} meaning before translation", - "connotations": "Implied meanings and connotations in ${sourceLanguageName}", - "register": "Formal/informal/technical classification in ${sourceLanguageName}" + "literal_meaning": "Literal meaning before translation", + "connotations": "Implied meanings and connotations", + "register": "Formal/informal/technical classification" }, - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}", - "examples": ["${sourceLanguageName} linguistic example 1", "${sourceLanguageName} linguistic example 2"], - "related_forms": ["Related ${sourceLanguageName} word 1", "Related ${sourceLanguageName} word 2"], - "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this ${sourceLanguageName} expression linguistically" + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}", + "examples": ["Linguistic example 1", "Linguistic example 2"], + "related_forms": ["Related word 1", "Related word 2"], + "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this expression linguistically" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} linguistic aspects.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the linguistic aspects of the source.` ); default: return ( baseContext + ` -Provide a comprehensive analysis of this ${sourceLanguageName} text covering cultural, historical, and linguistic aspects in the following JSON structure: +Provide a comprehensive analysis of this text covering cultural, historical, and linguistic aspects in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_analysis": { - "cultural_context": "${sourceLanguageName} cultural background and significance", - "social_usage": "How this is used socially in ${sourceLanguageName} culture", - "regional_notes": "Regional or cultural variations within ${sourceLanguageName}-speaking areas" + "cultural_context": "Cultural background and significance", + "social_usage": "How this is used socially in the source culture", + "regional_notes": "Regional or cultural variations within the source language-speaking areas" }, "historical_analysis": { - "origins": "${sourceLanguageName} historical origins and background", - "evolution": "How this ${sourceLanguageName} expression evolved over time", - "historical_significance": "Historical importance in ${sourceLanguageName} culture" + "origins": "Historical origins and background", + "evolution": "How this expression evolved over time", + "historical_significance": "Historical importance in the source culture" }, "linguistic_analysis": { - "etymology": "${sourceLanguageName} word origins and linguistic development", - "grammar_notes": "${sourceLanguageName} grammatical considerations", - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}" + "etymology": "Word origins and linguistic development", + "grammar_notes": "Grammatical considerations", + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}" }, "practical_usage": { - "examples": ["${sourceLanguageName} example 1", "${sourceLanguageName} example 2", "${sourceLanguageName} example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality": "Formality level in ${sourceLanguageName} culture" + "examples": ["Example 1", "Example 2", "Example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality": "Formality level in the source culture" }, - "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Related ${sourceLanguageName} expression 1", "Related ${sourceLanguageName} expression 2"], - "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this ${sourceLanguageName} expression" + "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Related expression 1", "Related expression 2"], + "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); } } diff --git a/context_providers/openaiContextProvider.js b/context_providers/openaiContextProvider.js index 834a77e..e660bbc 100644 --- a/context_providers/openaiContextProvider.js +++ b/context_providers/openaiContextProvider.js @@ -164,31 +164,29 @@ function getLanguageName(langCode) { */ function createContextPrompt(text, contextType, metadata = {}) { const { - sourceLanguage = 'unknown', targetLanguage = 'unknown', surroundingContext = '', } = metadata; // Get language name for the target language code const targetLanguageName = getLanguageName(targetLanguage); - const sourceLanguageName = getLanguageName(sourceLanguage); const baseContext = ` -Analyze this ${sourceLanguageName} text for ${contextType} context: +Analyze this text for ${contextType} context: Text to analyze: "${text}" -Source language: ${sourceLanguage} (${sourceLanguageName}) Target language for response: ${targetLanguage} (${targetLanguageName}) ${surroundingContext ? `Context: "${surroundingContext}"` : ''} CRITICAL INSTRUCTIONS: -1. Write your ENTIRE response in ${targetLanguageName} language -2. Analyze and discuss the ${sourceLanguageName} language content, culture, and context -3. Explain ${sourceLanguageName} cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker -4. Do NOT analyze ${targetLanguageName} language or culture - focus on the ${sourceLanguageName} source material -5. Help ${targetLanguageName} speakers understand this ${sourceLanguageName} text better - -Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this ${sourceLanguageName} content. +1. First, IDENTIFY the language of the "Text to analyze" +2. Write your ENTIRE response in ${targetLanguageName} language +3. Analyze and discuss the content, culture, and context of the identified source language +4. Explain cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker +5. Do NOT analyze ${targetLanguageName} language or culture - focus on the source material +6. Help ${targetLanguageName} speakers understand this text better + +Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this content. `; switch (contextType) { @@ -196,116 +194,116 @@ Provide a clear, educational explanation that helps ${targetLanguageName} speake return ( baseContext + ` -Provide a comprehensive cultural analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a comprehensive cultural analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_context": { - "origins": "${sourceLanguageName} cultural origins and background of this expression", - "social_context": "How this is used in ${sourceLanguageName} society and conversational context", - "regional_variations": "How this ${sourceLanguageName} expression varies across different ${sourceLanguageName}-speaking regions" + "origins": "Cultural origins and background of this expression", + "social_context": "How this is used in the source culture and conversational context", + "regional_variations": "How this expression varies across different regions speaking the source language" }, "usage": { - "examples": ["${sourceLanguageName} usage example 1", "${sourceLanguageName} usage example 2", "${sourceLanguageName} usage example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality_level": "Formality level in ${sourceLanguageName} culture" + "examples": ["Usage example 1", "Usage example 2", "Usage example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality_level": "Formality level in the source culture" }, - "cultural_significance": "Why this expression is culturally important in ${sourceLanguageName} culture", - "learning_tips": "Practical advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Similar ${sourceLanguageName} expression 1", "Similar ${sourceLanguageName} expression 2"], - "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this ${sourceLanguageName} expression" + "cultural_significance": "Why this expression is culturally important in the source culture", + "learning_tips": "Practical advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Similar expression 1", "Similar expression 2"], + "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); case 'historical': return ( baseContext + ` -Provide a detailed historical analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a detailed historical analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "historical_context": { - "time_period": "Historical period relevant to this ${sourceLanguageName} expression", - "historical_figures": "Important ${sourceLanguageName} historical figures connected to this expression", - "events": "${sourceLanguageName} historical events that shaped this expression" + "time_period": "Historical period relevant to this expression", + "historical_figures": "Important historical figures connected to this expression", + "events": "Historical events that shaped this expression" }, "evolution": { - "original_meaning": "How this ${sourceLanguageName} expression was originally used", - "changes_over_time": "How this ${sourceLanguageName} expression's meaning evolved", - "modern_usage": "How this ${sourceLanguageName} expression is used today" + "original_meaning": "How this expression was originally used", + "changes_over_time": "How this expression's meaning evolved", + "modern_usage": "How this expression is used today" }, - "historical_significance": "Why this expression is historically important in ${sourceLanguageName} culture/history", - "examples": ["${sourceLanguageName} historical usage example 1", "${sourceLanguageName} historical usage example 2"], - "related_terms": ["Related ${sourceLanguageName} historical term 1", "Related ${sourceLanguageName} historical term 2"], - "learning_context": "How understanding ${sourceLanguageName} history helps ${targetLanguageName} speakers learn this expression" + "historical_significance": "Why this expression is historically important in the source culture/history", + "examples": ["Historical usage example 1", "Historical usage example 2"], + "related_terms": ["Related historical term 1", "Related historical term 2"], + "learning_context": "How understanding the source history helps ${targetLanguageName} speakers learn this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} historical context.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the historical context of the source.` ); case 'linguistic': return ( baseContext + ` -Provide an in-depth linguistic analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide an in-depth linguistic analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "etymology": { - "word_origins": "${sourceLanguageName} language family and root origins of this expression", - "historical_development": "How this ${sourceLanguageName} word/phrase developed linguistically" + "word_origins": "Language family and root origins of this expression", + "historical_development": "How this word/phrase developed linguistically" }, "grammar": { - "structure": "${sourceLanguageName} grammatical structure and patterns of this expression", - "usage_rules": "${sourceLanguageName} grammar rules for proper usage" + "structure": "Grammatical structure and patterns of this expression", + "usage_rules": "Grammar rules for proper usage" }, "semantics": { - "literal_meaning": "Literal ${sourceLanguageName} meaning before translation", - "connotations": "Implied meanings and connotations in ${sourceLanguageName}", - "register": "Formal/informal/technical classification in ${sourceLanguageName}" + "literal_meaning": "Literal meaning before translation", + "connotations": "Implied meanings and connotations", + "register": "Formal/informal/technical classification" }, - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}", - "examples": ["${sourceLanguageName} linguistic example 1", "${sourceLanguageName} linguistic example 2"], - "related_forms": ["Related ${sourceLanguageName} word 1", "Related ${sourceLanguageName} word 2"], - "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this ${sourceLanguageName} expression linguistically" + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}", + "examples": ["Linguistic example 1", "Linguistic example 2"], + "related_forms": ["Related word 1", "Related word 2"], + "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this expression linguistically" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} linguistic aspects.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the linguistic aspects of the source.` ); default: return ( baseContext + ` -Provide a comprehensive analysis of this ${sourceLanguageName} text covering cultural, historical, and linguistic aspects in the following JSON structure: +Provide a comprehensive analysis of this text covering cultural, historical, and linguistic aspects in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_analysis": { - "cultural_context": "${sourceLanguageName} cultural background and significance", - "social_usage": "How this is used socially in ${sourceLanguageName} culture", - "regional_notes": "Regional or cultural variations within ${sourceLanguageName}-speaking areas" + "cultural_context": "Cultural background and significance", + "social_usage": "How this is used socially in the source culture", + "regional_notes": "Regional or cultural variations within the source language-speaking areas" }, "historical_analysis": { - "origins": "${sourceLanguageName} historical origins and background", - "evolution": "How this ${sourceLanguageName} expression evolved over time", - "historical_significance": "Historical importance in ${sourceLanguageName} culture" + "origins": "Historical origins and background", + "evolution": "How this expression evolved over time", + "historical_significance": "Historical importance in the source culture" }, "linguistic_analysis": { - "etymology": "${sourceLanguageName} word origins and linguistic development", - "grammar_notes": "${sourceLanguageName} grammatical considerations", - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}" + "etymology": "Word origins and linguistic development", + "grammar_notes": "Grammatical considerations", + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}" }, "practical_usage": { - "examples": ["${sourceLanguageName} example 1", "${sourceLanguageName} example 2", "${sourceLanguageName} example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality": "Formality level in ${sourceLanguageName} culture" + "examples": ["Example 1", "Example 2", "Example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality": "Formality level in the source culture" }, - "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Related ${sourceLanguageName} expression 1", "Related ${sourceLanguageName} expression 2"], - "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this ${sourceLanguageName} expression" + "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Related expression 1", "Related expression 2"], + "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); } } From 139637a56609e5376c38ac0a78c8e3c0ddf22ab9 Mon Sep 17 00:00:00 2001 From: Michael Wang Date: Fri, 5 Dec 2025 18:00:10 -0500 Subject: [PATCH 23/23] feat: persist and sync target language across tabs using chrome.storage. --- sidepanel/hooks/SidePanelContext.jsx | 48 +++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/sidepanel/hooks/SidePanelContext.jsx b/sidepanel/hooks/SidePanelContext.jsx index 7410d8f..d06ebe5 100644 --- a/sidepanel/hooks/SidePanelContext.jsx +++ b/sidepanel/hooks/SidePanelContext.jsx @@ -4,6 +4,7 @@ import React, { useState, useEffect, useMemo, + useRef, } from 'react'; import { useSidePanelCommunication } from './useSidePanelCommunication'; @@ -24,6 +25,51 @@ export function SidePanelProvider({ children }) { const [activeTabId, setActiveTabId] = useState(null); const { onMessage, getActiveTab, postMessage, getBinding } = useSidePanelCommunication(); + const globalTargetLangRef = useRef('zh-CN'); + + // Sync target language with storage + useEffect(() => { + const updateLanguage = (lang) => { + if (!lang) return; + globalTargetLangRef.current = lang; + + // Update all tabs with the new language + setTabState((prev) => { + const newState = { ...prev }; + let hasChanges = false; + + Object.keys(newState).forEach((tId) => { + if (newState[tId].targetLanguage !== lang) { + newState[tId] = { + ...newState[tId], + targetLanguage: lang + }; + hasChanges = true; + } + }); + + return hasChanges ? newState : prev; + }); + }; + + // Load initial + chrome.storage.sync.get('targetLanguage', (items) => { + if (items.targetLanguage) { + updateLanguage(items.targetLanguage); + } + }); + + // Listen for changes + const handleStorageChange = (changes, area) => { + if (area === 'sync' && changes.targetLanguage) { + updateLanguage(changes.targetLanguage.newValue); + } + }; + + chrome.storage.onChanged.addListener(handleStorageChange); + return () => chrome.storage.onChanged.removeListener(handleStorageChange); + }, []); + // Initial setup and tab activation listener useEffect(() => { const handleTabActivated = (tabId) => { @@ -36,7 +82,7 @@ export function SidePanelProvider({ children }) { isAnalyzing: false, error: null, sourceLanguage: 'en', - targetLanguage: 'zh-CN', + targetLanguage: globalTargetLangRef.current, }, })); };