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 (
+
+ );
+ }
+
+ 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
+
+
+ auto_awesome
+
+ {isAnalyzing ? 'Analyzing...' : 'Analyze'}
+
+
+
+
+
+ Words to Analyze
+
+
+
+ {Array.from(selectedWords).map((word) => (
+
+ {word}
+ handleWordRemove(word)}
+ aria-label={`Remove ${word}`}
+ >
+ ×
+
+
+ ))}
+ {selectedWords.size === 0 && (
+
+ Click on subtitle words to add them for analysis...
+
+ )}
+
+
+
+
+ {isAnalyzing && (
+
+ )}
+
+ {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
+
+
+
+
+ My First List
+ Vocabulary for Beginners
+ Advanced Terminology
+
+
+ unfold_more
+
+
+
+
+ filter_list
+
+
+
+
+
+
+ info
+
+
+ Words Lists feature coming soon!
+
+ This feature is currently in development. Enable it in
+ Settings to try the preview.
+
+
+
+
+
+
+
+ >
+ );
+}
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 */}
+
+
+
+
+ {t('useSidePanel', 'Use Side Panel:')}
+
+
+ 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).'
+ )}
+
+
+
+
+ {t('autoOpenSidePanel', 'Auto-Open Side Panel:')}
+
+
+ onSettingChange('sidePanelAutoOpen', checked)
+ }
+ />
+
+
+ {t(
+ 'autoOpenSidePanelDescription',
+ 'Automatically open the side panel when you click on subtitle words.'
+ )}
+
+
+
+
+ {t('persistAcrossTabs', 'Persist State Across Tabs:')}
+
+
+ onSettingChange('sidePanelPersistAcrossTabs', checked)
+ }
+ />
+
+
+ {t(
+ 'persistAcrossTabsDescription',
+ 'Keep the side panel open and preserve selected words when switching between tabs.'
+ )}
+
+
+
+
+ {t('defaultTab', 'Default Tab:')}
+
+
+ onSettingChange('sidePanelDefaultTab', e.target.value)
+ }
+ >
+
+ {t('tabAIAnalysis', 'AI Analysis')}
+
+
+ {t('tabWordsLists', 'Words Lists')}
+
+
+
+
+
+
+ {t('sidePanelTheme', 'Side Panel Theme:')}
+
+
+ onSettingChange('sidePanelTheme', e.target.value)
+ }
+ >
+ {t('themeAuto', 'Auto (System)')}
+ {t('themeLight', 'Light')}
+ {t('themeDark', 'Dark')}
+
+
+
+
+ {/* Video Control */}
+
+
+
+
+ {t('autoPauseVideo', 'Auto-Pause Video:')}
+
+
+ onSettingChange('sidePanelAutoPauseVideo', checked)
+ }
+ />
+
+
+ {t(
+ 'autoPauseVideoDescription',
+ 'Automatically pause the video when you open the side panel to select words.'
+ )}
+
+
+
+
+ {t('autoResumeVideo', 'Auto-Resume Video:')}
+
+
+ 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')}
+
+
+
+
+ {t('wordListsEnableLabel', 'Enable Words 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}
handleWordRemove(word)}
+ disabled={isAnalyzing}
aria-label={`Remove ${word}`}
>
×
@@ -101,32 +102,47 @@ export function AIAnalysisTab() {
Results for "{Array.from(selectedWords).join('", "')}"
-
- {analysisResult.culturalContext && (
-
-
Cultural Context
-
- {analysisResult.culturalContext}
-
-
- )}
- {analysisResult.historicalContext && (
-
-
Historical Context
-
- {analysisResult.historicalContext}
-
-
- )}
- {analysisResult.linguisticAnalysis && (
-
-
Linguistic Analysis
-
- {analysisResult.linguisticAnalysis}
-
-
- )}
+
+ {/* Definition */}
+ {analysisResult?.definition && (
+
+
Definition
+
+ {analysisResult.definition}
+
+
+ )}
+
+ {/* Cultural */}
+ {(analysisResult?.cultural_analysis || analysisResult?.culturalContext) && (
+
+
Cultural Context
+
+ {analysisResult?.culturalContext || analysisResult?.cultural_analysis?.cultural_context || analysisResult?.cultural_analysis}
+
+ )}
+
+ {/* Historical */}
+ {(analysisResult?.historical_analysis || analysisResult?.historicalContext) && (
+
+
Historical Context
+
+ {analysisResult?.historicalContext || analysisResult?.historical_analysis?.historical_significance || analysisResult?.historical_analysis}
+
+
+ )}
+
+ {/* Linguistic */}
+ {(analysisResult?.linguistic_analysis || analysisResult?.linguisticAnalysis) && (
+
+
Linguistic Analysis
+
+ {analysisResult?.linguisticAnalysis || analysisResult?.linguistic_analysis?.translation_notes || analysisResult?.linguistic_analysis}
+
+
+ )}
+
)}
@@ -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:')}
- {t('featureHighestQuality', 'Highest quality translation')}
- {t('featureApiKeyRequired', 'API key required')}
- {t('featureLimitedLanguages', 'Limited language support')}
+
+ {t(
+ 'featureHighestQuality',
+ 'Highest quality translation'
+ )}
+
+
+ {t('featureApiKeyRequired', 'API key required')}
+
+
+ {t(
+ 'featureLimitedLanguages',
+ 'Limited language support'
+ )}
+
{t('featureUsageLimits', 'Usage limits apply')}
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 }) {
{t('featureFree', 'Free to use')}
{t('featureNoApiKey', 'No API key required')}
- {t('featureWideLanguageSupport', 'Wide language support')}
- {t('featureFastTranslation', 'Fast translation')}
+
+ {t(
+ 'featureWideLanguageSupport',
+ 'Wide language support'
+ )}
+
+
+ {t('featureFastTranslation', 'Fast translation')}
+
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 }) {
{t('featureFree', 'Free to use')}
{t('featureNoApiKey', 'No API key required')}
- {t('featureHighQuality', 'High quality translation')}
- {t('featureGoodPerformance', 'Good performance')}
+
+ {t(
+ 'featureHighQuality',
+ 'High quality translation'
+ )}
+
+
+ {t('featureGoodPerformance', 'Good performance')}
+
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({
))
) : (
- {fetchingModels ? 'Loading...' : 'No models available'}
+
+ {fetchingModels
+ ? 'Loading...'
+ : 'No models available'}
+
)}
@@ -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 (
- {t('vertexServiceAccountLabel', 'Service Account JSON:')}
+
+ {t('vertexServiceAccountLabel', 'Service Account JSON:')}
+
{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
+ )
}
>
OpenAI GPT
@@ -132,31 +138,34 @@ export function AIContextSection({ t, settings, onSettingChange }) {
onSettingChange('openaiModel', e.target.value)
}
>
-
GPT-4.1 Nano
-
GPT-4.1 Mini (Recommended)
-
GPT-4o Mini
-
GPT-4o
@@ -169,7 +178,10 @@ export function AIContextSection({ t, settings, onSettingChange }) {
{/* Card 4: Gemini Configuration */}
{aiContextEnabled && aiContextProvider === 'gemini' && (
-
Gemini 2.5 Flash (Recommended)
-
Gemini 2.5 Pro
-
Gemini 1.5 Flash
-
Gemini 1.5 Pro
@@ -254,7 +266,10 @@ export function AIContextSection({ t, settings, onSettingChange }) {
- {t('contextTypeHistoricalLabel', 'Historical Context:')}
+ {t(
+ 'contextTypeHistoricalLabel',
+ 'Historical Context:'
+ )}
- {t('contextTypeLinguisticLabel', 'Linguistic Context:')}
+ {t(
+ 'contextTypeLinguisticLabel',
+ 'Linguistic Context:'
+ )}
- {t('aiContextTimeoutLabel', 'Request Timeout (ms):')}
+ {t(
+ 'aiContextTimeoutLabel',
+ 'Request Timeout (ms):'
+ )}
- onSettingChange('aiContextTimeout', parseInt(e.target.value))
+ onSettingChange(
+ 'aiContextTimeout',
+ parseInt(e.target.value)
+ )
}
/>
- {t('aiContextRateLimitLabel', 'Rate Limit (requests/min):')}
+ {t(
+ 'aiContextRateLimitLabel',
+ 'Rate Limit (requests/min):'
+ )}
- 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
+ )
}
/>
- {t('aiContextRetryAttemptsLabel', 'Retry Attempts:')}
+ {t(
+ 'aiContextRetryAttemptsLabel',
+ 'Retry Attempts:'
+ )}
- 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('batchingEnabledLabel', 'Enable Batch Translation:')}
+ {t(
+ 'batchingEnabledLabel',
+ 'Enable Batch Translation:'
+ )}
{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('globalBatchSizeLabel', 'Global Batch Size:')}
+ {t(
+ 'globalBatchSizeLabel',
+ 'Global Batch Size:'
+ )}
{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('smartBatchingLabel', 'Smart Batch Optimization:')}
+ {t(
+ 'smartBatchingLabel',
+ 'Smart Batch Optimization:'
+ )}
{t(
@@ -195,7 +216,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('maxConcurrentBatchesLabel', 'Maximum Concurrent Batches:')}
+ {t(
+ 'maxConcurrentBatchesLabel',
+ 'Maximum Concurrent Batches:'
+ )}
{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('openaieBatchSizeLabel', 'OpenAI Batch Size:')}
+ {t(
+ 'openaieBatchSizeLabel',
+ 'OpenAI Batch Size:'
+ )}
- {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 }) {
@@ -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 }) {
@@ -319,10 +382,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
@@ -341,7 +413,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
)}
- {t('openaieDelayLabel', 'OpenAI Request Delay (ms):')}
+ {t(
+ 'openaieDelayLabel',
+ 'OpenAI Request Delay (ms):'
+ )}
- {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 }) {
@@ -394,10 +487,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
@@ -416,10 +518,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
@@ -438,10 +549,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
@@ -460,10 +580,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
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 @@