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/CHANGELOG.md b/CHANGELOG.md index d0ebbba..759e959 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,40 +5,60 @@ All notable changes to DualSub will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.5.0] - 2025-11-19 + +### ✨ Added + +- **Unified AI Side Panel**: Migrated the AI Context Analysis modal into the new Side Panel interface. This provides a persistent, non-intrusive workspace for exploring cultural and linguistic context without obstructing the video playback. + +### 🐛 Fixed + +- **Side Panel Desync Edge Cases**: + - Fixed an issue where deselecting words in the side panel or switching videos caused synchronization errors. + - Implemented a "Single Source of Truth" architecture where the content script is the authoritative state holder. + - Eliminated race conditions by removing optimistic updates from the side panel to the background. +- **Word Selection Order**: + - Ensured that selected words always maintain their original sentence order (DOM order) in the side panel, even after deselection and re-selection. +- **Duplicate Event Listeners**: + - Fixed a bug where `dualsub-word-selected` listeners were accumulating, causing multiple events for a single click. Added proper cleanup logic. + ## [2.4.0] - 2025-09-30 ### 🎉 Major Changes + - **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 +66,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.md b/README.md index 553ade4..1cd65c0 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,13 @@ For full license terms, see [LICENSE](LICENSE) file. ## 📋 Changelog -### Version 2.4.0 (Current) +### Version 2.5.0 (Current) + +- 🤖 **Unified AI Experience**: Integrated AI Context Analysis into the Side Panel for a seamless, persistent workspace. +- 🐛 **Stability Improvements**: Fixed desync issues when switching videos or deselecting words in the side panel. +- ✨ **Better UX**: Improved word selection ordering to always match sentence structure. + +### Version 2.4.0 - 🎉 **Full React Migration**: Popup and options pages migrated to React with 100% functional parity - ✨ Modern component-based architecture with custom hooks diff --git a/README_zh.md b/README_zh.md index 33d10e4..17715fc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -84,7 +84,7 @@ ```bash # 生产构建 npm run build - + # 开发模式(自动重新构建) npm run dev ``` @@ -259,7 +259,13 @@ npm test -- --coverage ## 📋 更新日志 -### 版本 2.4.0(当前) +### 版本 2.5.0(当前) + +- 🤖 **统一 AI 体验**:将 AI 上下文分析集成到侧边栏中,提供无缝、持久的工作空间。 +- 🐛 **稳定性改进**:修复了切换视频或在侧边栏中取消选择单词时的不同步问题。 +- ✨ **体验优化**:改进了单词选择排序,使其始终与句子结构匹配。 + +### 版本 2.4.0 - ⚛️ **React 迁移**:将弹出窗口和选项页面完全迁移到 React - 🏗️ **现代化构建**:使用 Vite 进行快速开发和优化构建 diff --git a/_locales/en/messages.json b/_locales/en/messages.json index acffd65..ad4abbe 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1,34 +1,82 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "Displays dual language subtitles on streaming platforms." }, - "pageTitle": { "message": "DualSub Settings" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "Enable Dual Subtitles:" }, - "useNativeSubtitlesLabel": { "message": "Use Official Subtitles:" }, - "originalLanguageLabel": { "message": "Language Set:" }, - "translationSettingsLegend": { "message": "Translation Settings" }, - "providerLabel": { "message": "Provider:" }, - "targetLanguageLabel": { "message": "Translate to:" }, - "batchSizeLabel": { "message": "Batch Size:" }, - "requestDelayLabel": { "message": "Request Delay (ms):" }, + "pageTitle": { + "message": "DualSub Settings" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "Enable Dual Subtitles:" + }, + "useNativeSubtitlesLabel": { + "message": "Use Official Subtitles:" + }, + "originalLanguageLabel": { + "message": "Language Set:" + }, + "translationSettingsLegend": { + "message": "Translation Settings" + }, + "providerLabel": { + "message": "Provider:" + }, + "targetLanguageLabel": { + "message": "Translate to:" + }, + "batchSizeLabel": { + "message": "Batch Size:" + }, + "requestDelayLabel": { + "message": "Request Delay (ms):" + }, "subtitleAppearanceTimingLegend": { "message": "Subtitle Appearance & Timing" }, - "displayOrderLabel": { "message": "Display Order:" }, - "layoutLabel": { "message": "Layout:" }, - "fontSizeLabel": { "message": "Font Size:" }, - "verticalGapLabel": { "message": "Vertical Gap:" }, - "subtitleVerticalPositionLabel": { "message": "Vertical Position:" }, - "timeOffsetLabel": { "message": "Time Offset(s):" }, - "displayOrderOriginalFirst": { "message": "Original First" }, - "displayOrderTranslationFirst": { "message": "Translation First" }, - "layoutTopBottom": { "message": "Top / Bottom" }, - "layoutLeftRight": { "message": "Left / Right" }, - "uiLanguageLabel": { "message": "Language:" }, - "openOptionsButton": { "message": "Advanced Settings" }, - "statusLanguageSetTo": { "message": "Language Set (Refresh Page): " }, + "displayOrderLabel": { + "message": "Display Order:" + }, + "layoutLabel": { + "message": "Layout:" + }, + "fontSizeLabel": { + "message": "Font Size:" + }, + "verticalGapLabel": { + "message": "Vertical Gap:" + }, + "subtitleVerticalPositionLabel": { + "message": "Vertical Position:" + }, + "timeOffsetLabel": { + "message": "Time Offset(s):" + }, + "displayOrderOriginalFirst": { + "message": "Original First" + }, + "displayOrderTranslationFirst": { + "message": "Translation First" + }, + "layoutTopBottom": { + "message": "Top / Bottom" + }, + "layoutLeftRight": { + "message": "Left / Right" + }, + "uiLanguageLabel": { + "message": "Language:" + }, + "openOptionsButton": { + "message": "Advanced Settings" + }, + "statusLanguageSetTo": { + "message": "Language Set (Refresh Page): " + }, "statusDualEnabled": { "message": "Dual Subtitles Enabled. (Refresh Page)" }, @@ -41,81 +89,174 @@ "statusSmartTranslationDisabled": { "message": "Smart Translation Disabled. (Refresh Page)" }, - "statusOriginalLanguage": { "message": "Language Set (Refresh Page): " }, - "statusTimeOffset": { "message": "Time offset: " }, - "statusDisplayOrderUpdated": { "message": "Display order updated." }, + "statusOriginalLanguage": { + "message": "Language Set (Refresh Page): " + }, + "statusTimeOffset": { + "message": "Time offset: " + }, + "statusDisplayOrderUpdated": { + "message": "Display order updated." + }, "statusLayoutOrientationUpdated": { "message": "Layout orientation updated." }, - "statusFontSize": { "message": "Font size: " }, - "statusVerticalGap": { "message": "Vertical gap: " }, - "statusVerticalPosition": { "message": "Vertical position: " }, - "statusInvalidOffset": { "message": "Invalid offset, reverting." }, + "statusFontSize": { + "message": "Font size: " + }, + "statusVerticalGap": { + "message": "Vertical gap: " + }, + "statusVerticalPosition": { + "message": "Vertical position: " + }, + "statusInvalidOffset": { + "message": "Invalid offset, reverting." + }, "statusSettingNotApplied": { "message": "Setting not applied. Refresh page." }, - - "optionsPageTitle": { "message": "DualSub Options" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "General" }, - "navTranslation": { "message": "Translation" }, - "navProviders": { "message": "Providers" }, - "navAbout": { "message": "About" }, - "sectionGeneral": { "message": "General" }, - "cardUILanguageTitle": { "message": "UI Language" }, + "optionsPageTitle": { + "message": "DualSub Options" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "General" + }, + "navTranslation": { + "message": "Translation" + }, + "navProviders": { + "message": "Providers" + }, + "navAbout": { + "message": "About" + }, + "sectionGeneral": { + "message": "General" + }, + "cardUILanguageTitle": { + "message": "UI Language" + }, "cardUILanguageDesc": { "message": "Choose the display language for the extension's interface." }, - "cardHideOfficialSubtitlesTitle": { "message": "Hide Official Subtitles" }, + "cardHideOfficialSubtitlesTitle": { + "message": "Hide Official Subtitles" + }, "cardHideOfficialSubtitlesDesc": { "message": "Hide the official subtitles from the video platform when DualSub is active." }, - "hideOfficialSubtitlesLabel": { "message": "Hide official subtitles:" }, - "sectionTranslation": { "message": "Translation" }, - "cardTranslationEngineTitle": { "message": "Translation Engine" }, + "hideOfficialSubtitlesLabel": { + "message": "Hide official subtitles:" + }, + "sectionTranslation": { + "message": "Translation" + }, + "cardTranslationEngineTitle": { + "message": "Translation Engine" + }, "cardTranslationEngineDesc": { "message": "Select your preferred translation service." }, - "cardPerformanceTitle": { "message": "Performance" }, + "cardPerformanceTitle": { + "message": "Performance" + }, "cardPerformanceDesc": { "message": "Adjust how the extension handles translation requests to balance speed and stability." }, - "sectionProviders": { "message": "Provider Settings" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "Provider Settings" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "Enter your API key for DeepL Translate. Choose between Free and Pro plans." }, - "apiKeyLabel": { "message": "API Key:" }, - "apiPlanLabel": { "message": "API Plan:" }, - "apiPlanFree": { "message": "DeepL API Free" }, - "apiPlanPro": { "message": "DeepL API Pro" }, - "sectionAbout": { "message": "About" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "Version" }, + "apiKeyLabel": { + "message": "API Key:" + }, + "apiPlanLabel": { + "message": "API Plan:" + }, + "apiPlanFree": { + "message": "DeepL API Free" + }, + "apiPlanPro": { + "message": "DeepL API Pro" + }, + "sectionAbout": { + "message": "About" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "Version" + }, "aboutDescription": { "message": "This extension helps you watch videos with dual language subtitles on various platforms." }, - "aboutDevelopment": { "message": "Developed by QuellaMC & 1jifang." }, - - "lang_en": { "message": "English" }, - "lang_es": { "message": "Spanish" }, - "lang_fr": { "message": "French" }, - "lang_de": { "message": "German" }, - "lang_it": { "message": "Italian" }, - "lang_pt": { "message": "Portuguese" }, - "lang_ja": { "message": "Japanese" }, - "lang_ko": { "message": "Korean" }, - "lang_zh_CN": { "message": "Chinese (Simp)" }, - "lang_zh_TW": { "message": "Chinese (Trad)" }, - "lang_ru": { "message": "Russian" }, - "lang_ar": { "message": "Arabic" }, - "lang_hi": { "message": "Hindi" }, - - "testDeepLButton": { "message": "Test DeepL Connection" }, - "deeplApiKeyError": { "message": "Please enter your DeepL API key first." }, - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API key needs testing." }, - "testingButton": { "message": "Testing..." }, - "testingConnection": { "message": "Testing DeepL connection..." }, + "aboutDevelopment": { + "message": "Developed by QuellaMC & 1jifang." + }, + "lang_en": { + "message": "English" + }, + "lang_es": { + "message": "Spanish" + }, + "lang_fr": { + "message": "French" + }, + "lang_de": { + "message": "German" + }, + "lang_it": { + "message": "Italian" + }, + "lang_pt": { + "message": "Portuguese" + }, + "lang_ja": { + "message": "Japanese" + }, + "lang_ko": { + "message": "Korean" + }, + "lang_zh_CN": { + "message": "Chinese (Simp)" + }, + "lang_zh_TW": { + "message": "Chinese (Trad)" + }, + "lang_ru": { + "message": "Russian" + }, + "lang_ar": { + "message": "Arabic" + }, + "lang_hi": { + "message": "Hindi" + }, + "testDeepLButton": { + "message": "Test DeepL Connection" + }, + "deeplApiKeyError": { + "message": "Please enter your DeepL API key first." + }, + "deeplTestNeedsTesting": { + "message": "⚠️ DeepL API key needs testing." + }, + "testingButton": { + "message": "Testing..." + }, + "testingConnection": { + "message": "Testing DeepL connection..." + }, "deeplTestSuccess": { "message": "✅ DeepL API test successful!" }, @@ -128,58 +269,108 @@ "deeplTestQuotaExceeded": { "message": "❌ DeepL API quota exceeded. Please check your usage limits." }, - "deeplTestApiError": { "message": "❌ DeepL API error (%d): %s" }, + "deeplTestApiError": { + "message": "❌ DeepL API error (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ Network error: Could not connect to DeepL API. Check your internet connection." }, - "deeplTestGenericError": { "message": "❌ Test failed: %s" }, - "deepLApiUnavailable": { "message": "DeepL API Unavailable" }, + "deeplTestGenericError": { + "message": "❌ Test failed: %s" + }, + "deepLApiUnavailable": { + "message": "DeepL API Unavailable" + }, "deepLApiUnavailableTooltip": { "message": "DeepL API script failed to load" }, "deeplApiNotLoadedError": { "message": "❌ DeepL API script is not available. Please refresh the page." }, - "cardGoogleTitle": { "message": "Google Translate" }, + "cardGoogleTitle": { + "message": "Google Translate" + }, "cardGoogleDesc": { "message": "Free translation service provided by Google. No additional configuration required." }, - "cardMicrosoftTitle": { "message": "Microsoft Translate" }, + "cardMicrosoftTitle": { + "message": "Microsoft Translate" + }, "cardMicrosoftDesc": { "message": "Free translation service provided by Microsoft Edge. No additional configuration required." }, - "cardDeepLFreeTitle": { "message": "DeepL Translate (Free)" }, + "cardDeepLFreeTitle": { + "message": "DeepL Translate (Free)" + }, "cardDeepLFreeDesc": { "message": "Free DeepL translation service with high quality results. No API key required - uses DeepL's web interface." }, - "providerStatus": { "message": "Status:" }, - "statusReady": { "message": "Ready to use" }, - "providerFeatures": { "message": "Features:" }, - "featureFree": { "message": "Free to use" }, - "featureNoApiKey": { "message": "No API key required" }, - "featureWideLanguageSupport": { "message": "Wide language support" }, - "featureFastTranslation": { "message": "Fast translation" }, - "featureHighQuality": { "message": "High quality translation" }, - "featureGoodPerformance": { "message": "Good performance" }, - "featureHighestQuality": { "message": "Highest quality translation" }, - "featureApiKeyRequired": { "message": "API key required" }, - "featureLimitedLanguages": { "message": "Limited language support" }, - "featureUsageLimits": { "message": "Usage limits apply" }, - "featureMultipleBackups": { "message": "Multiple backup methods" }, - - "providerNotes": { "message": "Notes:" }, + "providerStatus": { + "message": "Status:" + }, + "statusReady": { + "message": "Ready to use" + }, + "providerFeatures": { + "message": "Features:" + }, + "featureFree": { + "message": "Free to use" + }, + "featureNoApiKey": { + "message": "No API key required" + }, + "featureWideLanguageSupport": { + "message": "Wide language support" + }, + "featureFastTranslation": { + "message": "Fast translation" + }, + "featureHighQuality": { + "message": "High quality translation" + }, + "featureGoodPerformance": { + "message": "Good performance" + }, + "featureHighestQuality": { + "message": "Highest quality translation" + }, + "featureApiKeyRequired": { + "message": "API key required" + }, + "featureLimitedLanguages": { + "message": "Limited language support" + }, + "featureUsageLimits": { + "message": "Usage limits apply" + }, + "featureMultipleBackups": { + "message": "Multiple backup methods" + }, + "providerNotes": { + "message": "Notes:" + }, "noteSlowForSecurity": { "message": "Slightly slower due to security measures" }, "noteAutoFallback": { "message": "Automatic fallback to alternative services" }, - "noteRecommendedDefault": { "message": "Recommended as default provider" }, - - "providerGoogleName": { "message": "Google Translate (Free)" }, - "providerMicrosoftName": { "message": "Microsoft Translate (Free)" }, - "providerDeepLName": { "message": "DeepL (API Key Required)" }, - "providerDeepLFreeName": { "message": "DeepL Translate (Free)" }, + "noteRecommendedDefault": { + "message": "Recommended as default provider" + }, + "providerGoogleName": { + "message": "Google Translate (Free)" + }, + "providerMicrosoftName": { + "message": "Microsoft Translate (Free)" + }, + "providerDeepLName": { + "message": "DeepL (API Key Required)" + }, + "providerDeepLFreeName": { + "message": "DeepL Translate (Free)" + }, "providerOpenAICompatibleName": { "message": "OpenAI Compatible (API Key Required)" }, @@ -192,75 +383,171 @@ "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." }, - "vertexAccessTokenLabel": { "message": "Access Token:" }, - "vertexProjectIdLabel": { "message": "Project ID:" }, - "vertexLocationLabel": { "message": "Location:" }, - "vertexModelLabel": { "message": "Model:" }, - "vertexMissingConfig": { "message": "Please enter access token and project ID." }, - "vertexConnectionFailed": { "message": "Connection failed: %s" }, - "vertexServiceAccountLabel": { "message": "Service Account JSON:" }, - "vertexImportButton": { "message": "Import JSON File" }, - "vertexRefreshButton": { "message": "🔄 Refresh Token" }, - "vertexImportHint": { "message": "Auto-fills credentials below" }, - "vertexImporting": { "message": "Importing..." }, - "vertexRefreshingToken": { "message": "Refreshing access token..." }, - "vertexGeneratingToken": { "message": "Generating access token..." }, - "vertexImportSuccess": { "message": "Service account imported and token generated." }, - "vertexImportFailed": { "message": "Import failed: %s" }, - "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." }, - "featureVertexServiceAccount": { "message": "Service account JSON import" }, - "featureVertexAutoToken": { "message": "Automatic token generation" }, - "featureVertexGemini": { "message": "Google Gemini models via Vertex AI" }, - "providerNote": { "message": "Note:" }, - "vertexNote": { "message": "Access tokens expire after 1 hour. Your service account is securely stored for easy token refresh - just click the Refresh Token button when needed." }, - "baseUrlLabel": { "message": "Base URL:" }, - "modelLabel": { "message": "Model:" }, - "featureCustomizable": { "message": "Customizable endpoint and model" }, - "fetchModelsButton": { "message": "Fetch Models" }, - "testConnectionButton": { "message": "Test Connection" }, + "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." + }, + "vertexConnectionFailed": { + "message": "Connection failed: %s" + }, + "vertexServiceAccountLabel": { + "message": "Service Account JSON:" + }, + "vertexImportButton": { + "message": "Import JSON File" + }, + "vertexRefreshButton": { + "message": "🔄 Refresh Token" + }, + "vertexImportHint": { + "message": "Auto-fills credentials below" + }, + "vertexImporting": { + "message": "Importing..." + }, + "vertexRefreshingToken": { + "message": "Refreshing access token..." + }, + "vertexGeneratingToken": { + "message": "Generating access token..." + }, + "vertexImportSuccess": { + "message": "Service account imported and token generated." + }, + "vertexImportFailed": { + "message": "Import failed: %s" + }, + "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." + }, + "featureVertexServiceAccount": { + "message": "Service account JSON import" + }, + "featureVertexAutoToken": { + "message": "Automatic token generation" + }, + "featureVertexGemini": { + "message": "Google Gemini models via Vertex AI" + }, + "providerNote": { + "message": "Note:" + }, + "vertexNote": { + "message": "Access tokens expire after 1 hour. Your service account is securely stored for easy token refresh - just click the Refresh Token button when needed." + }, + "baseUrlLabel": { + "message": "Base URL:" + }, + "modelLabel": { + "message": "Model:" + }, + "featureCustomizable": { + "message": "Customizable endpoint and model" + }, + "fetchModelsButton": { + "message": "Fetch Models" + }, + "testConnectionButton": { + "message": "Test Connection" + }, "openaiApiKeyPlaceholder": { "message": "Enter your OpenAI-compatible API key" }, "openaiBaseUrlPlaceholder": { "message": "e.g., https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "Please enter your API key first." }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API key needs testing." }, + "openaiApiKeyError": { + "message": "Please enter your API key first." + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API key needs testing." + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI-compatible API key needs testing." }, - "openaiTestingConnection": { "message": "Testing connection..." }, - "openaiConnectionSuccessful": { "message": "Connection successful!" }, - "openaiConnectionFailed": { "message": "Connection failed: %s" }, - "openaieFetchingModels": { "message": "Fetching models..." }, + "openaiTestingConnection": { + "message": "Testing connection..." + }, + "openaiConnectionSuccessful": { + "message": "Connection successful!" + }, + "openaiConnectionFailed": { + "message": "Connection failed: %s" + }, + "openaieFetchingModels": { + "message": "Fetching models..." + }, "openaiModelsFetchedSuccessfully": { "message": "Models fetched successfully." }, - "openaiFailedToFetchModels": { "message": "Failed to fetch models: %s" }, - - "cardLoggingLevelTitle": { "message": "Logging Level" }, + "openaiFailedToFetchModels": { + "message": "Failed to fetch models: %s" + }, + "cardLoggingLevelTitle": { + "message": "Logging Level" + }, "cardLoggingLevelDesc": { "message": "Control the amount of debug information displayed in browser console. Higher levels include all lower level messages." }, - "loggingLevelLabel": { "message": "Logging Level:" }, - "loggingLevelOff": { "message": "Off" }, - "loggingLevelError": { "message": "Error Only" }, - "loggingLevelWarn": { "message": "Warnings & Errors" }, - "loggingLevelInfo": { "message": "Info & Above" }, - "loggingLevelDebug": { "message": "Debug (All)" }, - - "cardBatchTranslationTitle": { "message": "Batch Translation" }, + "loggingLevelLabel": { + "message": "Logging Level:" + }, + "loggingLevelOff": { + "message": "Off" + }, + "loggingLevelError": { + "message": "Error Only" + }, + "loggingLevelWarn": { + "message": "Warnings & Errors" + }, + "loggingLevelInfo": { + "message": "Info & Above" + }, + "loggingLevelDebug": { + "message": "Debug (All)" + }, + "cardBatchTranslationTitle": { + "message": "Batch Translation" + }, "cardBatchTranslationDesc": { "message": "Batch translation processes multiple subtitle segments together, reducing API calls by 80-90% and improving performance. Configure optimal settings for your preferred translation provider." }, - "batchingEnabledLabel": { "message": "Enable Batch Translation:" }, + "batchingEnabledLabel": { + "message": "Enable Batch Translation:" + }, "batchingEnabledHelp": { "message": "Groups multiple subtitle segments into single translation requests" }, @@ -270,61 +557,87 @@ "useProviderDefaultsHelp": { "message": "Automatically use optimal batch sizes for each translation provider" }, - "globalBatchSizeLabel": { "message": "Global Batch Size:" }, + "globalBatchSizeLabel": { + "message": "Global Batch Size:" + }, "globalBatchSizeHelp": { "message": "Number of subtitle segments to process together (1-15)" }, - "smartBatchingLabel": { "message": "Smart Batch Optimization:" }, + "smartBatchingLabel": { + "message": "Smart Batch Optimization:" + }, "smartBatchingHelp": { "message": "Prioritizes subtitle segments based on playback position" }, - "maxConcurrentBatchesLabel": { "message": "Maximum Concurrent Batches:" }, + "maxConcurrentBatchesLabel": { + "message": "Maximum Concurrent Batches:" + }, "maxConcurrentBatchesHelp": { "message": "Number of translation batches to process simultaneously" }, - - "cardProviderBatchTitle": { "message": "Provider-Specific Batch Sizes" }, + "cardProviderBatchTitle": { + "message": "Provider-Specific Batch Sizes" + }, "cardProviderBatchDesc": { "message": "Configure optimal batch sizes for each translation provider. These settings are used when \"Use Provider-Optimized Settings\" is enabled." }, - "openaieBatchSizeLabel": { "message": "OpenAI Batch Size:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI Batch Size:" + }, "openaieBatchSizeHelp": { "message": "Recommended: 5-10 segments (default: 8)" }, - "googleBatchSizeLabel": { "message": "Google Translate Batch Size:" }, + "googleBatchSizeLabel": { + "message": "Google Translate Batch Size:" + }, "googleBatchSizeHelp": { "message": "Recommended: 3-5 segments (default: 4)" }, - "deeplBatchSizeLabel": { "message": "DeepL Batch Size:" }, + "deeplBatchSizeLabel": { + "message": "DeepL Batch Size:" + }, "deeplBatchSizeHelp": { "message": "Recommended: 2-3 segments (default: 3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft Translate Batch Size:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft Translate Batch Size:" + }, "microsoftBatchSizeHelp": { "message": "Recommended: 3-5 segments (default: 4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI Batch Size:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI Batch Size:" + }, "vertexBatchSizeHelp": { "message": "Recommended: 5-10 segments (default: 8)" }, - - "cardProviderDelayTitle": { "message": "Provider-Specific Request Delays" }, + "cardProviderDelayTitle": { + "message": "Provider-Specific Request Delays" + }, "cardProviderDelayDesc": { "message": "Configure mandatory delays between translation requests to prevent account lockouts. These delays are applied even when batch processing is enabled." }, - "openaieDelayLabel": { "message": "OpenAI Request Delay (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI Request Delay (ms):" + }, "openaieDelayHelp": { "message": "Minimum delay between requests (default: 100ms)" }, - "googleDelayLabel": { "message": "Google Translate Request Delay (ms):" }, + "googleDelayLabel": { + "message": "Google Translate Request Delay (ms):" + }, "googleDelayHelp": { "message": "Required delay to prevent temporary lockouts (default: 1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API Request Delay (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API Request Delay (ms):" + }, "deeplDelayHelp": { "message": "Delay for DeepL API requests (default: 500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL Free Request Delay (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL Free Request Delay (ms):" + }, "deeplFreeDelayHelp": { "message": "Conservative delay for free tier (default: 2000ms)" }, @@ -340,117 +653,246 @@ "vertexDelayHelp": { "message": "Minimum delay between requests (default: 100ms)" }, - - "aiContextModalTitle": { "message": "AI Context Analysis" }, - "aiContextSelectedWords": { "message": "Selected Words" }, - "aiContextNoWordsSelected": { "message": "No words selected" }, - "aiContextClickHint": { "message": "💡 Click a word to add or remove it." }, - "aiContextStartAnalysis": { "message": "Start Analysis" }, - "aiContextPauseAnalysis": { "message": "⏸ Pause" }, - "aiContextPauseAnalysisTitle": { "message": "Pause Analysis" }, + "aiContextModalTitle": { + "message": "AI Context Analysis" + }, + "aiContextSelectedWords": { + "message": "Selected Words" + }, + "aiContextNoWordsSelected": { + "message": "No words selected" + }, + "aiContextClickHint": { + "message": "💡 Click a word to add or remove it." + }, + "aiContextStartAnalysis": { + "message": "Start Analysis" + }, + "aiContextPauseAnalysis": { + "message": "⏸ Pause" + }, + "aiContextPauseAnalysisTitle": { + "message": "Pause Analysis" + }, "aiContextInitialMessage": { "message": "Select words from the subtitles to begin analysis." }, - "aiContextAnalyzing": { "message": "Analyzing context..." }, - "aiContextPauseNote": { "message": "Click ⏸ to pause analysis" }, - "aiContextAnalysisFailed": { "message": "Analysis Failed" }, - "aiContextNoContent": { "message": "No Analysis Content" }, + "aiContextAnalyzing": { + "message": "Analyzing context..." + }, + "aiContextPauseNote": { + "message": "Click ⏸ to pause analysis" + }, + "aiContextAnalysisFailed": { + "message": "Analysis Failed" + }, + "aiContextNoContent": { + "message": "No Analysis Content" + }, "aiContextNoContentMessage": { "message": "Analysis completed but no content was returned." }, - "aiContextDefinition": { "message": "📖 Definition" }, - "aiContextCultural": { "message": "🌍 Cultural Context" }, - "aiContextCulturalSignificance": { "message": "⭐ Cultural Significance" }, - "aiContextHistorical": { "message": "📜 Historical Context" }, + "aiContextDefinition": { + "message": "📖 Definition" + }, + "aiContextCultural": { + "message": "🌍 Cultural Context" + }, + "aiContextCulturalSignificance": { + "message": "⭐ Cultural Significance" + }, + "aiContextHistorical": { + "message": "📜 Historical Context" + }, "aiContextHistoricalSignificance": { "message": "📜 Historical Significance" }, - "aiContextEvolution": { "message": "🔄 Evolution Over Time" }, - "aiContextLinguistic": { "message": "🔤 Linguistic Analysis" }, - "aiContextGrammar": { "message": "📝 Grammar & Semantics" }, - "aiContextUsage": { "message": "💡 Usage & Examples" }, - "aiContextExamples": { "message": "Examples:" }, - "aiContextLearningTips": { "message": "🎯 Learning Tips" }, - "aiContextRelatedExpressions": { "message": "🔗 Related Expressions" }, - "aiContextKeyInsights": { "message": "🔑 Key Insights" }, - "aiContextTypeCultural": { "message": "Cultural" }, - "aiContextTypeHistorical": { "message": "Historical" }, - "aiContextTypeLinguistic": { "message": "Linguistic" }, - "aiContextTypeComprehensive": { "message": "Comprehensive" }, - "aiContextTypeGeneric": { "message": "Context" }, - "aiContextClose": { "message": "Close" }, - "aiContextAnalysisResults": { "message": "Analysis Results" }, - "aiContextRetrying": { "message": "Analysis failed, regenerating..." }, - "aiContextRetryNotification": { "message": "Analysis failed, retrying..." }, - "aiContextRetryButton": { "message": "Try Again" }, + "aiContextEvolution": { + "message": "🔄 Evolution Over Time" + }, + "aiContextLinguistic": { + "message": "🔤 Linguistic Analysis" + }, + "aiContextGrammar": { + "message": "📝 Grammar & Semantics" + }, + "aiContextUsage": { + "message": "💡 Usage & Examples" + }, + "aiContextExamples": { + "message": "Examples:" + }, + "aiContextLearningTips": { + "message": "🎯 Learning Tips" + }, + "aiContextRelatedExpressions": { + "message": "🔗 Related Expressions" + }, + "aiContextKeyInsights": { + "message": "🔑 Key Insights" + }, + "aiContextTypeCultural": { + "message": "Cultural" + }, + "aiContextTypeHistorical": { + "message": "Historical" + }, + "aiContextTypeLinguistic": { + "message": "Linguistic" + }, + "aiContextTypeComprehensive": { + "message": "Comprehensive" + }, + "aiContextTypeGeneric": { + "message": "Context" + }, + "aiContextClose": { + "message": "Close" + }, + "aiContextAnalysisResults": { + "message": "Analysis Results" + }, + "aiContextRetrying": { + "message": "Analysis failed, regenerating..." + }, + "aiContextRetryNotification": { + "message": "Analysis failed, retrying..." + }, + "aiContextRetryButton": { + "message": "Try Again" + }, "aiContextMalformedResponse": { "message": "The AI service returned an invalid response format. This may be due to temporary service issues." }, "aiContextJsonCodeBlock": { "message": "The AI service returned unprocessed JSON code instead of structured data. This indicates a formatting error in the response." }, - "aiContextCulturalContext": { "message": "Cultural Context:" }, - "aiContextSocialUsage": { "message": "Social Usage:" }, - "aiContextRegionalNotes": { "message": "Regional Notes:" }, - "aiContextOrigins": { "message": "Origins:" }, - "aiContextHistoricalContext": { "message": "Historical Context:" }, - "aiContextHistoricalSignificance": { + "aiContextCulturalContext": { + "message": "Cultural Context:" + }, + "aiContextSocialUsage": { + "message": "Social Usage:" + }, + "aiContextRegionalNotes": { + "message": "Regional Notes:" + }, + "aiContextOrigins": { + "message": "Origins:" + }, + "aiContextHistoricalContext": { + "message": "Historical Context:" + }, + "aiContextHistoricalSignificanceLabel": { "message": "Historical Significance:" }, - "aiContextEvolution": { "message": "Evolution:" }, - "aiContextEtymology": { "message": "Etymology:" }, - "aiContextGrammarNotes": { "message": "Grammar Notes:" }, - "aiContextTranslationNotes": { "message": "Translation Notes:" }, - "aiContextLinguisticAnalysis": { "message": "Linguistic Analysis:" }, - "aiContextGrammarSemantics": { "message": "Grammar & Semantics:" }, - "aiContextUsageExamples": { "message": "Usage & Examples:" }, - "aiContextLearningTips": { "message": "Learning Tips:" }, - "aiContextRelatedExpressions": { "message": "Related Expressions:" }, - "aiContextKeyInsights": { "message": "Key Insights:" }, - - "navAIContext": { "message": "AI Context" }, - "sectionAIContext": { "message": "AI Context Assistant" }, - "cardAIContextToggleTitle": { "message": "Enable AI Context Analysis" }, + "aiContextEvolutionLabel": { + "message": "Evolution:" + }, + "aiContextEtymology": { + "message": "Etymology:" + }, + "aiContextGrammarNotes": { + "message": "Grammar Notes:" + }, + "aiContextTranslationNotes": { + "message": "Translation Notes:" + }, + "aiContextLinguisticAnalysis": { + "message": "Linguistic Analysis:" + }, + "aiContextGrammarSemantics": { + "message": "Grammar & Semantics:" + }, + "aiContextUsageExamples": { + "message": "Usage & Examples:" + }, + "aiContextLearningTipsLabel": { + "message": "Learning Tips:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "Related Expressions:" + }, + "aiContextKeyInsightsLabel": { + "message": "Key Insights:" + }, + "navAIContext": { + "message": "AI Context" + }, + "sectionAIContext": { + "message": "AI Context Assistant" + }, + "cardAIContextToggleTitle": { + "message": "Enable AI Context Analysis" + }, "cardAIContextToggleDesc": { "message": "Enable AI-powered cultural, historical, and linguistic context analysis for subtitle text. Click on words or phrases in subtitles to get detailed explanations." }, - "aiContextEnabledLabel": { "message": "Enable AI Context:" }, - "cardAIContextProviderTitle": { "message": "AI Provider" }, + "aiContextEnabledLabel": { + "message": "Enable AI Context:" + }, + "cardAIContextProviderTitle": { + "message": "AI Provider" + }, "cardAIContextProviderDesc": { "message": "Choose the AI service provider for context analysis. Different providers may offer varying quality and response times." }, - "aiContextProviderLabel": { "message": "Provider:" }, - "cardOpenAIContextTitle": { "message": "OpenAI Configuration" }, + "aiContextProviderLabel": { + "message": "Provider:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI Configuration" + }, "cardOpenAIContextDesc": { "message": "Configure your OpenAI API settings for context analysis. You need a valid OpenAI API key." }, - "openaiApiKeyLabel": { "message": "API Key:" }, - "openaiBaseUrlLabel": { "message": "Base URL:" }, - "openaiModelLabel": { "message": "Model:" }, - "cardGeminiContextTitle": { "message": "Google Gemini Configuration" }, + "openaiApiKeyLabel": { + "message": "API Key:" + }, + "openaiBaseUrlLabel": { + "message": "Base URL:" + }, + "openaiModelLabel": { + "message": "Model:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini Configuration" + }, "cardGeminiContextDesc": { "message": "Configure your Google Gemini API settings for context analysis. You need a valid Gemini API key." }, - "geminiApiKeyLabel": { "message": "API Key:" }, - "geminiModelLabel": { "message": "Model:" }, - "cardAIContextTypesTitle": { "message": "Context Types" }, + "geminiApiKeyLabel": { + "message": "API Key:" + }, + "geminiModelLabel": { + "message": "Model:" + }, + "cardAIContextTypesTitle": { + "message": "Context Types" + }, "cardAIContextTypesDesc": { "message": "Enable the types of context analysis you want to use. You can enable multiple types." }, - "contextTypeCulturalLabel": { "message": "Cultural Context:" }, + "contextTypeCulturalLabel": { + "message": "Cultural Context:" + }, "contextTypeCulturalHelp": { "message": "Analyze cultural references, idioms, and social context" }, - "contextTypeHistoricalLabel": { "message": "Historical Context:" }, + "contextTypeHistoricalLabel": { + "message": "Historical Context:" + }, "contextTypeHistoricalHelp": { "message": "Provide historical background and time period context" }, - "contextTypeLinguisticLabel": { "message": "Linguistic Analysis:" }, + "contextTypeLinguisticLabel": { + "message": "Linguistic Analysis:" + }, "contextTypeLinguisticHelp": { "message": "Explain grammar, etymology, and language structure" }, - - "cardAIContextPrivacyTitle": { "message": "Privacy & Data" }, + "cardAIContextPrivacyTitle": { + "message": "Privacy & Data" + }, "cardAIContextPrivacyDesc": { "message": "Control how your data is handled during context analysis." }, @@ -466,26 +908,91 @@ "aiContextDataSharingHelp": { "message": "Help improve the service by sharing anonymous usage data" }, - "cardAIContextAdvancedTitle": { "message": "Advanced Settings" }, + "cardAIContextAdvancedTitle": { + "message": "Advanced Settings" + }, "cardAIContextAdvancedDesc": { "message": "Configure advanced options for AI context analysis behavior." }, - "aiContextTimeoutLabel": { "message": "Request Timeout (ms):" }, + "aiContextTimeoutLabel": { + "message": "Request Timeout (ms):" + }, "aiContextTimeoutHelp": { "message": "Maximum time to wait for AI response" }, - "aiContextRateLimitLabel": { "message": "Rate Limit (requests/min):" }, + "aiContextRateLimitLabel": { + "message": "Rate Limit (requests/min):" + }, "aiContextRateLimitHelp": { "message": "Maximum number of requests per minute" }, - "aiContextCacheEnabledLabel": { "message": "Enable Caching:" }, + "aiContextCacheEnabledLabel": { + "message": "Enable Caching:" + }, "aiContextCacheEnabledHelp": { "message": "Cache analysis results to reduce API calls" }, - "aiContextRetryAttemptsLabel": { "message": "Retry Attempts:" }, + "aiContextRetryAttemptsLabel": { + "message": "Retry Attempts:" + }, "aiContextRetryAttemptsHelp": { "message": "Number of times to retry failed requests" }, - "showAdvancedSettings": { "message": "Show Advanced Settings" }, - "hideAdvancedSettings": { "message": "Hide Advanced Settings" } -} + "sidepanelLoading": { + "message": "Loading..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI Analysis" + }, + "sidepanelTabWordsLists": { + "message": "Words Lists" + }, + "sidepanelAnalyzeButton": { + "message": "Analyze" + }, + "sidepanelAnalyzing": { + "message": "Analyzing..." + }, + "sidepanelWordsToAnalyze": { + "message": "Words to Analyze" + }, + "sidepanelWordInputPlaceholder": { + "message": "Click on subtitle words to add them for analysis..." + }, + "sidepanelErrorRetry": { + "message": "Retry" + }, + "sidepanelResultsTitle": { + "message": "Results for \"%s\"" + }, + "sidepanelSectionDefinition": { + "message": "Definition" + }, + "sidepanelSectionCultural": { + "message": "Cultural Context" + }, + "sidepanelSectionHistorical": { + "message": "Historical Context" + }, + "sidepanelSectionLinguistic": { + "message": "Linguistic Analysis" + }, + "sidepanelMyWordsTitle": { + "message": "My Words" + }, + "sidepanelFeatureComingSoon": { + "message": "Words Lists feature coming soon!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "This feature is currently in development. Enable it in Settings to try the preview." + }, + "sidepanelErrorNoWords": { + "message": "No words selected for analysis" + }, + "sidepanelErrorDisabled": { + "message": "AI Context analysis is disabled. Enable it in settings." + }, + "sidepanelErrorGeneric": { + "message": "An error occurred during analysis." + } +} \ No newline at end of file diff --git a/_locales/es/messages.json b/_locales/es/messages.json index d04809d..cc752d2 100644 --- a/_locales/es/messages.json +++ b/_locales/es/messages.json @@ -1,33 +1,79 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "Muestra subtítulos en dos idiomas en plataformas de streaming." }, - "pageTitle": { "message": "Configuración de DualSub" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "Habilitar Subtítulos Duales:" }, - "useNativeSubtitlesLabel": { "message": "Usar Subtítulos Oficiales:" }, - "originalLanguageLabel": { "message": "Idioma Original:" }, - "translationSettingsLegend": { "message": "Configuración de Traducción" }, - "providerLabel": { "message": "Proveedor:" }, - "targetLanguageLabel": { "message": "Traducir a:" }, - "batchSizeLabel": { "message": "Tamaño de Lote:" }, - "requestDelayLabel": { "message": "Retraso de Solicitud (ms):" }, + "pageTitle": { + "message": "Configuración de DualSub" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "Habilitar Subtítulos Duales:" + }, + "useNativeSubtitlesLabel": { + "message": "Usar Subtítulos Oficiales:" + }, + "originalLanguageLabel": { + "message": "Idioma Original:" + }, + "translationSettingsLegend": { + "message": "Configuración de Traducción" + }, + "providerLabel": { + "message": "Proveedor:" + }, + "targetLanguageLabel": { + "message": "Traducir a:" + }, + "batchSizeLabel": { + "message": "Tamaño de Lote:" + }, + "requestDelayLabel": { + "message": "Retraso de Solicitud (ms):" + }, "subtitleAppearanceTimingLegend": { "message": "Apariencia y Sincronización de Subtítulos" }, - "displayOrderLabel": { "message": "Orden de Visualización:" }, - "layoutLabel": { "message": "Diseño:" }, - "fontSizeLabel": { "message": "Tamaño de Fuente:" }, - "verticalGapLabel": { "message": "Espaciado Vertical:" }, - "subtitleVerticalPositionLabel": { "message": "Posición Vertical:" }, - "timeOffsetLabel": { "message": "Desfase de Tiempo(s):" }, - "displayOrderOriginalFirst": { "message": "Original Primero" }, - "displayOrderTranslationFirst": { "message": "Traducción Primero" }, - "layoutTopBottom": { "message": "Arriba / Abajo" }, - "layoutLeftRight": { "message": "Izquierda / Derecha" }, - "uiLanguageLabel": { "message": "Idioma:" }, - "openOptionsButton": { "message": "Configuración Avanzada" }, + "displayOrderLabel": { + "message": "Orden de Visualización:" + }, + "layoutLabel": { + "message": "Diseño:" + }, + "fontSizeLabel": { + "message": "Tamaño de Fuente:" + }, + "verticalGapLabel": { + "message": "Espaciado Vertical:" + }, + "subtitleVerticalPositionLabel": { + "message": "Posición Vertical:" + }, + "timeOffsetLabel": { + "message": "Desfase de Tiempo(s):" + }, + "displayOrderOriginalFirst": { + "message": "Original Primero" + }, + "displayOrderTranslationFirst": { + "message": "Traducción Primero" + }, + "layoutTopBottom": { + "message": "Arriba / Abajo" + }, + "layoutLeftRight": { + "message": "Izquierda / Derecha" + }, + "uiLanguageLabel": { + "message": "Idioma:" + }, + "openOptionsButton": { + "message": "Configuración Avanzada" + }, "statusLanguageSetTo": { "message": "Idioma Configurado (Recargar Página): " }, @@ -46,29 +92,54 @@ "statusOriginalLanguage": { "message": "Idioma Configurado (Recargar Página): " }, - "statusTimeOffset": { "message": "Desfase de tiempo: " }, + "statusTimeOffset": { + "message": "Desfase de tiempo: " + }, "statusDisplayOrderUpdated": { "message": "Orden de visualización actualizado." }, "statusLayoutOrientationUpdated": { "message": "Orientación del diseño actualizada." }, - "statusFontSize": { "message": "Tamaño de fuente: " }, - "statusVerticalGap": { "message": "Espaciado vertical: " }, - "statusVerticalPosition": { "message": "Posición vertical: " }, - "statusInvalidOffset": { "message": "Desfase inválido, revirtiendo." }, + "statusFontSize": { + "message": "Tamaño de fuente: " + }, + "statusVerticalGap": { + "message": "Espaciado vertical: " + }, + "statusVerticalPosition": { + "message": "Posición vertical: " + }, + "statusInvalidOffset": { + "message": "Desfase inválido, revirtiendo." + }, "statusSettingNotApplied": { "message": "Configuración no aplicada. Recargar página." }, - - "optionsPageTitle": { "message": "Opciones de DualSub" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "General" }, - "navTranslation": { "message": "Traducción" }, - "navProviders": { "message": "Proveedores" }, - "navAbout": { "message": "Acerca de" }, - "sectionGeneral": { "message": "General" }, - "cardUILanguageTitle": { "message": "Idioma de la Interfaz" }, + "optionsPageTitle": { + "message": "Opciones de DualSub" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "General" + }, + "navTranslation": { + "message": "Traducción" + }, + "navProviders": { + "message": "Proveedores" + }, + "navAbout": { + "message": "Acerca de" + }, + "sectionGeneral": { + "message": "General" + }, + "cardUILanguageTitle": { + "message": "Idioma de la Interfaz" + }, "cardUILanguageDesc": { "message": "Elige el idioma de visualización para la interfaz de la extensión." }, @@ -81,56 +152,114 @@ "hideOfficialSubtitlesLabel": { "message": "Ocultar subtítulos oficiales:" }, - "sectionTranslation": { "message": "Traducción" }, - "cardTranslationEngineTitle": { "message": "Motor de Traducción" }, + "sectionTranslation": { + "message": "Traducción" + }, + "cardTranslationEngineTitle": { + "message": "Motor de Traducción" + }, "cardTranslationEngineDesc": { "message": "Selecciona tu servicio de traducción preferido." }, - "cardPerformanceTitle": { "message": "Rendimiento" }, + "cardPerformanceTitle": { + "message": "Rendimiento" + }, "cardPerformanceDesc": { "message": "Ajusta cómo la extensión maneja las solicitudes de traducción para equilibrar velocidad y estabilidad." }, - "sectionProviders": { "message": "Configuración de Proveedores" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "Configuración de Proveedores" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "Ingresa tu clave API para DeepL Translate. Elige entre planes Gratuito y Pro." }, - "apiKeyLabel": { "message": "Clave API:" }, - "apiPlanLabel": { "message": "Plan API:" }, - "apiPlanFree": { "message": "DeepL API Gratuito" }, - "apiPlanPro": { "message": "DeepL API Pro" }, - "sectionAbout": { "message": "Acerca de" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "Versión" }, + "apiKeyLabel": { + "message": "Clave API:" + }, + "apiPlanLabel": { + "message": "Plan API:" + }, + "apiPlanFree": { + "message": "DeepL API Gratuito" + }, + "apiPlanPro": { + "message": "DeepL API Pro" + }, + "sectionAbout": { + "message": "Acerca de" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "Versión" + }, "aboutDescription": { "message": "Esta extensión te ayuda a ver videos con subtítulos en dos idiomas en varias plataformas." }, - "aboutDevelopment": { "message": "Desarrollado por QuellaMC & 1jifang." }, - - "lang_en": { "message": "Inglés" }, - "lang_es": { "message": "Español" }, - "lang_fr": { "message": "Francés" }, - "lang_de": { "message": "Alemán" }, - "lang_it": { "message": "Italiano" }, - "lang_pt": { "message": "Portugués" }, - "lang_ja": { "message": "Japonés" }, - "lang_ko": { "message": "Coreano" }, - "lang_zh_CN": { "message": "Chino (Simp)" }, - "lang_zh_TW": { "message": "Chino (Trad)" }, - "lang_ru": { "message": "Ruso" }, - "lang_ar": { "message": "Árabe" }, - "lang_hi": { "message": "Hindi" }, - - "testDeepLButton": { "message": "Probar Conexión DeepL" }, + "aboutDevelopment": { + "message": "Desarrollado por QuellaMC & 1jifang." + }, + "lang_en": { + "message": "Inglés" + }, + "lang_es": { + "message": "Español" + }, + "lang_fr": { + "message": "Francés" + }, + "lang_de": { + "message": "Alemán" + }, + "lang_it": { + "message": "Italiano" + }, + "lang_pt": { + "message": "Portugués" + }, + "lang_ja": { + "message": "Japonés" + }, + "lang_ko": { + "message": "Coreano" + }, + "lang_zh_CN": { + "message": "Chino (Simp)" + }, + "lang_zh_TW": { + "message": "Chino (Trad)" + }, + "lang_ru": { + "message": "Ruso" + }, + "lang_ar": { + "message": "Árabe" + }, + "lang_hi": { + "message": "Hindi" + }, + "testDeepLButton": { + "message": "Probar Conexión DeepL" + }, "deeplApiKeyError": { "message": "Por favor ingresa tu clave API de DeepL primero." }, - "testingButton": { "message": "Probando..." }, - "testingConnection": { "message": "Probando conexión DeepL..." }, + "testingButton": { + "message": "Probando..." + }, + "testingConnection": { + "message": "Probando conexión DeepL..." + }, "deeplTestSuccess": { "message": "✅ ¡Prueba de API DeepL exitosa! Tradujo \"Hello\" a \"%s\"" }, - "deeplTestSuccessSimple": { "message": "✅ ¡Prueba de API DeepL exitosa!" }, + "deeplTestSuccessSimple": { + "message": "✅ ¡Prueba de API DeepL exitosa!" + }, "deeplTestUnexpectedFormat": { "message": "⚠️ DeepL API respondió pero con formato inesperado" }, @@ -140,47 +269,87 @@ "deeplTestQuotaExceeded": { "message": "❌ Cuota de API DeepL excedida. Por favor verifica tus límites de uso." }, - "deeplTestApiError": { "message": "❌ Error de API DeepL (%d): %s" }, + "deeplTestApiError": { + "message": "❌ Error de API DeepL (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ Error de red: No se pudo conectar a la API de DeepL. Verifica tu conexión a internet." }, - "deeplTestGenericError": { "message": "❌ Prueba fallida: %s" }, - "deepLApiUnavailable": { "message": "API DeepL No Disponible" }, + "deeplTestGenericError": { + "message": "❌ Prueba fallida: %s" + }, + "deepLApiUnavailable": { + "message": "API DeepL No Disponible" + }, "deepLApiUnavailableTooltip": { "message": "El script de la API DeepL falló al cargar" }, "deeplApiNotLoadedError": { "message": "❌ El script de la API DeepL no está disponible. Por favor recarga la página." }, - - "cardGoogleTitle": { "message": "Google Translate" }, + "cardGoogleTitle": { + "message": "Google Translate" + }, "cardGoogleDesc": { "message": "Servicio de traducción gratuito proporcionado por Google. No requiere configuración adicional." }, - "cardMicrosoftTitle": { "message": "Microsoft Translate" }, + "cardMicrosoftTitle": { + "message": "Microsoft Translate" + }, "cardMicrosoftDesc": { "message": "Servicio de traducción gratuito proporcionado por Microsoft Edge. No requiere configuración adicional." }, - "cardDeepLFreeTitle": { "message": "DeepL Translate (Gratuito)" }, + "cardDeepLFreeTitle": { + "message": "DeepL Translate (Gratuito)" + }, "cardDeepLFreeDesc": { "message": "Servicio de traducción DeepL gratuito con resultados de alta calidad. No requiere clave API - usa la interfaz web de DeepL." }, - "providerStatus": { "message": "Estado:" }, - "statusReady": { "message": "Listo para usar" }, - "providerFeatures": { "message": "Características:" }, - "featureFree": { "message": "Gratis para usar" }, - "featureNoApiKey": { "message": "No requiere clave API" }, - "featureWideLanguageSupport": { "message": "Amplio soporte de idiomas" }, - "featureFastTranslation": { "message": "Traducción rápida" }, - "featureHighQuality": { "message": "Traducción de alta calidad" }, - "featureGoodPerformance": { "message": "Buen rendimiento" }, - "featureHighestQuality": { "message": "Traducción de la más alta calidad" }, - "featureApiKeyRequired": { "message": "Requiere clave API" }, - "featureLimitedLanguages": { "message": "Soporte limitado de idiomas" }, - "featureUsageLimits": { "message": "Se aplican límites de uso" }, - "featureMultipleBackups": { "message": "Múltiples métodos de respaldo" }, - - "providerNotes": { "message": "Notas:" }, + "providerStatus": { + "message": "Estado:" + }, + "statusReady": { + "message": "Listo para usar" + }, + "providerFeatures": { + "message": "Características:" + }, + "featureFree": { + "message": "Gratis para usar" + }, + "featureNoApiKey": { + "message": "No requiere clave API" + }, + "featureWideLanguageSupport": { + "message": "Amplio soporte de idiomas" + }, + "featureFastTranslation": { + "message": "Traducción rápida" + }, + "featureHighQuality": { + "message": "Traducción de alta calidad" + }, + "featureGoodPerformance": { + "message": "Buen rendimiento" + }, + "featureHighestQuality": { + "message": "Traducción de la más alta calidad" + }, + "featureApiKeyRequired": { + "message": "Requiere clave API" + }, + "featureLimitedLanguages": { + "message": "Soporte limitado de idiomas" + }, + "featureUsageLimits": { + "message": "Se aplican límites de uso" + }, + "featureMultipleBackups": { + "message": "Múltiples métodos de respaldo" + }, + "providerNotes": { + "message": "Notas:" + }, "noteSlowForSecurity": { "message": "Ligeramente más lento debido a medidas de seguridad" }, @@ -190,11 +359,18 @@ "noteRecommendedDefault": { "message": "Recomendado como proveedor predeterminado" }, - - "providerGoogleName": { "message": "Google Translate (Gratuito)" }, - "providerMicrosoftName": { "message": "Microsoft Translate (Gratuito)" }, - "providerDeepLName": { "message": "DeepL (Requiere Clave API)" }, - "providerDeepLFreeName": { "message": "DeepL Translate (Gratuito)" }, + "providerGoogleName": { + "message": "Google Translate (Gratuito)" + }, + "providerMicrosoftName": { + "message": "Microsoft Translate (Gratuito)" + }, + "providerDeepLName": { + "message": "DeepL (Requiere Clave API)" + }, + "providerDeepLFreeName": { + "message": "DeepL Translate (Gratuito)" + }, "providerOpenAICompatibleName": { "message": "Compatible con OpenAI (Requiere Clave API)" }, @@ -207,43 +383,108 @@ "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." }, - "vertexAccessTokenLabel": { "message": "Token de Acceso:" }, - "vertexProjectIdLabel": { "message": "ID del Proyecto:" }, - "vertexLocationLabel": { "message": "Ubicación:" }, - "vertexModelLabel": { "message": "Modelo:" }, - "vertexMissingConfig": { "message": "Por favor ingresa el token de acceso y el ID del proyecto." }, - "vertexConnectionFailed": { "message": "Conexión fallida: %s" }, - "vertexServiceAccountLabel": { "message": "JSON de Cuenta de Servicio:" }, - "vertexImportButton": { "message": "Importar Archivo JSON" }, - "vertexRefreshButton": { "message": "🔄 Actualizar Token" }, - "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." }, - "vertexImportFailed": { "message": "Importación fallida: %s" }, - "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" }, - "featureVertexAutoToken": { "message": "Generación automática de tokens" }, - "featureVertexGemini": { "message": "Modelos Google Gemini a través de Vertex AI" }, - "vertexNote": { "message": "Los tokens de acceso expiran después de 1 hora. Tu cuenta de servicio está almacenada de forma segura para facilitar la actualización del token - solo haz clic en el botón Actualizar Token cuando sea necesario." }, - "baseUrlLabel": { "message": "URL Base:" }, - "modelLabel": { "message": "Modelo:" }, - "featureCustomizable": { "message": "Endpoint y modelo personalizables" }, - "fetchModelsButton": { "message": "Obtener Modelos" }, - "testConnectionButton": { "message": "Probar Conexión" }, - + "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." + }, + "vertexConnectionFailed": { + "message": "Conexión fallida: %s" + }, + "vertexServiceAccountLabel": { + "message": "JSON de Cuenta de Servicio:" + }, + "vertexImportButton": { + "message": "Importar Archivo JSON" + }, + "vertexRefreshButton": { + "message": "🔄 Actualizar Token" + }, + "vertexImportHint": { + "message": "Rellena automáticamente las credenciales a continuación" + }, + "vertexImporting": { + "message": "Importando..." + }, + "vertexRefreshingToken": { + "message": "Actualizando token de acceso..." + }, + "vertexGeneratingToken": { + "message": "Generando token de acceso..." + }, + "vertexImportSuccess": { + "message": "Cuenta de servicio importada y token generado." + }, + "vertexImportFailed": { + "message": "Importación fallida: %s" + }, + "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" + }, + "featureVertexAutoToken": { + "message": "Generación automática de tokens" + }, + "featureVertexGemini": { + "message": "Modelos Google Gemini a través de Vertex AI" + }, + "vertexNote": { + "message": "Los tokens de acceso expiran después de 1 hora. Tu cuenta de servicio está almacenada de forma segura para facilitar la actualización del token - solo haz clic en el botón Actualizar Token cuando sea necesario." + }, + "baseUrlLabel": { + "message": "URL Base:" + }, + "modelLabel": { + "message": "Modelo:" + }, + "featureCustomizable": { + "message": "Endpoint y modelo personalizables" + }, + "fetchModelsButton": { + "message": "Obtener Modelos" + }, + "testConnectionButton": { + "message": "Probar Conexión" + }, "openaiApiKeyPlaceholder": { "message": "Ingresa tu clave API compatible con OpenAI" }, - "openaiBaseUrlPlaceholder": { "message": "ej., https://api.openai.com/v1" }, + "openaiBaseUrlPlaceholder": { + "message": "ej., https://api.openai.com/v1" + }, "openaiApiKeyError": { "message": "Por favor ingresa tu clave API primero." }, @@ -253,31 +494,57 @@ "openaiTestNeedsTesting": { "message": "⚠️ La clave API compatible con OpenAI necesita ser probada." }, - "openaiTestingConnection": { "message": "Probando conexión..." }, - "openaiConnectionSuccessful": { "message": "¡Conexión exitosa!" }, - "openaiConnectionFailed": { "message": "Conexión falló: %s" }, - "openaieFetchingModels": { "message": "Obteniendo modelos..." }, + "openaiTestingConnection": { + "message": "Probando conexión..." + }, + "openaiConnectionSuccessful": { + "message": "¡Conexión exitosa!" + }, + "openaiConnectionFailed": { + "message": "Conexión falló: %s" + }, + "openaieFetchingModels": { + "message": "Obteniendo modelos..." + }, "openaiModelsFetchedSuccessfully": { "message": "Modelos obtenidos exitosamente." }, - "openaiFailedToFetchModels": { "message": "Error al obtener modelos: %s" }, - - "cardLoggingLevelTitle": { "message": "Nivel de Registro" }, + "openaiFailedToFetchModels": { + "message": "Error al obtener modelos: %s" + }, + "cardLoggingLevelTitle": { + "message": "Nivel de Registro" + }, "cardLoggingLevelDesc": { "message": "Controla la cantidad de información de depuración mostrada en la consola del navegador. Los niveles más altos incluyen todos los mensajes de niveles inferiores." }, - "loggingLevelLabel": { "message": "Nivel de Registro:" }, - "loggingLevelOff": { "message": "Desactivado" }, - "loggingLevelError": { "message": "Solo Errores" }, - "loggingLevelWarn": { "message": "Advertencias y Errores" }, - "loggingLevelInfo": { "message": "Info y Superior" }, - "loggingLevelDebug": { "message": "Debug (Todo)" }, - - "cardBatchTranslationTitle": { "message": "Traducción por Lotes" }, + "loggingLevelLabel": { + "message": "Nivel de Registro:" + }, + "loggingLevelOff": { + "message": "Desactivado" + }, + "loggingLevelError": { + "message": "Solo Errores" + }, + "loggingLevelWarn": { + "message": "Advertencias y Errores" + }, + "loggingLevelInfo": { + "message": "Info y Superior" + }, + "loggingLevelDebug": { + "message": "Debug (Todo)" + }, + "cardBatchTranslationTitle": { + "message": "Traducción por Lotes" + }, "cardBatchTranslationDesc": { "message": "La traducción por lotes procesa múltiples segmentos de subtítulos juntos, reduciendo las llamadas a la API en un 80-90% y mejorando el rendimiento. Configure los ajustes óptimos para su proveedor de traducción preferido." }, - "batchingEnabledLabel": { "message": "Habilitar Traducción por Lotes:" }, + "batchingEnabledLabel": { + "message": "Habilitar Traducción por Lotes:" + }, "batchingEnabledHelp": { "message": "Agrupa múltiples segmentos de subtítulos en solicitudes de traducción únicas" }, @@ -287,34 +554,45 @@ "useProviderDefaultsHelp": { "message": "Usar automáticamente tamaños de lote óptimos para cada proveedor de traducción" }, - "globalBatchSizeLabel": { "message": "Tamaño de Lote Global:" }, + "globalBatchSizeLabel": { + "message": "Tamaño de Lote Global:" + }, "globalBatchSizeHelp": { "message": "Número de segmentos de subtítulos a procesar juntos (1-15)" }, - "smartBatchingLabel": { "message": "Optimización Inteligente de Lotes:" }, + "smartBatchingLabel": { + "message": "Optimización Inteligente de Lotes:" + }, "smartBatchingHelp": { "message": "Prioriza segmentos de subtítulos basándose en la posición de reproducción" }, - "maxConcurrentBatchesLabel": { "message": "Máximo de Lotes Concurrentes:" }, + "maxConcurrentBatchesLabel": { + "message": "Máximo de Lotes Concurrentes:" + }, "maxConcurrentBatchesHelp": { "message": "Número de lotes de traducción a procesar simultáneamente" }, - "cardProviderBatchTitle": { "message": "Tamaños de Lote Específicos del Proveedor" }, "cardProviderBatchDesc": { "message": "Configure tamaños de lote óptimos para cada proveedor de traducción. Estas configuraciones se usan cuando \"Usar Configuración Optimizada del Proveedor\" está habilitado." }, - "openaieBatchSizeLabel": { "message": "Tamaño de Lote OpenAI:" }, + "openaieBatchSizeLabel": { + "message": "Tamaño de Lote OpenAI:" + }, "openaieBatchSizeHelp": { "message": "Recomendado: 5-10 segmentos (predeterminado: 8)" }, - "googleBatchSizeLabel": { "message": "Tamaño de Lote Google Translate:" }, + "googleBatchSizeLabel": { + "message": "Tamaño de Lote Google Translate:" + }, "googleBatchSizeHelp": { "message": "Recomendado: 3-5 segmentos (predeterminado: 4)" }, - "deeplBatchSizeLabel": { "message": "Tamaño de Lote DeepL:" }, + "deeplBatchSizeLabel": { + "message": "Tamaño de Lote DeepL:" + }, "deeplBatchSizeHelp": { "message": "Recomendado: 2-3 segmentos (predeterminado: 3)" }, @@ -324,22 +602,24 @@ "microsoftBatchSizeHelp": { "message": "Recomendado: 3-5 segmentos (predeterminado: 4)" }, - "vertexBatchSizeLabel": { "message": "Tamaño de Lote Vertex AI:" }, + "vertexBatchSizeLabel": { + "message": "Tamaño de Lote Vertex AI:" + }, "vertexBatchSizeHelp": { "message": "Recomendado: 5-10 segmentos (predeterminado: 8)" }, - "deeplTestNeedsTesting": { "message": "⚠️ La clave API de DeepL necesita ser probada." }, - "cardProviderDelayTitle": { "message": "Retrasos de Solicitud Específicos del Proveedor" }, "cardProviderDelayDesc": { "message": "Configure retrasos obligatorios entre solicitudes de traducción para prevenir bloqueos de cuenta. Estos retrasos se aplican incluso cuando el procesamiento por lotes está habilitado." }, - "openaieDelayLabel": { "message": "Retraso de Solicitud OpenAI (ms):" }, + "openaieDelayLabel": { + "message": "Retraso de Solicitud OpenAI (ms):" + }, "openaieDelayHelp": { "message": "Retraso mínimo entre solicitudes (predeterminado: 100ms)" }, @@ -349,7 +629,9 @@ "googleDelayHelp": { "message": "Retraso requerido para prevenir bloqueos temporales (predeterminado: 1500ms)" }, - "deeplDelayLabel": { "message": "Retraso de Solicitud API DeepL (ms):" }, + "deeplDelayLabel": { + "message": "Retraso de Solicitud API DeepL (ms):" + }, "deeplDelayHelp": { "message": "Retraso para solicitudes de API DeepL (predeterminado: 500ms)" }, @@ -371,118 +653,240 @@ "vertexDelayHelp": { "message": "Retraso mínimo entre solicitudes (predeterminado: 100ms)" }, - - "aiContextModalTitle": { "message": "Análisis de Contexto IA" }, - "aiContextSelectedWords": { "message": "Palabras Seleccionadas" }, - "aiContextNoWordsSelected": { "message": "No hay palabras seleccionadas" }, + "aiContextModalTitle": { + "message": "Análisis de Contexto IA" + }, + "aiContextSelectedWords": { + "message": "Palabras Seleccionadas" + }, + "aiContextNoWordsSelected": { + "message": "No hay palabras seleccionadas" + }, "aiContextClickHint": { "message": "💡 Haz clic en una palabra para agregarla o quitarla." }, - "aiContextStartAnalysis": { "message": "Iniciar Análisis" }, - "aiContextPauseAnalysis": { "message": "⏸ Pausar" }, - "aiContextPauseAnalysisTitle": { "message": "Pausar Análisis" }, + "aiContextStartAnalysis": { + "message": "Iniciar Análisis" + }, + "aiContextPauseAnalysis": { + "message": "⏸ Pausar" + }, + "aiContextPauseAnalysisTitle": { + "message": "Pausar Análisis" + }, "aiContextInitialMessage": { "message": "Selecciona palabras de los subtítulos para comenzar el análisis." }, - "aiContextAnalyzing": { "message": "Analizando contexto..." }, + "aiContextAnalyzing": { + "message": "Analizando contexto..." + }, "aiContextPauseNote": { "message": "Haz clic en ⏸ para pausar el análisis" }, - "aiContextAnalysisFailed": { "message": "Análisis Fallido" }, - "aiContextNoContent": { "message": "Sin Contenido de Análisis" }, + "aiContextAnalysisFailed": { + "message": "Análisis Fallido" + }, + "aiContextNoContent": { + "message": "Sin Contenido de Análisis" + }, "aiContextNoContentMessage": { "message": "El análisis se completó pero no se devolvió contenido." }, - "aiContextDefinition": { "message": "📖 Definición" }, - "aiContextCultural": { "message": "🌍 Contexto Cultural" }, - "aiContextCulturalSignificance": { "message": "⭐ Significado Cultural" }, - "aiContextHistorical": { "message": "📜 Contexto Histórico" }, + "aiContextDefinition": { + "message": "📖 Definición" + }, + "aiContextCultural": { + "message": "🌍 Contexto Cultural" + }, + "aiContextCulturalSignificance": { + "message": "⭐ Significado Cultural" + }, + "aiContextHistorical": { + "message": "📜 Contexto Histórico" + }, "aiContextHistoricalSignificance": { "message": "📜 Significado Histórico" }, - "aiContextEvolution": { "message": "🔄 Evolución en el Tiempo" }, - "aiContextLinguistic": { "message": "🔤 Análisis Lingüístico" }, - "aiContextGrammar": { "message": "📝 Gramática y Semántica" }, - "aiContextUsage": { "message": "💡 Uso y Ejemplos" }, - "aiContextExamples": { "message": "Ejemplos:" }, - "aiContextLearningTips": { "message": "🎯 Consejos de Aprendizaje" }, - "aiContextRelatedExpressions": { "message": "🔗 Expresiones Relacionadas" }, - "aiContextKeyInsights": { "message": "🔑 Perspectivas Clave" }, - "aiContextTypeCultural": { "message": "Cultural" }, - "aiContextTypeHistorical": { "message": "Histórico" }, - "aiContextTypeLinguistic": { "message": "Lingüístico" }, - "aiContextTypeComprehensive": { "message": "Integral" }, - "aiContextTypeGeneric": { "message": "Contexto" }, - "aiContextClose": { "message": "Cerrar" }, - "aiContextAnalysisResults": { "message": "Resultados del Análisis" }, - "aiContextRetrying": { "message": "Análisis falló, regenerando..." }, + "aiContextEvolution": { + "message": "🔄 Evolución en el Tiempo" + }, + "aiContextLinguistic": { + "message": "🔤 Análisis Lingüístico" + }, + "aiContextGrammar": { + "message": "📝 Gramática y Semántica" + }, + "aiContextUsage": { + "message": "💡 Uso y Ejemplos" + }, + "aiContextExamples": { + "message": "Ejemplos:" + }, + "aiContextLearningTips": { + "message": "🎯 Consejos de Aprendizaje" + }, + "aiContextRelatedExpressions": { + "message": "🔗 Expresiones Relacionadas" + }, + "aiContextKeyInsights": { + "message": "🔑 Perspectivas Clave" + }, + "aiContextTypeCultural": { + "message": "Cultural" + }, + "aiContextTypeHistorical": { + "message": "Histórico" + }, + "aiContextTypeLinguistic": { + "message": "Lingüístico" + }, + "aiContextTypeComprehensive": { + "message": "Integral" + }, + "aiContextTypeGeneric": { + "message": "Contexto" + }, + "aiContextClose": { + "message": "Cerrar" + }, + "aiContextAnalysisResults": { + "message": "Resultados del Análisis" + }, + "aiContextRetrying": { + "message": "Análisis falló, regenerando..." + }, "aiContextRetryNotification": { "message": "Análisis falló, reintentando..." }, - "aiContextRetryButton": { "message": "Intentar de Nuevo" }, + "aiContextRetryButton": { + "message": "Intentar de Nuevo" + }, "aiContextMalformedResponse": { "message": "El servicio de IA devolvió un formato de respuesta inválido. Esto puede deberse a problemas temporales del servicio." }, "aiContextJsonCodeBlock": { "message": "El servicio de IA devolvió código JSON sin procesar en lugar de datos estructurados. Esto indica un error de formato en la respuesta." }, - "aiContextCulturalContext": { "message": "Contexto Cultural:" }, - "aiContextSocialUsage": { "message": "Uso Social:" }, - "aiContextRegionalNotes": { "message": "Notas Regionales:" }, - "aiContextOrigins": { "message": "Orígenes:" }, - "aiContextHistoricalContext": { "message": "Contexto Histórico:" }, - "aiContextHistoricalSignificance": { "message": "Significado Histórico:" }, - "aiContextEvolution": { "message": "Evolución:" }, - "aiContextEtymology": { "message": "Etimología:" }, - "aiContextGrammarNotes": { "message": "Notas Gramaticales:" }, - "aiContextTranslationNotes": { "message": "Notas de Traducción:" }, - "aiContextLinguisticAnalysis": { "message": "Análisis Lingüístico:" }, - "aiContextGrammarSemantics": { "message": "Gramática y Semántica:" }, - "aiContextUsageExamples": { "message": "Uso y Ejemplos:" }, - "aiContextLearningTips": { "message": "Consejos de Aprendizaje:" }, - "aiContextRelatedExpressions": { "message": "Expresiones Relacionadas:" }, - "aiContextKeyInsights": { "message": "Puntos Clave:" }, - - "navAIContext": { "message": "Contexto IA" }, - "sectionAIContext": { "message": "Asistente de Contexto IA" }, + "aiContextCulturalContext": { + "message": "Contexto Cultural:" + }, + "aiContextSocialUsage": { + "message": "Uso Social:" + }, + "aiContextRegionalNotes": { + "message": "Notas Regionales:" + }, + "aiContextOrigins": { + "message": "Orígenes:" + }, + "aiContextHistoricalContext": { + "message": "Contexto Histórico:" + }, + "aiContextHistoricalSignificanceLabel": { + "message": "Significado Histórico:" + }, + "aiContextEvolutionLabel": { + "message": "Evolución:" + }, + "aiContextEtymology": { + "message": "Etimología:" + }, + "aiContextGrammarNotesLabel": { + "message": "Notas Gramaticales:" + }, + "aiContextTranslationNotesLabel": { + "message": "Notas de Traducción:" + }, + "aiContextLinguisticAnalysisLabel": { + "message": "Análisis Lingüístico:" + }, + "aiContextGrammarSemanticsLabel": { + "message": "Gramática y Semántica:" + }, + "aiContextUsageExamplesLabel": { + "message": "Uso y Ejemplos:" + }, + "aiContextLearningTipsLabel": { + "message": "Consejos de Aprendizaje:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "Expresiones Relacionadas:" + }, + "aiContextKeyInsightsLabel": { + "message": "Puntos Clave:" + }, + "navAIContext": { + "message": "Contexto IA" + }, + "sectionAIContext": { + "message": "Asistente de Contexto IA" + }, "cardAIContextToggleTitle": { "message": "Habilitar Análisis de Contexto IA" }, "cardAIContextToggleDesc": { "message": "Habilita el análisis de contexto cultural, histórico y lingüístico impulsado por IA para el texto de los subtítulos. Haz clic en palabras o frases en los subtítulos para obtener explicaciones detalladas." }, - "aiContextEnabledLabel": { "message": "Habilitar Contexto IA:" }, - "cardAIContextProviderTitle": { "message": "Proveedor de IA" }, + "aiContextEnabledLabel": { + "message": "Habilitar Contexto IA:" + }, + "cardAIContextProviderTitle": { + "message": "Proveedor de IA" + }, "cardAIContextProviderDesc": { "message": "Elige el proveedor de servicios de IA para el análisis de contexto. Diferentes proveedores pueden ofrecer calidad y tiempos de respuesta variables." }, - "aiContextProviderLabel": { "message": "Proveedor:" }, - "cardOpenAIContextTitle": { "message": "Configuración de OpenAI" }, + "aiContextProviderLabel": { + "message": "Proveedor:" + }, + "cardOpenAIContextTitle": { + "message": "Configuración de OpenAI" + }, "cardOpenAIContextDesc": { "message": "Configura tus ajustes de API de OpenAI para el análisis de contexto. Necesitas una clave API válida de OpenAI." }, - "openaiApiKeyLabel": { "message": "Clave API:" }, - "openaiBaseUrlLabel": { "message": "URL Base:" }, - "openaiModelLabel": { "message": "Modelo:" }, - "cardGeminiContextTitle": { "message": "Configuración de Google Gemini" }, + "openaiApiKeyLabel": { + "message": "Clave API:" + }, + "openaiBaseUrlLabel": { + "message": "URL Base:" + }, + "openaiModelLabel": { + "message": "Modelo:" + }, + "cardGeminiContextTitle": { + "message": "Configuración de Google Gemini" + }, "cardGeminiContextDesc": { "message": "Configura tus ajustes de API de Google Gemini para el análisis de contexto. Necesitas una clave API válida de Gemini." }, - "geminiApiKeyLabel": { "message": "Clave API:" }, - "geminiModelLabel": { "message": "Modelo:" }, - "cardAIContextTypesTitle": { "message": "Tipos de Contexto" }, + "geminiApiKeyLabel": { + "message": "Clave API:" + }, + "geminiModelLabel": { + "message": "Modelo:" + }, + "cardAIContextTypesTitle": { + "message": "Tipos de Contexto" + }, "cardAIContextTypesDesc": { "message": "Habilita los tipos de análisis de contexto que quieres usar. Puedes habilitar múltiples tipos." }, - "contextTypeCulturalLabel": { "message": "Contexto Cultural:" }, + "contextTypeCulturalLabel": { + "message": "Contexto Cultural:" + }, "contextTypeCulturalHelp": { "message": "Analizar referencias culturales, modismos y contexto social" }, - "contextTypeHistoricalLabel": { "message": "Contexto Histórico:" }, + "contextTypeHistoricalLabel": { + "message": "Contexto Histórico:" + }, "contextTypeHistoricalHelp": { "message": "Proporcionar antecedentes históricos y contexto del período de tiempo" }, - "contextTypeLinguisticLabel": { "message": "Análisis Lingüístico:" }, + "contextTypeLinguisticLabel": { + "message": "Análisis Lingüístico:" + }, "contextTypeLinguisticHelp": { "message": "Explicar gramática, etimología y estructura del lenguaje" }, @@ -498,15 +902,21 @@ "interactiveSubtitlesEnabledHelp": { "message": "Hacer que las palabras de los subtítulos sean clicables para el análisis de contexto" }, - "contextOnClickLabel": { "message": "Contexto al Hacer Clic:" }, + "contextOnClickLabel": { + "message": "Contexto al Hacer Clic:" + }, "contextOnClickHelp": { "message": "Mostrar análisis de contexto al hacer clic en palabras" }, - "contextOnSelectionLabel": { "message": "Contexto en Selección:" }, + "contextOnSelectionLabel": { + "message": "Contexto en Selección:" + }, "contextOnSelectionHelp": { "message": "Mostrar análisis de contexto al seleccionar texto" }, - "cardAIContextPrivacyTitle": { "message": "Privacidad y Datos" }, + "cardAIContextPrivacyTitle": { + "message": "Privacidad y Datos" + }, "cardAIContextPrivacyDesc": { "message": "Controla cómo se manejan tus datos durante el análisis de contexto." }, @@ -522,7 +932,9 @@ "aiContextDataSharingHelp": { "message": "Ayuda a mejorar el servicio compartiendo datos de uso anónimos" }, - "cardAIContextAdvancedTitle": { "message": "Configuración Avanzada" }, + "cardAIContextAdvancedTitle": { + "message": "Configuración Avanzada" + }, "cardAIContextAdvancedDesc": { "message": "Configure opciones avanzadas para el comportamiento del análisis de contexto de IA." }, @@ -538,14 +950,79 @@ "aiContextRateLimitHelp": { "message": "Número máximo de solicitudes por minuto" }, - "aiContextCacheEnabledLabel": { "message": "Habilitar Caché:" }, + "aiContextCacheEnabledLabel": { + "message": "Habilitar Caché:" + }, "aiContextCacheEnabledHelp": { "message": "Almacenar en caché los resultados del análisis para reducir las llamadas a la API" }, - "aiContextRetryAttemptsLabel": { "message": "Intentos de Reintento:" }, + "aiContextRetryAttemptsLabel": { + "message": "Intentos de Reintento:" + }, "aiContextRetryAttemptsHelp": { "message": "Número de veces para reintentar solicitudes fallidas" }, - "showAdvancedSettings": { "message": "Mostrar Configuración Avanzada" }, - "hideAdvancedSettings": { "message": "Ocultar Configuración Avanzada" } -} + "showAdvancedSettings": { + "message": "Mostrar Configuración Avanzada" + }, + "hideAdvancedSettings": { + "message": "Ocultar Configuración Avanzada" + }, + "sidepanelLoading": { + "message": "Cargando..." + }, + "sidepanelTabAIAnalysis": { + "message": "Análisis IA" + }, + "sidepanelTabWordsLists": { + "message": "Listas de Palabras" + }, + "sidepanelAnalyzeButton": { + "message": "Analizar" + }, + "sidepanelAnalyzing": { + "message": "Analizando..." + }, + "sidepanelWordsToAnalyze": { + "message": "Palabras a Analizar" + }, + "sidepanelWordInputPlaceholder": { + "message": "Haz clic en las palabras de los subtítulos para añadirlas al análisis..." + }, + "sidepanelErrorRetry": { + "message": "Reintentar" + }, + "sidepanelResultsTitle": { + "message": "Resultados para \"%s\"" + }, + "sidepanelSectionDefinition": { + "message": "Definición" + }, + "sidepanelSectionCultural": { + "message": "Contexto Cultural" + }, + "sidepanelSectionHistorical": { + "message": "Contexto Histórico" + }, + "sidepanelSectionLinguistic": { + "message": "Análisis Lingüístico" + }, + "sidepanelMyWordsTitle": { + "message": "Mis Palabras" + }, + "sidepanelFeatureComingSoon": { + "message": "¡La función de Listas de Palabras llegará pronto!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "Esta función está actualmente en desarrollo. Habilítala en Configuración para probar la vista previa." + }, + "sidepanelErrorNoWords": { + "message": "No hay palabras seleccionadas para el análisis" + }, + "sidepanelErrorDisabled": { + "message": "El análisis de contexto IA está deshabilitado. Habilítalo en la configuración." + }, + "sidepanelErrorGeneric": { + "message": "Ocurrió un error durante el análisis." + } +} \ No newline at end of file diff --git a/_locales/ja/messages.json b/_locales/ja/messages.json index 483428d..a0e0efd 100644 --- a/_locales/ja/messages.json +++ b/_locales/ja/messages.json @@ -1,31 +1,79 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "ストリーミングプラットフォームで二言語字幕を表示します。" }, - "pageTitle": { "message": "DualSub 設定" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "二言語字幕を有効にする:" }, - "useNativeSubtitlesLabel": { "message": "公式字幕を使用:" }, - "originalLanguageLabel": { "message": "言語設定:" }, - "translationSettingsLegend": { "message": "翻訳設定" }, - "providerLabel": { "message": "プロバイダー:" }, - "targetLanguageLabel": { "message": "翻訳先:" }, - "batchSizeLabel": { "message": "バッチサイズ:" }, - "requestDelayLabel": { "message": "リクエスト遅延 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "字幕の外観とタイミング" }, - "displayOrderLabel": { "message": "表示順序:" }, - "layoutLabel": { "message": "レイアウト:" }, - "fontSizeLabel": { "message": "フォントサイズ:" }, - "verticalGapLabel": { "message": "垂直間隔:" }, - "subtitleVerticalPositionLabel": { "message": "垂直位置:" }, - "timeOffsetLabel": { "message": "時間オフセット(秒):" }, - "displayOrderOriginalFirst": { "message": "原文を上に" }, - "displayOrderTranslationFirst": { "message": "翻訳を上に" }, - "layoutTopBottom": { "message": "上下配置" }, - "layoutLeftRight": { "message": "左右配置" }, - "uiLanguageLabel": { "message": "言語:" }, - "openOptionsButton": { "message": "詳細設定" }, + "pageTitle": { + "message": "DualSub 設定" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "二言語字幕を有効にする:" + }, + "useNativeSubtitlesLabel": { + "message": "公式字幕を使用:" + }, + "originalLanguageLabel": { + "message": "言語設定:" + }, + "translationSettingsLegend": { + "message": "翻訳設定" + }, + "providerLabel": { + "message": "プロバイダー:" + }, + "targetLanguageLabel": { + "message": "翻訳先:" + }, + "batchSizeLabel": { + "message": "バッチサイズ:" + }, + "requestDelayLabel": { + "message": "リクエスト遅延 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "字幕の外観とタイミング" + }, + "displayOrderLabel": { + "message": "表示順序:" + }, + "layoutLabel": { + "message": "レイアウト:" + }, + "fontSizeLabel": { + "message": "フォントサイズ:" + }, + "verticalGapLabel": { + "message": "垂直間隔:" + }, + "subtitleVerticalPositionLabel": { + "message": "垂直位置:" + }, + "timeOffsetLabel": { + "message": "時間オフセット(秒):" + }, + "displayOrderOriginalFirst": { + "message": "原文を上に" + }, + "displayOrderTranslationFirst": { + "message": "翻訳を上に" + }, + "layoutTopBottom": { + "message": "上下配置" + }, + "layoutLeftRight": { + "message": "左右配置" + }, + "uiLanguageLabel": { + "message": "言語:" + }, + "openOptionsButton": { + "message": "詳細設定" + }, "statusLanguageSetTo": { "message": "言語設定完了(ページを更新してください):" }, @@ -44,83 +92,168 @@ "statusOriginalLanguage": { "message": "言語設定完了(ページを更新してください):" }, - "statusTimeOffset": { "message": "時間オフセット:" }, - "statusDisplayOrderUpdated": { "message": "表示順序が更新されました。" }, + "statusTimeOffset": { + "message": "時間オフセット:" + }, + "statusDisplayOrderUpdated": { + "message": "表示順序が更新されました。" + }, "statusLayoutOrientationUpdated": { "message": "レイアウト方向が更新されました。" }, - "statusFontSize": { "message": "フォントサイズ:" }, - "statusVerticalGap": { "message": "垂直間隔:" }, - "statusVerticalPosition": { "message": "垂直位置:" }, + "statusFontSize": { + "message": "フォントサイズ:" + }, + "statusVerticalGap": { + "message": "垂直間隔:" + }, + "statusVerticalPosition": { + "message": "垂直位置:" + }, "statusInvalidOffset": { "message": "無効なオフセットです。元に戻します。" }, "statusSettingNotApplied": { "message": "設定が適用されませんでした。ページを更新してください。" }, - - "optionsPageTitle": { "message": "DualSub オプション" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "一般" }, - "navTranslation": { "message": "翻訳" }, - "navProviders": { "message": "プロバイダー" }, - "navAbout": { "message": "について" }, - "sectionGeneral": { "message": "一般" }, - "cardUILanguageTitle": { "message": "UI言語" }, + "optionsPageTitle": { + "message": "DualSub オプション" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "一般" + }, + "navTranslation": { + "message": "翻訳" + }, + "navProviders": { + "message": "プロバイダー" + }, + "navAbout": { + "message": "について" + }, + "sectionGeneral": { + "message": "一般" + }, + "cardUILanguageTitle": { + "message": "UI言語" + }, "cardUILanguageDesc": { "message": "拡張機能のインターフェースの表示言語を選択します。" }, - "cardHideOfficialSubtitlesTitle": { "message": "公式字幕を非表示" }, + "cardHideOfficialSubtitlesTitle": { + "message": "公式字幕を非表示" + }, "cardHideOfficialSubtitlesDesc": { "message": "DualSubがアクティブな時、動画プラットフォームの公式字幕を非表示にします。" }, - "hideOfficialSubtitlesLabel": { "message": "公式字幕を非表示:" }, - "sectionTranslation": { "message": "翻訳" }, - "cardTranslationEngineTitle": { "message": "翻訳エンジン" }, + "hideOfficialSubtitlesLabel": { + "message": "公式字幕を非表示:" + }, + "sectionTranslation": { + "message": "翻訳" + }, + "cardTranslationEngineTitle": { + "message": "翻訳エンジン" + }, "cardTranslationEngineDesc": { "message": "お好みの翻訳サービスを選択してください。" }, - "cardPerformanceTitle": { "message": "パフォーマンス" }, + "cardPerformanceTitle": { + "message": "パフォーマンス" + }, "cardPerformanceDesc": { "message": "拡張機能が翻訳リクエストを処理する方法を調整して、速度と安定性のバランスを取ります。" }, - "sectionProviders": { "message": "プロバイダー設定" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "プロバイダー設定" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "DeepL TranslateのAPIキーを入力してください。FreeプランまたはProプランから選択できます。" }, - "apiKeyLabel": { "message": "APIキー:" }, - "apiPlanLabel": { "message": "APIプラン:" }, - "apiPlanFree": { "message": "DeepL API Free" }, - "apiPlanPro": { "message": "DeepL API Pro" }, - "sectionAbout": { "message": "について" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "バージョン" }, + "apiKeyLabel": { + "message": "APIキー:" + }, + "apiPlanLabel": { + "message": "APIプラン:" + }, + "apiPlanFree": { + "message": "DeepL API Free" + }, + "apiPlanPro": { + "message": "DeepL API Pro" + }, + "sectionAbout": { + "message": "について" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "バージョン" + }, "aboutDescription": { "message": "この拡張機能は、様々なプラットフォームで二言語字幕付きの動画を視聴するのに役立ちます。" }, "aboutDevelopment": { "message": "QuellaMC & 1jifang によって開発されました。" }, - - "lang_en": { "message": "英語" }, - "lang_es": { "message": "スペイン語" }, - "lang_fr": { "message": "フランス語" }, - "lang_de": { "message": "ドイツ語" }, - "lang_it": { "message": "イタリア語" }, - "lang_pt": { "message": "ポルトガル語" }, - "lang_ja": { "message": "日本語" }, - "lang_ko": { "message": "韓国語" }, - "lang_zh_CN": { "message": "中国語(簡体)" }, - "lang_zh_TW": { "message": "中国語(繁体)" }, - "lang_ru": { "message": "ロシア語" }, - "lang_ar": { "message": "アラビア語" }, - "lang_hi": { "message": "ヒンディー語" }, - - "testDeepLButton": { "message": "DeepL接続をテスト" }, - "deeplApiKeyError": { "message": "まずDeepL APIキーを入力してください。" }, - "testingButton": { "message": "テスト中..." }, - "testingConnection": { "message": "DeepL接続をテストしています..." }, + "lang_en": { + "message": "英語" + }, + "lang_es": { + "message": "スペイン語" + }, + "lang_fr": { + "message": "フランス語" + }, + "lang_de": { + "message": "ドイツ語" + }, + "lang_it": { + "message": "イタリア語" + }, + "lang_pt": { + "message": "ポルトガル語" + }, + "lang_ja": { + "message": "日本語" + }, + "lang_ko": { + "message": "韓国語" + }, + "lang_zh_CN": { + "message": "中国語(簡体)" + }, + "lang_zh_TW": { + "message": "中国語(繁体)" + }, + "lang_ru": { + "message": "ロシア語" + }, + "lang_ar": { + "message": "アラビア語" + }, + "lang_hi": { + "message": "ヒンディー語" + }, + "testDeepLButton": { + "message": "DeepL接続をテスト" + }, + "deeplApiKeyError": { + "message": "まずDeepL APIキーを入力してください。" + }, + "testingButton": { + "message": "テスト中..." + }, + "testingConnection": { + "message": "DeepL接続をテストしています..." + }, "deeplTestSuccess": { "message": "✅ DeepL APIテストが成功しました!\"Hello\"を\"%s\"に翻訳しました" }, @@ -136,312 +269,615 @@ "deeplTestQuotaExceeded": { "message": "❌ DeepL APIの使用量制限に達しました。使用制限を確認してください。" }, - "deeplTestApiError": { "message": "❌ DeepL APIエラー (%d): %s" }, + "deeplTestApiError": { + "message": "❌ DeepL APIエラー (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ ネットワークエラー:DeepL APIに接続できませんでした。インターネット接続を確認してください。" }, - "deeplTestGenericError": { "message": "❌ テストに失敗しました:%s" }, - "deepLApiUnavailable": { "message": "DeepL API利用不可" }, + "deeplTestGenericError": { + "message": "❌ テストに失敗しました:%s" + }, + "deepLApiUnavailable": { + "message": "DeepL API利用不可" + }, "deepLApiUnavailableTooltip": { "message": "DeepL APIスクリプトの読み込みに失敗しました" }, "deeplApiNotLoadedError": { "message": "❌ DeepL APIスクリプトが利用できません。ページを更新してください。" }, - - "cardGoogleTitle": { "message": "Google翻訳" }, + "cardGoogleTitle": { + "message": "Google翻訳" + }, "cardGoogleDesc": { "message": "Googleが提供する無料の翻訳サービスです。追加の設定は必要ありません。" }, - "cardMicrosoftTitle": { "message": "Microsoft翻訳" }, + "cardMicrosoftTitle": { + "message": "Microsoft翻訳" + }, "cardMicrosoftDesc": { "message": "Microsoft Edgeが提供する無料の翻訳サービスです。追加の設定は必要ありません。" }, - "cardDeepLFreeTitle": { "message": "DeepL翻訳(無料)" }, + "cardDeepLFreeTitle": { + "message": "DeepL翻訳(無料)" + }, "cardDeepLFreeDesc": { "message": "高品質な結果を提供する無料のDeepL翻訳サービス。APIキー不要 - DeepLのWebインターフェースを使用します。" }, - "providerStatus": { "message": "ステータス:" }, - "statusReady": { "message": "使用可能" }, - "providerFeatures": { "message": "機能:" }, - "featureFree": { "message": "無料で使用可能" }, - "featureNoApiKey": { "message": "APIキー不要" }, - "featureWideLanguageSupport": { "message": "幅広い言語サポート" }, - "featureFastTranslation": { "message": "高速翻訳" }, - "featureHighQuality": { "message": "高品質翻訳" }, - "featureGoodPerformance": { "message": "良好なパフォーマンス" }, - "featureHighestQuality": { "message": "最高品質翻訳" }, - "featureApiKeyRequired": { "message": "APIキーが必要" }, - "featureLimitedLanguages": { "message": "限定的な言語サポート" }, - "featureUsageLimits": { "message": "使用制限が適用されます" }, - "featureMultipleBackups": { "message": "複数のバックアップ方法" }, - - "providerNotes": { "message": "注意事項:" }, - "noteSlowForSecurity": { "message": "セキュリティ対策のため若干低速" }, - "noteAutoFallback": { "message": "代替サービスへの自動フォールバック" }, - "noteRecommendedDefault": { "message": "デフォルトプロバイダーとして推奨" }, - - "providerGoogleName": { "message": "Google翻訳(無料)" }, - "providerMicrosoftName": { "message": "Microsoft翻訳(無料)" }, - "providerDeepLName": { "message": "DeepL(APIキー必須)" }, - "providerDeepLFreeName": { "message": "DeepL翻訳(無料)" }, - "providerOpenAICompatibleName": { "message": "OpenAI互換(APIキー必須)" }, - "providerVertexGeminiName": { "message": "Vertex AI Gemini(APIキー必須)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI互換(APIキー必須)" }, + "providerStatus": { + "message": "ステータス:" + }, + "statusReady": { + "message": "使用可能" + }, + "providerFeatures": { + "message": "機能:" + }, + "featureFree": { + "message": "無料で使用可能" + }, + "featureNoApiKey": { + "message": "APIキー不要" + }, + "featureWideLanguageSupport": { + "message": "幅広い言語サポート" + }, + "featureFastTranslation": { + "message": "高速翻訳" + }, + "featureHighQuality": { + "message": "高品質翻訳" + }, + "featureGoodPerformance": { + "message": "良好なパフォーマンス" + }, + "featureHighestQuality": { + "message": "最高品質翻訳" + }, + "featureApiKeyRequired": { + "message": "APIキーが必要" + }, + "featureLimitedLanguages": { + "message": "限定的な言語サポート" + }, + "featureUsageLimits": { + "message": "使用制限が適用されます" + }, + "featureMultipleBackups": { + "message": "複数のバックアップ方法" + }, + "providerNotes": { + "message": "注意事項:" + }, + "noteSlowForSecurity": { + "message": "セキュリティ対策のため若干低速" + }, + "noteAutoFallback": { + "message": "代替サービスへの自動フォールバック" + }, + "noteRecommendedDefault": { + "message": "デフォルトプロバイダーとして推奨" + }, + "providerGoogleName": { + "message": "Google翻訳(無料)" + }, + "providerMicrosoftName": { + "message": "Microsoft翻訳(無料)" + }, + "providerDeepLName": { + "message": "DeepL(APIキー必須)" + }, + "providerDeepLFreeName": { + "message": "DeepL翻訳(無料)" + }, + "providerOpenAICompatibleName": { + "message": "OpenAI互換(APIキー必須)" + }, + "providerVertexGeminiName": { + "message": "Vertex AI Gemini(APIキー必須)" + }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI互換(APIキー必須)" + }, "cardOpenAICompatibleDesc": { "message": "GeminiなどのOpenAI互換サービス用のAPIキーと設定を入力してください。" }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(APIキー必須)" }, - "cardVertexGeminiDesc": { "message": "アクセストークンとVertex プロジェクト設定を入力するか、サービスアカウントJSONファイルをインポートしてください。" }, - "vertexAccessTokenLabel": { "message": "アクセストークン:" }, - "vertexProjectIdLabel": { "message": "プロジェクトID:" }, - "vertexLocationLabel": { "message": "ロケーション:" }, - "vertexModelLabel": { "message": "モデル:" }, - "vertexMissingConfig": { "message": "アクセストークンとプロジェクトIDを入力してください。" }, - "vertexConnectionFailed": { "message": "接続に失敗しました:%s" }, - "vertexServiceAccountLabel": { "message": "サービスアカウントJSON:" }, - "vertexImportButton": { "message": "JSONファイルをインポート" }, - "vertexRefreshButton": { "message": "🔄 トークンを更新" }, - "vertexImportHint": { "message": "以下の認証情報を自動入力" }, - "vertexImporting": { "message": "インポート中..." }, - "vertexRefreshingToken": { "message": "アクセストークンを更新中..." }, - "vertexGeneratingToken": { "message": "アクセストークンを生成中..." }, - "vertexImportSuccess": { "message": "サービスアカウントがインポートされ、トークンが生成されました。" }, - "vertexImportFailed": { "message": "インポートに失敗しました:%s" }, - "vertexTokenRefreshed": { "message": "アクセストークンが正常に更新されました。" }, - "vertexRefreshFailed": { "message": "トークンの更新に失敗しました:%s" }, - "vertexTokenExpired": { "message": "⚠️ アクセストークンが期限切れです。更新をクリックして更新してください。" }, - "vertexTokenExpiringSoon": { "message": "⚠️ トークンは%s分で期限切れになります。更新を検討してください。" }, - "vertexConfigured": { "message": "⚠️ Vertex AIが設定されています。接続をテストしてください。" }, - "vertexNotConfigured": { "message": "サービスアカウントJSONをインポートするか、認証情報を入力してください。" }, - "featureVertexServiceAccount": { "message": "サービスアカウントJSONのインポート" }, - "featureVertexAutoToken": { "message": "自動トークン生成" }, - "featureVertexGemini": { "message": "Vertex AI経由のGoogle Geminiモデル" }, - "vertexNote": { "message": "アクセストークンは1時間後に期限切れになります。サービスアカウントは安全に保存されており、簡単にトークンを更新できます - 必要に応じてトークン更新ボタンをクリックしてください。" }, - "baseUrlLabel": { "message": "ベースURL:" }, - "modelLabel": { "message": "モデル:" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini(APIキー必須)" + }, + "cardVertexGeminiDesc": { + "message": "アクセストークンとVertex プロジェクト設定を入力するか、サービスアカウントJSONファイルをインポートしてください。" + }, + "vertexAccessTokenLabel": { + "message": "アクセストークン:" + }, + "vertexProjectIdLabel": { + "message": "プロジェクトID:" + }, + "vertexLocationLabel": { + "message": "ロケーション:" + }, + "vertexModelLabel": { + "message": "モデル:" + }, + "vertexMissingConfig": { + "message": "アクセストークンとプロジェクトIDを入力してください。" + }, + "vertexConnectionFailed": { + "message": "接続に失敗しました:%s" + }, + "vertexServiceAccountLabel": { + "message": "サービスアカウントJSON:" + }, + "vertexImportButton": { + "message": "JSONファイルをインポート" + }, + "vertexRefreshButton": { + "message": "🔄 トークンを更新" + }, + "vertexImportHint": { + "message": "以下の認証情報を自動入力" + }, + "vertexImporting": { + "message": "インポート中..." + }, + "vertexRefreshingToken": { + "message": "アクセストークンを更新中..." + }, + "vertexGeneratingToken": { + "message": "アクセストークンを生成中..." + }, + "vertexImportSuccess": { + "message": "サービスアカウントがインポートされ、トークンが生成されました。" + }, + "vertexImportFailed": { + "message": "インポートに失敗しました:%s" + }, + "vertexTokenRefreshed": { + "message": "アクセストークンが正常に更新されました。" + }, + "vertexRefreshFailed": { + "message": "トークンの更新に失敗しました:%s" + }, + "vertexTokenExpired": { + "message": "⚠️ アクセストークンが期限切れです。更新をクリックして更新してください。" + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ トークンは%s分で期限切れになります。更新を検討してください。" + }, + "vertexConfigured": { + "message": "⚠️ Vertex AIが設定されています。接続をテストしてください。" + }, + "vertexNotConfigured": { + "message": "サービスアカウントJSONをインポートするか、認証情報を入力してください。" + }, + "featureVertexServiceAccount": { + "message": "サービスアカウントJSONのインポート" + }, + "featureVertexAutoToken": { + "message": "自動トークン生成" + }, + "featureVertexGemini": { + "message": "Vertex AI経由のGoogle Geminiモデル" + }, + "vertexNote": { + "message": "アクセストークンは1時間後に期限切れになります。サービスアカウントは安全に保存されており、簡単にトークンを更新できます - 必要に応じてトークン更新ボタンをクリックしてください。" + }, + "baseUrlLabel": { + "message": "ベースURL:" + }, + "modelLabel": { + "message": "モデル:" + }, "featureCustomizable": { "message": "カスタマイズ可能なエンドポイントとモデル" }, - "fetchModelsButton": { "message": "モデルを取得" }, - "testConnectionButton": { "message": "接続をテスト" }, - + "fetchModelsButton": { + "message": "モデルを取得" + }, + "testConnectionButton": { + "message": "接続をテスト" + }, "openaiApiKeyPlaceholder": { "message": "OpenAI互換APIキーを入力してください" }, - "openaiBaseUrlPlaceholder": { "message": "例:https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "まずAPIキーを入力してください。" }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ APIキーのテストが必要です。" }, + "openaiBaseUrlPlaceholder": { + "message": "例:https://api.openai.com/v1" + }, + "openaiApiKeyError": { + "message": "まずAPIキーを入力してください。" + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ APIキーのテストが必要です。" + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI互換APIキーのテストが必要です。" }, - "openaiTestingConnection": { "message": "接続をテスト中..." }, - "openaiConnectionSuccessful": { "message": "接続成功!" }, - "openaiConnectionFailed": { "message": "接続失敗:%s" }, - "openaieFetchingModels": { "message": "モデルを取得中..." }, + "openaiTestingConnection": { + "message": "接続をテスト中..." + }, + "openaiConnectionSuccessful": { + "message": "接続成功!" + }, + "openaiConnectionFailed": { + "message": "接続失敗:%s" + }, + "openaieFetchingModels": { + "message": "モデルを取得中..." + }, "openaiModelsFetchedSuccessfully": { "message": "モデルの取得に成功しました。" }, "openaiFailedToFetchModels": { "message": "モデルの取得に失敗しました:%s" }, - - "cardLoggingLevelTitle": { "message": "ログレベル" }, + "cardLoggingLevelTitle": { + "message": "ログレベル" + }, "cardLoggingLevelDesc": { "message": "ブラウザコンソールに表示されるデバッグ情報の量を制御します。高いレベルには低いレベルのメッセージも含まれます。" }, - "loggingLevelLabel": { "message": "ログレベル:" }, - "loggingLevelOff": { "message": "オフ" }, - "loggingLevelError": { "message": "エラーのみ" }, - "loggingLevelWarn": { "message": "警告とエラー" }, - "loggingLevelInfo": { "message": "情報以上" }, - "loggingLevelDebug": { "message": "デバッグ(すべて)" }, - - "cardBatchTranslationTitle": { "message": "バッチ翻訳" }, + "loggingLevelLabel": { + "message": "ログレベル:" + }, + "loggingLevelOff": { + "message": "オフ" + }, + "loggingLevelError": { + "message": "エラーのみ" + }, + "loggingLevelWarn": { + "message": "警告とエラー" + }, + "loggingLevelInfo": { + "message": "情報以上" + }, + "loggingLevelDebug": { + "message": "デバッグ(すべて)" + }, + "cardBatchTranslationTitle": { + "message": "バッチ翻訳" + }, "cardBatchTranslationDesc": { "message": "バッチ翻訳は複数の字幕セグメントを一緒に処理し、API呼び出しを80-90%削減してパフォーマンスを向上させます。お好みの翻訳プロバイダーに最適な設定を構成してください。" }, - "batchingEnabledLabel": { "message": "バッチ翻訳を有効にする:" }, + "batchingEnabledLabel": { + "message": "バッチ翻訳を有効にする:" + }, "batchingEnabledHelp": { "message": "複数の字幕セグメントを単一の翻訳リクエストにグループ化します" }, - "useProviderDefaultsLabel": { "message": "プロバイダー最適化設定を使用:" }, + "useProviderDefaultsLabel": { + "message": "プロバイダー最適化設定を使用:" + }, "useProviderDefaultsHelp": { "message": "各翻訳プロバイダーに最適なバッチサイズを自動的に使用します" }, - "globalBatchSizeLabel": { "message": "グローバルバッチサイズ:" }, + "globalBatchSizeLabel": { + "message": "グローバルバッチサイズ:" + }, "globalBatchSizeHelp": { "message": "一緒に処理する字幕セグメントの数(1-15)" }, - "smartBatchingLabel": { "message": "スマートバッチ最適化:" }, + "smartBatchingLabel": { + "message": "スマートバッチ最適化:" + }, "smartBatchingHelp": { "message": "再生位置に基づいて字幕セグメントを優先処理します" }, - "maxConcurrentBatchesLabel": { "message": "最大同時バッチ数:" }, + "maxConcurrentBatchesLabel": { + "message": "最大同時バッチ数:" + }, "maxConcurrentBatchesHelp": { "message": "同時に処理する翻訳バッチの数" }, - - "cardProviderBatchTitle": { "message": "プロバイダー固有のバッチサイズ" }, + "cardProviderBatchTitle": { + "message": "プロバイダー固有のバッチサイズ" + }, "cardProviderBatchDesc": { "message": "各翻訳プロバイダーに最適なバッチサイズを設定します。これらの設定は「プロバイダー最適化設定を使用」が有効な場合に使用されます。" }, - "openaieBatchSizeLabel": { "message": "OpenAIバッチサイズ:" }, + "openaieBatchSizeLabel": { + "message": "OpenAIバッチサイズ:" + }, "openaieBatchSizeHelp": { "message": "推奨:5-10セグメント(デフォルト:8)" }, - "googleBatchSizeLabel": { "message": "Google翻訳バッチサイズ:" }, + "googleBatchSizeLabel": { + "message": "Google翻訳バッチサイズ:" + }, "googleBatchSizeHelp": { "message": "推奨:3-5セグメント(デフォルト:4)" }, - "deeplBatchSizeLabel": { "message": "DeepLバッチサイズ:" }, + "deeplBatchSizeLabel": { + "message": "DeepLバッチサイズ:" + }, "deeplBatchSizeHelp": { "message": "推奨:2-3セグメント(デフォルト:3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft翻訳バッチサイズ:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft翻訳バッチサイズ:" + }, "microsoftBatchSizeHelp": { "message": "推奨:3-5セグメント(デフォルト:4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AIバッチサイズ:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AIバッチサイズ:" + }, "vertexBatchSizeHelp": { "message": "推奨:5-10セグメント(デフォルト:8)" }, - "deeplTestNeedsTesting": { "message": "⚠️ DeepL APIキーのテストが必要です。" }, - - "cardProviderDelayTitle": { "message": "プロバイダー固有のリクエスト遅延" }, + "cardProviderDelayTitle": { + "message": "プロバイダー固有のリクエスト遅延" + }, "cardProviderDelayDesc": { "message": "アカウントロックアウトを防ぐため、翻訳リクエスト間の必須遅延を設定します。これらの遅延はバッチ処理が有効な場合でも適用されます。" }, - "openaieDelayLabel": { "message": "OpenAIリクエスト遅延 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAIリクエスト遅延 (ms):" + }, "openaieDelayHelp": { "message": "リクエスト間の最小遅延(デフォルト:100ms)" }, - "googleDelayLabel": { "message": "Google翻訳リクエスト遅延 (ms):" }, + "googleDelayLabel": { + "message": "Google翻訳リクエスト遅延 (ms):" + }, "googleDelayHelp": { "message": "一時的なロックアウトを防ぐために必要な遅延(デフォルト:1500ms)" }, - "deeplDelayLabel": { "message": "DeepL APIリクエスト遅延 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL APIリクエスト遅延 (ms):" + }, "deeplDelayHelp": { "message": "DeepL APIリクエストの遅延(デフォルト:500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL無料リクエスト遅延 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL無料リクエスト遅延 (ms):" + }, "deeplFreeDelayHelp": { "message": "無料プランの保守的な遅延(デフォルト:2000ms)" }, - "microsoftDelayLabel": { "message": "Microsoft翻訳リクエスト遅延 (ms):" }, + "microsoftDelayLabel": { + "message": "Microsoft翻訳リクエスト遅延 (ms):" + }, "microsoftDelayHelp": { "message": "文字制限を尊重するための遅延(デフォルト:800ms)" }, - "vertexDelayLabel": { "message": "Vertex AIリクエスト遅延 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AIリクエスト遅延 (ms):" + }, "vertexDelayHelp": { "message": "リクエスト間の最小遅延(デフォルト:100ms)" }, - - "aiContextModalTitle": { "message": "AIコンテキスト分析" }, - "aiContextSelectedWords": { "message": "選択された単語" }, - "aiContextNoWordsSelected": { "message": "単語が選択されていません" }, + "aiContextModalTitle": { + "message": "AIコンテキスト分析" + }, + "aiContextSelectedWords": { + "message": "選択された単語" + }, + "aiContextNoWordsSelected": { + "message": "単語が選択されていません" + }, "aiContextClickHint": { "message": "💡 単語をクリックして追加または削除します。" }, - "aiContextStartAnalysis": { "message": "分析開始" }, - "aiContextPauseAnalysis": { "message": "⏸ 一時停止" }, - "aiContextPauseAnalysisTitle": { "message": "分析を一時停止" }, + "aiContextStartAnalysis": { + "message": "分析開始" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 一時停止" + }, + "aiContextPauseAnalysisTitle": { + "message": "分析を一時停止" + }, "aiContextInitialMessage": { "message": "字幕から単語を選択して分析を開始してください。" }, - "aiContextAnalyzing": { "message": "コンテキストを分析中..." }, - "aiContextPauseNote": { "message": "⏸ をクリックして分析を一時停止" }, - "aiContextAnalysisFailed": { "message": "分析失敗" }, - "aiContextNoContent": { "message": "分析コンテンツなし" }, + "aiContextAnalyzing": { + "message": "コンテキストを分析中..." + }, + "aiContextPauseNote": { + "message": "⏸ をクリックして分析を一時停止" + }, + "aiContextAnalysisFailed": { + "message": "分析失敗" + }, + "aiContextNoContent": { + "message": "分析コンテンツなし" + }, "aiContextNoContentMessage": { "message": "分析は完了しましたが、コンテンツが返されませんでした。" }, - "aiContextDefinition": { "message": "📖 定義" }, - "aiContextCultural": { "message": "🌍 文化的コンテキスト" }, - "aiContextCulturalSignificance": { "message": "⭐ 文化的意義" }, - "aiContextHistorical": { "message": "📜 歴史的コンテキスト" }, - "aiContextHistoricalSignificance": { "message": "📜 歴史的意義" }, - "aiContextEvolution": { "message": "🔄 時代による変遷" }, - "aiContextLinguistic": { "message": "🔤 言語学的分析" }, - "aiContextGrammar": { "message": "📝 文法と意味論" }, - "aiContextUsage": { "message": "💡 使用法と例" }, - "aiContextExamples": { "message": "例:" }, - "aiContextLearningTips": { "message": "🎯 学習のヒント" }, - "aiContextRelatedExpressions": { "message": "🔗 関連表現" }, - "aiContextKeyInsights": { "message": "🔑 重要な洞察" }, - "aiContextTypeCultural": { "message": "文化的" }, - "aiContextTypeHistorical": { "message": "歴史的" }, - "aiContextTypeLinguistic": { "message": "言語学的" }, - "aiContextTypeComprehensive": { "message": "包括的" }, - "aiContextTypeGeneric": { "message": "コンテキスト" }, - "aiContextClose": { "message": "閉じる" }, - "aiContextAnalysisResults": { "message": "分析結果" }, - "aiContextRetrying": { "message": "分析に失敗しました、再生成中..." }, + "aiContextDefinition": { + "message": "📖 定義" + }, + "aiContextCultural": { + "message": "🌍 文化的コンテキスト" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 文化的意義" + }, + "aiContextHistorical": { + "message": "📜 歴史的コンテキスト" + }, + "aiContextHistoricalSignificance": { + "message": "📜 歴史的意義" + }, + "aiContextEvolution": { + "message": "🔄 時代による変遷" + }, + "aiContextLinguistic": { + "message": "🔤 言語学的分析" + }, + "aiContextGrammar": { + "message": "📝 文法と意味論" + }, + "aiContextUsage": { + "message": "💡 使用法と例" + }, + "aiContextExamples": { + "message": "例:" + }, + "aiContextLearningTips": { + "message": "🎯 学習のヒント" + }, + "aiContextRelatedExpressions": { + "message": "🔗 関連表現" + }, + "aiContextKeyInsights": { + "message": "🔑 重要な洞察" + }, + "aiContextTypeCultural": { + "message": "文化的" + }, + "aiContextTypeHistorical": { + "message": "歴史的" + }, + "aiContextTypeLinguistic": { + "message": "言語学的" + }, + "aiContextTypeComprehensive": { + "message": "包括的" + }, + "aiContextTypeGeneric": { + "message": "コンテキスト" + }, + "aiContextClose": { + "message": "閉じる" + }, + "aiContextAnalysisResults": { + "message": "分析結果" + }, + "aiContextRetrying": { + "message": "分析に失敗しました、再生成中..." + }, "aiContextRetryNotification": { "message": "分析に失敗しました、再試行中..." }, - "aiContextRetryButton": { "message": "再試行" }, + "aiContextRetryButton": { + "message": "再試行" + }, "aiContextMalformedResponse": { "message": "AIサービスが無効な応答形式を返しました。これは一時的なサービスの問題が原因である可能性があります。" }, "aiContextJsonCodeBlock": { "message": "AIサービスが構造化データではなく未処理のJSONコードを返しました。これは応答の形式エラーを示しています。" }, - "aiContextCulturalContext": { "message": "文化的背景:" }, - "aiContextSocialUsage": { "message": "社会的用法:" }, - "aiContextRegionalNotes": { "message": "地域的特徴:" }, - "aiContextOrigins": { "message": "語源:" }, - "aiContextHistoricalContext": { "message": "歴史的背景:" }, - "aiContextEtymology": { "message": "語源学:" }, - "aiContextGrammarNotes": { "message": "文法注記:" }, - "aiContextTranslationNotes": { "message": "翻訳注記:" }, - "aiContextLinguisticAnalysis": { "message": "言語学的分析:" }, - "aiContextGrammarSemantics": { "message": "文法と意味論:" }, - "aiContextUsageExamples": { "message": "用法と例:" }, - - "navAIContext": { "message": "AIコンテキスト" }, - "sectionAIContext": { "message": "AIコンテキストアシスタント" }, - "cardAIContextToggleTitle": { "message": "AIコンテキスト分析を有効にする" }, + "aiContextCulturalContext": { + "message": "文化的背景:" + }, + "aiContextSocialUsage": { + "message": "社会的用法:" + }, + "aiContextRegionalNotes": { + "message": "地域的特徴:" + }, + "aiContextOrigins": { + "message": "語源:" + }, + "aiContextHistoricalContext": { + "message": "歴史的背景:" + }, + "aiContextEtymology": { + "message": "語源学:" + }, + "aiContextGrammarNotes": { + "message": "文法注記:" + }, + "aiContextTranslationNotes": { + "message": "翻訳注記:" + }, + "aiContextLinguisticAnalysis": { + "message": "言語学的分析:" + }, + "aiContextGrammarSemantics": { + "message": "文法と意味論:" + }, + "aiContextUsageExamples": { + "message": "用法と例:" + }, + "navAIContext": { + "message": "AIコンテキスト" + }, + "sectionAIContext": { + "message": "AIコンテキストアシスタント" + }, + "cardAIContextToggleTitle": { + "message": "AIコンテキスト分析を有効にする" + }, "cardAIContextToggleDesc": { "message": "字幕テキストのAI駆動による文化的、歴史的、言語学的コンテキスト分析を有効にします。字幕の単語やフレーズをクリックして詳細な説明を取得できます。" }, - "aiContextEnabledLabel": { "message": "AIコンテキストを有効にする:" }, - "cardAIContextProviderTitle": { "message": "AIプロバイダー" }, + "aiContextEnabledLabel": { + "message": "AIコンテキストを有効にする:" + }, + "cardAIContextProviderTitle": { + "message": "AIプロバイダー" + }, "cardAIContextProviderDesc": { "message": "コンテキスト分析用のAIサービスプロバイダーを選択します。プロバイダーによって品質や応答時間が異なる場合があります。" }, - "aiContextProviderLabel": { "message": "プロバイダー:" }, - "cardOpenAIContextTitle": { "message": "OpenAI設定" }, + "aiContextProviderLabel": { + "message": "プロバイダー:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI設定" + }, "cardOpenAIContextDesc": { "message": "コンテキスト分析用のOpenAI API設定を構成します。有効なOpenAI APIキーが必要です。" }, - "openaiApiKeyLabel": { "message": "APIキー:" }, - "openaiBaseUrlLabel": { "message": "ベースURL:" }, - "openaiModelLabel": { "message": "モデル:" }, - "cardGeminiContextTitle": { "message": "Google Gemini設定" }, + "openaiApiKeyLabel": { + "message": "APIキー:" + }, + "openaiBaseUrlLabel": { + "message": "ベースURL:" + }, + "openaiModelLabel": { + "message": "モデル:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini設定" + }, "cardGeminiContextDesc": { "message": "コンテキスト分析用のGoogle Gemini API設定を構成します。有効なGemini APIキーが必要です。" }, - "geminiApiKeyLabel": { "message": "APIキー:" }, - "geminiModelLabel": { "message": "モデル:" }, - "cardAIContextTypesTitle": { "message": "コンテキストタイプ" }, + "geminiApiKeyLabel": { + "message": "APIキー:" + }, + "geminiModelLabel": { + "message": "モデル:" + }, + "cardAIContextTypesTitle": { + "message": "コンテキストタイプ" + }, "cardAIContextTypesDesc": { "message": "使用したいコンテキスト分析のタイプを有効にします。複数のタイプを有効にできます。" }, - "contextTypeCulturalLabel": { "message": "文化的コンテキスト:" }, + "contextTypeCulturalLabel": { + "message": "文化的コンテキスト:" + }, "contextTypeCulturalHelp": { "message": "文化的参照、慣用句、社会的コンテキストを分析" }, - "contextTypeHistoricalLabel": { "message": "歴史的コンテキスト:" }, + "contextTypeHistoricalLabel": { + "message": "歴史的コンテキスト:" + }, "contextTypeHistoricalHelp": { "message": "歴史的背景と時代のコンテキストを提供" }, - "contextTypeLinguisticLabel": { "message": "言語学的分析:" }, - "contextTypeLinguisticHelp": { "message": "文法、語源、言語構造を説明" }, - "cardAIContextInteractiveTitle": { "message": "インタラクティブ機能" }, + "contextTypeLinguisticLabel": { + "message": "言語学的分析:" + }, + "contextTypeLinguisticHelp": { + "message": "文法、語源、言語構造を説明" + }, + "cardAIContextInteractiveTitle": { + "message": "インタラクティブ機能" + }, "cardAIContextInteractiveDesc": { "message": "コンテキスト分析をトリガーするために字幕とどのように相互作用するかを設定します。" }, @@ -451,15 +887,21 @@ "interactiveSubtitlesEnabledHelp": { "message": "コンテキスト分析のために字幕の単語をクリック可能にする" }, - "contextOnClickLabel": { "message": "クリック時のコンテキスト:" }, + "contextOnClickLabel": { + "message": "クリック時のコンテキスト:" + }, "contextOnClickHelp": { "message": "単語をクリックしたときにコンテキスト分析を表示" }, - "contextOnSelectionLabel": { "message": "選択時のコンテキスト:" }, + "contextOnSelectionLabel": { + "message": "選択時のコンテキスト:" + }, "contextOnSelectionHelp": { "message": "テキストを選択したときにコンテキスト分析を表示" }, - "cardAIContextPrivacyTitle": { "message": "プライバシーとデータ" }, + "cardAIContextPrivacyTitle": { + "message": "プライバシーとデータ" + }, "cardAIContextPrivacyDesc": { "message": "コンテキスト分析中のデータの処理方法を制御します。" }, @@ -469,26 +911,103 @@ "aiContextUserConsentHelp": { "message": "AIコンテキスト分析が機能するために必要" }, - "aiContextDataSharingLabel": { "message": "匿名使用分析を許可:" }, + "aiContextDataSharingLabel": { + "message": "匿名使用分析を許可:" + }, "aiContextDataSharingHelp": { "message": "匿名使用データを共有してサービスの改善に協力" }, - "cardAIContextAdvancedTitle": { "message": "詳細設定" }, + "cardAIContextAdvancedTitle": { + "message": "詳細設定" + }, "cardAIContextAdvancedDesc": { "message": "AIコンテキスト分析の動作に関する詳細オプションを設定します。" }, - "aiContextTimeoutLabel": { "message": "リクエストタイムアウト(ms):" }, - "aiContextTimeoutHelp": { "message": "AI応答を待つ最大時間" }, - "aiContextRateLimitLabel": { "message": "レート制限(リクエスト/分):" }, - "aiContextRateLimitHelp": { "message": "1分あたりの最大リクエスト数" }, - "aiContextCacheEnabledLabel": { "message": "キャッシュを有効にする:" }, + "aiContextTimeoutLabel": { + "message": "リクエストタイムアウト(ms):" + }, + "aiContextTimeoutHelp": { + "message": "AI応答を待つ最大時間" + }, + "aiContextRateLimitLabel": { + "message": "レート制限(リクエスト/分):" + }, + "aiContextRateLimitHelp": { + "message": "1分あたりの最大リクエスト数" + }, + "aiContextCacheEnabledLabel": { + "message": "キャッシュを有効にする:" + }, "aiContextCacheEnabledHelp": { "message": "API呼び出しを減らすために分析結果をキャッシュ" }, - "aiContextRetryAttemptsLabel": { "message": "再試行回数:" }, + "aiContextRetryAttemptsLabel": { + "message": "再試行回数:" + }, "aiContextRetryAttemptsHelp": { "message": "失敗したリクエストを再試行する回数" }, - "showAdvancedSettings": { "message": "詳細設定を表示" }, - "hideAdvancedSettings": { "message": "詳細設定を非表示" } -} + "showAdvancedSettings": { + "message": "詳細設定を表示" + }, + "hideAdvancedSettings": { + "message": "詳細設定を非表示" + }, + "sidepanelLoading": { + "message": "読み込み中..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI分析" + }, + "sidepanelTabWordsLists": { + "message": "単語リスト" + }, + "sidepanelAnalyzeButton": { + "message": "分析" + }, + "sidepanelAnalyzing": { + "message": "分析中..." + }, + "sidepanelWordsToAnalyze": { + "message": "分析する単語" + }, + "sidepanelWordInputPlaceholder": { + "message": "字幕の単語をクリックして分析に追加..." + }, + "sidepanelErrorRetry": { + "message": "再試行" + }, + "sidepanelResultsTitle": { + "message": "\"%s\" の結果" + }, + "sidepanelSectionDefinition": { + "message": "定義" + }, + "sidepanelSectionCultural": { + "message": "文化的コンテキスト" + }, + "sidepanelSectionHistorical": { + "message": "歴史的コンテキスト" + }, + "sidepanelSectionLinguistic": { + "message": "言語学的分析" + }, + "sidepanelMyWordsTitle": { + "message": "マイ単語" + }, + "sidepanelFeatureComingSoon": { + "message": "単語リスト機能は近日公開予定!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "この機能は現在開発中です。設定で有効にしてプレビューをお試しください。" + }, + "sidepanelErrorNoWords": { + "message": "分析する単語が選択されていません" + }, + "sidepanelErrorDisabled": { + "message": "AIコンテキスト分析は無効になっています。設定で有効にしてください。" + }, + "sidepanelErrorGeneric": { + "message": "分析中にエラーが発生しました。" + } +} \ No newline at end of file diff --git a/_locales/ko/messages.json b/_locales/ko/messages.json index ed66994..2e9d217 100644 --- a/_locales/ko/messages.json +++ b/_locales/ko/messages.json @@ -1,31 +1,79 @@ { - "appName": { "message": "DualSub" }, + "appName": { + "message": "DualSub" + }, "appDesc": { "message": "스트리밍 플랫폼에서 이중 언어 자막을 표시합니다." }, - "pageTitle": { "message": "DualSub 설정" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "이중 자막 활성화:" }, - "useNativeSubtitlesLabel": { "message": "공식 자막 사용:" }, - "originalLanguageLabel": { "message": "언어 설정:" }, - "translationSettingsLegend": { "message": "번역 설정" }, - "providerLabel": { "message": "제공업체:" }, - "targetLanguageLabel": { "message": "번역 대상:" }, - "batchSizeLabel": { "message": "배치 크기:" }, - "requestDelayLabel": { "message": "요청 지연 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "자막 외관 및 타이밍" }, - "displayOrderLabel": { "message": "표시 순서:" }, - "layoutLabel": { "message": "레이아웃:" }, - "fontSizeLabel": { "message": "글꼴 크기:" }, - "verticalGapLabel": { "message": "세로 간격:" }, - "subtitleVerticalPositionLabel": { "message": "세로 위치:" }, - "timeOffsetLabel": { "message": "시간 오프셋(초):" }, - "displayOrderOriginalFirst": { "message": "원문 먼저" }, - "displayOrderTranslationFirst": { "message": "번역 먼저" }, - "layoutTopBottom": { "message": "위아래 배치" }, - "layoutLeftRight": { "message": "좌우 배치" }, - "uiLanguageLabel": { "message": "언어:" }, - "openOptionsButton": { "message": "고급 설정" }, + "pageTitle": { + "message": "DualSub 설정" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "이중 자막 활성화:" + }, + "useNativeSubtitlesLabel": { + "message": "공식 자막 사용:" + }, + "originalLanguageLabel": { + "message": "언어 설정:" + }, + "translationSettingsLegend": { + "message": "번역 설정" + }, + "providerLabel": { + "message": "제공업체:" + }, + "targetLanguageLabel": { + "message": "번역 대상:" + }, + "batchSizeLabel": { + "message": "배치 크기:" + }, + "requestDelayLabel": { + "message": "요청 지연 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "자막 외관 및 타이밍" + }, + "displayOrderLabel": { + "message": "표시 순서:" + }, + "layoutLabel": { + "message": "레이아웃:" + }, + "fontSizeLabel": { + "message": "글꼴 크기:" + }, + "verticalGapLabel": { + "message": "세로 간격:" + }, + "subtitleVerticalPositionLabel": { + "message": "세로 위치:" + }, + "timeOffsetLabel": { + "message": "시간 오프셋(초):" + }, + "displayOrderOriginalFirst": { + "message": "원문 먼저" + }, + "displayOrderTranslationFirst": { + "message": "번역 먼저" + }, + "layoutTopBottom": { + "message": "위아래 배치" + }, + "layoutLeftRight": { + "message": "좌우 배치" + }, + "uiLanguageLabel": { + "message": "언어:" + }, + "openOptionsButton": { + "message": "고급 설정" + }, "statusLanguageSetTo": { "message": "언어 설정 완료 (페이지를 새로고침하세요): " }, @@ -44,81 +92,168 @@ "statusOriginalLanguage": { "message": "언어 설정 완료 (페이지를 새로고침하세요): " }, - "statusTimeOffset": { "message": "시간 오프셋: " }, + "statusTimeOffset": { + "message": "시간 오프셋: " + }, "statusDisplayOrderUpdated": { "message": "표시 순서가 업데이트되었습니다." }, "statusLayoutOrientationUpdated": { "message": "레이아웃 방향이 업데이트되었습니다." }, - "statusFontSize": { "message": "글꼴 크기: " }, - "statusVerticalGap": { "message": "세로 간격: " }, - "statusVerticalPosition": { "message": "세로 위치: " }, - "statusInvalidOffset": { "message": "잘못된 오프셋입니다. 되돌립니다." }, + "statusFontSize": { + "message": "글꼴 크기: " + }, + "statusVerticalGap": { + "message": "세로 간격: " + }, + "statusVerticalPosition": { + "message": "세로 위치: " + }, + "statusInvalidOffset": { + "message": "잘못된 오프셋입니다. 되돌립니다." + }, "statusSettingNotApplied": { "message": "설정이 적용되지 않았습니다. 페이지를 새로고침하세요." }, - - "optionsPageTitle": { "message": "DualSub 옵션" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "일반" }, - "navTranslation": { "message": "번역" }, - "navProviders": { "message": "제공업체" }, - "navAbout": { "message": "정보" }, - "sectionGeneral": { "message": "일반" }, - "cardUILanguageTitle": { "message": "UI 언어" }, + "optionsPageTitle": { + "message": "DualSub 옵션" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "일반" + }, + "navTranslation": { + "message": "번역" + }, + "navProviders": { + "message": "제공업체" + }, + "navAbout": { + "message": "정보" + }, + "sectionGeneral": { + "message": "일반" + }, + "cardUILanguageTitle": { + "message": "UI 언어" + }, "cardUILanguageDesc": { "message": "확장 프로그램 인터페이스의 표시 언어를 선택하세요." }, - "cardHideOfficialSubtitlesTitle": { "message": "공식 자막 숨기기" }, + "cardHideOfficialSubtitlesTitle": { + "message": "공식 자막 숨기기" + }, "cardHideOfficialSubtitlesDesc": { "message": "DualSub이 활성화된 상태에서 비디오 플랫폼의 공식 자막을 숨깁니다." }, - "hideOfficialSubtitlesLabel": { "message": "공식 자막 숨기기:" }, - "sectionTranslation": { "message": "번역" }, - "cardTranslationEngineTitle": { "message": "번역 엔진" }, + "hideOfficialSubtitlesLabel": { + "message": "공식 자막 숨기기:" + }, + "sectionTranslation": { + "message": "번역" + }, + "cardTranslationEngineTitle": { + "message": "번역 엔진" + }, "cardTranslationEngineDesc": { "message": "선호하는 번역 서비스를 선택하세요." }, - "cardPerformanceTitle": { "message": "성능" }, + "cardPerformanceTitle": { + "message": "성능" + }, "cardPerformanceDesc": { "message": "확장 프로그램이 번역 요청을 처리하는 방식을 조정하여 속도와 안정성의 균형을 맞춥니다." }, - "sectionProviders": { "message": "제공업체 설정" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "제공업체 설정" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "DeepL Translate의 API 키를 입력하세요. 무료 플랜 또는 프로 플랜 중에서 선택할 수 있습니다." }, - "apiKeyLabel": { "message": "API 키:" }, - "apiPlanLabel": { "message": "API 플랜:" }, - "apiPlanFree": { "message": "DeepL API 무료" }, - "apiPlanPro": { "message": "DeepL API 프로" }, - "sectionAbout": { "message": "정보" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "버전" }, + "apiKeyLabel": { + "message": "API 키:" + }, + "apiPlanLabel": { + "message": "API 플랜:" + }, + "apiPlanFree": { + "message": "DeepL API 무료" + }, + "apiPlanPro": { + "message": "DeepL API 프로" + }, + "sectionAbout": { + "message": "정보" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "버전" + }, "aboutDescription": { "message": "이 확장 프로그램은 다양한 플랫폼에서 이중 언어 자막으로 비디오를 시청하는 데 도움이 됩니다." }, - "aboutDevelopment": { "message": "QuellaMC & 1jifang이 개발했습니다." }, - - "lang_en": { "message": "영어" }, - "lang_es": { "message": "스페인어" }, - "lang_fr": { "message": "프랑스어" }, - "lang_de": { "message": "독일어" }, - "lang_it": { "message": "이탈리아어" }, - "lang_pt": { "message": "포르투갈어" }, - "lang_ja": { "message": "일본어" }, - "lang_ko": { "message": "한국어" }, - "lang_zh_CN": { "message": "중국어(간체)" }, - "lang_zh_TW": { "message": "중국어(번체)" }, - "lang_ru": { "message": "러시아어" }, - "lang_ar": { "message": "아랍어" }, - "lang_hi": { "message": "힌디어" }, - - "testDeepLButton": { "message": "DeepL 연결 테스트" }, - "deeplApiKeyError": { "message": "먼저 DeepL API 키를 입력해주세요." }, - "testingButton": { "message": "테스트 중..." }, - "testingConnection": { "message": "DeepL 연결을 테스트하고 있습니다..." }, + "aboutDevelopment": { + "message": "QuellaMC & 1jifang이 개발했습니다." + }, + "lang_en": { + "message": "영어" + }, + "lang_es": { + "message": "스페인어" + }, + "lang_fr": { + "message": "프랑스어" + }, + "lang_de": { + "message": "독일어" + }, + "lang_it": { + "message": "이탈리아어" + }, + "lang_pt": { + "message": "포르투갈어" + }, + "lang_ja": { + "message": "일본어" + }, + "lang_ko": { + "message": "한국어" + }, + "lang_zh_CN": { + "message": "중국어(간체)" + }, + "lang_zh_TW": { + "message": "중국어(번체)" + }, + "lang_ru": { + "message": "러시아어" + }, + "lang_ar": { + "message": "아랍어" + }, + "lang_hi": { + "message": "힌디어" + }, + "testDeepLButton": { + "message": "DeepL 연결 테스트" + }, + "deeplApiKeyError": { + "message": "먼저 DeepL API 키를 입력해주세요." + }, + "testingButton": { + "message": "테스트 중..." + }, + "testingConnection": { + "message": "DeepL 연결을 테스트하고 있습니다..." + }, "deeplTestSuccess": { "message": "✅ DeepL API 테스트가 성공했습니다! \"Hello\"를 \"%s\"로 번역했습니다" }, @@ -134,325 +269,654 @@ "deeplTestQuotaExceeded": { "message": "❌ DeepL API 사용량 한도에 도달했습니다. 사용 제한을 확인해주세요." }, - "deeplTestApiError": { "message": "❌ DeepL API 오류 (%d): %s" }, + "deeplTestApiError": { + "message": "❌ DeepL API 오류 (%d): %s" + }, "deeplTestNetworkError": { "message": "❌ 네트워크 오류: DeepL API에 연결할 수 없습니다. 인터넷 연결을 확인해주세요." }, - "deeplTestGenericError": { "message": "❌ 테스트에 실패했습니다: %s" }, - "deepLApiUnavailable": { "message": "DeepL API 사용 불가" }, + "deeplTestGenericError": { + "message": "❌ 테스트에 실패했습니다: %s" + }, + "deepLApiUnavailable": { + "message": "DeepL API 사용 불가" + }, "deepLApiUnavailableTooltip": { "message": "DeepL API 스크립트 로드에 실패했습니다" }, "deeplApiNotLoadedError": { "message": "❌ DeepL API 스크립트를 사용할 수 없습니다. 페이지를 새로고침해주세요." }, - - "cardGoogleTitle": { "message": "Google 번역" }, + "cardGoogleTitle": { + "message": "Google 번역" + }, "cardGoogleDesc": { "message": "Google에서 제공하는 무료 번역 서비스입니다. 추가 설정이 필요하지 않습니다." }, - "cardMicrosoftTitle": { "message": "Microsoft 번역" }, + "cardMicrosoftTitle": { + "message": "Microsoft 번역" + }, "cardMicrosoftDesc": { "message": "Microsoft Edge에서 제공하는 무료 번역 서비스입니다. 추가 설정이 필요하지 않습니다." }, - "cardDeepLFreeTitle": { "message": "DeepL 번역 (무료)" }, + "cardDeepLFreeTitle": { + "message": "DeepL 번역 (무료)" + }, "cardDeepLFreeDesc": { "message": "고품질 결과를 제공하는 무료 DeepL 번역 서비스입니다. API 키 불필요 - DeepL 웹 인터페이스를 사용합니다." }, - "providerStatus": { "message": "상태:" }, - "statusReady": { "message": "사용 가능" }, - "providerFeatures": { "message": "기능:" }, - "featureFree": { "message": "무료 사용" }, - "featureNoApiKey": { "message": "API 키 불필요" }, - "featureWideLanguageSupport": { "message": "광범위한 언어 지원" }, - "featureFastTranslation": { "message": "빠른 번역" }, - "featureHighQuality": { "message": "고품질 번역" }, - "featureGoodPerformance": { "message": "우수한 성능" }, - "featureHighestQuality": { "message": "최고품질 번역" }, - "featureApiKeyRequired": { "message": "API 키 필요" }, - "featureLimitedLanguages": { "message": "제한적인 언어 지원" }, - "featureUsageLimits": { "message": "사용 제한 적용" }, - "featureMultipleBackups": { "message": "다중 백업 방법" }, - - "providerNotes": { "message": "참고사항:" }, - "noteSlowForSecurity": { "message": "보안 조치로 인해 약간 느림" }, - "noteAutoFallback": { "message": "대체 서비스로 자동 전환" }, - "noteRecommendedDefault": { "message": "기본 제공업체로 권장" }, - - "providerGoogleName": { "message": "Google 번역 (무료)" }, - "providerMicrosoftName": { "message": "Microsoft 번역 (무료)" }, - "providerDeepLName": { "message": "DeepL (API 키 필요)" }, - "providerDeepLFreeName": { "message": "DeepL 번역 (무료)" }, - "providerOpenAICompatibleName": { "message": "OpenAI 호환 (API 키 필요)" }, - "providerVertexGeminiName": { "message": "Vertex AI Gemini (API 키 필요)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI 호환 (API 키 필요)" }, + "providerStatus": { + "message": "상태:" + }, + "statusReady": { + "message": "사용 가능" + }, + "providerFeatures": { + "message": "기능:" + }, + "featureFree": { + "message": "무료 사용" + }, + "featureNoApiKey": { + "message": "API 키 불필요" + }, + "featureWideLanguageSupport": { + "message": "광범위한 언어 지원" + }, + "featureFastTranslation": { + "message": "빠른 번역" + }, + "featureHighQuality": { + "message": "고품질 번역" + }, + "featureGoodPerformance": { + "message": "우수한 성능" + }, + "featureHighestQuality": { + "message": "최고품질 번역" + }, + "featureApiKeyRequired": { + "message": "API 키 필요" + }, + "featureLimitedLanguages": { + "message": "제한적인 언어 지원" + }, + "featureUsageLimits": { + "message": "사용 제한 적용" + }, + "featureMultipleBackups": { + "message": "다중 백업 방법" + }, + "providerNotes": { + "message": "참고사항:" + }, + "noteSlowForSecurity": { + "message": "보안 조치로 인해 약간 느림" + }, + "noteAutoFallback": { + "message": "대체 서비스로 자동 전환" + }, + "noteRecommendedDefault": { + "message": "기본 제공업체로 권장" + }, + "providerGoogleName": { + "message": "Google 번역 (무료)" + }, + "providerMicrosoftName": { + "message": "Microsoft 번역 (무료)" + }, + "providerDeepLName": { + "message": "DeepL (API 키 필요)" + }, + "providerDeepLFreeName": { + "message": "DeepL 번역 (무료)" + }, + "providerOpenAICompatibleName": { + "message": "OpenAI 호환 (API 키 필요)" + }, + "providerVertexGeminiName": { + "message": "Vertex AI Gemini (API 키 필요)" + }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI 호환 (API 키 필요)" + }, "cardOpenAICompatibleDesc": { "message": "Gemini와 같은 OpenAI 호환 서비스를 위한 API 키와 설정을 입력하세요." }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini (API 키 필요)" }, - "cardVertexGeminiDesc": { "message": "액세스 토큰과 Vertex 프로젝트 설정을 입력하거나 서비스 계정 JSON 파일을 가져오세요." }, - "vertexAccessTokenLabel": { "message": "액세스 토큰:" }, - "vertexProjectIdLabel": { "message": "프로젝트 ID:" }, - "vertexLocationLabel": { "message": "위치:" }, - "vertexModelLabel": { "message": "모델:" }, - "vertexMissingConfig": { "message": "액세스 토큰과 프로젝트 ID를 입력하세요." }, - "vertexConnectionFailed": { "message": "연결 실패: %s" }, - "vertexServiceAccountLabel": { "message": "서비스 계정 JSON:" }, - "vertexImportButton": { "message": "JSON 파일 가져오기" }, - "vertexRefreshButton": { "message": "🔄 토큰 새로고침" }, - "vertexImportHint": { "message": "아래 자격 증명을 자동으로 입력합니다" }, - "vertexImporting": { "message": "가져오는 중..." }, - "vertexRefreshingToken": { "message": "액세스 토큰 새로고침 중..." }, - "vertexGeneratingToken": { "message": "액세스 토큰 생성 중..." }, - "vertexImportSuccess": { "message": "서비스 계정을 가져오고 토큰을 생성했습니다." }, - "vertexImportFailed": { "message": "가져오기 실패: %s" }, - "vertexTokenRefreshed": { "message": "액세스 토큰이 성공적으로 새로고침되었습니다." }, - "vertexRefreshFailed": { "message": "토큰 새로고침 실패: %s" }, - "vertexTokenExpired": { "message": "⚠️ 액세스 토큰이 만료되었습니다. 새로고침을 클릭하여 갱신하세요." }, - "vertexTokenExpiringSoon": { "message": "⚠️ 토큰이 %s분 후에 만료됩니다. 새로고침을 고려하세요." }, - "vertexConfigured": { "message": "⚠️ Vertex AI가 구성되었습니다. 연결을 테스트하세요." }, - "vertexNotConfigured": { "message": "서비스 계정 JSON을 가져오거나 자격 증명을 입력하세요." }, - "featureVertexServiceAccount": { "message": "서비스 계정 JSON 가져오기" }, - "featureVertexAutoToken": { "message": "자동 토큰 생성" }, - "featureVertexGemini": { "message": "Vertex AI를 통한 Google Gemini 모델" }, - "vertexNote": { "message": "액세스 토큰은 1시간 후에 만료됩니다. 서비스 계정은 안전하게 저장되어 쉽게 토큰을 새로고침할 수 있습니다 - 필요할 때 토큰 새로고침 버튼을 클릭하세요." }, - "baseUrlLabel": { "message": "기본 URL:" }, - "modelLabel": { "message": "모델:" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini (API 키 필요)" + }, + "cardVertexGeminiDesc": { + "message": "액세스 토큰과 Vertex 프로젝트 설정을 입력하거나 서비스 계정 JSON 파일을 가져오세요." + }, + "vertexAccessTokenLabel": { + "message": "액세스 토큰:" + }, + "vertexProjectIdLabel": { + "message": "프로젝트 ID:" + }, + "vertexLocationLabel": { + "message": "위치:" + }, + "vertexModelLabel": { + "message": "모델:" + }, + "vertexMissingConfig": { + "message": "액세스 토큰과 프로젝트 ID를 입력하세요." + }, + "vertexConnectionFailed": { + "message": "연결 실패: %s" + }, + "vertexServiceAccountLabel": { + "message": "서비스 계정 JSON:" + }, + "vertexImportButton": { + "message": "JSON 파일 가져오기" + }, + "vertexRefreshButton": { + "message": "🔄 토큰 새로고침" + }, + "vertexImportHint": { + "message": "아래 자격 증명을 자동으로 입력합니다" + }, + "vertexImporting": { + "message": "가져오는 중..." + }, + "vertexRefreshingToken": { + "message": "액세스 토큰 새로고침 중..." + }, + "vertexGeneratingToken": { + "message": "액세스 토큰 생성 중..." + }, + "vertexImportSuccess": { + "message": "서비스 계정을 가져오고 토큰을 생성했습니다." + }, + "vertexImportFailed": { + "message": "가져오기 실패: %s" + }, + "vertexTokenRefreshed": { + "message": "액세스 토큰이 성공적으로 새로고침되었습니다." + }, + "vertexRefreshFailed": { + "message": "토큰 새로고침 실패: %s" + }, + "vertexTokenExpired": { + "message": "⚠️ 액세스 토큰이 만료되었습니다. 새로고침을 클릭하여 갱신하세요." + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ 토큰이 %s분 후에 만료됩니다. 새로고침을 고려하세요." + }, + "vertexConfigured": { + "message": "⚠️ Vertex AI가 구성되었습니다. 연결을 테스트하세요." + }, + "vertexNotConfigured": { + "message": "서비스 계정 JSON을 가져오거나 자격 증명을 입력하세요." + }, + "featureVertexServiceAccount": { + "message": "서비스 계정 JSON 가져오기" + }, + "featureVertexAutoToken": { + "message": "자동 토큰 생성" + }, + "featureVertexGemini": { + "message": "Vertex AI를 통한 Google Gemini 모델" + }, + "vertexNote": { + "message": "액세스 토큰은 1시간 후에 만료됩니다. 서비스 계정은 안전하게 저장되어 쉽게 토큰을 새로고침할 수 있습니다 - 필요할 때 토큰 새로고침 버튼을 클릭하세요." + }, + "baseUrlLabel": { + "message": "기본 URL:" + }, + "modelLabel": { + "message": "모델:" + }, "featureCustomizable": { "message": "사용자 정의 가능한 엔드포인트 및 모델" }, - "fetchModelsButton": { "message": "모델 가져오기" }, - "testConnectionButton": { "message": "연결 테스트" }, - - "openaiApiKeyPlaceholder": { "message": "OpenAI 호환 API 키를 입력하세요" }, - "openaiBaseUrlPlaceholder": { "message": "예: https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "먼저 API 키를 입력해주세요." }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API 키 테스트가 필요합니다." }, + "fetchModelsButton": { + "message": "모델 가져오기" + }, + "testConnectionButton": { + "message": "연결 테스트" + }, + "openaiApiKeyPlaceholder": { + "message": "OpenAI 호환 API 키를 입력하세요" + }, + "openaiBaseUrlPlaceholder": { + "message": "예: https://api.openai.com/v1" + }, + "openaiApiKeyError": { + "message": "먼저 API 키를 입력해주세요." + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API 키 테스트가 필요합니다." + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI 호환 API 키 테스트가 필요합니다." }, - "openaiTestingConnection": { "message": "연결 테스트 중..." }, - "openaiConnectionSuccessful": { "message": "연결 성공!" }, - "openaiConnectionFailed": { "message": "연결 실패: %s" }, - "openaieFetchingModels": { "message": "모델 가져오는 중..." }, + "openaiTestingConnection": { + "message": "연결 테스트 중..." + }, + "openaiConnectionSuccessful": { + "message": "연결 성공!" + }, + "openaiConnectionFailed": { + "message": "연결 실패: %s" + }, + "openaieFetchingModels": { + "message": "모델 가져오는 중..." + }, "openaiModelsFetchedSuccessfully": { "message": "모델을 성공적으로 가져왔습니다." }, - "openaiFailedToFetchModels": { "message": "모델 가져오기 실패: %s" }, - - "cardLoggingLevelTitle": { "message": "로깅 레벨" }, + "openaiFailedToFetchModels": { + "message": "모델 가져오기 실패: %s" + }, + "cardLoggingLevelTitle": { + "message": "로깅 레벨" + }, "cardLoggingLevelDesc": { "message": "브라우저 콘솔에 표시되는 디버그 정보의 양을 제어합니다. 높은 레벨에는 낮은 레벨의 모든 메시지가 포함됩니다." }, - "loggingLevelLabel": { "message": "로깅 레벨:" }, - "loggingLevelOff": { "message": "끄기" }, - "loggingLevelError": { "message": "오류만" }, - "loggingLevelWarn": { "message": "경고 및 오류" }, - "loggingLevelInfo": { "message": "정보 이상" }, - "loggingLevelDebug": { "message": "디버그 (모두)" }, - - "cardBatchTranslationTitle": { "message": "배치 번역" }, + "loggingLevelLabel": { + "message": "로깅 레벨:" + }, + "loggingLevelOff": { + "message": "끄기" + }, + "loggingLevelError": { + "message": "오류만" + }, + "loggingLevelWarn": { + "message": "경고 및 오류" + }, + "loggingLevelInfo": { + "message": "정보 이상" + }, + "loggingLevelDebug": { + "message": "디버그 (모두)" + }, + "cardBatchTranslationTitle": { + "message": "배치 번역" + }, "cardBatchTranslationDesc": { "message": "배치 번역은 여러 자막 세그먼트를 함께 처리하여 API 호출을 80-90% 줄이고 성능을 향상시킵니다. 선호하는 번역 제공업체에 대한 최적 설정을 구성하세요." }, - "batchingEnabledLabel": { "message": "배치 번역 활성화:" }, + "batchingEnabledLabel": { + "message": "배치 번역 활성화:" + }, "batchingEnabledHelp": { "message": "여러 자막 세그먼트를 단일 번역 요청으로 그룹화합니다" }, - "useProviderDefaultsLabel": { "message": "제공업체 최적화 설정 사용:" }, + "useProviderDefaultsLabel": { + "message": "제공업체 최적화 설정 사용:" + }, "useProviderDefaultsHelp": { "message": "각 번역 제공업체에 대해 최적의 배치 크기를 자동으로 사용합니다" }, - "globalBatchSizeLabel": { "message": "전역 배치 크기:" }, + "globalBatchSizeLabel": { + "message": "전역 배치 크기:" + }, "globalBatchSizeHelp": { "message": "함께 처리할 자막 세그먼트 수 (1-15)" }, - "smartBatchingLabel": { "message": "스마트 배치 최적화:" }, + "smartBatchingLabel": { + "message": "스마트 배치 최적화:" + }, "smartBatchingHelp": { "message": "재생 위치를 기반으로 자막 세그먼트의 우선순위를 정합니다" }, - "maxConcurrentBatchesLabel": { "message": "최대 동시 배치 수:" }, + "maxConcurrentBatchesLabel": { + "message": "최대 동시 배치 수:" + }, "maxConcurrentBatchesHelp": { "message": "동시에 처리할 번역 배치 수" }, - - "cardProviderBatchTitle": { "message": "제공업체별 배치 크기" }, + "cardProviderBatchTitle": { + "message": "제공업체별 배치 크기" + }, "cardProviderBatchDesc": { "message": "각 번역 제공업체에 대한 최적 배치 크기를 구성합니다. 이 설정은 \"제공업체 최적화 설정 사용\"이 활성화된 경우에 사용됩니다." }, - "openaieBatchSizeLabel": { "message": "OpenAI 배치 크기:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI 배치 크기:" + }, "openaieBatchSizeHelp": { "message": "권장: 5-10개 세그먼트 (기본값: 8)" }, - "googleBatchSizeLabel": { "message": "Google 번역 배치 크기:" }, + "googleBatchSizeLabel": { + "message": "Google 번역 배치 크기:" + }, "googleBatchSizeHelp": { "message": "권장: 3-5개 세그먼트 (기본값: 4)" }, - "deeplBatchSizeLabel": { "message": "DeepL 배치 크기:" }, + "deeplBatchSizeLabel": { + "message": "DeepL 배치 크기:" + }, "deeplBatchSizeHelp": { "message": "권장: 2-3개 세그먼트 (기본값: 3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft 번역 배치 크기:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft 번역 배치 크기:" + }, "microsoftBatchSizeHelp": { "message": "권장: 3-5개 세그먼트 (기본값: 4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI 배치 크기:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI 배치 크기:" + }, "vertexBatchSizeHelp": { "message": "권장: 5-10개 세그먼트 (기본값: 8)" }, - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API 키 테스트가 필요합니다." }, - - "cardProviderDelayTitle": { "message": "제공업체별 요청 지연" }, + "cardProviderDelayTitle": { + "message": "제공업체별 요청 지연" + }, "cardProviderDelayDesc": { "message": "계정 잠금을 방지하기 위해 번역 요청 간의 필수 지연을 구성합니다. 이러한 지연은 배치 처리가 활성화된 경우에도 적용됩니다." }, - "openaieDelayLabel": { "message": "OpenAI 요청 지연 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI 요청 지연 (ms):" + }, "openaieDelayHelp": { "message": "요청 간 최소 지연 (기본값: 100ms)" }, - "googleDelayLabel": { "message": "Google 번역 요청 지연 (ms):" }, + "googleDelayLabel": { + "message": "Google 번역 요청 지연 (ms):" + }, "googleDelayHelp": { "message": "임시 잠금을 방지하기 위한 필수 지연 (기본값: 1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API 요청 지연 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API 요청 지연 (ms):" + }, "deeplDelayHelp": { "message": "DeepL API 요청 지연 (기본값: 500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL 무료 요청 지연 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL 무료 요청 지연 (ms):" + }, "deeplFreeDelayHelp": { "message": "무료 계층을 위한 보수적 지연 (기본값: 2000ms)" }, - "microsoftDelayLabel": { "message": "Microsoft 번역 요청 지연 (ms):" }, + "microsoftDelayLabel": { + "message": "Microsoft 번역 요청 지연 (ms):" + }, "microsoftDelayHelp": { "message": "문자 제한을 준수하기 위한 지연 (기본값: 800ms)" }, - "vertexDelayLabel": { "message": "Vertex AI 요청 지연 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AI 요청 지연 (ms):" + }, "vertexDelayHelp": { "message": "요청 사이의 최소 지연 (기본값: 100ms)" }, - - "aiContextModalTitle": { "message": "AI 컨텍스트 분석" }, - "aiContextSelectedWords": { "message": "선택된 단어" }, - "aiContextNoWordsSelected": { "message": "선택된 단어가 없습니다" }, + "aiContextModalTitle": { + "message": "AI 컨텍스트 분석" + }, + "aiContextSelectedWords": { + "message": "선택된 단어" + }, + "aiContextNoWordsSelected": { + "message": "선택된 단어가 없습니다" + }, "aiContextClickHint": { "message": "💡 단어를 클릭하여 추가하거나 제거하세요." }, - "aiContextStartAnalysis": { "message": "분석 시작" }, - "aiContextPauseAnalysis": { "message": "⏸ 일시정지" }, - "aiContextPauseAnalysisTitle": { "message": "분석 일시정지" }, + "aiContextStartAnalysis": { + "message": "분석 시작" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 일시정지" + }, + "aiContextPauseAnalysisTitle": { + "message": "분석 일시정지" + }, "aiContextInitialMessage": { "message": "자막에서 단어를 선택하여 분석을 시작하세요." }, - "aiContextAnalyzing": { "message": "컨텍스트 분석 중..." }, - "aiContextPauseNote": { "message": "⏸ 를 클릭하여 분석 일시정지" }, - "aiContextAnalysisFailed": { "message": "분석 실패" }, - "aiContextNoContent": { "message": "분석 콘텐츠 없음" }, + "aiContextAnalyzing": { + "message": "컨텍스트 분석 중..." + }, + "aiContextPauseNote": { + "message": "⏸ 를 클릭하여 분석 일시정지" + }, + "aiContextAnalysisFailed": { + "message": "분석 실패" + }, + "aiContextNoContent": { + "message": "분석 콘텐츠 없음" + }, "aiContextNoContentMessage": { "message": "분석이 완료되었지만 콘텐츠가 반환되지 않았습니다." }, - "aiContextDefinition": { "message": "📖 정의" }, - "aiContextCultural": { "message": "🌍 문화적 맥락" }, - "aiContextCulturalSignificance": { "message": "⭐ 문화적 의미" }, - "aiContextHistorical": { "message": "📜 역사적 맥락" }, - "aiContextHistoricalSignificance": { "message": "📜 역사적 의미" }, - "aiContextEvolution": { "message": "🔄 시대별 변화" }, - "aiContextLinguistic": { "message": "🔤 언어학적 분석" }, - "aiContextGrammar": { "message": "📝 문법 및 의미론" }, - "aiContextUsage": { "message": "💡 사용법 및 예시" }, - "aiContextExamples": { "message": "예시:" }, - "aiContextLearningTips": { "message": "🎯 학습 팁" }, - "aiContextRelatedExpressions": { "message": "🔗 관련 표현" }, - "aiContextKeyInsights": { "message": "🔑 핵심 통찰" }, - "aiContextTypeCultural": { "message": "문화적" }, - "aiContextTypeHistorical": { "message": "역사적" }, - "aiContextTypeLinguistic": { "message": "언어학적" }, - "aiContextTypeComprehensive": { "message": "포괄적" }, - "aiContextTypeGeneric": { "message": "맥락" }, - "aiContextClose": { "message": "닫기" }, - "aiContextAnalysisResults": { "message": "분석 결과" }, - "aiContextRetrying": { "message": "분석 실패, 재생성 중..." }, - "aiContextRetryNotification": { "message": "분석 실패, 재시도 중..." }, - "aiContextRetryButton": { "message": "다시 시도" }, + "aiContextDefinition": { + "message": "📖 정의" + }, + "aiContextCultural": { + "message": "🌍 문화적 맥락" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 문화적 의미" + }, + "aiContextHistorical": { + "message": "📜 역사적 맥락" + }, + "aiContextHistoricalSignificance": { + "message": "📜 역사적 의미" + }, + "aiContextEvolution": { + "message": "🔄 시대별 변화" + }, + "aiContextLinguistic": { + "message": "🔤 언어학적 분석" + }, + "aiContextGrammar": { + "message": "📝 문법 및 의미론" + }, + "aiContextUsage": { + "message": "💡 사용법 및 예시" + }, + "aiContextExamples": { + "message": "예시:" + }, + "aiContextLearningTips": { + "message": "🎯 학습 팁" + }, + "aiContextRelatedExpressions": { + "message": "🔗 관련 표현" + }, + "aiContextKeyInsights": { + "message": "🔑 핵심 통찰" + }, + "aiContextTypeCultural": { + "message": "문화적" + }, + "aiContextTypeHistorical": { + "message": "역사적" + }, + "aiContextTypeLinguistic": { + "message": "언어학적" + }, + "aiContextTypeComprehensive": { + "message": "포괄적" + }, + "aiContextTypeGeneric": { + "message": "맥락" + }, + "aiContextClose": { + "message": "닫기" + }, + "aiContextAnalysisResults": { + "message": "분석 결과" + }, + "aiContextRetrying": { + "message": "분석 실패, 재생성 중..." + }, + "aiContextRetryNotification": { + "message": "분석 실패, 재시도 중..." + }, + "aiContextRetryButton": { + "message": "다시 시도" + }, "aiContextMalformedResponse": { "message": "AI 서비스가 잘못된 응답 형식을 반환했습니다. 이는 일시적인 서비스 문제로 인한 것일 수 있습니다." }, "aiContextJsonCodeBlock": { "message": "AI 서비스가 구조화된 데이터 대신 처리되지 않은 JSON 코드를 반환했습니다. 이는 응답의 형식 오류를 나타냅니다." }, - "aiContextCulturalContext": { "message": "문화적 맥락:" }, - "aiContextSocialUsage": { "message": "사회적 용법:" }, - "aiContextRegionalNotes": { "message": "지역적 특징:" }, - "aiContextOrigins": { "message": "어원:" }, - "aiContextHistoricalContext": { "message": "역사적 배경:" }, - "aiContextHistoricalSignificance": { "message": "역사적 의미:" }, - "aiContextEvolution": { "message": "변화 과정:" }, - "aiContextEtymology": { "message": "어원학:" }, - "aiContextGrammarNotes": { "message": "문법 주석:" }, - "aiContextTranslationNotes": { "message": "번역 주석:" }, - "aiContextLinguisticAnalysis": { "message": "언어학적 분석:" }, - "aiContextGrammarSemantics": { "message": "문법과 의미론:" }, - "aiContextUsageExamples": { "message": "용법과 예시:" }, - "aiContextLearningTips": { "message": "학습 팁:" }, - "aiContextRelatedExpressions": { "message": "관련 표현:" }, - "aiContextKeyInsights": { "message": "핵심 통찰:" }, - - "navAIContext": { "message": "AI 컨텍스트" }, - "sectionAIContext": { "message": "AI 컨텍스트 어시스턴트" }, - "cardAIContextToggleTitle": { "message": "AI 컨텍스트 분석 활성화" }, + "aiContextCulturalContext": { + "message": "문화적 맥락:" + }, + "aiContextSocialUsage": { + "message": "사회적 용법:" + }, + "aiContextRegionalNotes": { + "message": "지역적 특징:" + }, + "aiContextOrigins": { + "message": "어원:" + }, + "aiContextHistoricalContext": { + "message": "역사적 배경:" + }, + "aiContextHistoricalSignificanceLabel": { + "message": "역사적 의미:" + }, + "aiContextEvolutionLabel": { + "message": "변화 과정:" + }, + "aiContextEtymology": { + "message": "어원학:" + }, + "aiContextGrammarNotesLabel": { + "message": "문법 주석:" + }, + "aiContextTranslationNotesLabel": { + "message": "번역 주석:" + }, + "aiContextLinguisticAnalysisLabel": { + "message": "언어학적 분석:" + }, + "aiContextGrammarSemanticsLabel": { + "message": "문법과 의미론:" + }, + "aiContextUsageExamplesLabel": { + "message": "용법과 예시:" + }, + "aiContextLearningTipsLabel": { + "message": "학습 팁:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "관련 표현:" + }, + "aiContextKeyInsightsLabel": { + "message": "핵심 통찰:" + }, + "navAIContext": { + "message": "AI 컨텍스트" + }, + "sectionAIContext": { + "message": "AI 컨텍스트 어시스턴트" + }, + "cardAIContextToggleTitle": { + "message": "AI 컨텍스트 분석 활성화" + }, "cardAIContextToggleDesc": { "message": "자막 텍스트에 대한 AI 기반 문화적, 역사적, 언어학적 컨텍스트 분석을 활성화합니다. 자막의 단어나 구문을 클릭하여 자세한 설명을 얻을 수 있습니다." }, - "aiContextEnabledLabel": { "message": "AI 컨텍스트 활성화:" }, - "cardAIContextProviderTitle": { "message": "AI 제공업체" }, + "aiContextEnabledLabel": { + "message": "AI 컨텍스트 활성화:" + }, + "cardAIContextProviderTitle": { + "message": "AI 제공업체" + }, "cardAIContextProviderDesc": { "message": "컨텍스트 분석을 위한 AI 서비스 제공업체를 선택합니다. 제공업체에 따라 품질과 응답 시간이 다를 수 있습니다." }, - "aiContextProviderLabel": { "message": "제공업체:" }, - "cardOpenAIContextTitle": { "message": "OpenAI 구성" }, + "aiContextProviderLabel": { + "message": "제공업체:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI 구성" + }, "cardOpenAIContextDesc": { "message": "컨텍스트 분석을 위한 OpenAI API 설정을 구성합니다. 유효한 OpenAI API 키가 필요합니다." }, - "openaiApiKeyLabel": { "message": "API 키:" }, - "openaiBaseUrlLabel": { "message": "기본 URL:" }, - "openaiModelLabel": { "message": "모델:" }, - "cardGeminiContextTitle": { "message": "Google Gemini 구성" }, + "openaiApiKeyLabel": { + "message": "API 키:" + }, + "openaiBaseUrlLabel": { + "message": "기본 URL:" + }, + "openaiModelLabel": { + "message": "모델:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini 구성" + }, "cardGeminiContextDesc": { "message": "컨텍스트 분석을 위한 Google Gemini API 설정을 구성합니다. 유효한 Gemini API 키가 필요합니다." }, - "geminiApiKeyLabel": { "message": "API 키:" }, - "geminiModelLabel": { "message": "모델:" }, - "cardAIContextTypesTitle": { "message": "컨텍스트 유형" }, + "geminiApiKeyLabel": { + "message": "API 키:" + }, + "geminiModelLabel": { + "message": "모델:" + }, + "cardAIContextTypesTitle": { + "message": "컨텍스트 유형" + }, "cardAIContextTypesDesc": { "message": "사용하려는 컨텍스트 분석 유형을 활성화합니다. 여러 유형을 활성화할 수 있습니다." }, - "contextTypeCulturalLabel": { "message": "문화적 컨텍스트:" }, + "contextTypeCulturalLabel": { + "message": "문화적 컨텍스트:" + }, "contextTypeCulturalHelp": { "message": "문화적 참조, 관용구, 사회적 맥락 분석" }, - "contextTypeHistoricalLabel": { "message": "역사적 컨텍스트:" }, + "contextTypeHistoricalLabel": { + "message": "역사적 컨텍스트:" + }, "contextTypeHistoricalHelp": { "message": "역사적 배경과 시대적 맥락 제공" }, - "contextTypeLinguisticLabel": { "message": "언어학적 분석:" }, - "contextTypeLinguisticHelp": { "message": "문법, 어원, 언어 구조 설명" }, - "cardAIContextInteractiveTitle": { "message": "상호작용 기능" }, + "contextTypeLinguisticLabel": { + "message": "언어학적 분석:" + }, + "contextTypeLinguisticHelp": { + "message": "문법, 어원, 언어 구조 설명" + }, + "cardAIContextInteractiveTitle": { + "message": "상호작용 기능" + }, "cardAIContextInteractiveDesc": { "message": "컨텍스트 분석을 트리거하기 위해 자막과 상호작용하는 방법을 구성합니다." }, - "interactiveSubtitlesEnabledLabel": { "message": "상호작용 자막 활성화:" }, + "interactiveSubtitlesEnabledLabel": { + "message": "상호작용 자막 활성화:" + }, "interactiveSubtitlesEnabledHelp": { "message": "컨텍스트 분석을 위해 자막 단어를 클릭 가능하게 만들기" }, - "contextOnClickLabel": { "message": "클릭 시 컨텍스트:" }, - "contextOnClickHelp": { "message": "단어를 클릭할 때 컨텍스트 분석 표시" }, - "contextOnSelectionLabel": { "message": "선택 시 컨텍스트:" }, + "contextOnClickLabel": { + "message": "클릭 시 컨텍스트:" + }, + "contextOnClickHelp": { + "message": "단어를 클릭할 때 컨텍스트 분석 표시" + }, + "contextOnSelectionLabel": { + "message": "선택 시 컨텍스트:" + }, "contextOnSelectionHelp": { "message": "텍스트를 선택할 때 컨텍스트 분석 표시" }, - "cardAIContextPrivacyTitle": { "message": "개인정보 및 데이터" }, + "cardAIContextPrivacyTitle": { + "message": "개인정보 및 데이터" + }, "cardAIContextPrivacyDesc": { "message": "컨텍스트 분석 중 데이터 처리 방법을 제어합니다." }, @@ -462,26 +926,103 @@ "aiContextUserConsentHelp": { "message": "AI 컨텍스트 분석이 작동하기 위해 필요" }, - "aiContextDataSharingLabel": { "message": "익명 사용 분석 허용:" }, + "aiContextDataSharingLabel": { + "message": "익명 사용 분석 허용:" + }, "aiContextDataSharingHelp": { "message": "익명 사용 데이터를 공유하여 서비스 개선에 도움" }, - "cardAIContextAdvancedTitle": { "message": "고급 설정" }, + "cardAIContextAdvancedTitle": { + "message": "고급 설정" + }, "cardAIContextAdvancedDesc": { "message": "AI 컨텍스트 분석 동작에 대한 고급 옵션을 구성합니다." }, - "aiContextTimeoutLabel": { "message": "요청 타임아웃 (ms):" }, - "aiContextTimeoutHelp": { "message": "AI 응답을 기다리는 최대 시간" }, - "aiContextRateLimitLabel": { "message": "속도 제한 (요청/분):" }, - "aiContextRateLimitHelp": { "message": "분당 최대 요청 수" }, - "aiContextCacheEnabledLabel": { "message": "캐싱 활성화:" }, + "aiContextTimeoutLabel": { + "message": "요청 타임아웃 (ms):" + }, + "aiContextTimeoutHelp": { + "message": "AI 응답을 기다리는 최대 시간" + }, + "aiContextRateLimitLabel": { + "message": "속도 제한 (요청/분):" + }, + "aiContextRateLimitHelp": { + "message": "분당 최대 요청 수" + }, + "aiContextCacheEnabledLabel": { + "message": "캐싱 활성화:" + }, "aiContextCacheEnabledHelp": { "message": "API 호출을 줄이기 위해 분석 결과 캐시" }, - "aiContextRetryAttemptsLabel": { "message": "재시도 횟수:" }, + "aiContextRetryAttemptsLabel": { + "message": "재시도 횟수:" + }, "aiContextRetryAttemptsHelp": { "message": "실패한 요청을 재시도하는 횟수" }, - "showAdvancedSettings": { "message": "고급 설정 표시" }, - "hideAdvancedSettings": { "message": "고급 설정 숨기기" } -} + "showAdvancedSettings": { + "message": "고급 설정 표시" + }, + "hideAdvancedSettings": { + "message": "고급 설정 숨기기" + }, + "sidepanelLoading": { + "message": "로딩 중..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI 분석" + }, + "sidepanelTabWordsLists": { + "message": "단어장" + }, + "sidepanelAnalyzeButton": { + "message": "분석" + }, + "sidepanelAnalyzing": { + "message": "분석 중..." + }, + "sidepanelWordsToAnalyze": { + "message": "분석할 단어" + }, + "sidepanelWordInputPlaceholder": { + "message": "자막의 단어를 클릭하여 분석에 추가하세요..." + }, + "sidepanelErrorRetry": { + "message": "재시도" + }, + "sidepanelResultsTitle": { + "message": "\"%s\"에 대한 결과" + }, + "sidepanelSectionDefinition": { + "message": "정의" + }, + "sidepanelSectionCultural": { + "message": "문화적 맥락" + }, + "sidepanelSectionHistorical": { + "message": "역사적 맥락" + }, + "sidepanelSectionLinguistic": { + "message": "언어학적 분석" + }, + "sidepanelMyWordsTitle": { + "message": "내 단어" + }, + "sidepanelFeatureComingSoon": { + "message": "단어장 기능이 곧 제공됩니다!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "이 기능은 현재 개발 중입니다. 설정에서 활성화하여 미리보기를 사용해보세요." + }, + "sidepanelErrorNoWords": { + "message": "분석할 단어가 선택되지 않았습니다" + }, + "sidepanelErrorDisabled": { + "message": "AI 컨텍스트 분석이 비활성화되어 있습니다. 설정에서 활성화하세요." + }, + "sidepanelErrorGeneric": { + "message": "분석 중 오류가 발생했습니다." + } +} \ No newline at end of file diff --git a/_locales/zh_CN/messages.json b/_locales/zh_CN/messages.json index 61c8a50..67b5b03 100644 --- a/_locales/zh_CN/messages.json +++ b/_locales/zh_CN/messages.json @@ -1,400 +1,883 @@ { - "appName": { "message": "DualSub" }, - "appDesc": { "message": "在流媒体平台上显示双语字幕。" }, - "pageTitle": { "message": "DualSub 设置" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "启用双语字幕:" }, - "useNativeSubtitlesLabel": { "message": "使用官方字幕:" }, - "originalLanguageLabel": { "message": "原始语言:" }, - "translationSettingsLegend": { "message": "翻译设置" }, - "providerLabel": { "message": "提供商:" }, - "targetLanguageLabel": { "message": "翻译为:" }, - "batchSizeLabel": { "message": "批量大小:" }, - "requestDelayLabel": { "message": "请求延迟 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "字幕外观和时序" }, - "displayOrderLabel": { "message": "显示顺序:" }, - "layoutLabel": { "message": "布局:" }, - "fontSizeLabel": { "message": "字体大小:" }, - "verticalGapLabel": { "message": "垂直间距:" }, - "subtitleVerticalPositionLabel": { "message": "垂直位置:" }, - "timeOffsetLabel": { "message": "时间偏移(秒):" }, - "displayOrderOriginalFirst": { "message": "原文在上" }, - "displayOrderTranslationFirst": { "message": "译文在上" }, - "layoutTopBottom": { "message": "上下排列" }, - "layoutLeftRight": { "message": "左右排列" }, - "uiLanguageLabel": { "message": "语言:" }, - "openOptionsButton": { "message": "高级设置" }, - "statusLanguageSetTo": { "message": "刷新页面后生效:" }, - "statusDualEnabled": { "message": "双语字幕已启用。(刷新页面)" }, - "statusDualDisabled": { "message": "双语字幕已禁用。(刷新页面)" }, + "appName": { + "message": "DualSub" + }, + "appDesc": { + "message": "在流媒体平台上显示双语字幕。" + }, + "pageTitle": { + "message": "DualSub 设置" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "启用双语字幕:" + }, + "useNativeSubtitlesLabel": { + "message": "使用官方字幕:" + }, + "originalLanguageLabel": { + "message": "原始语言:" + }, + "translationSettingsLegend": { + "message": "翻译设置" + }, + "providerLabel": { + "message": "提供商:" + }, + "targetLanguageLabel": { + "message": "翻译为:" + }, + "batchSizeLabel": { + "message": "批量大小:" + }, + "requestDelayLabel": { + "message": "请求延迟 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "字幕外观和时序" + }, + "displayOrderLabel": { + "message": "显示顺序:" + }, + "layoutLabel": { + "message": "布局:" + }, + "fontSizeLabel": { + "message": "字体大小:" + }, + "verticalGapLabel": { + "message": "垂直间距:" + }, + "subtitleVerticalPositionLabel": { + "message": "垂直位置:" + }, + "timeOffsetLabel": { + "message": "时间偏移(秒):" + }, + "displayOrderOriginalFirst": { + "message": "原文在上" + }, + "displayOrderTranslationFirst": { + "message": "译文在上" + }, + "layoutTopBottom": { + "message": "上下排列" + }, + "layoutLeftRight": { + "message": "左右排列" + }, + "uiLanguageLabel": { + "message": "语言:" + }, + "openOptionsButton": { + "message": "高级设置" + }, + "statusLanguageSetTo": { + "message": "刷新页面后生效:" + }, + "statusDualEnabled": { + "message": "双语字幕已启用。(刷新页面)" + }, + "statusDualDisabled": { + "message": "双语字幕已禁用。(刷新页面)" + }, "statusSmartTranslationEnabled": { "message": "智能翻译已启用。(刷新页面)" }, "statusSmartTranslationDisabled": { "message": "智能翻译已禁用。(刷新页面)" }, - "statusOriginalLanguage": { "message": "刷新页面后生效:" }, - "statusTimeOffset": { "message": "时间偏移:" }, - "statusDisplayOrderUpdated": { "message": "显示顺序已更新。" }, - "statusLayoutOrientationUpdated": { "message": "布局方向已更新。" }, - "statusFontSize": { "message": "字体大小:" }, - "statusVerticalGap": { "message": "垂直间距:" }, - "statusVerticalPosition": { "message": "垂直位置:" }, - "statusInvalidOffset": { "message": "无效偏移,已还原。" }, - "statusSettingNotApplied": { "message": "设置未应用。请刷新页面。" }, - - "optionsPageTitle": { "message": "DualSub 选项" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "常规" }, - "navTranslation": { "message": "翻译" }, - "navProviders": { "message": "提供商" }, - "navAbout": { "message": "关于" }, - "sectionGeneral": { "message": "常规" }, - "cardUILanguageTitle": { "message": "界面语言" }, - "cardUILanguageDesc": { "message": "选择扩展界面的显示语言。" }, - "cardHideOfficialSubtitlesTitle": { "message": "隐藏官方字幕" }, + "statusOriginalLanguage": { + "message": "刷新页面后生效:" + }, + "statusTimeOffset": { + "message": "时间偏移:" + }, + "statusDisplayOrderUpdated": { + "message": "显示顺序已更新。" + }, + "statusLayoutOrientationUpdated": { + "message": "布局方向已更新。" + }, + "statusFontSize": { + "message": "字体大小:" + }, + "statusVerticalGap": { + "message": "垂直间距:" + }, + "statusVerticalPosition": { + "message": "垂直位置:" + }, + "statusInvalidOffset": { + "message": "无效偏移,已还原。" + }, + "statusSettingNotApplied": { + "message": "设置未应用。请刷新页面。" + }, + "optionsPageTitle": { + "message": "DualSub 选项" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "常规" + }, + "navTranslation": { + "message": "翻译" + }, + "navProviders": { + "message": "提供商" + }, + "navAbout": { + "message": "关于" + }, + "sectionGeneral": { + "message": "常规" + }, + "cardUILanguageTitle": { + "message": "界面语言" + }, + "cardUILanguageDesc": { + "message": "选择扩展界面的显示语言。" + }, + "cardHideOfficialSubtitlesTitle": { + "message": "隐藏官方字幕" + }, "cardHideOfficialSubtitlesDesc": { "message": "当 DualSub 激活时,隐藏视频平台的官方字幕。" }, - "hideOfficialSubtitlesLabel": { "message": "隐藏官方字幕:" }, - "sectionTranslation": { "message": "翻译" }, - "cardTranslationEngineTitle": { "message": "翻译引擎" }, - "cardTranslationEngineDesc": { "message": "选择您喜欢的翻译服务。" }, - "cardPerformanceTitle": { "message": "性能" }, + "hideOfficialSubtitlesLabel": { + "message": "隐藏官方字幕:" + }, + "sectionTranslation": { + "message": "翻译" + }, + "cardTranslationEngineTitle": { + "message": "翻译引擎" + }, + "cardTranslationEngineDesc": { + "message": "选择您喜欢的翻译服务。" + }, + "cardPerformanceTitle": { + "message": "性能" + }, "cardPerformanceDesc": { "message": "调整扩展处理翻译请求的方式,以平衡速度和稳定性。" }, - "sectionProviders": { "message": "提供商设置" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "提供商设置" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "输入您的 DeepL 翻译 API 密钥。选择免费或专业版计划。" }, - "apiKeyLabel": { "message": "API 密钥:" }, - "apiPlanLabel": { "message": "API 计划:" }, - "apiPlanFree": { "message": "DeepL API 免费版" }, - "apiPlanPro": { "message": "DeepL API 专业版" }, - "sectionAbout": { "message": "关于" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "版本" }, + "apiKeyLabel": { + "message": "API 密钥:" + }, + "apiPlanLabel": { + "message": "API 计划:" + }, + "apiPlanFree": { + "message": "DeepL API 免费版" + }, + "apiPlanPro": { + "message": "DeepL API 专业版" + }, + "sectionAbout": { + "message": "关于" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "版本" + }, "aboutDescription": { "message": "此扩展帮助您在各种平台上观看双语字幕视频。" }, - "aboutDevelopment": { "message": "由 QuellaMC & 1jifang 开发。" }, - - "lang_en": { "message": "英语" }, - "lang_es": { "message": "西班牙语" }, - "lang_fr": { "message": "法语" }, - "lang_de": { "message": "德语" }, - "lang_it": { "message": "意大利语" }, - "lang_pt": { "message": "葡萄牙语" }, - "lang_ja": { "message": "日语" }, - "lang_ko": { "message": "韩语" }, - "lang_zh_CN": { "message": "中文 (简体)" }, - "lang_zh_TW": { "message": "中文 (繁体)" }, - "lang_ru": { "message": "俄语" }, - "lang_ar": { "message": "阿拉伯语" }, - "lang_hi": { "message": "印地语" }, - - "testDeepLButton": { "message": "测试 DeepL 连接" }, - "deeplApiKeyError": { "message": "请先输入您的 DeepL API 密钥。" }, - "testingButton": { "message": "测试中..." }, - "testingConnection": { "message": "正在测试 DeepL 连接..." }, + "aboutDevelopment": { + "message": "由 QuellaMC & 1jifang 开发。" + }, + "lang_en": { + "message": "英语" + }, + "lang_es": { + "message": "西班牙语" + }, + "lang_fr": { + "message": "法语" + }, + "lang_de": { + "message": "德语" + }, + "lang_it": { + "message": "意大利语" + }, + "lang_pt": { + "message": "葡萄牙语" + }, + "lang_ja": { + "message": "日语" + }, + "lang_ko": { + "message": "韩语" + }, + "lang_zh_CN": { + "message": "中文 (简体)" + }, + "lang_zh_TW": { + "message": "中文 (繁体)" + }, + "lang_ru": { + "message": "俄语" + }, + "lang_ar": { + "message": "阿拉伯语" + }, + "lang_hi": { + "message": "印地语" + }, + "testDeepLButton": { + "message": "测试 DeepL 连接" + }, + "deeplApiKeyError": { + "message": "请先输入您的 DeepL API 密钥。" + }, + "testingButton": { + "message": "测试中..." + }, + "testingConnection": { + "message": "正在测试 DeepL 连接..." + }, "deeplTestSuccess": { "message": "✅ DeepL API 测试成功" }, - "deeplTestUnexpectedFormat": { "message": "⚠️ DeepL API 响应但格式异常" }, - "deeplTestInvalidKey": { "message": "❌ DeepL API 密钥无效或被拒绝。" }, + "deeplTestUnexpectedFormat": { + "message": "⚠️ DeepL API 响应但格式异常" + }, + "deeplTestInvalidKey": { + "message": "❌ DeepL API 密钥无效或被拒绝。" + }, "deeplTestQuotaExceeded": { "message": "❌ DeepL API 配额已超限。请检查您的使用限制。" }, - "deeplTestApiError": { "message": "❌ DeepL API 错误 (%d):%s" }, + "deeplTestApiError": { + "message": "❌ DeepL API 错误 (%d):%s" + }, "deeplTestNetworkError": { "message": "❌ 网络错误:无法连接到 DeepL API。请检查您的网络连接。" }, - "deeplTestGenericError": { "message": "❌ 测试失败:%s" }, - "deepLApiUnavailable": { "message": "DeepL API 不可用" }, - "deepLApiUnavailableTooltip": { "message": "DeepL API 脚本加载失败" }, + "deeplTestGenericError": { + "message": "❌ 测试失败:%s" + }, + "deepLApiUnavailable": { + "message": "DeepL API 不可用" + }, + "deepLApiUnavailableTooltip": { + "message": "DeepL API 脚本加载失败" + }, "deeplApiNotLoadedError": { "message": "❌ DeepL API 脚本不可用。请刷新页面。" }, - - "cardGoogleTitle": { "message": "谷歌翻译" }, - "cardGoogleDesc": { "message": "由谷歌提供的免费翻译服务。无需额外配置。" }, - "cardMicrosoftTitle": { "message": "微软翻译" }, + "cardGoogleTitle": { + "message": "谷歌翻译" + }, + "cardGoogleDesc": { + "message": "由谷歌提供的免费翻译服务。无需额外配置。" + }, + "cardMicrosoftTitle": { + "message": "微软翻译" + }, "cardMicrosoftDesc": { "message": "由微软 Edge 提供的免费翻译服务。无需额外配置。" }, - "cardDeepLFreeTitle": { "message": "DeepL 翻译(免费)" }, + "cardDeepLFreeTitle": { + "message": "DeepL 翻译(免费)" + }, "cardDeepLFreeDesc": { "message": "免费的 DeepL 翻译服务,提供高质量的翻译结果。无需 API 密钥 - 使用 DeepL 的网页接口。" }, - "providerStatus": { "message": "状态:" }, - "statusReady": { "message": "可以使用" }, - "providerFeatures": { "message": "特性:" }, - "featureFree": { "message": "免费使用" }, - "featureNoApiKey": { "message": "无需 API 密钥" }, - "featureWideLanguageSupport": { "message": "广泛的语言支持" }, - "featureFastTranslation": { "message": "快速翻译" }, - "featureHighQuality": { "message": "高质量翻译" }, - "featureGoodPerformance": { "message": "良好的性能" }, - "featureHighestQuality": { "message": "最高质量翻译" }, - "featureApiKeyRequired": { "message": "需要 API 密钥" }, - "featureLimitedLanguages": { "message": "有限的语言支持" }, - "featureUsageLimits": { "message": "使用限制适用" }, - "featureMultipleBackups": { "message": "多种备用方法" }, - - "providerNotes": { "message": "注意事项:" }, - "noteSlowForSecurity": { "message": "由于安全措施,速度稍慢" }, - "noteAutoFallback": { "message": "自动回退到其他服务" }, - "noteRecommendedDefault": { "message": "推荐作为默认提供商" }, - - "providerGoogleName": { "message": "谷歌翻译(免费)" }, - "providerMicrosoftName": { "message": "微软翻译(免费)" }, - "providerDeepLName": { "message": "DeepL(需要 API 密钥)" }, - "providerDeepLFreeName": { "message": "DeepL 翻译(免费)" }, + "providerStatus": { + "message": "状态:" + }, + "statusReady": { + "message": "可以使用" + }, + "providerFeatures": { + "message": "特性:" + }, + "featureFree": { + "message": "免费使用" + }, + "featureNoApiKey": { + "message": "无需 API 密钥" + }, + "featureWideLanguageSupport": { + "message": "广泛的语言支持" + }, + "featureFastTranslation": { + "message": "快速翻译" + }, + "featureHighQuality": { + "message": "高质量翻译" + }, + "featureGoodPerformance": { + "message": "良好的性能" + }, + "featureHighestQuality": { + "message": "最高质量翻译" + }, + "featureApiKeyRequired": { + "message": "需要 API 密钥" + }, + "featureLimitedLanguages": { + "message": "有限的语言支持" + }, + "featureUsageLimits": { + "message": "使用限制适用" + }, + "featureMultipleBackups": { + "message": "多种备用方法" + }, + "providerNotes": { + "message": "注意事项:" + }, + "noteSlowForSecurity": { + "message": "由于安全措施,速度稍慢" + }, + "noteAutoFallback": { + "message": "自动回退到其他服务" + }, + "noteRecommendedDefault": { + "message": "推荐作为默认提供商" + }, + "providerGoogleName": { + "message": "谷歌翻译(免费)" + }, + "providerMicrosoftName": { + "message": "微软翻译(免费)" + }, + "providerDeepLName": { + "message": "DeepL(需要 API 密钥)" + }, + "providerDeepLFreeName": { + "message": "DeepL 翻译(免费)" + }, "providerOpenAICompatibleName": { "message": "OpenAI 兼容(需要 API 密钥)" }, "providerVertexGeminiName": { "message": "Vertex AI Gemini(需要 API 密钥)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI 兼容(需要 API 密钥)" }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI 兼容(需要 API 密钥)" + }, "cardOpenAICompatibleDesc": { "message": "输入您的 API 密钥和设置,用于 Gemini 等 OpenAI 兼容服务。" }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(需要 API 密钥)" }, - "cardVertexGeminiDesc": { "message": "输入您的访问令牌和 Vertex 项目设置,或导入服务账号 JSON 文件。" }, - "vertexAccessTokenLabel": { "message": "访问令牌:" }, - "vertexProjectIdLabel": { "message": "项目 ID:" }, - "vertexLocationLabel": { "message": "位置:" }, - "vertexModelLabel": { "message": "模型:" }, - "vertexMissingConfig": { "message": "请输入访问令牌和项目 ID。" }, - "vertexConnectionFailed": { "message": "连接失败:%s" }, - "vertexServiceAccountLabel": { "message": "服务账号 JSON:" }, - "vertexImportButton": { "message": "导入 JSON 文件" }, - "vertexRefreshButton": { "message": "🔄 刷新令牌" }, - "vertexImportHint": { "message": "自动填充下方凭据" }, - "vertexImporting": { "message": "导入中..." }, - "vertexRefreshingToken": { "message": "正在刷新访问令牌..." }, - "vertexGeneratingToken": { "message": "正在生成访问令牌..." }, - "vertexImportSuccess": { "message": "服务账号已导入并生成令牌。" }, - "vertexImportFailed": { "message": "导入失败:%s" }, - "vertexTokenRefreshed": { "message": "访问令牌刷新成功。" }, - "vertexRefreshFailed": { "message": "令牌刷新失败:%s" }, - "vertexTokenExpired": { "message": "⚠️ 访问令牌已过期。点击刷新以续期。" }, - "vertexTokenExpiringSoon": { "message": "⚠️ 令牌将在 %s 分钟后过期。建议刷新。" }, - "vertexConfigured": { "message": "⚠️ Vertex AI 已配置。请测试连接。" }, - "vertexNotConfigured": { "message": "请导入服务账号 JSON 或输入凭据。" }, - "featureVertexServiceAccount": { "message": "服务账号 JSON 导入" }, - "featureVertexAutoToken": { "message": "自动生成令牌" }, - "featureVertexGemini": { "message": "通过 Vertex AI 使用 Google Gemini 模型" }, - "providerNote": { "message": "注意:" }, - "vertexNote": { "message": "访问令牌在 1 小时后过期。您的服务账号已安全存储,需要时只需点击刷新令牌按钮即可。" }, - "baseUrlLabel": { "message": "基础 URL:" }, - "modelLabel": { "message": "模型:" }, - "featureCustomizable": { "message": "可自定义端点和模型" }, - "fetchModelsButton": { "message": "获取模型" }, - "testConnectionButton": { "message": "测试连接" }, - - "openaiApiKeyPlaceholder": { "message": "输入您的 OpenAI 兼容 API 密钥" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini(需要 API 密钥)" + }, + "cardVertexGeminiDesc": { + "message": "输入您的访问令牌和 Vertex 项目设置,或导入服务账号 JSON 文件。" + }, + "vertexAccessTokenLabel": { + "message": "访问令牌:" + }, + "vertexProjectIdLabel": { + "message": "项目 ID:" + }, + "vertexLocationLabel": { + "message": "位置:" + }, + "vertexModelLabel": { + "message": "模型:" + }, + "vertexMissingConfig": { + "message": "请输入访问令牌和项目 ID。" + }, + "vertexConnectionFailed": { + "message": "连接失败:%s" + }, + "vertexServiceAccountLabel": { + "message": "服务账号 JSON:" + }, + "vertexImportButton": { + "message": "导入 JSON 文件" + }, + "vertexRefreshButton": { + "message": "🔄 刷新令牌" + }, + "vertexImportHint": { + "message": "自动填充下方凭据" + }, + "vertexImporting": { + "message": "导入中..." + }, + "vertexRefreshingToken": { + "message": "正在刷新访问令牌..." + }, + "vertexGeneratingToken": { + "message": "正在生成访问令牌..." + }, + "vertexImportSuccess": { + "message": "服务账号已导入并生成令牌。" + }, + "vertexImportFailed": { + "message": "导入失败:%s" + }, + "vertexTokenRefreshed": { + "message": "访问令牌刷新成功。" + }, + "vertexRefreshFailed": { + "message": "令牌刷新失败:%s" + }, + "vertexTokenExpired": { + "message": "⚠️ 访问令牌已过期。点击刷新以续期。" + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ 令牌将在 %s 分钟后过期。建议刷新。" + }, + "vertexConfigured": { + "message": "⚠️ Vertex AI 已配置。请测试连接。" + }, + "vertexNotConfigured": { + "message": "请导入服务账号 JSON 或输入凭据。" + }, + "featureVertexServiceAccount": { + "message": "服务账号 JSON 导入" + }, + "featureVertexAutoToken": { + "message": "自动生成令牌" + }, + "featureVertexGemini": { + "message": "通过 Vertex AI 使用 Google Gemini 模型" + }, + "providerNote": { + "message": "注意:" + }, + "vertexNote": { + "message": "访问令牌在 1 小时后过期。您的服务账号已安全存储,需要时只需点击刷新令牌按钮即可。" + }, + "baseUrlLabel": { + "message": "基础 URL:" + }, + "modelLabel": { + "message": "模型:" + }, + "featureCustomizable": { + "message": "可自定义端点和模型" + }, + "fetchModelsButton": { + "message": "获取模型" + }, + "testConnectionButton": { + "message": "测试连接" + }, + "openaiApiKeyPlaceholder": { + "message": "输入您的 OpenAI 兼容 API 密钥" + }, "openaiBaseUrlPlaceholder": { "message": "例如:https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "请先输入您的 API 密钥。" }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API 密钥需要测试。" }, + "openaiApiKeyError": { + "message": "请先输入您的 API 密钥。" + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API 密钥需要测试。" + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI 兼容 API 密钥需要测试。" }, - "openaiTestingConnection": { "message": "正在测试连接..." }, - "openaiConnectionSuccessful": { "message": "连接成功!" }, - "openaiConnectionFailed": { "message": "连接失败:%s" }, - "openaieFetchingModels": { "message": "正在获取模型..." }, - "openaiModelsFetchedSuccessfully": { "message": "模型获取成功。" }, - "openaiFailedToFetchModels": { "message": "获取模型失败:%s" }, - - "cardLoggingLevelTitle": { "message": "日志级别" }, + "openaiTestingConnection": { + "message": "正在测试连接..." + }, + "openaiConnectionSuccessful": { + "message": "连接成功!" + }, + "openaiConnectionFailed": { + "message": "连接失败:%s" + }, + "openaieFetchingModels": { + "message": "正在获取模型..." + }, + "openaiModelsFetchedSuccessfully": { + "message": "模型获取成功。" + }, + "openaiFailedToFetchModels": { + "message": "获取模型失败:%s" + }, + "cardLoggingLevelTitle": { + "message": "日志级别" + }, "cardLoggingLevelDesc": { "message": "控制浏览器控制台中显示的调试信息量。较高级别包含所有较低级别的消息。" }, - "loggingLevelLabel": { "message": "日志级别:" }, - "loggingLevelOff": { "message": "关闭" }, - "loggingLevelError": { "message": "仅错误" }, - "loggingLevelWarn": { "message": "警告和错误" }, - "loggingLevelInfo": { "message": "信息及以上" }, - "loggingLevelDebug": { "message": "调试(全部)" }, - - "cardBatchTranslationTitle": { "message": "批量翻译" }, + "loggingLevelLabel": { + "message": "日志级别:" + }, + "loggingLevelOff": { + "message": "关闭" + }, + "loggingLevelError": { + "message": "仅错误" + }, + "loggingLevelWarn": { + "message": "警告和错误" + }, + "loggingLevelInfo": { + "message": "信息及以上" + }, + "loggingLevelDebug": { + "message": "调试(全部)" + }, + "cardBatchTranslationTitle": { + "message": "批量翻译" + }, "cardBatchTranslationDesc": { "message": "批量翻译将多个字幕片段一起处理,减少 80-90% 的 API 调用并提高性能。为您首选的翻译提供商配置最佳设置。" }, - "batchingEnabledLabel": { "message": "启用批量翻译:" }, + "batchingEnabledLabel": { + "message": "启用批量翻译:" + }, "batchingEnabledHelp": { "message": "将多个字幕片段组合成单个翻译请求" }, - "useProviderDefaultsLabel": { "message": "使用提供商优化设置:" }, + "useProviderDefaultsLabel": { + "message": "使用提供商优化设置:" + }, "useProviderDefaultsHelp": { "message": "自动为每个翻译提供商使用最佳批量大小" }, - "globalBatchSizeLabel": { "message": "全局批量大小:" }, + "globalBatchSizeLabel": { + "message": "全局批量大小:" + }, "globalBatchSizeHelp": { "message": "一起处理的字幕片段数量(1-15)" }, - "smartBatchingLabel": { "message": "智能批量优化:" }, + "smartBatchingLabel": { + "message": "智能批量优化:" + }, "smartBatchingHelp": { "message": "根据播放位置优先处理字幕片段" }, - "maxConcurrentBatchesLabel": { "message": "最大并发批次:" }, + "maxConcurrentBatchesLabel": { + "message": "最大并发批次:" + }, "maxConcurrentBatchesHelp": { "message": "同时处理的翻译批次数量" }, - - "cardProviderBatchTitle": { "message": "提供商特定批量大小" }, + "cardProviderBatchTitle": { + "message": "提供商特定批量大小" + }, "cardProviderBatchDesc": { "message": "为每个翻译提供商配置最佳批量大小。这些设置在启用\"使用提供商优化设置\"时使用。" }, - "openaieBatchSizeLabel": { "message": "OpenAI 批量大小:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI 批量大小:" + }, "openaieBatchSizeHelp": { "message": "推荐:5-10 个片段(默认:8)" }, - "googleBatchSizeLabel": { "message": "谷歌翻译批量大小:" }, + "googleBatchSizeLabel": { + "message": "谷歌翻译批量大小:" + }, "googleBatchSizeHelp": { "message": "推荐:3-5 个片段(默认:4)" }, - "deeplBatchSizeLabel": { "message": "DeepL 批量大小:" }, + "deeplBatchSizeLabel": { + "message": "DeepL 批量大小:" + }, "deeplBatchSizeHelp": { "message": "推荐:2-3 个片段(默认:3)" }, - "microsoftBatchSizeLabel": { "message": "微软翻译批量大小:" }, + "microsoftBatchSizeLabel": { + "message": "微软翻译批量大小:" + }, "microsoftBatchSizeHelp": { "message": "推荐:3-5 个片段(默认:4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI 批量大小:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI 批量大小:" + }, "vertexBatchSizeHelp": { "message": "推荐:5-10 个片段(默认:8)" }, - - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API 密钥需要测试。" }, - - "cardProviderDelayTitle": { "message": "提供商特定请求延迟" }, + "deeplTestNeedsTesting": { + "message": "⚠️ DeepL API 密钥需要测试。" + }, + "cardProviderDelayTitle": { + "message": "提供商特定请求延迟" + }, "cardProviderDelayDesc": { "message": "配置翻译请求之间的强制延迟以防止账户锁定。即使启用批量处理,这些延迟也会应用。" }, - "openaieDelayLabel": { "message": "OpenAI 请求延迟 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI 请求延迟 (ms):" + }, "openaieDelayHelp": { "message": "请求之间的最小延迟(默认:100ms)" }, - "googleDelayLabel": { "message": "谷歌翻译请求延迟 (ms):" }, + "googleDelayLabel": { + "message": "谷歌翻译请求延迟 (ms):" + }, "googleDelayHelp": { "message": "防止临时锁定所需的延迟(默认:1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API 请求延迟 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API 请求延迟 (ms):" + }, "deeplDelayHelp": { "message": "DeepL API 请求的延迟(默认:500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL 免费请求延迟 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL 免费请求延迟 (ms):" + }, "deeplFreeDelayHelp": { "message": "免费层的保守延迟(默认:2000ms)" }, - "microsoftDelayLabel": { "message": "微软翻译请求延迟 (ms):" }, + "microsoftDelayLabel": { + "message": "微软翻译请求延迟 (ms):" + }, "microsoftDelayHelp": { "message": "尊重字符限制的延迟(默认:800ms)" }, - "vertexDelayLabel": { "message": "Vertex AI 请求延迟 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AI 请求延迟 (ms):" + }, "vertexDelayHelp": { "message": "请求之间的最小延迟(默认:100ms)" }, - - "aiContextModalTitle": { "message": "AI 上下文分析" }, - "aiContextSelectedWords": { "message": "选中的词语" }, - "aiContextNoWordsSelected": { "message": "未选择词语" }, - "aiContextClickHint": { "message": "💡 点击词语来添加或移除。" }, - "aiContextStartAnalysis": { "message": "开始分析" }, - "aiContextPauseAnalysis": { "message": "⏸ 暂停" }, - "aiContextPauseAnalysisTitle": { "message": "暂停分析" }, - "aiContextInitialMessage": { "message": "从字幕中选择词语开始分析。" }, - "aiContextAnalyzing": { "message": "正在分析上下文..." }, - "aiContextPauseNote": { "message": "点击 ⏸ 暂停分析" }, - "aiContextAnalysisFailed": { "message": "分析失败" }, - "aiContextNoContent": { "message": "无分析内容" }, - "aiContextNoContentMessage": { "message": "分析已完成但未返回内容。" }, - "aiContextDefinition": { "message": "📖 定义" }, - "aiContextCultural": { "message": "🌍 文化背景" }, - "aiContextCulturalSignificance": { "message": "⭐ 文化意义" }, - "aiContextHistorical": { "message": "📜 历史背景" }, - "aiContextHistoricalSignificance": { "message": "📜 历史意义" }, - "aiContextEvolution": { "message": "🔄 历史演变" }, - "aiContextLinguistic": { "message": "🔤 语言学分析" }, - "aiContextGrammar": { "message": "📝 语法与语义" }, - "aiContextUsage": { "message": "💡 用法与例句" }, - "aiContextExamples": { "message": "例句:" }, - "aiContextLearningTips": { "message": "🎯 学习提示" }, - "aiContextRelatedExpressions": { "message": "🔗 相关表达" }, - "aiContextKeyInsights": { "message": "🔑 关键见解" }, - "aiContextTypeCultural": { "message": "文化" }, - "aiContextTypeHistorical": { "message": "历史" }, - "aiContextTypeLinguistic": { "message": "语言学" }, - "aiContextTypeComprehensive": { "message": "综合" }, - "aiContextTypeGeneric": { "message": "上下文" }, - "aiContextClose": { "message": "关闭" }, - "aiContextAnalysisResults": { "message": "分析结果" }, - "aiContextRetrying": { "message": "分析失败,正在重新生成..." }, - "aiContextRetryNotification": { "message": "分析失败,正在重试..." }, - "aiContextRetryButton": { "message": "重试" }, + "aiContextModalTitle": { + "message": "AI 上下文分析" + }, + "aiContextSelectedWords": { + "message": "选中的词语" + }, + "aiContextNoWordsSelected": { + "message": "未选择词语" + }, + "aiContextClickHint": { + "message": "💡 点击词语来添加或移除。" + }, + "aiContextStartAnalysis": { + "message": "开始分析" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 暂停" + }, + "aiContextPauseAnalysisTitle": { + "message": "暂停分析" + }, + "aiContextInitialMessage": { + "message": "从字幕中选择词语开始分析。" + }, + "aiContextAnalyzing": { + "message": "正在分析上下文..." + }, + "aiContextPauseNote": { + "message": "点击 ⏸ 暂停分析" + }, + "aiContextAnalysisFailed": { + "message": "分析失败" + }, + "aiContextNoContent": { + "message": "无分析内容" + }, + "aiContextNoContentMessage": { + "message": "分析已完成但未返回内容。" + }, + "aiContextDefinition": { + "message": "📖 定义" + }, + "aiContextCultural": { + "message": "🌍 文化背景" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 文化意义" + }, + "aiContextHistorical": { + "message": "📜 历史背景" + }, + "aiContextHistoricalSignificance": { + "message": "📜 历史意义" + }, + "aiContextEvolution": { + "message": "🔄 历史演变" + }, + "aiContextLinguistic": { + "message": "🔤 语言学分析" + }, + "aiContextGrammar": { + "message": "📝 语法与语义" + }, + "aiContextUsage": { + "message": "💡 用法与例句" + }, + "aiContextExamples": { + "message": "例句:" + }, + "aiContextLearningTips": { + "message": "🎯 学习提示" + }, + "aiContextRelatedExpressions": { + "message": "🔗 相关表达" + }, + "aiContextKeyInsights": { + "message": "🔑 关键见解" + }, + "aiContextTypeCultural": { + "message": "文化" + }, + "aiContextTypeHistorical": { + "message": "历史" + }, + "aiContextTypeLinguistic": { + "message": "语言学" + }, + "aiContextTypeComprehensive": { + "message": "综合" + }, + "aiContextTypeGeneric": { + "message": "上下文" + }, + "aiContextClose": { + "message": "关闭" + }, + "aiContextAnalysisResults": { + "message": "分析结果" + }, + "aiContextRetrying": { + "message": "分析失败,正在重新生成..." + }, + "aiContextRetryNotification": { + "message": "分析失败,正在重试..." + }, + "aiContextRetryButton": { + "message": "重试" + }, "aiContextMalformedResponse": { "message": "AI 服务返回了无效的响应格式。这可能是由于临时服务问题。" }, "aiContextJsonCodeBlock": { "message": "AI 服务返回了未处理的 JSON 代码而不是结构化数据。这表明响应中存在格式错误。" }, - "aiContextCulturalContext": { "message": "文化背景:" }, - "aiContextSocialUsage": { "message": "社会用法:" }, - "aiContextRegionalNotes": { "message": "地区特色:" }, - "aiContextOrigins": { "message": "词源:" }, - "aiContextHistoricalContext": { "message": "历史背景:" }, - "aiContextEtymology": { "message": "词源学:" }, - "aiContextGrammarNotes": { "message": "语法注释:" }, - "aiContextTranslationNotes": { "message": "翻译注释:" }, - "aiContextLinguisticAnalysis": { "message": "语言学分析:" }, - "aiContextGrammarSemantics": { "message": "语法与语义:" }, - "aiContextUsageExamples": { "message": "用法与例句:" }, - - "navAIContext": { "message": "AI 上下文" }, - "sectionAIContext": { "message": "AI 上下文助手" }, - "cardAIContextToggleTitle": { "message": "启用 AI 上下文分析" }, + "aiContextCulturalContext": { + "message": "文化背景:" + }, + "aiContextSocialUsage": { + "message": "社会用法:" + }, + "aiContextRegionalNotes": { + "message": "地区特色:" + }, + "aiContextOrigins": { + "message": "词源:" + }, + "aiContextHistoricalContext": { + "message": "历史背景:" + }, + "aiContextEtymology": { + "message": "词源学:" + }, + "aiContextGrammarNotes": { + "message": "语法注释:" + }, + "aiContextTranslationNotes": { + "message": "翻译注释:" + }, + "aiContextLinguisticAnalysis": { + "message": "语言学分析:" + }, + "aiContextGrammarSemantics": { + "message": "语法与语义:" + }, + "aiContextUsageExamples": { + "message": "用法与例句:" + }, + "navAIContext": { + "message": "AI 上下文" + }, + "sectionAIContext": { + "message": "AI 上下文助手" + }, + "cardAIContextToggleTitle": { + "message": "启用 AI 上下文分析" + }, "cardAIContextToggleDesc": { "message": "为字幕文本启用 AI 驱动的文化、历史和语言学上下文分析。点击字幕中的单词或短语以获得详细解释。" }, - "aiContextEnabledLabel": { "message": "启用 AI 上下文:" }, - "cardAIContextProviderTitle": { "message": "AI 提供商" }, + "aiContextEnabledLabel": { + "message": "启用 AI 上下文:" + }, + "cardAIContextProviderTitle": { + "message": "AI 提供商" + }, "cardAIContextProviderDesc": { "message": "选择用于上下文分析的 AI 服务提供商。不同的提供商可能提供不同的质量和响应时间。" }, - "aiContextProviderLabel": { "message": "提供商:" }, - "cardOpenAIContextTitle": { "message": "OpenAI 配置" }, + "aiContextProviderLabel": { + "message": "提供商:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI 配置" + }, "cardOpenAIContextDesc": { "message": "配置用于上下文分析的 OpenAI API 设置。您需要一个有效的 OpenAI API 密钥。" }, - "openaiApiKeyLabel": { "message": "API 密钥:" }, - "openaiBaseUrlLabel": { "message": "基础 URL:" }, - "openaiModelLabel": { "message": "模型:" }, - "cardGeminiContextTitle": { "message": "Google Gemini 配置" }, + "openaiApiKeyLabel": { + "message": "API 密钥:" + }, + "openaiBaseUrlLabel": { + "message": "基础 URL:" + }, + "openaiModelLabel": { + "message": "模型:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini 配置" + }, "cardGeminiContextDesc": { "message": "配置用于上下文分析的 Google Gemini API 设置。您需要一个有效的 Gemini API 密钥。" }, - "geminiApiKeyLabel": { "message": "API 密钥:" }, - "geminiModelLabel": { "message": "模型:" }, - "cardAIContextTypesTitle": { "message": "上下文类型" }, + "geminiApiKeyLabel": { + "message": "API 密钥:" + }, + "geminiModelLabel": { + "message": "模型:" + }, + "cardAIContextTypesTitle": { + "message": "上下文类型" + }, "cardAIContextTypesDesc": { "message": "启用您想要使用的上下文分析类型。您可以启用多种类型。" }, - "contextTypeCulturalLabel": { "message": "文化上下文:" }, - "contextTypeCulturalHelp": { "message": "分析文化参考、习语和社会背景" }, - "contextTypeHistoricalLabel": { "message": "历史上下文:" }, - "contextTypeHistoricalHelp": { "message": "提供历史背景和时代背景" }, - "contextTypeLinguisticLabel": { "message": "语言学分析:" }, - "contextTypeLinguisticHelp": { "message": "解释语法、词源和语言结构" }, - - "cardAIContextPrivacyTitle": { "message": "隐私和数据" }, + "contextTypeCulturalLabel": { + "message": "文化上下文:" + }, + "contextTypeCulturalHelp": { + "message": "分析文化参考、习语和社会背景" + }, + "contextTypeHistoricalLabel": { + "message": "历史上下文:" + }, + "contextTypeHistoricalHelp": { + "message": "提供历史背景和时代背景" + }, + "contextTypeLinguisticLabel": { + "message": "语言学分析:" + }, + "contextTypeLinguisticHelp": { + "message": "解释语法、词源和语言结构" + }, + "cardAIContextPrivacyTitle": { + "message": "隐私和数据" + }, "cardAIContextPrivacyDesc": { "message": "控制在上下文分析期间如何处理您的数据。" }, @@ -404,22 +887,103 @@ "aiContextUserConsentHelp": { "message": "AI 上下文分析功能正常运行所必需" }, - "aiContextDataSharingLabel": { "message": "允许匿名使用分析:" }, + "aiContextDataSharingLabel": { + "message": "允许匿名使用分析:" + }, "aiContextDataSharingHelp": { "message": "通过分享匿名使用数据帮助改进服务" }, - "cardAIContextAdvancedTitle": { "message": "高级设置" }, + "cardAIContextAdvancedTitle": { + "message": "高级设置" + }, "cardAIContextAdvancedDesc": { "message": "配置 AI 上下文分析行为的高级选项。" }, - "aiContextTimeoutLabel": { "message": "请求超时 (ms):" }, - "aiContextTimeoutHelp": { "message": "等待 AI 响应的最长时间" }, - "aiContextRateLimitLabel": { "message": "速率限制 (请求/分钟):" }, - "aiContextRateLimitHelp": { "message": "每分钟最大请求数" }, - "aiContextCacheEnabledLabel": { "message": "启用缓存:" }, - "aiContextCacheEnabledHelp": { "message": "缓存分析结果以减少 API 调用" }, - "aiContextRetryAttemptsLabel": { "message": "重试次数:" }, - "aiContextRetryAttemptsHelp": { "message": "重试失败请求的次数" }, - "showAdvancedSettings": { "message": "显示高级设置" }, - "hideAdvancedSettings": { "message": "隐藏高级设置" } -} + "aiContextTimeoutLabel": { + "message": "请求超时 (ms):" + }, + "aiContextTimeoutHelp": { + "message": "等待 AI 响应的最长时间" + }, + "aiContextRateLimitLabel": { + "message": "速率限制 (请求/分钟):" + }, + "aiContextRateLimitHelp": { + "message": "每分钟最大请求数" + }, + "aiContextCacheEnabledLabel": { + "message": "启用缓存:" + }, + "aiContextCacheEnabledHelp": { + "message": "缓存分析结果以减少 API 调用" + }, + "aiContextRetryAttemptsLabel": { + "message": "重试次数:" + }, + "aiContextRetryAttemptsHelp": { + "message": "重试失败请求的次数" + }, + "showAdvancedSettings": { + "message": "显示高级设置" + }, + "hideAdvancedSettings": { + "message": "隐藏高级设置" + }, + "sidepanelLoading": { + "message": "加载中..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI 分析" + }, + "sidepanelTabWordsLists": { + "message": "单词列表" + }, + "sidepanelAnalyzeButton": { + "message": "分析" + }, + "sidepanelAnalyzing": { + "message": "分析中..." + }, + "sidepanelWordsToAnalyze": { + "message": "待分析单词" + }, + "sidepanelWordInputPlaceholder": { + "message": "点击字幕中的单词以添加分析..." + }, + "sidepanelErrorRetry": { + "message": "重试" + }, + "sidepanelResultsTitle": { + "message": "“%s”的结果" + }, + "sidepanelSectionDefinition": { + "message": "定义" + }, + "sidepanelSectionCultural": { + "message": "文化背景" + }, + "sidepanelSectionHistorical": { + "message": "历史背景" + }, + "sidepanelSectionLinguistic": { + "message": "语言学分析" + }, + "sidepanelMyWordsTitle": { + "message": "我的单词" + }, + "sidepanelFeatureComingSoon": { + "message": "单词列表功能即将推出!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "此功能目前正在开发中。在设置中启用以尝试预览。" + }, + "sidepanelErrorNoWords": { + "message": "未选择要分析的单词" + }, + "sidepanelErrorDisabled": { + "message": "AI 上下文分析已禁用。请在设置中启用。" + }, + "sidepanelErrorGeneric": { + "message": "分析过程中发生错误。" + } +} \ No newline at end of file diff --git a/_locales/zh_TW/messages.json b/_locales/zh_TW/messages.json index 642b2ee..7cbb0f1 100644 --- a/_locales/zh_TW/messages.json +++ b/_locales/zh_TW/messages.json @@ -1,418 +1,922 @@ { - "appName": { "message": "DualSub" }, - "appDesc": { "message": "在串流平台上顯示雙語字幕。" }, - "pageTitle": { "message": "DualSub 設定" }, - "h1Title": { "message": "DualSub" }, - "enableSubtitlesLabel": { "message": "啟用雙語字幕:" }, - "useNativeSubtitlesLabel": { "message": "使用官方字幕:" }, - "originalLanguageLabel": { "message": "原始語言:" }, - "translationSettingsLegend": { "message": "翻譯設定" }, - "providerLabel": { "message": "提供商:" }, - "targetLanguageLabel": { "message": "翻譯為:" }, - "batchSizeLabel": { "message": "批次大小:" }, - "requestDelayLabel": { "message": "請求延遲 (ms):" }, - "subtitleAppearanceTimingLegend": { "message": "字幕外觀和時序" }, - "displayOrderLabel": { "message": "顯示順序:" }, - "layoutLabel": { "message": "佈局:" }, - "fontSizeLabel": { "message": "字體大小:" }, - "verticalGapLabel": { "message": "垂直間距:" }, - "subtitleVerticalPositionLabel": { "message": "垂直位置:" }, - "timeOffsetLabel": { "message": "時間偏移(秒):" }, - "displayOrderOriginalFirst": { "message": "原文在上" }, - "displayOrderTranslationFirst": { "message": "譯文在上" }, - "layoutTopBottom": { "message": "上下排列" }, - "layoutLeftRight": { "message": "左右排列" }, - "uiLanguageLabel": { "message": "語言:" }, - "openOptionsButton": { "message": "進階設定" }, - "statusLanguageSetTo": { "message": "重新整理頁面後生效:" }, - "statusDualEnabled": { "message": "雙語字幕已啟用。(重新整理頁面)" }, - "statusDualDisabled": { "message": "雙語字幕已停用。(重新整理頁面)" }, + "appName": { + "message": "DualSub" + }, + "appDesc": { + "message": "在串流平台上顯示雙語字幕。" + }, + "pageTitle": { + "message": "DualSub 設定" + }, + "h1Title": { + "message": "DualSub" + }, + "enableSubtitlesLabel": { + "message": "啟用雙語字幕:" + }, + "useNativeSubtitlesLabel": { + "message": "使用官方字幕:" + }, + "originalLanguageLabel": { + "message": "原始語言:" + }, + "translationSettingsLegend": { + "message": "翻譯設定" + }, + "providerLabel": { + "message": "提供商:" + }, + "targetLanguageLabel": { + "message": "翻譯為:" + }, + "batchSizeLabel": { + "message": "批次大小:" + }, + "requestDelayLabel": { + "message": "請求延遲 (ms):" + }, + "subtitleAppearanceTimingLegend": { + "message": "字幕外觀和時序" + }, + "displayOrderLabel": { + "message": "顯示順序:" + }, + "layoutLabel": { + "message": "佈局:" + }, + "fontSizeLabel": { + "message": "字體大小:" + }, + "verticalGapLabel": { + "message": "垂直間距:" + }, + "subtitleVerticalPositionLabel": { + "message": "垂直位置:" + }, + "timeOffsetLabel": { + "message": "時間偏移(秒):" + }, + "displayOrderOriginalFirst": { + "message": "原文在上" + }, + "displayOrderTranslationFirst": { + "message": "譯文在上" + }, + "layoutTopBottom": { + "message": "上下排列" + }, + "layoutLeftRight": { + "message": "左右排列" + }, + "uiLanguageLabel": { + "message": "語言:" + }, + "openOptionsButton": { + "message": "進階設定" + }, + "statusLanguageSetTo": { + "message": "重新整理頁面後生效:" + }, + "statusDualEnabled": { + "message": "雙語字幕已啟用。(重新整理頁面)" + }, + "statusDualDisabled": { + "message": "雙語字幕已停用。(重新整理頁面)" + }, "statusSmartTranslationEnabled": { "message": "智慧翻譯已啟用。(重新整理頁面)" }, "statusSmartTranslationDisabled": { "message": "智慧翻譯已停用。(重新整理頁面)" }, - "statusOriginalLanguage": { "message": "重新整理頁面後生效:" }, - "statusTimeOffset": { "message": "時間偏移:" }, - "statusDisplayOrderUpdated": { "message": "顯示順序已更新。" }, - "statusLayoutOrientationUpdated": { "message": "佈局方向已更新。" }, - "statusFontSize": { "message": "字體大小:" }, - "statusVerticalGap": { "message": "垂直間距:" }, - "statusVerticalPosition": { "message": "垂直位置:" }, - "statusInvalidOffset": { "message": "無效偏移,已還原。" }, - "statusSettingNotApplied": { "message": "設定未套用。請重新整理頁面。" }, - - "optionsPageTitle": { "message": "DualSub 選項" }, - "optionsH1Title": { "message": "DualSub" }, - "navGeneral": { "message": "一般" }, - "navTranslation": { "message": "翻譯" }, - "navProviders": { "message": "提供商" }, - "navAbout": { "message": "關於" }, - "sectionGeneral": { "message": "一般" }, - "cardUILanguageTitle": { "message": "介面語言" }, - "cardUILanguageDesc": { "message": "選擇擴充功能介面的顯示語言。" }, - "cardHideOfficialSubtitlesTitle": { "message": "隱藏官方字幕" }, + "statusOriginalLanguage": { + "message": "重新整理頁面後生效:" + }, + "statusTimeOffset": { + "message": "時間偏移:" + }, + "statusDisplayOrderUpdated": { + "message": "顯示順序已更新。" + }, + "statusLayoutOrientationUpdated": { + "message": "佈局方向已更新。" + }, + "statusFontSize": { + "message": "字體大小:" + }, + "statusVerticalGap": { + "message": "垂直間距:" + }, + "statusVerticalPosition": { + "message": "垂直位置:" + }, + "statusInvalidOffset": { + "message": "無效偏移,已還原。" + }, + "statusSettingNotApplied": { + "message": "設定未套用。請重新整理頁面。" + }, + "optionsPageTitle": { + "message": "DualSub 選項" + }, + "optionsH1Title": { + "message": "DualSub" + }, + "navGeneral": { + "message": "一般" + }, + "navTranslation": { + "message": "翻譯" + }, + "navProviders": { + "message": "提供商" + }, + "navAbout": { + "message": "關於" + }, + "sectionGeneral": { + "message": "一般" + }, + "cardUILanguageTitle": { + "message": "介面語言" + }, + "cardUILanguageDesc": { + "message": "選擇擴充功能介面的顯示語言。" + }, + "cardHideOfficialSubtitlesTitle": { + "message": "隱藏官方字幕" + }, "cardHideOfficialSubtitlesDesc": { "message": "當 DualSub 啟用時,隱藏影片平台的官方字幕。" }, - "hideOfficialSubtitlesLabel": { "message": "隱藏官方字幕:" }, - "sectionTranslation": { "message": "翻譯" }, - "cardTranslationEngineTitle": { "message": "翻譯引擎" }, - "cardTranslationEngineDesc": { "message": "選擇您偏好的翻譯服務。" }, - "cardPerformanceTitle": { "message": "效能" }, + "hideOfficialSubtitlesLabel": { + "message": "隱藏官方字幕:" + }, + "sectionTranslation": { + "message": "翻譯" + }, + "cardTranslationEngineTitle": { + "message": "翻譯引擎" + }, + "cardTranslationEngineDesc": { + "message": "選擇您偏好的翻譯服務。" + }, + "cardPerformanceTitle": { + "message": "效能" + }, "cardPerformanceDesc": { "message": "調整擴充功能處理翻譯請求的方式,以平衡速度和穩定性。" }, - "sectionProviders": { "message": "提供商設定" }, - "cardDeepLTitle": { "message": "DeepL" }, + "sectionProviders": { + "message": "提供商設定" + }, + "cardDeepLTitle": { + "message": "DeepL" + }, "cardDeepLDesc": { "message": "輸入您的 DeepL 翻譯 API 金鑰。選擇免費或專業版方案。" }, - "apiKeyLabel": { "message": "API 金鑰:" }, - "apiPlanLabel": { "message": "API 方案:" }, - "apiPlanFree": { "message": "DeepL API 免費版" }, - "apiPlanPro": { "message": "DeepL API 專業版" }, - "sectionAbout": { "message": "關於" }, - "cardAboutTitle": { "message": "DualSub" }, - "aboutVersion": { "message": "版本" }, + "apiKeyLabel": { + "message": "API 金鑰:" + }, + "apiPlanLabel": { + "message": "API 方案:" + }, + "apiPlanFree": { + "message": "DeepL API 免費版" + }, + "apiPlanPro": { + "message": "DeepL API 專業版" + }, + "sectionAbout": { + "message": "關於" + }, + "cardAboutTitle": { + "message": "DualSub" + }, + "aboutVersion": { + "message": "版本" + }, "aboutDescription": { "message": "此擴充功能幫助您在各種平台上觀看雙語字幕影片。" }, - "aboutDevelopment": { "message": "由 QuellaMC & 1jifang 開發。" }, - - "lang_en": { "message": "英語" }, - "lang_es": { "message": "西班牙語" }, - "lang_fr": { "message": "法語" }, - "lang_de": { "message": "德語" }, - "lang_it": { "message": "義大利語" }, - "lang_pt": { "message": "葡萄牙語" }, - "lang_ja": { "message": "日語" }, - "lang_ko": { "message": "韓語" }, - "lang_zh_CN": { "message": "中文 (簡體)" }, - "lang_zh_TW": { "message": "中文 (繁體)" }, - "lang_ru": { "message": "俄語" }, - "lang_ar": { "message": "阿拉伯語" }, - "lang_hi": { "message": "印地語" }, - - "testDeepLButton": { "message": "測試 DeepL 連線" }, - "deeplApiKeyError": { "message": "請先輸入您的 DeepL API 金鑰。" }, - "testingButton": { "message": "測試中..." }, - "testingConnection": { "message": "正在測試 DeepL 連線..." }, + "aboutDevelopment": { + "message": "由 QuellaMC & 1jifang 開發。" + }, + "lang_en": { + "message": "英語" + }, + "lang_es": { + "message": "西班牙語" + }, + "lang_fr": { + "message": "法語" + }, + "lang_de": { + "message": "德語" + }, + "lang_it": { + "message": "義大利語" + }, + "lang_pt": { + "message": "葡萄牙語" + }, + "lang_ja": { + "message": "日語" + }, + "lang_ko": { + "message": "韓語" + }, + "lang_zh_CN": { + "message": "中文 (簡體)" + }, + "lang_zh_TW": { + "message": "中文 (繁體)" + }, + "lang_ru": { + "message": "俄語" + }, + "lang_ar": { + "message": "阿拉伯語" + }, + "lang_hi": { + "message": "印地語" + }, + "testDeepLButton": { + "message": "測試 DeepL 連線" + }, + "deeplApiKeyError": { + "message": "請先輸入您的 DeepL API 金鑰。" + }, + "testingButton": { + "message": "測試中..." + }, + "testingConnection": { + "message": "正在測試 DeepL 連線..." + }, "deeplTestSuccess": { "message": "✅ DeepL API 測試成功!將 \"Hello\" 翻譯為 \"%s\"" }, - "deeplTestSuccessSimple": { "message": "✅ DeepL API 測試成功!" }, - "deeplTestUnexpectedFormat": { "message": "⚠️ DeepL API 回應但格式異常" }, - "deeplTestInvalidKey": { "message": "❌ DeepL API 金鑰無效或被拒絕。" }, + "deeplTestSuccessSimple": { + "message": "✅ DeepL API 測試成功!" + }, + "deeplTestUnexpectedFormat": { + "message": "⚠️ DeepL API 回應但格式異常" + }, + "deeplTestInvalidKey": { + "message": "❌ DeepL API 金鑰無效或被拒絕。" + }, "deeplTestQuotaExceeded": { "message": "❌ DeepL API 配額已超限。請檢查您的使用限制。" }, - "deeplTestApiError": { "message": "❌ DeepL API 錯誤 (%d):%s" }, + "deeplTestApiError": { + "message": "❌ DeepL API 錯誤 (%d):%s" + }, "deeplTestNetworkError": { "message": "❌ 網路錯誤:無法連線到 DeepL API。請檢查您的網路連線。" }, - "deeplTestGenericError": { "message": "❌ 測試失敗:%s" }, - "deepLApiUnavailable": { "message": "DeepL API 不可用" }, - "deepLApiUnavailableTooltip": { "message": "DeepL API 指令碼載入失敗" }, + "deeplTestGenericError": { + "message": "❌ 測試失敗:%s" + }, + "deepLApiUnavailable": { + "message": "DeepL API 不可用" + }, + "deepLApiUnavailableTooltip": { + "message": "DeepL API 指令碼載入失敗" + }, "deeplApiNotLoadedError": { "message": "❌ DeepL API 指令碼不可用。請重新整理頁面。" }, - - "cardGoogleTitle": { "message": "Google 翻譯" }, + "cardGoogleTitle": { + "message": "Google 翻譯" + }, "cardGoogleDesc": { "message": "由 Google 提供的免費翻譯服務。無需額外設定。" }, - "cardMicrosoftTitle": { "message": "Microsoft 翻譯" }, + "cardMicrosoftTitle": { + "message": "Microsoft 翻譯" + }, "cardMicrosoftDesc": { "message": "由 Microsoft Edge 提供的免費翻譯服務。無需額外設定。" }, - "cardDeepLFreeTitle": { "message": "DeepL 翻譯(免費)" }, + "cardDeepLFreeTitle": { + "message": "DeepL 翻譯(免費)" + }, "cardDeepLFreeDesc": { "message": "免費的 DeepL 翻譯服務,提供高品質的翻譯結果。無需 API 金鑰 - 使用 DeepL 的網頁介面。" }, - "providerStatus": { "message": "狀態:" }, - "statusReady": { "message": "可以使用" }, - "providerFeatures": { "message": "特性:" }, - "featureFree": { "message": "免費使用" }, - "featureNoApiKey": { "message": "無需 API 金鑰" }, - "featureWideLanguageSupport": { "message": "廣泛的語言支援" }, - "featureFastTranslation": { "message": "快速翻譯" }, - "featureHighQuality": { "message": "高品質翻譯" }, - "featureGoodPerformance": { "message": "良好的效能" }, - "featureHighestQuality": { "message": "最高品質翻譯" }, - "featureApiKeyRequired": { "message": "需要 API 金鑰" }, - "featureLimitedLanguages": { "message": "有限的語言支援" }, - "featureUsageLimits": { "message": "使用限制適用" }, - "featureMultipleBackups": { "message": "多種備用方法" }, - - "providerNotes": { "message": "注意事項:" }, - "noteSlowForSecurity": { "message": "由於安全措施,速度稍慢" }, - "noteAutoFallback": { "message": "自動回退到其他服務" }, - "noteRecommendedDefault": { "message": "推薦作為預設提供商" }, - - "providerGoogleName": { "message": "Google 翻譯(免費)" }, - "providerMicrosoftName": { "message": "Microsoft 翻譯(免費)" }, - "providerDeepLName": { "message": "DeepL(需要 API 金鑰)" }, - "providerDeepLFreeName": { "message": "DeepL 翻譯(免費)" }, + "providerStatus": { + "message": "狀態:" + }, + "statusReady": { + "message": "可以使用" + }, + "providerFeatures": { + "message": "特性:" + }, + "featureFree": { + "message": "免費使用" + }, + "featureNoApiKey": { + "message": "無需 API 金鑰" + }, + "featureWideLanguageSupport": { + "message": "廣泛的語言支援" + }, + "featureFastTranslation": { + "message": "快速翻譯" + }, + "featureHighQuality": { + "message": "高品質翻譯" + }, + "featureGoodPerformance": { + "message": "良好的效能" + }, + "featureHighestQuality": { + "message": "最高品質翻譯" + }, + "featureApiKeyRequired": { + "message": "需要 API 金鑰" + }, + "featureLimitedLanguages": { + "message": "有限的語言支援" + }, + "featureUsageLimits": { + "message": "使用限制適用" + }, + "featureMultipleBackups": { + "message": "多種備用方法" + }, + "providerNotes": { + "message": "注意事項:" + }, + "noteSlowForSecurity": { + "message": "由於安全措施,速度稍慢" + }, + "noteAutoFallback": { + "message": "自動回退到其他服務" + }, + "noteRecommendedDefault": { + "message": "推薦作為預設提供商" + }, + "providerGoogleName": { + "message": "Google 翻譯(免費)" + }, + "providerMicrosoftName": { + "message": "Microsoft 翻譯(免費)" + }, + "providerDeepLName": { + "message": "DeepL(需要 API 金鑰)" + }, + "providerDeepLFreeName": { + "message": "DeepL 翻譯(免費)" + }, "providerOpenAICompatibleName": { "message": "OpenAI 相容(需要 API 金鑰)" }, "providerVertexGeminiName": { "message": "Vertex AI Gemini(需要 API 金鑰)" }, - "cardOpenAICompatibleTitle": { "message": "OpenAI 相容(需要 API 金鑰)" }, + "cardOpenAICompatibleTitle": { + "message": "OpenAI 相容(需要 API 金鑰)" + }, "cardOpenAICompatibleDesc": { "message": "輸入您的 API 金鑰和設定,用於 Gemini 等 OpenAI 相容服務。" }, - "cardVertexGeminiTitle": { "message": "Vertex AI Gemini(需要 API 金鑰)" }, - "cardVertexGeminiDesc": { "message": "輸入您的存取權杖和 Vertex 專案設定,或匯入服務帳戶 JSON 檔案。" }, - "vertexAccessTokenLabel": { "message": "存取權杖:" }, - "vertexProjectIdLabel": { "message": "專案 ID:" }, - "vertexLocationLabel": { "message": "位置:" }, - "vertexModelLabel": { "message": "模型:" }, - "vertexMissingConfig": { "message": "請輸入存取權杖和專案 ID。" }, - "vertexConnectionFailed": { "message": "連線失敗:%s" }, - "vertexServiceAccountLabel": { "message": "服務帳戶 JSON:" }, - "vertexImportButton": { "message": "匯入 JSON 檔案" }, - "vertexRefreshButton": { "message": "🔄 重新整理權杖" }, - "vertexImportHint": { "message": "自動填入下方憑證" }, - "vertexImporting": { "message": "匯入中..." }, - "vertexRefreshingToken": { "message": "正在重新整理存取權杖..." }, - "vertexGeneratingToken": { "message": "正在產生存取權杖..." }, - "vertexImportSuccess": { "message": "服務帳戶已匯入並產生權杖。" }, - "vertexImportFailed": { "message": "匯入失敗:%s" }, - "vertexTokenRefreshed": { "message": "存取權杖已成功重新整理。" }, - "vertexRefreshFailed": { "message": "權杖重新整理失敗:%s" }, - "vertexTokenExpired": { "message": "⚠️ 存取權杖已過期。點擊重新整理以更新。" }, - "vertexTokenExpiringSoon": { "message": "⚠️ 權杖將在 %s 分鐘後過期。建議重新整理。" }, - "vertexConfigured": { "message": "⚠️ Vertex AI 已設定。請測試連線。" }, - "vertexNotConfigured": { "message": "請匯入服務帳戶 JSON 或輸入憑證。" }, - "featureVertexServiceAccount": { "message": "服務帳戶 JSON 匯入" }, - "featureVertexAutoToken": { "message": "自動產生權杖" }, - "featureVertexGemini": { "message": "透過 Vertex AI 使用 Google Gemini 模型" }, - "vertexNote": { "message": "存取權杖在 1 小時後過期。您的服務帳戶已安全儲存,需要時只需點擊重新整理權杖按鈕即可。" }, - "baseUrlLabel": { "message": "基礎 URL:" }, - "modelLabel": { "message": "模型:" }, - "featureCustomizable": { "message": "可自訂端點和模型" }, - "fetchModelsButton": { "message": "取得模型" }, - "testConnectionButton": { "message": "測試連線" }, - - "openaiApiKeyPlaceholder": { "message": "輸入您的 OpenAI 相容 API 金鑰" }, + "cardVertexGeminiTitle": { + "message": "Vertex AI Gemini(需要 API 金鑰)" + }, + "cardVertexGeminiDesc": { + "message": "輸入您的存取權杖和 Vertex 專案設定,或匯入服務帳戶 JSON 檔案。" + }, + "vertexAccessTokenLabel": { + "message": "存取權杖:" + }, + "vertexProjectIdLabel": { + "message": "專案 ID:" + }, + "vertexLocationLabel": { + "message": "位置:" + }, + "vertexModelLabel": { + "message": "模型:" + }, + "vertexMissingConfig": { + "message": "請輸入存取權杖和專案 ID。" + }, + "vertexConnectionFailed": { + "message": "連線失敗:%s" + }, + "vertexServiceAccountLabel": { + "message": "服務帳戶 JSON:" + }, + "vertexImportButton": { + "message": "匯入 JSON 檔案" + }, + "vertexRefreshButton": { + "message": "🔄 重新整理權杖" + }, + "vertexImportHint": { + "message": "自動填入下方憑證" + }, + "vertexImporting": { + "message": "匯入中..." + }, + "vertexRefreshingToken": { + "message": "正在重新整理存取權杖..." + }, + "vertexGeneratingToken": { + "message": "正在產生存取權杖..." + }, + "vertexImportSuccess": { + "message": "服務帳戶已匯入並產生權杖。" + }, + "vertexImportFailed": { + "message": "匯入失敗:%s" + }, + "vertexTokenRefreshed": { + "message": "存取權杖已成功重新整理。" + }, + "vertexRefreshFailed": { + "message": "權杖重新整理失敗:%s" + }, + "vertexTokenExpired": { + "message": "⚠️ 存取權杖已過期。點擊重新整理以更新。" + }, + "vertexTokenExpiringSoon": { + "message": "⚠️ 權杖將在 %s 分鐘後過期。建議重新整理。" + }, + "vertexConfigured": { + "message": "⚠️ Vertex AI 已設定。請測試連線。" + }, + "vertexNotConfigured": { + "message": "請匯入服務帳戶 JSON 或輸入憑證。" + }, + "featureVertexServiceAccount": { + "message": "服務帳戶 JSON 匯入" + }, + "featureVertexAutoToken": { + "message": "自動產生權杖" + }, + "featureVertexGemini": { + "message": "透過 Vertex AI 使用 Google Gemini 模型" + }, + "vertexNote": { + "message": "存取權杖在 1 小時後過期。您的服務帳戶已安全儲存,需要時只需點擊重新整理權杖按鈕即可。" + }, + "baseUrlLabel": { + "message": "基礎 URL:" + }, + "modelLabel": { + "message": "模型:" + }, + "featureCustomizable": { + "message": "可自訂端點和模型" + }, + "fetchModelsButton": { + "message": "取得模型" + }, + "testConnectionButton": { + "message": "測試連線" + }, + "openaiApiKeyPlaceholder": { + "message": "輸入您的 OpenAI 相容 API 金鑰" + }, "openaiBaseUrlPlaceholder": { "message": "例如:https://api.openai.com/v1" }, - "openaiApiKeyError": { "message": "請先輸入您的 API 金鑰。" }, - "openaiApiKeyNeedsTesting": { "message": "⚠️ API 金鑰需要測試。" }, + "openaiApiKeyError": { + "message": "請先輸入您的 API 金鑰。" + }, + "openaiApiKeyNeedsTesting": { + "message": "⚠️ API 金鑰需要測試。" + }, "openaiTestNeedsTesting": { "message": "⚠️ OpenAI 相容 API 金鑰需要測試。" }, - "openaiTestingConnection": { "message": "正在測試連線..." }, - "openaiConnectionSuccessful": { "message": "連線成功!" }, - "openaiConnectionFailed": { "message": "連線失敗:%s" }, - "openaieFetchingModels": { "message": "正在取得模型..." }, - "openaiModelsFetchedSuccessfully": { "message": "模型取得成功。" }, - "openaiFailedToFetchModels": { "message": "取得模型失敗:%s" }, - - "cardLoggingLevelTitle": { "message": "日誌級別" }, + "openaiTestingConnection": { + "message": "正在測試連線..." + }, + "openaiConnectionSuccessful": { + "message": "連線成功!" + }, + "openaiConnectionFailed": { + "message": "連線失敗:%s" + }, + "openaieFetchingModels": { + "message": "正在取得模型..." + }, + "openaiModelsFetchedSuccessfully": { + "message": "模型取得成功。" + }, + "openaiFailedToFetchModels": { + "message": "取得模型失敗:%s" + }, + "cardLoggingLevelTitle": { + "message": "日誌級別" + }, "cardLoggingLevelDesc": { "message": "控制瀏覽器控制台中顯示的除錯資訊量。較高級別包含所有較低級別的訊息。" }, - "loggingLevelLabel": { "message": "日誌級別:" }, - "loggingLevelOff": { "message": "關閉" }, - "loggingLevelError": { "message": "僅錯誤" }, - "loggingLevelWarn": { "message": "警告和錯誤" }, - "loggingLevelInfo": { "message": "資訊及以上" }, - "loggingLevelDebug": { "message": "除錯(全部)" }, - - "cardBatchTranslationTitle": { "message": "批次翻譯" }, + "loggingLevelLabel": { + "message": "日誌級別:" + }, + "loggingLevelOff": { + "message": "關閉" + }, + "loggingLevelError": { + "message": "僅錯誤" + }, + "loggingLevelWarn": { + "message": "警告和錯誤" + }, + "loggingLevelInfo": { + "message": "資訊及以上" + }, + "loggingLevelDebug": { + "message": "除錯(全部)" + }, + "cardBatchTranslationTitle": { + "message": "批次翻譯" + }, "cardBatchTranslationDesc": { "message": "批次翻譯將多個字幕片段一起處理,減少 80-90% 的 API 呼叫並提高效能。為您偏好的翻譯提供商設定最佳設定。" }, - "batchingEnabledLabel": { "message": "啟用批次翻譯:" }, + "batchingEnabledLabel": { + "message": "啟用批次翻譯:" + }, "batchingEnabledHelp": { "message": "將多個字幕片段組合成單一翻譯請求" }, - "useProviderDefaultsLabel": { "message": "使用提供商最佳化設定:" }, + "useProviderDefaultsLabel": { + "message": "使用提供商最佳化設定:" + }, "useProviderDefaultsHelp": { "message": "自動為每個翻譯提供商使用最佳批次大小" }, - "globalBatchSizeLabel": { "message": "全域批次大小:" }, + "globalBatchSizeLabel": { + "message": "全域批次大小:" + }, "globalBatchSizeHelp": { "message": "一起處理的字幕片段數量(1-15)" }, - "smartBatchingLabel": { "message": "智慧批次最佳化:" }, + "smartBatchingLabel": { + "message": "智慧批次最佳化:" + }, "smartBatchingHelp": { "message": "根據播放位置優先處理字幕片段" }, - "maxConcurrentBatchesLabel": { "message": "最大並行批次:" }, + "maxConcurrentBatchesLabel": { + "message": "最大並行批次:" + }, "maxConcurrentBatchesHelp": { "message": "同時處理的翻譯批次數量" }, - - "cardProviderBatchTitle": { "message": "提供商特定批次大小" }, + "cardProviderBatchTitle": { + "message": "提供商特定批次大小" + }, "cardProviderBatchDesc": { "message": "為每個翻譯提供商設定最佳批次大小。這些設定在啟用「使用提供商最佳化設定」時使用。" }, - "openaieBatchSizeLabel": { "message": "OpenAI 批次大小:" }, + "openaieBatchSizeLabel": { + "message": "OpenAI 批次大小:" + }, "openaieBatchSizeHelp": { "message": "建議:5-10 個片段(預設:8)" }, - "googleBatchSizeLabel": { "message": "Google 翻譯批次大小:" }, + "googleBatchSizeLabel": { + "message": "Google 翻譯批次大小:" + }, "googleBatchSizeHelp": { "message": "建議:3-5 個片段(預設:4)" }, - "deeplBatchSizeLabel": { "message": "DeepL 批次大小:" }, + "deeplBatchSizeLabel": { + "message": "DeepL 批次大小:" + }, "deeplBatchSizeHelp": { "message": "建議:2-3 個片段(預設:3)" }, - "microsoftBatchSizeLabel": { "message": "Microsoft 翻譯批次大小:" }, + "microsoftBatchSizeLabel": { + "message": "Microsoft 翻譯批次大小:" + }, "microsoftBatchSizeHelp": { "message": "建議:3-5 個片段(預設:4)" }, - "vertexBatchSizeLabel": { "message": "Vertex AI 批次大小:" }, + "vertexBatchSizeLabel": { + "message": "Vertex AI 批次大小:" + }, "vertexBatchSizeHelp": { "message": "建議:5-10 個片段(預設:8)" }, - - "deeplTestNeedsTesting": { "message": "⚠️ DeepL API 金鑰需要測試。" }, - - "cardProviderDelayTitle": { "message": "提供商特定請求延遲" }, + "deeplTestNeedsTesting": { + "message": "⚠️ DeepL API 金鑰需要測試。" + }, + "cardProviderDelayTitle": { + "message": "提供商特定請求延遲" + }, "cardProviderDelayDesc": { "message": "設定翻譯請求之間的強制延遲以防止帳戶鎖定。即使啟用批次處理,這些延遲也會套用。" }, - "openaieDelayLabel": { "message": "OpenAI 請求延遲 (ms):" }, + "openaieDelayLabel": { + "message": "OpenAI 請求延遲 (ms):" + }, "openaieDelayHelp": { "message": "請求之間的最小延遲(預設:100ms)" }, - "googleDelayLabel": { "message": "Google 翻譯請求延遲 (ms):" }, + "googleDelayLabel": { + "message": "Google 翻譯請求延遲 (ms):" + }, "googleDelayHelp": { "message": "防止臨時鎖定所需的延遲(預設:1500ms)" }, - "deeplDelayLabel": { "message": "DeepL API 請求延遲 (ms):" }, + "deeplDelayLabel": { + "message": "DeepL API 請求延遲 (ms):" + }, "deeplDelayHelp": { "message": "DeepL API 請求的延遲(預設:500ms)" }, - "deeplFreeDelayLabel": { "message": "DeepL 免費請求延遲 (ms):" }, + "deeplFreeDelayLabel": { + "message": "DeepL 免費請求延遲 (ms):" + }, "deeplFreeDelayHelp": { "message": "免費層的保守延遲(預設:2000ms)" }, - "microsoftDelayLabel": { "message": "Microsoft 翻譯請求延遲 (ms):" }, + "microsoftDelayLabel": { + "message": "Microsoft 翻譯請求延遲 (ms):" + }, "microsoftDelayHelp": { "message": "尊重字元限制的延遲(預設:800ms)" }, - "vertexDelayLabel": { "message": "Vertex AI 請求延遲 (ms):" }, + "vertexDelayLabel": { + "message": "Vertex AI 請求延遲 (ms):" + }, "vertexDelayHelp": { "message": "請求之間的最小延遲(預設:100ms)" }, - - "aiContextModalTitle": { "message": "AI 上下文分析" }, - "aiContextSelectedWords": { "message": "選中的詞語" }, - "aiContextNoWordsSelected": { "message": "未選擇詞語" }, - "aiContextClickHint": { "message": "💡 點擊詞語來新增或移除。" }, - "aiContextStartAnalysis": { "message": "開始分析" }, - "aiContextPauseAnalysis": { "message": "⏸ 暫停" }, - "aiContextPauseAnalysisTitle": { "message": "暫停分析" }, - "aiContextInitialMessage": { "message": "從字幕中選擇詞語開始分析。" }, - "aiContextAnalyzing": { "message": "正在分析上下文..." }, - "aiContextPauseNote": { "message": "點擊 ⏸ 暫停分析" }, - "aiContextAnalysisFailed": { "message": "分析失敗" }, - "aiContextNoContent": { "message": "無分析內容" }, - "aiContextNoContentMessage": { "message": "分析已完成但未返回內容。" }, - "aiContextDefinition": { "message": "📖 定義" }, - "aiContextCultural": { "message": "🌍 文化背景" }, - "aiContextCulturalSignificance": { "message": "⭐ 文化意義" }, - "aiContextHistorical": { "message": "📜 歷史背景" }, - "aiContextHistoricalSignificance": { "message": "📜 歷史意義" }, - "aiContextEvolution": { "message": "🔄 歷史演變" }, - "aiContextLinguistic": { "message": "🔤 語言學分析" }, - "aiContextGrammar": { "message": "📝 語法與語義" }, - "aiContextUsage": { "message": "💡 用法與例句" }, - "aiContextExamples": { "message": "例句:" }, - "aiContextLearningTips": { "message": "🎯 學習提示" }, - "aiContextRelatedExpressions": { "message": "🔗 相關表達" }, - "aiContextKeyInsights": { "message": "🔑 關鍵見解" }, - "aiContextTypeCultural": { "message": "文化" }, - "aiContextTypeHistorical": { "message": "歷史" }, - "aiContextTypeLinguistic": { "message": "語言學" }, - "aiContextTypeComprehensive": { "message": "綜合" }, - "aiContextTypeGeneric": { "message": "上下文" }, - "aiContextClose": { "message": "關閉" }, - "aiContextAnalysisResults": { "message": "分析結果" }, - "aiContextRetrying": { "message": "分析失敗,正在重新生成..." }, - "aiContextRetryNotification": { "message": "分析失敗,正在重試..." }, - "aiContextRetryButton": { "message": "重試" }, + "aiContextModalTitle": { + "message": "AI 上下文分析" + }, + "aiContextSelectedWords": { + "message": "選中的詞語" + }, + "aiContextNoWordsSelected": { + "message": "未選擇詞語" + }, + "aiContextClickHint": { + "message": "💡 點擊詞語來新增或移除。" + }, + "aiContextStartAnalysis": { + "message": "開始分析" + }, + "aiContextPauseAnalysis": { + "message": "⏸ 暫停" + }, + "aiContextPauseAnalysisTitle": { + "message": "暫停分析" + }, + "aiContextInitialMessage": { + "message": "從字幕中選擇詞語開始分析。" + }, + "aiContextAnalyzing": { + "message": "正在分析上下文..." + }, + "aiContextPauseNote": { + "message": "點擊 ⏸ 暫停分析" + }, + "aiContextAnalysisFailed": { + "message": "分析失敗" + }, + "aiContextNoContent": { + "message": "無分析內容" + }, + "aiContextNoContentMessage": { + "message": "分析已完成但未返回內容。" + }, + "aiContextDefinition": { + "message": "📖 定義" + }, + "aiContextCultural": { + "message": "🌍 文化背景" + }, + "aiContextCulturalSignificance": { + "message": "⭐ 文化意義" + }, + "aiContextHistorical": { + "message": "📜 歷史背景" + }, + "aiContextHistoricalSignificance": { + "message": "📜 歷史意義" + }, + "aiContextEvolution": { + "message": "🔄 歷史演變" + }, + "aiContextLinguistic": { + "message": "🔤 語言學分析" + }, + "aiContextGrammar": { + "message": "📝 語法與語義" + }, + "aiContextUsage": { + "message": "💡 用法與例句" + }, + "aiContextExamples": { + "message": "例句:" + }, + "aiContextLearningTips": { + "message": "🎯 學習提示" + }, + "aiContextRelatedExpressions": { + "message": "🔗 相關表達" + }, + "aiContextKeyInsights": { + "message": "🔑 關鍵見解" + }, + "aiContextTypeCultural": { + "message": "文化" + }, + "aiContextTypeHistorical": { + "message": "歷史" + }, + "aiContextTypeLinguistic": { + "message": "語言學" + }, + "aiContextTypeComprehensive": { + "message": "綜合" + }, + "aiContextTypeGeneric": { + "message": "上下文" + }, + "aiContextClose": { + "message": "關閉" + }, + "aiContextAnalysisResults": { + "message": "分析結果" + }, + "aiContextRetrying": { + "message": "分析失敗,正在重新生成..." + }, + "aiContextRetryNotification": { + "message": "分析失敗,正在重試..." + }, + "aiContextRetryButton": { + "message": "重試" + }, "aiContextMalformedResponse": { "message": "AI 服務返回了無效的回應格式。這可能是由於臨時服務問題。" }, "aiContextJsonCodeBlock": { "message": "AI 服務返回了未處理的 JSON 代碼而不是結構化數據。這表明回應中存在格式錯誤。" }, - "aiContextCulturalContext": { "message": "文化背景:" }, - "aiContextSocialUsage": { "message": "社會用法:" }, - "aiContextRegionalNotes": { "message": "地區特色:" }, - "aiContextOrigins": { "message": "詞源:" }, - "aiContextHistoricalContext": { "message": "歷史背景:" }, - "aiContextHistoricalSignificance": { "message": "歷史意義:" }, - "aiContextEvolution": { "message": "演變過程:" }, - "aiContextEtymology": { "message": "詞源學:" }, - "aiContextGrammarNotes": { "message": "語法註釋:" }, - "aiContextTranslationNotes": { "message": "翻譯註釋:" }, - "aiContextLinguisticAnalysis": { "message": "語言學分析:" }, - "aiContextGrammarSemantics": { "message": "語法與語義:" }, - "aiContextUsageExamples": { "message": "用法與例句:" }, - "aiContextLearningTips": { "message": "學習技巧:" }, - "aiContextRelatedExpressions": { "message": "相關表達:" }, - "aiContextKeyInsights": { "message": "關鍵要點:" }, - - "navAIContext": { "message": "AI 上下文" }, - "sectionAIContext": { "message": "AI 上下文助手" }, - "cardAIContextToggleTitle": { "message": "啟用 AI 上下文分析" }, + "aiContextCulturalContext": { + "message": "文化背景:" + }, + "aiContextSocialUsage": { + "message": "社會用法:" + }, + "aiContextRegionalNotes": { + "message": "地區特色:" + }, + "aiContextOrigins": { + "message": "詞源:" + }, + "aiContextHistoricalContext": { + "message": "歷史背景:" + }, + "aiContextHistoricalSignificanceLabel": { + "message": "歷史意義:" + }, + "aiContextEvolutionLabel": { + "message": "演變過程:" + }, + "aiContextEtymology": { + "message": "詞源學:" + }, + "aiContextGrammarNotesLabel": { + "message": "語法註釋:" + }, + "aiContextTranslationNotesLabel": { + "message": "翻譯註釋:" + }, + "aiContextLinguisticAnalysisLabel": { + "message": "語言學分析:" + }, + "aiContextGrammarSemanticsLabel": { + "message": "語法與語義:" + }, + "aiContextUsageExamplesLabel": { + "message": "用法與例句:" + }, + "aiContextLearningTipsLabel": { + "message": "學習技巧:" + }, + "aiContextRelatedExpressionsLabel": { + "message": "相關表達:" + }, + "aiContextKeyInsightsLabel": { + "message": "關鍵要點:" + }, + "navAIContext": { + "message": "AI 上下文" + }, + "sectionAIContext": { + "message": "AI 上下文助手" + }, + "cardAIContextToggleTitle": { + "message": "啟用 AI 上下文分析" + }, "cardAIContextToggleDesc": { "message": "為字幕文字啟用 AI 驅動的文化、歷史和語言學上下文分析。點擊字幕中的單詞或短語以獲得詳細解釋。" }, - "aiContextEnabledLabel": { "message": "啟用 AI 上下文:" }, - "cardAIContextProviderTitle": { "message": "AI 提供商" }, + "aiContextEnabledLabel": { + "message": "啟用 AI 上下文:" + }, + "cardAIContextProviderTitle": { + "message": "AI 提供商" + }, "cardAIContextProviderDesc": { "message": "選擇用於上下文分析的 AI 服務提供商。不同的提供商可能提供不同的品質和回應時間。" }, - "aiContextProviderLabel": { "message": "提供商:" }, - "cardOpenAIContextTitle": { "message": "OpenAI 設定" }, + "aiContextProviderLabel": { + "message": "提供商:" + }, + "cardOpenAIContextTitle": { + "message": "OpenAI 設定" + }, "cardOpenAIContextDesc": { "message": "設定用於上下文分析的 OpenAI API 設定。您需要一個有效的 OpenAI API 金鑰。" }, - "openaiApiKeyLabel": { "message": "API 金鑰:" }, - "openaiBaseUrlLabel": { "message": "基礎 URL:" }, - "openaiModelLabel": { "message": "模型:" }, - "cardGeminiContextTitle": { "message": "Google Gemini 設定" }, + "openaiApiKeyLabel": { + "message": "API 金鑰:" + }, + "openaiBaseUrlLabel": { + "message": "基礎 URL:" + }, + "openaiModelLabel": { + "message": "模型:" + }, + "cardGeminiContextTitle": { + "message": "Google Gemini 設定" + }, "cardGeminiContextDesc": { "message": "設定用於上下文分析的 Google Gemini API 設定。您需要一個有效的 Gemini API 金鑰。" }, - "geminiApiKeyLabel": { "message": "API 金鑰:" }, - "geminiModelLabel": { "message": "模型:" }, - "cardAIContextTypesTitle": { "message": "上下文類型" }, + "geminiApiKeyLabel": { + "message": "API 金鑰:" + }, + "geminiModelLabel": { + "message": "模型:" + }, + "cardAIContextTypesTitle": { + "message": "上下文類型" + }, "cardAIContextTypesDesc": { "message": "啟用您想要使用的上下文分析類型。您可以啟用多種類型。" }, - "contextTypeCulturalLabel": { "message": "文化上下文:" }, - "contextTypeCulturalHelp": { "message": "分析文化參考、習語和社會背景" }, - "contextTypeHistoricalLabel": { "message": "歷史上下文:" }, - "contextTypeHistoricalHelp": { "message": "提供歷史背景和時代背景" }, - "contextTypeLinguisticLabel": { "message": "語言學分析:" }, - "contextTypeLinguisticHelp": { "message": "解釋語法、詞源和語言結構" }, - "cardAIContextInteractiveTitle": { "message": "互動功能" }, + "contextTypeCulturalLabel": { + "message": "文化上下文:" + }, + "contextTypeCulturalHelp": { + "message": "分析文化參考、習語和社會背景" + }, + "contextTypeHistoricalLabel": { + "message": "歷史上下文:" + }, + "contextTypeHistoricalHelp": { + "message": "提供歷史背景和時代背景" + }, + "contextTypeLinguisticLabel": { + "message": "語言學分析:" + }, + "contextTypeLinguisticHelp": { + "message": "解釋語法、詞源和語言結構" + }, + "cardAIContextInteractiveTitle": { + "message": "互動功能" + }, "cardAIContextInteractiveDesc": { "message": "設定如何與字幕互動以觸發上下文分析。" }, - "interactiveSubtitlesEnabledLabel": { "message": "啟用互動式字幕:" }, + "interactiveSubtitlesEnabledLabel": { + "message": "啟用互動式字幕:" + }, "interactiveSubtitlesEnabledHelp": { "message": "使字幕單詞可點擊以進行上下文分析" }, - "contextOnClickLabel": { "message": "點擊時顯示上下文:" }, - "contextOnClickHelp": { "message": "點擊單詞時顯示上下文分析" }, - "contextOnSelectionLabel": { "message": "選擇時顯示上下文:" }, - "contextOnSelectionHelp": { "message": "選擇文字時顯示上下文分析" }, - "cardAIContextPrivacyTitle": { "message": "隱私和資料" }, + "contextOnClickLabel": { + "message": "點擊時顯示上下文:" + }, + "contextOnClickHelp": { + "message": "點擊單詞時顯示上下文分析" + }, + "contextOnSelectionLabel": { + "message": "選擇時顯示上下文:" + }, + "contextOnSelectionHelp": { + "message": "選擇文字時顯示上下文分析" + }, + "cardAIContextPrivacyTitle": { + "message": "隱私和資料" + }, "cardAIContextPrivacyDesc": { "message": "控制在上下文分析期間如何處理您的資料。" }, @@ -422,22 +926,103 @@ "aiContextUserConsentHelp": { "message": "AI 上下文分析功能正常運作所必需" }, - "aiContextDataSharingLabel": { "message": "允許匿名使用分析:" }, + "aiContextDataSharingLabel": { + "message": "允許匿名使用分析:" + }, "aiContextDataSharingHelp": { "message": "透過分享匿名使用資料幫助改進服務" }, - "cardAIContextAdvancedTitle": { "message": "進階設定" }, + "cardAIContextAdvancedTitle": { + "message": "進階設定" + }, "cardAIContextAdvancedDesc": { "message": "設定 AI 上下文分析行為的進階選項。" }, - "aiContextTimeoutLabel": { "message": "請求逾時 (ms):" }, - "aiContextTimeoutHelp": { "message": "等待 AI 回應的最長時間" }, - "aiContextRateLimitLabel": { "message": "速率限制 (請求/分鐘):" }, - "aiContextRateLimitHelp": { "message": "每分鐘最大請求數" }, - "aiContextCacheEnabledLabel": { "message": "啟用快取:" }, - "aiContextCacheEnabledHelp": { "message": "快取分析結果以減少 API 呼叫" }, - "aiContextRetryAttemptsLabel": { "message": "重試次數:" }, - "aiContextRetryAttemptsHelp": { "message": "重試失敗請求的次數" }, - "showAdvancedSettings": { "message": "顯示進階設定" }, - "hideAdvancedSettings": { "message": "隱藏進階設定" } -} + "aiContextTimeoutLabel": { + "message": "請求逾時 (ms):" + }, + "aiContextTimeoutHelp": { + "message": "等待 AI 回應的最長時間" + }, + "aiContextRateLimitLabel": { + "message": "速率限制 (請求/分鐘):" + }, + "aiContextRateLimitHelp": { + "message": "每分鐘最大請求數" + }, + "aiContextCacheEnabledLabel": { + "message": "啟用快取:" + }, + "aiContextCacheEnabledHelp": { + "message": "快取分析結果以減少 API 呼叫" + }, + "aiContextRetryAttemptsLabel": { + "message": "重試次數:" + }, + "aiContextRetryAttemptsHelp": { + "message": "重試失敗請求的次數" + }, + "showAdvancedSettings": { + "message": "顯示進階設定" + }, + "hideAdvancedSettings": { + "message": "隱藏進階設定" + }, + "sidepanelLoading": { + "message": "載入中..." + }, + "sidepanelTabAIAnalysis": { + "message": "AI 分析" + }, + "sidepanelTabWordsLists": { + "message": "單字列表" + }, + "sidepanelAnalyzeButton": { + "message": "分析" + }, + "sidepanelAnalyzing": { + "message": "分析中..." + }, + "sidepanelWordsToAnalyze": { + "message": "待分析單字" + }, + "sidepanelWordInputPlaceholder": { + "message": "點擊字幕中的單字以新增至分析..." + }, + "sidepanelErrorRetry": { + "message": "重試" + }, + "sidepanelResultsTitle": { + "message": "「%s」的結果" + }, + "sidepanelSectionDefinition": { + "message": "定義" + }, + "sidepanelSectionCultural": { + "message": "文化背景" + }, + "sidepanelSectionHistorical": { + "message": "歷史背景" + }, + "sidepanelSectionLinguistic": { + "message": "語言學分析" + }, + "sidepanelMyWordsTitle": { + "message": "我的單字" + }, + "sidepanelFeatureComingSoon": { + "message": "單字列表功能即將推出!" + }, + "sidepanelFeatureComingSoonDesc": { + "message": "此功能目前正在開發中。在設定中啟用以嘗試預覽。" + }, + "sidepanelErrorNoWords": { + "message": "未選擇要分析的單字" + }, + "sidepanelErrorDisabled": { + "message": "AI 上下文分析已停用。請在設定中啟用。" + }, + "sidepanelErrorGeneric": { + "message": "分析過程中發生錯誤。" + } +} \ No newline at end of file diff --git a/background/handlers/messageHandler.js b/background/handlers/messageHandler.js index 1130123..3fc21f7 100644 --- a/background/handlers/messageHandler.js +++ b/background/handlers/messageHandler.js @@ -121,15 +121,40 @@ class MessageHandler { /** * Set service dependencies (will be injected after services are created) + * Supports either positional args or an options object: + * setServices({ translationService, subtitleService, aiContextService?, sidePanelService? }) + * For backward compatibility, the positional signature remains supported. */ - setServices(translationService, subtitleService, aiContextService = null) { - this.translationService = translationService; - this.subtitleService = subtitleService; - this.aiContextService = aiContextService; + setServices(translationService, subtitleService, aiContextService = null, sidePanelService = null) { + /** @type {{translationService?: any, subtitleService?: any, aiContextService?: any, sidePanelService?: any}} */ + let services; + if ( + arguments.length === 1 && + translationService && + typeof translationService === 'object' && + (Object.prototype.hasOwnProperty.call(translationService, 'translationService') || + Object.prototype.hasOwnProperty.call(translationService, 'subtitleService')) + ) { + services = translationService; + } else { + services = { + translationService, + subtitleService, + aiContextService, + sidePanelService, + }; + } + + this.translationService = services.translationService || null; + this.subtitleService = services.subtitleService || null; + this.aiContextService = services.aiContextService || null; + this.sidePanelService = services.sidePanelService || null; + this.logger.debug('Services injected into message handler', { - hasTranslation: !!translationService, - hasSubtitle: !!subtitleService, - hasAIContext: !!aiContextService, + hasTranslation: !!this.translationService, + hasSubtitle: !!this.subtitleService, + hasAIContext: !!this.aiContextService, + hasSidePanel: !!this.sidePanelService, }); } @@ -216,6 +241,24 @@ 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_SELECTION_SYNC: + return this.handleSidePanelSelectionSyncMessage(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 +1010,194 @@ 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 open reason before opening (do NOT override activeTab to avoid UI flips) + try { + if (message.options?.openReason) { + this.sidePanelService.updateTabState(tabId, { + ...(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 + } + + handleSidePanelSelectionSyncMessage(message, sender, sendResponse) { + if (!this.sidePanelService) { + sendResponse({ + success: false, + error: 'Side panel service not available', + }); + return true; + } + + const tabId = sender.tab?.id; + if (!tabId) { + sendResponse({ + success: false, + error: 'No tab ID available', + }); + return true; + } + + this.sidePanelService + .forwardSelectionSync(tabId, message?.data ?? message) + .then(() => { + sendResponse({ success: true }); + }) + .catch((error) => { + this.logger.error('Failed to forward selection sync', error, { + tabId, + }); + sendResponse({ + success: false, + error: error.message || 'Failed to forward selection sync', + }); + }); + + return true; + } + + /** + * Proxy a side panel or content message to the tab's content script + */ + 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..28fa76c 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,21 +79,27 @@ 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'); // Inject services into message handler - messageHandler.setServices( - translationProviders, + messageHandler.setServices({ + translationService: translationProviders, subtitleService, - aiContextService - ); + aiContextService, + sidePanelService, + }); backgroundLogger.info('Services injected into message handler'); // Initialize default settings using the configuration service @@ -141,5 +152,6 @@ export { subtitleService, loggingManager, messageHandler, + sidePanelService, backgroundLogger, }; diff --git a/background/parsers/netflixParser.js b/background/parsers/netflixParser.js index 1c4d581..d71f860 100644 --- a/background/parsers/netflixParser.js +++ b/background/parsers/netflixParser.js @@ -94,21 +94,61 @@ class NetflixParser { hasTargetTrack: !!targetTrack, }); - // Process original language subtitles + // Process original language subtitles (with fallback selection) let originalVttText = ''; let sourceLanguage = originalLanguage; - if (originalTrack) { + // Choose effective original track with fallback when requested language is unavailable + let selectedOriginalTrack = originalTrack; + if (!selectedOriginalTrack) { + // Try English first + const englishCandidate = availableLanguages.find( + (lang) => + lang?.normalizedCode === 'en' || + (typeof lang?.normalizedCode === 'string' && + lang.normalizedCode.startsWith('en')) + ); + + const fallbackCandidate = + englishCandidate || availableLanguages[0] || null; + + if (fallbackCandidate) { + this.logger.info( + 'Requested original language not found, using fallback', + { + requested: normalizeLanguageCode(originalLanguage), + fallbackDisplayName: fallbackCandidate.displayName, + fallbackNormalized: + fallbackCandidate.normalizedCode, + } + ); + selectedOriginalTrack = { + language: fallbackCandidate.rawCode, + trackType: fallbackCandidate.trackType, + downloadUrl: fallbackCandidate.downloadUrl, + }; + } else { + this.logger.warn( + 'No available languages to fallback to for Netflix subtitles' + ); + } + } + + if (selectedOriginalTrack) { this.logger.debug('Processing original track', { - language: originalTrack.language, - trackType: originalTrack.trackType, + language: selectedOriginalTrack.language, + trackType: selectedOriginalTrack.trackType, }); const originalSubtitleText = - await this.fetchNetflixSubtitleContent(originalTrack); + await this.fetchNetflixSubtitleContent( + selectedOriginalTrack + ); originalVttText = ttmlParser.convertTtmlToVtt(originalSubtitleText); - sourceLanguage = normalizeLanguageCode(originalTrack.language); + sourceLanguage = normalizeLanguageCode( + selectedOriginalTrack.language + ); } // Process target language subtitles @@ -155,7 +195,7 @@ class NetflixParser { targetLanguage: normalizeLanguageCode(targetLanguage), useNativeTarget: useNativeTarget, availableLanguages: availableLanguages, - url: originalTrack?.downloadUrl || 'Netflix TTML', + url: selectedOriginalTrack?.downloadUrl || 'Netflix TTML', }; this.logger.info('Netflix subtitle processing completed', { diff --git a/background/services/sidePanelService.js b/background/services/sidePanelService.js new file mode 100644 index 0000000..47bc9d3 --- /dev/null +++ b/background/services/sidePanelService.js @@ -0,0 +1,626 @@ +/** + * 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.5.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 + // New: window-scoped connection tracking + this.activeConnectionsByWindow = new Map(); // Map> + this.panelBindingByInstance = new Map(); // Map + } + + /** + * 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) { + let tabId = port.sender?.tab?.id ?? null; + let windowId = port.sender?.tab?.windowId ?? null; + let panelInstanceId = null; + + if (tabId != null) { + this.logger.info('Side panel connected', { tabId }); + this.activeConnections.set(tabId, port); + } else { + this.logger.warn('Side panel connection without tab ID (awaiting register message)'); + } + + // Handle messages from side panel + port.onMessage.addListener((message) => { + // Update tabId once the side panel sends an explicit register payload + if (message?.action === MessageActions.SIDEPANEL_REGISTER) { + const claimedTabId = message?.data?.tabId; + const claimedWindowId = message?.data?.windowId ?? null; + const claimedInstanceId = message?.data?.panelInstanceId ?? null; + if (typeof claimedTabId === 'number') { + tabId = claimedTabId; + } + if (typeof claimedWindowId === 'number') { + windowId = claimedWindowId; + } + if (typeof claimedInstanceId === 'string') { + panelInstanceId = claimedInstanceId; + } + // Remove any previous mappings that point to this same port + try { + for (const [tid, p] of this.activeConnections.entries()) { + if (p === port && tid !== tabId) { + this.activeConnections.delete(tid); + } + } + } catch (_) { } + if (typeof tabId === 'number') { + this.activeConnections.set(tabId, port); + } + // Track window-scoped connection + if (panelInstanceId) { + this.panelBindingByInstance.set(panelInstanceId, { tabId, windowId }); + if (typeof windowId === 'number') { + if (!this.activeConnectionsByWindow.has(windowId)) { + this.activeConnectionsByWindow.set(windowId, new Map()); + } + const winMap = this.activeConnectionsByWindow.get(windowId); + winMap.set(panelInstanceId, port); + } + } + } + + this.handleSidePanelMessage(message, port, tabId); + }); + + // Handle disconnection + port.onDisconnect.addListener(() => { + try { + if (panelInstanceId && typeof windowId === 'number') { + const winMap = this.activeConnectionsByWindow.get(windowId); + if (winMap) { + winMap.delete(panelInstanceId); + if (winMap.size === 0) { + this.activeConnectionsByWindow.delete(windowId); + } + } + this.panelBindingByInstance.delete(panelInstanceId); + } + } catch (_) { } + if (tabId != null) { + this.logger.info('Side panel disconnected', { tabId }); + this.activeConnections.delete(tabId); + } else { + this.logger.info('Side panel disconnected before registration'); + } + }); + } + + /** + * 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_SELECTION_SYNC: + // Accept selection sync from side panel via long-lived port. + // tabId is resolved from the registered mapping/connection rather than sender.tab. + await this.forwardSelectionSync(tabId, data ?? {}); + break; + case MessageActions.SIDEPANEL_SCOPE_CHANGED: + // Advisory: scope policy changed in the panel; currently no-op at service layer + this.logger.debug('Scope policy changed (advisory)', { tabId, details: data }); + break; + case MessageActions.SIDEPANEL_APPLY_SCOPE_BUCKET: + // Apply a stored bucket to the bound tab by updating content highlights + try { + const words = Array.isArray(data?.selectedWords) ? data.selectedWords : []; + if (typeof tabId === 'number') { + await chrome.tabs.sendMessage(tabId, { + action: MessageActions.SIDEPANEL_UPDATE_STATE, + data: { selectedWords: words, clearSelection: true }, + source: 'background', + }); + // Update authoritative selection state + await this.forwardSelectionSync(tabId, { selectedWords: words, reason: 'apply-scope-bucket' }); + } + } catch (err) { + this.logger.warn('Failed to apply scope bucket', { error: err?.message, tabId }); + } + 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, removing prior mappings for this port + try { + for (const [tid, p] of this.activeConnections.entries()) { + if (p === port && tid !== claimedTabId) { + this.activeConnections.delete(tid); + } + } + } catch (_) { } + this.activeConnections.set(claimedTabId, port); + + // Track window-scoped connection + const claimedWindowId = data?.windowId; + const claimedInstanceId = data?.panelInstanceId; + if (claimedInstanceId) { + this.panelBindingByInstance.set(claimedInstanceId, { tabId: claimedTabId, windowId: claimedWindowId }); + if (typeof claimedWindowId === 'number') { + if (!this.activeConnectionsByWindow.has(claimedWindowId)) { + this.activeConnectionsByWindow.set(claimedWindowId, new Map()); + } + const winMap = this.activeConnectionsByWindow.get(claimedWindowId); + winMap.set(claimedInstanceId, port); + } + } + + // Initialize state for this tab if missing + let st = this.tabStates.get(claimedTabId); + + // If no state exists, try to fetch it from the content script to ensure sync + if (!st || (!st.selectedWords && !st.pendingWordSelection)) { + try { + this.logger.debug('Fetching initial state from content script', { tabId: claimedTabId }); + const response = await chrome.tabs.sendMessage(claimedTabId, { + action: MessageActions.SIDEPANEL_GET_STATE, + source: 'background' + }); + + if (response && response.success && Array.isArray(response.selectedWords)) { + this.updateTabState(claimedTabId, { + selectedWords: response.selectedWords, + sourceLanguage: response.sourceLanguage + }); + st = this.tabStates.get(claimedTabId); + } + } catch (err) { + // Content script might not be ready or supported on this page + this.logger.debug('Failed to fetch initial state from content script', { tabId: claimedTabId, error: err.message }); + } + } + + // Deliver stored selection state with priority, then any pending single-word fallback. + if (st) { + const selectedWordsFromState = Array.isArray(st.selectedWords) ? st.selectedWords : []; + if (selectedWordsFromState.length > 0) { + // Prefer authoritative stored selection + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords: selectedWordsFromState, + reason: 'state-sync-on-register', + tabId: claimedTabId, + }, + }); + + // Clear any obsolete pendingWordSelection + if (st.pendingWordSelection) { + const newState = { ...st }; + delete newState.pendingWordSelection; + this.tabStates.set(claimedTabId, newState); + } + } else if (st.pendingWordSelection) { + // Fallback to pending single-word selection if no stored array is present + const pending = st.pendingWordSelection; + const selectedWords = Array.isArray(pending?.selectedWords) && pending.selectedWords.length > 0 + ? pending.selectedWords + : pending?.word + ? [pending.word] + : []; + + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords, + reason: pending?.reason || 'initial-pending-selection', + tabId: claimedTabId, + }, + }); + + // Persist and clear pending after delivery + const newState = { ...st }; + delete newState.pendingWordSelection; + if (selectedWords.length > 0) { + newState.selectedWords = selectedWords; + } + this.tabStates.set(claimedTabId, newState); + } else { + // Nothing to sync + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { selectedWords: [], reason: 'empty-state-on-register', tabId: claimedTabId }, + }); + } + } else { + // No state found even after fetch attempt + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { selectedWords: [], reason: 'no-state-on-register', tabId: claimedTabId }, + }); + } + } 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 }); + + // Notify any existing side panel in the same window to switch binding + // This ensures the UI updates to the target tab even if 'follow active tab' is disabled + try { + const tab = await chrome.tabs.get(tabId); + if (tab && typeof tab.windowId === 'number') { + const winMap = this.activeConnectionsByWindow.get(tab.windowId); + if (winMap) { + for (const port of winMap.values()) { + try { + port.postMessage({ + action: 'sidePanelForceBindTab', + data: { tabId, windowId: tab.windowId } + }); + } catch (_) { } + } + } + } + } catch (bindingError) { + this.logger.warn('Failed to force bind side panel', { error: bindingError.message, 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) { + // Ensure the side panel is open for this tab + await this.openSidePanelImmediate(tabId, { pauseVideo: true }); + + // NOTE: We do NOT update the selectedWords state here. + // The content script is the source of truth and sends a separate + // SIDEPANEL_SELECTION_SYNC message with the authoritative list. + // Updating state here based on a single word toggle causes race conditions + // and "deselection jump" bugs where words are re-added. + + this.logger.debug('Word selection event received (state update deferred to sync)', { + tabId, + word: wordData?.word + }); + } + + /** + * Forward selection synchronization (e.g., subtitle change clears selection) + */ + async forwardSelectionSync(tabId, payload = {}) { + const port = this.activeConnections.get(tabId); + const incomingWords = (Array.isArray(payload?.selectedWords) ? payload.selectedWords : []) + .map((w) => (typeof w === 'string' ? w.trim() : '')) + .filter((w) => w.length > 0); + + // Deduplicate while preserving order + const normalizedWords = incomingWords.reduce((acc, word) => { + if (!acc.includes(word)) acc.push(word); + return acc; + }, []); + + const state = this.tabStates.get(tabId) || {}; + state.selectedWords = normalizedWords; + delete state.pendingWordSelection; + this.tabStates.set(tabId, state); + + if (port) { + try { + port.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords: normalizedWords, + reason: payload.reason || 'unknown', + tabId, + }, + }); + this.logger.debug('Selection sync forwarded to side panel', { + tabId, + count: normalizedWords.length, + }); + } catch (err) { + this.logger.error('Failed to forward selection sync', err, { + tabId, + }); + } + } else { + // Broadcast fallback with tabId so the side panel can self-filter + try { + for (const p of this.activeConnections.values()) { + try { + p.postMessage({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + data: { + selectedWords: normalizedWords, + reason: payload.reason || 'unknown', + tabId, + }, + }); + } catch (_) { } + } + this.logger.debug('Selection sync broadcast to all ports (no direct mapping)', { + tabId, + count: normalizedWords.length, + }); + } catch (err) { + this.logger.error('Failed broadcast fallback for selection sync', err, { tabId }); + } + } + } + + /** + * 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, windowId } = activeInfo; + this.logger.debug('Tab activated', { tabId, windowId }); + + // Notify only active side panel connections in the same window when possible + if (typeof windowId === 'number' && this.activeConnectionsByWindow.has(windowId)) { + const winMap = this.activeConnectionsByWindow.get(windowId); + for (const port of winMap.values()) { + try { + port.postMessage({ + action: 'tabActivated', + data: { tabId, windowId }, + }); + } catch (error) { + this.logger.warn('Failed to notify a side panel of tab activation', { + error: error.message, + }); + } + } + } else { + // Fallback legacy behavior + for (const port of this.activeConnections.values()) { + try { + port.postMessage({ + action: 'tabActivated', + data: { tabId, windowId }, + }); + } catch (error) { + this.logger.warn('Failed to notify a side panel of tab activation', { + error: error.message, + }); + } + } + } + } + + /** + * 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/background/services/translationService.js b/background/services/translationService.js index 86e6780..1074c04 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, }, }, }; @@ -750,14 +756,14 @@ class TranslationService { errorRate: this.performanceMetrics.totalTranslations > 0 ? (this.performanceMetrics.errors / - this.performanceMetrics.totalTranslations) * - 100 + this.performanceMetrics.totalTranslations) * + 100 : 0, cacheHitRate: this.performanceMetrics.totalTranslations > 0 ? (this.performanceMetrics.cacheHits / - this.performanceMetrics.totalTranslations) * - 100 + this.performanceMetrics.totalTranslations) * + 100 : 0, }; } @@ -1048,7 +1054,7 @@ class TranslationService { sourceLang, targetLang, selectedProvider.batchOptimizations?.delimiter || - '|SUBTITLE_BREAK|' + '|SUBTITLE_BREAK|' ); // Update rate limit tracker diff --git a/background/test-final-regression.js b/background/test-final-regression.js index 66e3f6b..34b3bfa 100644 --- a/background/test-final-regression.js +++ b/background/test-final-regression.js @@ -124,7 +124,7 @@ async function testAllServices() { // Test Message Handler console.log('📊 Testing Message Handler...'); messageHandler.initialize(); - messageHandler.setServices(translationProviders, subtitleService); + messageHandler.setServices({ translationService: translationProviders, subtitleService }); if (messageHandler.isInitialized) { results.messageHandler = true; diff --git a/background/test-integration.js b/background/test-integration.js index ce2cebe..653bb97 100644 --- a/background/test-integration.js +++ b/background/test-integration.js @@ -37,7 +37,7 @@ async function testPhase1Integration() { // Test message handler console.log('✅ Testing message handler...'); messageHandler.initialize(); - messageHandler.setServices(translationProviders, subtitleService); + messageHandler.setServices({ translationService: translationProviders, subtitleService }); console.log('✅ Message handler initialized'); console.log('🎉 All Phase 1 services initialized successfully!'); diff --git a/background/test-service-integration.js b/background/test-service-integration.js index c977874..7ec6fa5 100644 --- a/background/test-service-integration.js +++ b/background/test-service-integration.js @@ -260,7 +260,7 @@ async function testMessageHandler() { messageHandler.initialize(); // Set services - messageHandler.setServices(translationProviders, subtitleService); + messageHandler.setServices({ translationService: translationProviders, subtitleService }); // Test that handler is initialized if (!messageHandler.isInitialized) { diff --git a/config/configSchema.js b/config/configSchema.js index a27f663..7097f6e 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' }, @@ -197,6 +205,40 @@ 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' }, + + // Window follow behavior + sidePanelFollowActiveTabInWindow: { defaultValue: true, type: Boolean, scope: 'sync' }, + + // Scope policies per tab (string enum: 'video' | 'tab' | 'window' | 'global') + sidePanelScopePolicyAIAnalysisTab: { defaultValue: 'video', type: String, scope: 'sync' }, + sidePanelScopePolicyWordsListsTab: { defaultValue: 'global', type: String, scope: 'sync' }, + + // Selection buckets for scoped selections (stored locally per device) + sidePanelSelectionBuckets: { defaultValue: {}, type: Object, scope: 'local' }, + + // Global words lists (synced across devices) + sidePanelWordLists: { defaultValue: { lists: [], version: 1, lastUpdated: 0 }, type: Object, scope: 'sync' }, + // --- 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/config/configSchema.test.js b/config/configSchema.test.js index 47b6aca..a0a9a67 100644 --- a/config/configSchema.test.js +++ b/config/configSchema.test.js @@ -181,7 +181,7 @@ describe('configSchema', () => { expect(actualSettings).toEqual( expect.arrayContaining(expectedSettings) ); - expect(actualSettings.length).toBe(66); + expect(actualSettings.length).toBe(81); }); it('should have correct scope distribution', () => { @@ -196,7 +196,7 @@ describe('configSchema', () => { 'aiContextDebugMode', ]) ); - expect(localKeys.length).toBe(3); + expect(localKeys.length).toBe(5); // Sync scope should contain all other settings including loggingLevel and OpenAI settings expect(syncKeys.length).toBeGreaterThan(10); diff --git a/content_scripts/aicontext/core/AIContextManager.js b/content_scripts/aicontext/core/AIContextManager.js index ac1460d..b370c22 100644 --- a/content_scripts/aicontext/core/AIContextManager.js +++ b/content_scripts/aicontext/core/AIContextManager.js @@ -428,6 +428,19 @@ export class AIContextManager { featureToggleListener ); + // Listen for selection cleared events to sync with side panel + const selectionClearedListener = () => { + this._handleSelectionCleared(); + }; + document.addEventListener( + EVENT_TYPES.SELECTION_CLEARED, + selectionClearedListener + ); + this.eventListeners.set( + EVENT_TYPES.SELECTION_CLEARED, + selectionClearedListener + ); + // Setup cross-platform communication this._setupCrossPlatformCommunication(); @@ -829,6 +842,25 @@ export class AIContextManager { } } + /** + * Notify side panel when selection is cleared + * @private + */ + _handleSelectionCleared() { + try { + chrome.runtime.sendMessage({ + action: 'sidePanelClearSelection', + source: 'content_script', + timestamp: Date.now(), + }); + this._log('debug', 'Notified side panel of selection clear'); + } catch (error) { + this._log('warn', 'Failed to notify side panel of selection clear', { + error: error.message, + }); + } + } + _dispatchEvent(type, detail) { // Track event metrics this.metrics.eventCounts[type] = diff --git a/content_scripts/aicontext/ui/events/ModalController.js b/content_scripts/aicontext/ui/events/ModalController.js index 513320a..ecf63e8 100644 --- a/content_scripts/aicontext/ui/events/ModalController.js +++ b/content_scripts/aicontext/ui/events/ModalController.js @@ -50,7 +50,7 @@ export class ModalController { ); if (original) original.classList.add('dualsub-subtitles-disabled'); - } catch (_) {} + } catch (_) { } // Force-hide remove buttons immediately for robustness try { const selected = document.getElementById( @@ -61,21 +61,21 @@ export class ModalController { .forEach((el) => { el.style.display = 'none'; }); - } catch (_) {} - } catch (_) {} + } catch (_) { } + } catch (_) { } // Freeze selection persistence and suppress immediate restorations try { this.core.selectionPersistence.lastManualSelectionTs = Date.now(); - } catch (_) {} + } catch (_) { } // Ensure UI reflects disabled removal (hide X icons) and keep highlights visible try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } try { this.core.syncSelectionHighlights(); - } catch (_) {} + } catch (_) { } // Switch button to pause state try { @@ -99,7 +99,7 @@ export class ModalController { this.pauseAnalysis(); }); } - } catch (_) {} + } catch (_) { } // Resolve language prefs let targetLanguage = 'en'; @@ -126,7 +126,7 @@ export class ModalController { if (result.originalLanguage) sourceLanguage = result.originalLanguage; } - } catch (_) {} + } catch (_) { } // Dispatch analysis request const requestId = `analysis-${Date.now()}`; @@ -163,7 +163,7 @@ export class ModalController { detail: { requestId: this.core.currentRequest }, }) ); - } catch (_) {} + } catch (_) { } this.core.isAnalyzing = false; this.core.currentRequest = null; @@ -176,13 +176,13 @@ export class ModalController { selectedWordsElement?.classList.remove( 'dualsub-processing-disabled' ); - } catch (_) {} + } catch (_) { } // Reset state back to selection this.core.setState(MODAL_STATES.SELECTION); this.ui.showInitialState(); try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } // Ensure processing classes cleared try { const content = @@ -204,7 +204,7 @@ export class ModalController { ); if (original) original.classList.remove('dualsub-subtitles-disabled'); - } catch (_) {} + } catch (_) { } // Ensure chips show remove buttons again after unfreezing try { const selected = document.getElementById( @@ -215,8 +215,8 @@ export class ModalController { .forEach((el) => { el.style.removeProperty('display'); }); - } catch (_) {} - } catch (_) {} + } catch (_) { } + } catch (_) { } // Reset Start button this.resetAnalysisButton(); } @@ -244,7 +244,7 @@ export class ModalController { this.core.selectedText = ''; try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } // Clear visual highlights on subtitles when closing try { const original = document.getElementById( @@ -259,7 +259,7 @@ export class ModalController { el.classList.remove('dualsub-word-selected') ); } - } catch (_) {} + } catch (_) { } // Hide modal via animations if available if ( this.animations && @@ -304,7 +304,7 @@ export class ModalController { // Store raw result for observability try { this.core.setAnalysisResult(result); - } catch (_) {} + } catch (_) { } const html = this._buildResultsHtml(result); if ( @@ -326,10 +326,10 @@ export class ModalController { 'dualsub-processing-disabled' ); } - } catch (_) {} + } catch (_) { } try { this.ui.updateSelectionDisplay(); - } catch (_) {} + } catch (_) { } // Re-enable subtitles interaction visuals try { const original = document.getElementById( @@ -337,7 +337,7 @@ export class ModalController { ); if (original) original.classList.remove('dualsub-subtitles-disabled'); - } catch (_) {} + } catch (_) { } // Ensure chips show remove buttons again after results try { const selected = document.getElementById( @@ -348,7 +348,7 @@ export class ModalController { .forEach((el) => { el.style.removeProperty('display'); }); - } catch (_) {} + } catch (_) { } this.resetAnalysisButton(); return; } @@ -639,8 +639,8 @@ export class ModalController { origin: 'aiContextOrigins', historicalcontext: 'aiContextHistoricalContext', historical: 'aiContextHistoricalContext', - historicalsignificance: 'aiContextHistoricalSignificance', - evolution: 'aiContextEvolution', + historicalsignificance: 'aiContextHistoricalSignificanceLabel', + evolution: 'aiContextEvolutionLabel', linguisticanalysis: 'aiContextLinguisticAnalysis', linguistic: 'aiContextLinguisticAnalysis', etymology: 'aiContextEtymology', @@ -652,15 +652,15 @@ export class ModalController { usageexamples: 'aiContextUsageExamples', usage: 'aiContextUsageExamples', examples: 'aiContextUsageExamples', - learningtips: 'aiContextLearningTips', - learning: 'aiContextLearningTips', - tips: 'aiContextLearningTips', - relatedexpressions: 'aiContextRelatedExpressions', - related: 'aiContextRelatedExpressions', - expressions: 'aiContextRelatedExpressions', - keyinsights: 'aiContextKeyInsights', - insights: 'aiContextKeyInsights', - key: 'aiContextKeyInsights', + learningtips: 'aiContextLearningTipsLabel', + learning: 'aiContextLearningTipsLabel', + tips: 'aiContextLearningTipsLabel', + relatedexpressions: 'aiContextRelatedExpressionsLabel', + related: 'aiContextRelatedExpressionsLabel', + expressions: 'aiContextRelatedExpressionsLabel', + keyinsights: 'aiContextKeyInsightsLabel', + insights: 'aiContextKeyInsightsLabel', + key: 'aiContextKeyInsightsLabel', }; const messageKey = fieldMappings[normalizedField]; @@ -668,7 +668,7 @@ export class ModalController { try { const msg = this.ui._getLocalizedMessage(messageKey); if (msg) return msg; - } catch (_) {} + } catch (_) { } } // Fallback: Capitalize and append colon return ( @@ -753,7 +753,7 @@ export class ModalController { this._getLocalizedMessage( 'aiContextRetryNotification' ) || 'Analysis failed, retrying...'; - } catch (_) {} + } catch (_) { } const newRequestId = `analysis-${Date.now()}`; this.core.currentRequest = newRequestId; diff --git a/content_scripts/aicontext/ui/modal-events.js b/content_scripts/aicontext/ui/modal-events.js index faaabef..a13d48d 100644 --- a/content_scripts/aicontext/ui/modal-events.js +++ b/content_scripts/aicontext/ui/modal-events.js @@ -715,6 +715,7 @@ export class AIContextModalEvents { * @private */ _handleWordSelectionEvent(event) { + this._pauseVideo(); const { word, action, position, element, subtitleType } = event.detail; // Handle legacy event format (from interactiveSubtitleFormatter.js) @@ -814,6 +815,23 @@ export class AIContextModalEvents { this.core.syncSelectionHighlights(); } catch (_) {} + // Send a message to the background to update the side panel state + try { + chrome.runtime.sendMessage({ + action: 'forwardWordSelection', + data: { + word, + action: effectiveAction, + subtitleType, + reason: 'word-click', + }, + }); + } catch (error) { + this.core._log('warn', 'Failed to send word selection to background', { + error: error.message, + }); + } + // If modal isn't visible yet, show it now only when there is an active selection if (!this.core.isVisible) { if (this.core.selectedWords.size > 0) { @@ -857,6 +875,29 @@ export class AIContextModalEvents { }, 16); } + /** + * Pause video playback via content script integration + * @private + */ + async _pauseVideo() { + try { + if ( + this.core && + this.core.contentScript && + this.core.contentScript.activePlatform && + typeof this.core.contentScript.activePlatform.pausePlayback === + 'function' + ) { + this.core._log('debug', 'Pausing video for modal display'); + await this.core.contentScript.activePlatform.pausePlayback(); + } + } catch (error) { + this.core._log('warn', 'Failed to pause video for modal', { + error: error.message, + }); + } + } + /** * Handle analysis request event * @param {CustomEvent} event - Analysis request event diff --git a/content_scripts/aicontext/utils/selectionPersistence.js b/content_scripts/aicontext/utils/selectionPersistence.js index de31264..977efc4 100644 --- a/content_scripts/aicontext/utils/selectionPersistence.js +++ b/content_scripts/aicontext/utils/selectionPersistence.js @@ -177,23 +177,17 @@ export class SelectionPersistenceManager { // Use debounced restoration to prevent race conditions this._scheduleRestorationDebounced('event'); } else { - // Content has actually changed, capture current state - this.modalCore.captureSelectionState(oldText); + // Content has changed, clear the current selection this._log( - 'debug', - 'Content changed, captured old state for potential restoration' + 'info', + 'Subtitle content changed, clearing selection', + { + source: 'event', + oldContent: oldText.substring(0, 50), + newContent: newText.substring(0, 50), + } ); - // For old state, avoid restoration attempts - if (!isRecent) { - this._log( - 'debug', - 'Skipping restoration due to stale selection state', - { - stateAge, - threshold: ageThreshold, - } - ); - } + this.modalCore.clearSelection(); } } } catch (error) { @@ -374,18 +368,17 @@ export class SelectionPersistenceManager { // Use debounced restoration to prevent race conditions this._scheduleRestorationDebounced('mutation'); } else { - // Content has actually changed, capture new state - this.modalCore.captureSelectionState(currentContent); - if (!isRecent) { - this._log( - 'debug', - 'Skipping restoration due to stale selection state', - { - stateAge, - threshold: ageThreshold, - } - ); - } + // Content has changed, clear the current selection + this._log( + 'info', + 'Subtitle content changed, clearing selection', + { + source: 'mutation', + lastContent: lastContent?.substring(0, 50) || '', + currentContent: currentContent.substring(0, 50), + } + ); + this.modalCore.clearSelection(); } } diff --git a/content_scripts/core/BaseContentScript.js b/content_scripts/core/BaseContentScript.js index 74c8915..af9529c 100644 --- a/content_scripts/core/BaseContentScript.js +++ b/content_scripts/core/BaseContentScript.js @@ -69,7 +69,7 @@ * * @abstract * @author DualSub Extension - * @version 1.0.0 + * @version 2.5.0 * @since 1.0.0 * * @example @@ -125,7 +125,10 @@ import { MessageHandlerRegistry, } from './utils.js'; import { COMMON_CONSTANTS } from './constants.js'; -import { getOrCreateUiRoot } from '../shared/subtitleUtilities.js'; +import { + getOrCreateUiRoot, + finalizeExpiredSubtitleIfNeeded, +} from '../shared/subtitleUtilities.js'; import { MessageActions } from '../shared/constants/messageActions.js'; import { NavigationDetectionManager } from '../shared/navigationUtils.js'; @@ -273,12 +276,12 @@ export class BaseContentScript { // Keep compatibility with existing URL-change flow try { this.checkForUrlChange(); - } catch (_) {} + } catch (_) { } }, onPageTransition: (wasPlayer, isPlayer) => { try { this._handlePageTransition(wasPlayer, isPlayer); - } catch (_) {} + } catch (_) { } }, logger: (level, message, data) => this.logWithFallback(level, message, data), @@ -301,6 +304,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 +343,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 +891,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 +1230,304 @@ 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(), + } + ); + + // Cleanup existing integration to prevent duplicate listeners + if (this.sidePanelIntegration) { + try { + this.sidePanelIntegration.destroy(); + } catch (e) { + this.logWithFallback('warn', 'Error destroying previous side panel integration', { error: e.message }); + } + } + + // Create inline side panel integration + this.sidePanelIntegration = { + initialized: false, + sidePanelEnabled: false, + useSidePanel: false, + isAnalyzing: false, + boundHandler: null, + boundSubtitleChangeHandler: null, + selectedWords: new Set(), + + async initialize() { + if (this.initialized) return; + + this.selectedWords = new Set(); + + // Prepare logger bridge and messaging wrapper + this._log = (level, message, data) => { + try { + 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 subtitle content changes to clear stale selections + this.boundSubtitleChangeHandler = this.handleSubtitleContentChange.bind( + this + ); + document.addEventListener( + 'dualsub-subtitle-content-changing', + this.boundSubtitleChangeHandler, + { capture: false } + ); + + // Listen for storage changes + chrome.storage.onChanged.addListener((changes, area) => { + if (area === 'sync') { + 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) Toggle visual selection immediately to reflect DOM state + if (element) { + if (element.classList.contains('dualsub-word-selected')) { + element.classList.remove('dualsub-word-selected'); + } else { + element.classList.add('dualsub-word-selected'); + } + } + + const normalizedWord = (word || '').trim(); + if (normalizedWord) { + const isSelectedNow = + element?.classList?.contains('dualsub-word-selected') ?? + !this.selectedWords.has(normalizedWord); + if (isSelectedNow) { + this.selectedWords.add(normalizedWord); + } else { + this.selectedWords.delete(normalizedWord); + } + } + + // 3) After DOM reflects the new selection, compute canonical ordered list and broadcast + try { + // Use DOM order to preserve sentence structure (user preference) + // This ensures "what are you listening" stays in order even if "what" is deselected and re-selected + const selectedElements = document.querySelectorAll('.dualsub-interactive-word.dualsub-word-selected'); + const words = Array.from(selectedElements) + .map(el => el.getAttribute('data-word')) + .filter(w => w) + .map(w => w.trim()); + + // Update internal Set to match DOM state (for consistency) + this.selectedWords = new Set(words); + + void this._send({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + selectedWords: words, + timestamp: Date.now(), + reason: 'word-click', + }); + } catch (_) { } + + // 4) Forward word selection (non-authoritative, kept for compatibility) + void this._send({ + action: MessageActions.SIDEPANEL_WORD_SELECTED, + word, + sourceLanguage, + targetLanguage, + context, + subtitleType, + selectionAction: 'toggle', + reason: 'word-click', + timestamp: Date.now(), + }); + } catch (error) { + console.error('[SidePanelIntegration] Error forwarding word selection:', error); + } + }, + + handleSubtitleContentChange(event) { + if (!this.sidePanelEnabled || !this.useSidePanel) { + return; + } + + const detail = event?.detail || {}; + if (detail.type && detail.type !== 'original') { + return; + } + + if (!this.selectedWords || this.selectedWords.size === 0) { + return; + } + + try { + document + .querySelectorAll('.dualsub-interactive-word.dualsub-word-selected') + .forEach((el) => el.classList.remove('dualsub-word-selected')); + } catch (_) { } + + this.selectedWords.clear(); + + try { + void this._send({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + selectedWords: [], + timestamp: Date.now(), + reason: 'subtitle-change', + }); + } catch (error) { + console.error('[SidePanelIntegration] Error syncing cleared selection:', error); + } + }, + + destroy() { + if (!this.initialized) return; + if (this.boundHandler) { + document.removeEventListener( + 'dualsub-word-selected', + this.boundHandler, + { capture: true } + ); + this.boundHandler = null; + } + if (this.boundSubtitleChangeHandler) { + document.removeEventListener( + 'dualsub-subtitle-content-changing', + this.boundSubtitleChangeHandler, + { capture: false } + ); + this.boundSubtitleChangeHandler = null; + } + this.selectedWords = new Set(); + this.initialized = false; + }, + + 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 @@ -1682,10 +2016,10 @@ export class BaseContentScript { _getRetryConfiguration() { return { maxRetries: - this.currentConfig?.platformInitMaxRetries || + this.currentConfig?.platformInitMaxRetries ?? COMMON_CONSTANTS.PLATFORM_INIT_MAX_RETRIES, retryDelay: - this.currentConfig?.platformInitRetryDelay || + this.currentConfig?.platformInitRetryDelay ?? COMMON_CONSTANTS.PLATFORM_INIT_RETRY_DELAY, }; } @@ -1709,13 +2043,12 @@ export class BaseContentScript { /** * Log initialization start with attempt information * @private - * @param {number} retryCount - Current retry count - * @param {number} maxRetries - Maximum retries allowed + * @param {Object} context - Initialization context */ - _logInitializationStart(retryCount, maxRetries) { + _logInitializationStart(context) { this.logWithFallback('info', 'Starting platform initialization', { - attempt: retryCount + 1, - maxRetries: maxRetries + 1, + attempt: context.attempt, + maxRetries: context.totalAttempts, }); } @@ -1749,7 +2082,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; } @@ -1769,13 +2105,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; @@ -2756,11 +3095,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 +3120,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 +3336,246 @@ 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 { + // Use the internal Set to preserve insertion order, rather than DOM order + const words = Array.from(this.sidePanelIntegration?.selectedWords || []); + + // 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 (this.sidePanelIntegration && this.sidePanelIntegration.selectedWords) { + this.sidePanelIntegration.selectedWords.clear(); + } + } + + if (Array.isArray(data.selectedWords)) { + data.selectedWords.forEach((word) => { + const normalizedWord = (word || '').trim(); + if (!normalizedWord) return; + + // Find elements for this word + // Note: This is a simplified selector; production might need more specific targeting + const elements = document.querySelectorAll( + `.dualsub-interactive-word[data-word="${normalizedWord.replace(/"/g, '\\"')}"]` + ); + elements.forEach((el) => { + if (el) el.classList.add('dualsub-word-selected'); + }); + }); + + if (this.sidePanelIntegration) { + this.sidePanelIntegration.selectedWords = new Set(data.selectedWords); + } + } + + // Broadcast the new state back to background to ensure authoritative state is in sync + // We use DOM order here to maintain consistency with handleWordSelection + if (this.sidePanelIntegration) { + try { + const selectedElements = document.querySelectorAll('.dualsub-interactive-word.dualsub-word-selected'); + const words = Array.from(selectedElements) + .map(el => el.getAttribute('data-word')) + .filter(w => w) + .map(w => w.trim()); + + // If no words found in DOM but we have them in Set (e.g. virtualized/hidden), + // fallback to the Set (which came from the update request) + const finalWords = words.length > 0 ? words : (data.selectedWords || []); + + void this.sidePanelIntegration._send({ + action: MessageActions.SIDEPANEL_SELECTION_SYNC, + selectedWords: finalWords, + timestamp: Date.now(), + reason: 'sidepanel-update', + }); + } catch (_) { } + } + + 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() @@ -3045,6 +3628,13 @@ export class BaseContentScript { 'debug', 'Page visible, resuming operations' ); + try { + finalizeExpiredSubtitleIfNeeded(); + } catch (err) { + this.logWithFallback('warn', 'Failed to finalize subtitles after visibility restore', { + error: err?.message, + }); + } // Re-check video setup when page becomes visible if ( this.activePlatform && diff --git a/content_scripts/shared/constants/messageActions.js b/content_scripts/shared/constants/messageActions.js index befdfe6..a56c30f 100644 --- a/content_scripts/shared/constants/messageActions.js +++ b/content_scripts/shared/constants/messageActions.js @@ -19,4 +19,19 @@ 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', + SIDEPANEL_REGISTER: 'sidePanelRegister', + SIDEPANEL_SET_ANALYZING: 'sidePanelSetAnalyzing', + SIDEPANEL_SELECTION_SYNC: 'sidePanelSelectionSync', + // Scope and binding related (future-ready) + SIDEPANEL_SCOPE_CHANGED: 'sidePanelScopeChanged', + SIDEPANEL_APPLY_SCOPE_BUCKET: 'sidePanelApplyScopeBucket', }; diff --git a/content_scripts/shared/interactiveSubtitleFormatter.js b/content_scripts/shared/interactiveSubtitleFormatter.js index baa0b47..7c954f4 100644 --- a/content_scripts/shared/interactiveSubtitleFormatter.js +++ b/content_scripts/shared/interactiveSubtitleFormatter.js @@ -8,6 +8,7 @@ * @version 1.0.0 */ + // Robust logging function that's always available const logWithFallback = (() => { let currentLogger = (level, message, data) => { @@ -496,63 +497,36 @@ function handleInteractiveWordClick(event) { targetLanguage, }); - // Check if video is paused for enhanced selection mode - const videoElement = document.querySelector('video'); - const isVideoPaused = videoElement ? videoElement.paused : false; + // Enhanced selection mode - dispatch word selection event + // Determine subtitle type from element's container + const subtitleType = getSubtitleTypeFromElement(target); - logWithFallback('info', 'Interactive word clicked', { + logWithFallback('info', 'Dispatching word selection event', { word, - isVideoPaused, - hasVideoElement: !!videoElement, - targetElement: target.tagName, - targetClass: target.className, + subtitleType, }); - if (isVideoPaused) { - // Enhanced selection mode - dispatch word selection event - // Determine subtitle type from element's container - const subtitleType = getSubtitleTypeFromElement(target); - - logWithFallback('info', 'Dispatching word selection event', { - word, - subtitleType, - isVideoPaused, - }); - - document.dispatchEvent( - new CustomEvent('dualsub-word-selected', { - detail: { - word, - element: target, - sourceLanguage, - targetLanguage, - context, - subtitleType, - }, - }) - ); - - logWithFallback( - 'debug', - 'Word selection event dispatched (video paused)', - { - word, - subtitleType, - } - ); - } else { - // Video is playing - no action taken - // Context analysis can only be initiated through the modal when video is paused - logWithFallback( - 'debug', - 'Word click ignored - video is playing. Pause video to select words for analysis.', - { + document.dispatchEvent( + new CustomEvent('dualsub-word-selected', { + detail: { word, + element: target, sourceLanguage, targetLanguage, - } - ); - } + context, + subtitleType, + }, + }) + ); + + logWithFallback( + 'debug', + 'Word selection event dispatched (video paused)', + { + word, + subtitleType, + } + ); } /** diff --git a/content_scripts/shared/subtitleUtilities.js b/content_scripts/shared/subtitleUtilities.js index 8912c5b..8cd7635 100644 --- a/content_scripts/shared/subtitleUtilities.js +++ b/content_scripts/shared/subtitleUtilities.js @@ -58,54 +58,51 @@ export function computeTextSignature(textOrHtml) { return s; } -/** - * Dispatch subtitle content change event with debouncing to prevent rapid-fire events - * @param {string} type - Subtitle type ('original' or 'translated') - * @param {string} oldContent - Previous content - * @param {string} newContent - New content - * @param {HTMLElement} element - Subtitle element - */ -function dispatchContentChangeDebounced(type, oldContent, newContent, element) { - // Phase 2: Gate dispatching based on normalized signatures +function dispatchContentChange(type, oldContent, newContent, element, { immediate = false } = {}) { try { const oldSig = computeTextSignature(oldContent || ''); const newSig = computeTextSignature(newContent || ''); - if (oldSig === newSig) { - return; // no effective change + if (!immediate && oldSig === newSig) { + return; } - } catch (_) {} - const existingTimeout = contentChangeDebounceTimeouts.get(element); - if (existingTimeout) { - clearTimeout(existingTimeout); - } - const timeoutId = setTimeout(() => { - document.dispatchEvent( - new CustomEvent('dualsub-subtitle-content-changing', { - detail: { - type, - oldContent, - newContent, - element, - }, - }) - ); - logWithFallback( - 'debug', - 'Debounced subtitle content change event dispatched', - { + const existingTimeout = contentChangeDebounceTimeouts.get(element); + if (existingTimeout) { + clearTimeout(existingTimeout); + contentChangeDebounceTimeouts.delete(element); + } + + const dispatch = () => { + document.dispatchEvent( + new CustomEvent('dualsub-subtitle-content-changing', { + detail: { + type, + oldContent, + newContent, + element, + }, + }) + ); + + logWithFallback('debug', `${immediate ? 'Immediate' : 'Debounced'} subtitle content change dispatched`, { type, oldContentLength: oldContent.length, newContentLength: newContent.length, - } - ); + }); + }; - // Clean up the timeout from the map - contentChangeDebounceTimeouts.delete(element); - }, CONTENT_CHANGE_DEBOUNCE_DELAY); + if (immediate) { + dispatch(); + return; + } - // Store the timeout for this element - contentChangeDebounceTimeouts.set(element, timeoutId); + const timeoutId = setTimeout(() => { + dispatch(); + contentChangeDebounceTimeouts.delete(element); + }, CONTENT_CHANGE_DEBOUNCE_DELAY); + + contentChangeDebounceTimeouts.set(element, timeoutId); + } catch (_) {} } // Initialize logger when available @@ -1135,9 +1132,15 @@ function attemptToSetupProgressBarObserver( progressBarObserver = new MutationObserver((mutations) => { const selectActiveThumb = () => { if (progressBarHost && progressBarHost.shadowRoot) { - return progressBarHost.shadowRoot.querySelector( + // Original, more specific selector + let thumb = progressBarHost.shadowRoot.querySelector( '.progress-bar__seekable-range .progress-bar__thumb[aria-valuenow][aria-valuemax]' ); + if (thumb) return thumb; + + // Fallback to any element with aria-valuenow in the shadow root, which is common + thumb = progressBarHost.shadowRoot.querySelector('[aria-valuenow]'); + if (thumb) return thumb; } return null; }; @@ -1159,6 +1162,8 @@ function attemptToSetupProgressBarObserver( logWithFallback('debug', 'Progress bar mutation observed', { logPrefix, attributeName: mutation.attributeName || 'childList', + targetTag: targetElement.tagName, + targetClass: targetElement.className, nowStr, maxStr, textStr, @@ -1182,6 +1187,12 @@ function attemptToSetupProgressBarObserver( textStr || neighbor.getAttribute('aria-valuetext'); } + logWithFallback('debug', 'Progress bar neighbor search values', { + logPrefix, + nowStr, + maxStr, + textStr, + }); } const currentVideoElem = activePlatform.getVideoElement(); @@ -1205,25 +1216,33 @@ function attemptToSetupProgressBarObserver( } } let { duration: videoDuration } = currentVideoElem; - // Some players report 0/null until metadata is ready. Fallback to valuemax when it looks like seconds + // Some players report 0/null/Infinity until metadata is ready. Fallback to valuemax when it looks like seconds. if ( - (!videoDuration || Number.isNaN(videoDuration)) && + (!isFinite(videoDuration) || videoDuration <= 0) && !Number.isNaN(valuemax) && valuemax > 0 ) { videoDuration = valuemax; } + logWithFallback('debug', 'Progress bar time calculation values', { + logPrefix, + valuenow, + valuemax, + videoDuration, + }); + if (!Number.isNaN(valuenow)) { // Directly use valuenow as seconds when valuemax matches duration let calculatedTime = valuenow; if ( - !Number.isNaN(videoDuration) && + isFinite(videoDuration) && videoDuration > 0 && !Number.isNaN(valuemax) && valuemax > 0 ) { // If valuemax does not match duration yet, scale valuenow by valuemax + // Also, if videoDuration was Infinity and we fell back to valuemax, this should be false. if (Math.abs(valuemax - videoDuration) > 1.5) { calculatedTime = (valuenow / valuemax) * videoDuration; @@ -1616,6 +1635,14 @@ export function updateSubtitles( lastDisplayedCueWindow.videoId || platformVideoId; } + // Always dispatch content change when new text is set + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + originalTextFormatted, + originalSubtitleElement + ); + if (useNativeTarget) { if (originalText.trim()) { const newSig = computeTextSignature(originalText); @@ -1625,7 +1652,7 @@ export function updateSubtitles( originalSubtitleElement.innerHTML === '' ) { // Notify AI Context modal about subtitle content change (debounced) - dispatchContentChangeDebounced( + dispatchContentChange( 'original', originalSubtitleElement.innerHTML, originalTextFormatted, @@ -1645,8 +1672,21 @@ export function updateSubtitles( originalSubtitleElement.style.display = 'inline-block'; } else { if (originalSubtitleElement.innerHTML) { + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement, + { immediate: true } + ); originalSubtitleElement.innerHTML = ''; originalSubtitleElement.dataset.textSig = ''; + contentChanged = true; + lastDisplayedCueWindow = { + start: null, + end: null, + videoId: null, + }; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1680,8 +1720,16 @@ export function updateSubtitles( translatedSubtitleElement.style.display = 'inline-block'; } else { if (translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); translatedSubtitleElement.innerHTML = ''; translatedSubtitleElement.dataset.textSig = ''; + contentChanged = true; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1713,8 +1761,21 @@ export function updateSubtitles( originalSubtitleElement.style.display = 'inline-block'; } else { if (originalSubtitleElement.innerHTML) { + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement, + { immediate: true } + ); originalSubtitleElement.innerHTML = ''; originalSubtitleElement.dataset.textSig = ''; + contentChanged = true; + lastDisplayedCueWindow = { + start: null, + end: null, + videoId: null, + }; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1748,8 +1809,16 @@ export function updateSubtitles( translatedSubtitleElement.style.display = 'inline-block'; } else { if (translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); translatedSubtitleElement.innerHTML = ''; translatedSubtitleElement.dataset.textSig = ''; + contentChanged = true; if (currentWholeSecond !== lastLoggedTimeSec) { logWithFallback( 'debug', @@ -1838,6 +1907,17 @@ export function updateSubtitles( return; } + // Dispatch content change before clearing subtitles + if (originalSubtitleElement.innerHTML) { + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement, + { immediate: true } + ); + } + if (originalSubtitleElement.innerHTML) originalSubtitleElement.innerHTML = ''; originalSubtitleElement.style.display = 'none'; @@ -1882,6 +1962,74 @@ export function clearSubtitlesDisplayAndQueue( } } +export function finalizeExpiredSubtitleIfNeeded(thresholdSeconds = 0.1) { + try { + if (!lastDisplayedCueWindow || lastDisplayedCueWindow.end == null) { + return false; + } + + const video = + document.querySelector('video[data-listener-attached="true"]') || + document.querySelector('video'); + const currentTime = + typeof video?.currentTime === 'number' ? video.currentTime : null; + + if ( + currentTime == null || + currentTime <= (lastDisplayedCueWindow.end ?? 0) + thresholdSeconds + ) { + return false; + } + + let cleared = false; + + if (originalSubtitleElement && originalSubtitleElement.innerHTML) { + dispatchContentChange( + 'original', + originalSubtitleElement.innerHTML, + '', + originalSubtitleElement, + { immediate: true } + ); + originalSubtitleElement.innerHTML = ''; + originalSubtitleElement.dataset.textSig = ''; + originalSubtitleElement.style.display = 'none'; + cleared = true; + } + + if (translatedSubtitleElement && translatedSubtitleElement.innerHTML) { + dispatchContentChange( + 'translated', + translatedSubtitleElement.innerHTML, + '', + translatedSubtitleElement, + { immediate: true } + ); + translatedSubtitleElement.innerHTML = ''; + translatedSubtitleElement.dataset.textSig = ''; + translatedSubtitleElement.style.display = 'none'; + cleared = true; + } + + if (cleared) { + document + .querySelectorAll( + '.dualsub-interactive-word.dualsub-word-selected' + ) + .forEach((el) => el.classList.remove('dualsub-word-selected')); + + lastDisplayedCueWindow = { start: null, end: null, videoId: null }; + } + + return cleared; + } catch (error) { + logWithFallback('warn', 'Failed to finalize expired subtitle', { + error: error?.message, + }); + return false; + } +} + export function clearSubtitleDOM() { if (subtitleContainer && subtitleContainer.parentElement) { subtitleContainer.parentElement.removeChild(subtitleContainer); @@ -2376,6 +2524,28 @@ export async function processSubtitleQueue( processingQueue = false; } + // After processing a batch, force an update of the subtitles on screen. + // This ensures newly available translations are rendered without waiting for the next timeupdate event. + const videoElementForUpdate = activePlatform?.getVideoElement(); + if (videoElementForUpdate) { + let currentTimeForUpdate = videoElementForUpdate.currentTime; + + // Use the more accurate progress bar time if available, especially for platforms like Disney+. + if ( + activePlatform.supportsProgressBarTracking?.() !== false && + lastProgressBarTime >= 0 + ) { + currentTimeForUpdate = lastProgressBarTime; + } + + updateSubtitles( + currentTimeForUpdate, + activePlatform, + config, + logPrefix + ); + } + const currentContextVideoIdForNextCheck = activePlatform?.getCurrentVideoId(); const moreRelevantCuesExist = subtitleQueue.some( diff --git a/content_scripts/tests/BaseContentScript.test.js b/content_scripts/tests/BaseContentScript.test.js index e411fac..971ff66 100644 --- a/content_scripts/tests/BaseContentScript.test.js +++ b/content_scripts/tests/BaseContentScript.test.js @@ -297,7 +297,7 @@ describe('BaseContentScript', () => { return 'partial'; } getPlatformClass() { - return class {}; + return class { }; } // Missing other abstract methods } @@ -1017,6 +1017,7 @@ describe('BaseContentScript', () => { contentScript.configService = mockModules.configService; contentScript.currentConfig = { subtitlesEnabled: true, + platformInitRetryDelay: 0, }; }); @@ -1115,9 +1116,9 @@ describe('BaseContentScript', () => { const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); // Set multiple intervals - intervalManager.set('test1', () => {}, 1000); - intervalManager.set('test2', () => {}, 2000); - intervalManager.set('test3', () => {}, 3000); + intervalManager.set('test1', () => { }, 1000); + intervalManager.set('test2', () => { }, 2000); + intervalManager.set('test3', () => { }, 3000); expect(intervalManager.count()).toBe(3); @@ -1223,7 +1224,7 @@ describe('BaseContentScript', () => { contentScript.currentConfig = { subtitlesEnabled: true, platformInitMaxRetries: 3, - platformInitRetryDelay: 1000, + platformInitRetryDelay: 0, platformInitTimeout: 5000, }; contentScript.contentLogger = mockLogger; @@ -1267,7 +1268,7 @@ describe('BaseContentScript', () => { }); contentScript.currentConfig.platformInitMaxRetries = 3; - contentScript.currentConfig.platformInitRetryDelay = 10; + contentScript.currentConfig.platformInitRetryDelay = 0; contentScript.startVideoElementDetection = jest.fn(); contentScript.processBufferedEvents = jest.fn(); @@ -1285,7 +1286,7 @@ describe('BaseContentScript', () => { }; contentScript.currentConfig.platformInitMaxRetries = 2; - contentScript.currentConfig.platformInitRetryDelay = 10; + contentScript.currentConfig.platformInitRetryDelay = 0; const result = await contentScript.initializePlatform(); @@ -1726,8 +1727,8 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification initialize() { return Promise.resolve(); } - handleNativeSubtitles() {} - cleanup() {} + handleNativeSubtitles() { } + cleanup() { } }; } getInjectScriptConfig() { @@ -1770,8 +1771,8 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification initialize() { return Promise.resolve(); } - handleNativeSubtitles() {} - cleanup() {} + handleNativeSubtitles() { } + cleanup() { } }; } getInjectScriptConfig() { @@ -1862,7 +1863,7 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification return name; } getPlatformClass() { - return class MockPlatform {}; + return class MockPlatform { }; } getInjectScriptConfig() { return { @@ -1871,8 +1872,8 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification eventId, }; } - setupNavigationDetection() {} - checkForUrlChange() {} + setupNavigationDetection() { } + checkForUrlChange() { } handlePlatformSpecificMessage(req, res) { res({ platform: name, @@ -1907,7 +1908,7 @@ describe('Platform-Specific Method Mocking and Common Functionality Verification // Verify handler information const handlers = contentScript.getRegisteredHandlers(); - expect(handlers).toHaveLength(3); + expect(handlers).toHaveLength(8); const toggleHandler = handlers.find( (h) => h.action === 'toggleSubtitles' diff --git a/context_providers/geminiContextProvider.js b/context_providers/geminiContextProvider.js index caab919..34f2513 100644 --- a/context_providers/geminiContextProvider.js +++ b/context_providers/geminiContextProvider.js @@ -79,7 +79,6 @@ export function getDefaultModel() { */ function createContextPrompt(text, contextType, metadata = {}) { const { - sourceLanguage = 'unknown', targetLanguage = 'unknown', surroundingContext = '', } = metadata; @@ -120,24 +119,23 @@ function createContextPrompt(text, contextType, metadata = {}) { }; const targetLanguageName = getLanguageName(targetLanguage); - const sourceLanguageName = getLanguageName(sourceLanguage); const baseContext = ` -Analyze this ${sourceLanguageName} text for ${contextType} context: +Analyze this text for ${contextType} context: Text to analyze: "${text}" -Source language: ${sourceLanguage} (${sourceLanguageName}) Target language for response: ${targetLanguage} (${targetLanguageName}) ${surroundingContext ? `Context: "${surroundingContext}"` : ''} CRITICAL INSTRUCTIONS: -1. Write your ENTIRE response in ${targetLanguageName} language -2. Analyze and discuss the ${sourceLanguageName} language content, culture, and context -3. Explain ${sourceLanguageName} cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker -4. Do NOT analyze ${targetLanguageName} language or culture - focus on the ${sourceLanguageName} source material -5. Help ${targetLanguageName} speakers understand this ${sourceLanguageName} text better - -Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this ${sourceLanguageName} content. +1. First, IDENTIFY the language of the "Text to analyze" +2. Write your ENTIRE response in ${targetLanguageName} language +3. Analyze and discuss the content, culture, and context of the identified source language +4. Explain cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker +5. Do NOT analyze ${targetLanguageName} language or culture - focus on the source material +6. Help ${targetLanguageName} speakers understand this text better + +Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this content. `; switch (contextType) { @@ -145,116 +143,116 @@ Provide a clear, educational explanation that helps ${targetLanguageName} speake return ( baseContext + ` -Provide a comprehensive cultural analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a comprehensive cultural analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_context": { - "origins": "${sourceLanguageName} cultural origins and background of this expression", - "social_context": "How this is used in ${sourceLanguageName} society and conversational context", - "regional_variations": "How this ${sourceLanguageName} expression varies across different ${sourceLanguageName}-speaking regions" + "origins": "Cultural origins and background of this expression", + "social_context": "How this is used in the source culture and conversational context", + "regional_variations": "How this expression varies across different regions speaking the source language" }, "usage": { - "examples": ["${sourceLanguageName} usage example 1", "${sourceLanguageName} usage example 2", "${sourceLanguageName} usage example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality_level": "Formality level in ${sourceLanguageName} culture" + "examples": ["Usage example 1", "Usage example 2", "Usage example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality_level": "Formality level in the source culture" }, - "cultural_significance": "Why this expression is culturally important in ${sourceLanguageName} culture", - "learning_tips": "Practical advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Similar ${sourceLanguageName} expression 1", "Similar ${sourceLanguageName} expression 2"], - "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this ${sourceLanguageName} expression" + "cultural_significance": "Why this expression is culturally important in the source culture", + "learning_tips": "Practical advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Similar expression 1", "Similar expression 2"], + "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); case 'historical': return ( baseContext + ` -Provide a detailed historical analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a detailed historical analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "historical_context": { - "time_period": "Historical period relevant to this ${sourceLanguageName} expression", - "historical_figures": "Important ${sourceLanguageName} historical figures connected to this expression", - "events": "${sourceLanguageName} historical events that shaped this expression" + "time_period": "Historical period relevant to this expression", + "historical_figures": "Important historical figures connected to this expression", + "events": "Historical events that shaped this expression" }, "evolution": { - "original_meaning": "How this ${sourceLanguageName} expression was originally used", - "changes_over_time": "How this ${sourceLanguageName} expression's meaning evolved", - "modern_usage": "How this ${sourceLanguageName} expression is used today" + "original_meaning": "How this expression was originally used", + "changes_over_time": "How this expression's meaning evolved", + "modern_usage": "How this expression is used today" }, - "historical_significance": "Why this expression is historically important in ${sourceLanguageName} culture/history", - "examples": ["${sourceLanguageName} historical usage example 1", "${sourceLanguageName} historical usage example 2"], - "related_terms": ["Related ${sourceLanguageName} historical term 1", "Related ${sourceLanguageName} historical term 2"], - "learning_context": "How understanding ${sourceLanguageName} history helps ${targetLanguageName} speakers learn this expression" + "historical_significance": "Why this expression is historically important in the source culture/history", + "examples": ["Historical usage example 1", "Historical usage example 2"], + "related_terms": ["Related historical term 1", "Related historical term 2"], + "learning_context": "How understanding the source history helps ${targetLanguageName} speakers learn this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} historical context.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the historical context of the source.` ); case 'linguistic': return ( baseContext + ` -Provide an in-depth linguistic analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide an in-depth linguistic analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "etymology": { - "word_origins": "${sourceLanguageName} language family and root origins of this expression", - "historical_development": "How this ${sourceLanguageName} word/phrase developed linguistically" + "word_origins": "Language family and root origins of this expression", + "historical_development": "How this word/phrase developed linguistically" }, "grammar": { - "structure": "${sourceLanguageName} grammatical structure and patterns of this expression", - "usage_rules": "${sourceLanguageName} grammar rules for proper usage" + "structure": "Grammatical structure and patterns of this expression", + "usage_rules": "Grammar rules for proper usage" }, "semantics": { - "literal_meaning": "Literal ${sourceLanguageName} meaning before translation", - "connotations": "Implied meanings and connotations in ${sourceLanguageName}", - "register": "Formal/informal/technical classification in ${sourceLanguageName}" + "literal_meaning": "Literal meaning before translation", + "connotations": "Implied meanings and connotations", + "register": "Formal/informal/technical classification" }, - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}", - "examples": ["${sourceLanguageName} linguistic example 1", "${sourceLanguageName} linguistic example 2"], - "related_forms": ["Related ${sourceLanguageName} word 1", "Related ${sourceLanguageName} word 2"], - "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this ${sourceLanguageName} expression linguistically" + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}", + "examples": ["Linguistic example 1", "Linguistic example 2"], + "related_forms": ["Related word 1", "Related word 2"], + "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this expression linguistically" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} linguistic aspects.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the linguistic aspects of the source.` ); default: return ( baseContext + ` -Provide a comprehensive analysis of this ${sourceLanguageName} text covering cultural, historical, and linguistic aspects in the following JSON structure: +Provide a comprehensive analysis of this text covering cultural, historical, and linguistic aspects in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_analysis": { - "cultural_context": "${sourceLanguageName} cultural background and significance", - "social_usage": "How this is used socially in ${sourceLanguageName} culture", - "regional_notes": "Regional or cultural variations within ${sourceLanguageName}-speaking areas" + "cultural_context": "Cultural background and significance", + "social_usage": "How this is used socially in the source culture", + "regional_notes": "Regional or cultural variations within the source language-speaking areas" }, "historical_analysis": { - "origins": "${sourceLanguageName} historical origins and background", - "evolution": "How this ${sourceLanguageName} expression evolved over time", - "historical_significance": "Historical importance in ${sourceLanguageName} culture" + "origins": "Historical origins and background", + "evolution": "How this expression evolved over time", + "historical_significance": "Historical importance in the source culture" }, "linguistic_analysis": { - "etymology": "${sourceLanguageName} word origins and linguistic development", - "grammar_notes": "${sourceLanguageName} grammatical considerations", - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}" + "etymology": "Word origins and linguistic development", + "grammar_notes": "Grammatical considerations", + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}" }, "practical_usage": { - "examples": ["${sourceLanguageName} example 1", "${sourceLanguageName} example 2", "${sourceLanguageName} example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality": "Formality level in ${sourceLanguageName} culture" + "examples": ["Example 1", "Example 2", "Example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality": "Formality level in the source culture" }, - "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Related ${sourceLanguageName} expression 1", "Related ${sourceLanguageName} expression 2"], - "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this ${sourceLanguageName} expression" + "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Related expression 1", "Related expression 2"], + "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); } } diff --git a/context_providers/openaiContextProvider.js b/context_providers/openaiContextProvider.js index 834a77e..e660bbc 100644 --- a/context_providers/openaiContextProvider.js +++ b/context_providers/openaiContextProvider.js @@ -164,31 +164,29 @@ function getLanguageName(langCode) { */ function createContextPrompt(text, contextType, metadata = {}) { const { - sourceLanguage = 'unknown', targetLanguage = 'unknown', surroundingContext = '', } = metadata; // Get language name for the target language code const targetLanguageName = getLanguageName(targetLanguage); - const sourceLanguageName = getLanguageName(sourceLanguage); const baseContext = ` -Analyze this ${sourceLanguageName} text for ${contextType} context: +Analyze this text for ${contextType} context: Text to analyze: "${text}" -Source language: ${sourceLanguage} (${sourceLanguageName}) Target language for response: ${targetLanguage} (${targetLanguageName}) ${surroundingContext ? `Context: "${surroundingContext}"` : ''} CRITICAL INSTRUCTIONS: -1. Write your ENTIRE response in ${targetLanguageName} language -2. Analyze and discuss the ${sourceLanguageName} language content, culture, and context -3. Explain ${sourceLanguageName} cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker -4. Do NOT analyze ${targetLanguageName} language or culture - focus on the ${sourceLanguageName} source material -5. Help ${targetLanguageName} speakers understand this ${sourceLanguageName} text better - -Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this ${sourceLanguageName} content. +1. First, IDENTIFY the language of the "Text to analyze" +2. Write your ENTIRE response in ${targetLanguageName} language +3. Analyze and discuss the content, culture, and context of the identified source language +4. Explain cultural/historical/linguistic aspects TO a ${targetLanguageName} speaker +5. Do NOT analyze ${targetLanguageName} language or culture - focus on the source material +6. Help ${targetLanguageName} speakers understand this text better + +Provide a clear, educational explanation that helps ${targetLanguageName} speakers understand the deeper meaning of this content. `; switch (contextType) { @@ -196,116 +194,116 @@ Provide a clear, educational explanation that helps ${targetLanguageName} speake return ( baseContext + ` -Provide a comprehensive cultural analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a comprehensive cultural analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_context": { - "origins": "${sourceLanguageName} cultural origins and background of this expression", - "social_context": "How this is used in ${sourceLanguageName} society and conversational context", - "regional_variations": "How this ${sourceLanguageName} expression varies across different ${sourceLanguageName}-speaking regions" + "origins": "Cultural origins and background of this expression", + "social_context": "How this is used in the source culture and conversational context", + "regional_variations": "How this expression varies across different regions speaking the source language" }, "usage": { - "examples": ["${sourceLanguageName} usage example 1", "${sourceLanguageName} usage example 2", "${sourceLanguageName} usage example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality_level": "Formality level in ${sourceLanguageName} culture" + "examples": ["Usage example 1", "Usage example 2", "Usage example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality_level": "Formality level in the source culture" }, - "cultural_significance": "Why this expression is culturally important in ${sourceLanguageName} culture", - "learning_tips": "Practical advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Similar ${sourceLanguageName} expression 1", "Similar ${sourceLanguageName} expression 2"], - "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this ${sourceLanguageName} expression" + "cultural_significance": "Why this expression is culturally important in the source culture", + "learning_tips": "Practical advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Similar expression 1", "Similar expression 2"], + "sensitivities": "Cultural sensitivities ${targetLanguageName} speakers should know about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); case 'historical': return ( baseContext + ` -Provide a detailed historical analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide a detailed historical analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "historical_context": { - "time_period": "Historical period relevant to this ${sourceLanguageName} expression", - "historical_figures": "Important ${sourceLanguageName} historical figures connected to this expression", - "events": "${sourceLanguageName} historical events that shaped this expression" + "time_period": "Historical period relevant to this expression", + "historical_figures": "Important historical figures connected to this expression", + "events": "Historical events that shaped this expression" }, "evolution": { - "original_meaning": "How this ${sourceLanguageName} expression was originally used", - "changes_over_time": "How this ${sourceLanguageName} expression's meaning evolved", - "modern_usage": "How this ${sourceLanguageName} expression is used today" + "original_meaning": "How this expression was originally used", + "changes_over_time": "How this expression's meaning evolved", + "modern_usage": "How this expression is used today" }, - "historical_significance": "Why this expression is historically important in ${sourceLanguageName} culture/history", - "examples": ["${sourceLanguageName} historical usage example 1", "${sourceLanguageName} historical usage example 2"], - "related_terms": ["Related ${sourceLanguageName} historical term 1", "Related ${sourceLanguageName} historical term 2"], - "learning_context": "How understanding ${sourceLanguageName} history helps ${targetLanguageName} speakers learn this expression" + "historical_significance": "Why this expression is historically important in the source culture/history", + "examples": ["Historical usage example 1", "Historical usage example 2"], + "related_terms": ["Related historical term 1", "Related historical term 2"], + "learning_context": "How understanding the source history helps ${targetLanguageName} speakers learn this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} historical context.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the historical context of the source.` ); case 'linguistic': return ( baseContext + ` -Provide an in-depth linguistic analysis of this ${sourceLanguageName} text in the following JSON structure: +Provide an in-depth linguistic analysis of this text in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "etymology": { - "word_origins": "${sourceLanguageName} language family and root origins of this expression", - "historical_development": "How this ${sourceLanguageName} word/phrase developed linguistically" + "word_origins": "Language family and root origins of this expression", + "historical_development": "How this word/phrase developed linguistically" }, "grammar": { - "structure": "${sourceLanguageName} grammatical structure and patterns of this expression", - "usage_rules": "${sourceLanguageName} grammar rules for proper usage" + "structure": "Grammatical structure and patterns of this expression", + "usage_rules": "Grammar rules for proper usage" }, "semantics": { - "literal_meaning": "Literal ${sourceLanguageName} meaning before translation", - "connotations": "Implied meanings and connotations in ${sourceLanguageName}", - "register": "Formal/informal/technical classification in ${sourceLanguageName}" + "literal_meaning": "Literal meaning before translation", + "connotations": "Implied meanings and connotations", + "register": "Formal/informal/technical classification" }, - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}", - "examples": ["${sourceLanguageName} linguistic example 1", "${sourceLanguageName} linguistic example 2"], - "related_forms": ["Related ${sourceLanguageName} word 1", "Related ${sourceLanguageName} word 2"], - "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this ${sourceLanguageName} expression linguistically" + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}", + "examples": ["Linguistic example 1", "Linguistic example 2"], + "related_forms": ["Related word 1", "Related word 2"], + "learning_tips": "Specific tips for ${targetLanguageName} speakers to master this expression linguistically" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} linguistic aspects.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the linguistic aspects of the source.` ); default: return ( baseContext + ` -Provide a comprehensive analysis of this ${sourceLanguageName} text covering cultural, historical, and linguistic aspects in the following JSON structure: +Provide a comprehensive analysis of this text covering cultural, historical, and linguistic aspects in the following JSON structure: { - "definition": "Clear definition or meaning of this ${sourceLanguageName} expression", + "definition": "Clear definition or meaning of this expression", "cultural_analysis": { - "cultural_context": "${sourceLanguageName} cultural background and significance", - "social_usage": "How this is used socially in ${sourceLanguageName} culture", - "regional_notes": "Regional or cultural variations within ${sourceLanguageName}-speaking areas" + "cultural_context": "Cultural background and significance", + "social_usage": "How this is used socially in the source culture", + "regional_notes": "Regional or cultural variations within the source language-speaking areas" }, "historical_analysis": { - "origins": "${sourceLanguageName} historical origins and background", - "evolution": "How this ${sourceLanguageName} expression evolved over time", - "historical_significance": "Historical importance in ${sourceLanguageName} culture" + "origins": "Historical origins and background", + "evolution": "How this expression evolved over time", + "historical_significance": "Historical importance in the source culture" }, "linguistic_analysis": { - "etymology": "${sourceLanguageName} word origins and linguistic development", - "grammar_notes": "${sourceLanguageName} grammatical considerations", - "translation_notes": "Why this ${sourceLanguageName} expression is challenging to translate to ${targetLanguageName}" + "etymology": "Word origins and linguistic development", + "grammar_notes": "Grammatical considerations", + "translation_notes": "Why this expression is challenging to translate to ${targetLanguageName}" }, "practical_usage": { - "examples": ["${sourceLanguageName} example 1", "${sourceLanguageName} example 2", "${sourceLanguageName} example 3"], - "when_to_use": "When ${sourceLanguageName} speakers use this expression", - "formality": "Formality level in ${sourceLanguageName} culture" + "examples": ["Example 1", "Example 2", "Example 3"], + "when_to_use": "When speakers of the source language use this expression", + "formality": "Formality level in the source culture" }, - "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning ${sourceLanguageName}", - "related_expressions": ["Related ${sourceLanguageName} expression 1", "Related ${sourceLanguageName} expression 2"], - "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this ${sourceLanguageName} expression" + "learning_tips": "Comprehensive advice for ${targetLanguageName} speakers learning the source language", + "related_expressions": ["Related expression 1", "Related expression 2"], + "key_insights": "Most important things for ${targetLanguageName} speakers to understand about this expression" } -Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the ${sourceLanguageName} content.` +Respond ONLY with valid JSON in this exact structure. All text content within the JSON must be written in ${targetLanguageName} but analyze the source content.` ); } } 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/manifest.json b/manifest.json index 9348375..a3f0fb2 100644 --- a/manifest.json +++ b/manifest.json @@ -1,10 +1,14 @@ { "manifest_version": 3, "name": "__MSG_appName__", - "version": "2.4.1", + "version": "2.5.0", "description": "__MSG_appDesc__", "default_locale": "en", - "permissions": ["storage", "activeTab"], + "permissions": [ + "storage", + "activeTab", + "sidePanel" + ], "host_permissions": [ "*://*.disneyplus.com/*", "*://*.netflix.com/*", @@ -29,16 +33,28 @@ }, "content_scripts": [ { - "matches": ["*://*.disneyplus.com/*"], - "js": ["content_scripts/platforms/disneyPlusContent.js"], - "css": ["content_scripts/shared/content.css"], + "matches": [ + "*://*.disneyplus.com/*" + ], + "js": [ + "content_scripts/platforms/disneyPlusContent.js" + ], + "css": [ + "content_scripts/shared/content.css" + ], "run_at": "document_start", "type": "module" }, { - "matches": ["*://*.netflix.com/*"], - "js": ["content_scripts/platforms/netflixContent.js"], - "css": ["content_scripts/shared/content.css"], + "matches": [ + "*://*.netflix.com/*" + ], + "js": [ + "content_scripts/platforms/netflixContent.js" + ], + "css": [ + "content_scripts/shared/content.css" + ], "run_at": "document_start", "type": "module" } @@ -98,7 +114,10 @@ "video_platforms/netflixPlatform.js", "content_scripts/platforms/NetflixContentScript.js" ], - "matches": ["*://*.disneyplus.com/*", "*://*.netflix.com/*"] + "matches": [ + "*://*.disneyplus.com/*", + "*://*.netflix.com/*" + ] } ], "action": { @@ -117,5 +136,8 @@ "16": "icons/icon16.png", "48": "icons/icon48.png", "128": "icons/icon128.png" + }, + "side_panel": { + "default_path": "sidepanel/sidepanel.html" } -} +} \ No newline at end of file diff --git a/options/OptionsApp.jsx b/options/OptionsApp.jsx index 56b24f6..2e55a19 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() { @@ -24,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); @@ -77,6 +79,20 @@ export function OptionsApp() { onSettingChange={handleSettingChange} /> )} + {activeSection === 'word-lists' && ( + + )} + {activeSection === 'advanced' && ( + + )} {activeSection === 'about' && } 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 ( ); } - 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/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({ )) ) : ( - + )} @@ -130,8 +149,7 @@ export function OpenAICompatibleProviderCard({ > {testing ? t('testingButton', 'Testing...') - : t('testConnectionButton', 'Test Connection') - } + : t('testConnectionButton', 'Test Connection')} @@ -139,9 +157,21 @@ export function OpenAICompatibleProviderCard({
{t('providerFeatures', 'Features:')}
    -
  • {t('featureCustomizable', 'Customizable endpoint and model')}
  • -
  • {t('featureApiKeyRequired', 'API key required')}
  • -
  • {t('featureWideLanguageSupport', 'Wide language support')}
  • +
  • + {t( + 'featureCustomizable', + 'Customizable endpoint and model' + )} +
  • +
  • + {t('featureApiKeyRequired', 'API key required')} +
  • +
  • + {t( + 'featureWideLanguageSupport', + 'Wide language support' + )} +
diff --git a/options/components/providers/VertexProviderCard.jsx b/options/components/providers/VertexProviderCard.jsx index d4f099f..19ee528 100644 --- a/options/components/providers/VertexProviderCard.jsx +++ b/options/components/providers/VertexProviderCard.jsx @@ -18,23 +18,28 @@ export function VertexProviderCard({ onProviderChange, }) { const fileInputRef = useRef(null); - const { - testResult, - importResult, - testing, + const { + testResult, + importResult, + testing, importing, - testConnection, + testConnection, importServiceAccountJson, refreshToken, checkTokenExpiration, - initializeStatus - } = useVertexTest(t, onAccessTokenChange, onProjectIdChange, onProviderChange); + initializeStatus, + } = useVertexTest( + t, + onAccessTokenChange, + onProjectIdChange, + onProviderChange + ); // Initialize status and setup auto-refresh on mount useEffect(() => { const checkAndRefreshToken = async () => { const expirationInfo = await checkTokenExpiration(); - + if (expirationInfo) { // Auto-refresh if token is expired or will expire in less than 5 minutes if (expirationInfo.isExpired || expirationInfo.shouldRefresh) { @@ -44,7 +49,10 @@ export function VertexProviderCard({ console.log('[Vertex AI] Token auto-refreshed'); } catch (error) { // Error already handled in hook - console.error('[Vertex AI] Auto-refresh failed:', error); + console.error( + '[Vertex AI] Auto-refresh failed:', + error + ); } } } @@ -55,12 +63,21 @@ export function VertexProviderCard({ checkAndRefreshToken(); // Setup periodic check every 5 minutes - const interval = setInterval(() => { - checkAndRefreshToken(); - }, 5 * 60 * 1000); // Check every 5 minutes + const interval = setInterval( + () => { + checkAndRefreshToken(); + }, + 5 * 60 * 1000 + ); // Check every 5 minutes return () => clearInterval(interval); - }, [accessToken, projectId, initializeStatus, checkTokenExpiration, refreshToken]); + }, [ + accessToken, + projectId, + initializeStatus, + checkTokenExpiration, + refreshToken, + ]); const handleTest = () => { const loc = location || 'us-central1'; @@ -103,7 +120,10 @@ export function VertexProviderCard({ return ( - + {importing ? t('vertexImporting', 'Importing...') - : t('vertexImportButton', 'Import JSON File') - } + : t('vertexImportButton', 'Import JSON File')} @@ -198,8 +219,7 @@ export function VertexProviderCard({ > {testing ? t('testingButton', 'Testing...') - : t('testConnectionButton', 'Test Connection') - } + : t('testConnectionButton', 'Test Connection')} @@ -208,14 +228,33 @@ export function VertexProviderCard({
{t('providerFeatures', 'Features:')}
    -
  • {t('featureVertexServiceAccount', 'Service account JSON import')}
  • -
  • {t('featureVertexAutoToken', 'Automatic token generation')}
  • -
  • {t('featureVertexGemini', 'Google Gemini models via Vertex AI')}
  • -
  • {t('featureWideLanguageSupport', 'Wide language support')}
  • +
  • + {t( + 'featureVertexServiceAccount', + 'Service account JSON import' + )} +
  • +
  • + {t( + 'featureVertexAutoToken', + 'Automatic token generation' + )} +
  • +
  • + {t( + 'featureVertexGemini', + 'Google Gemini models via Vertex AI' + )} +
  • +
  • + {t( + 'featureWideLanguageSupport', + 'Wide language support' + )} +
); } - diff --git a/options/components/sections/AIContextSection.jsx b/options/components/sections/AIContextSection.jsx index 2c8abae..df04419 100644 --- a/options/components/sections/AIContextSection.jsx +++ b/options/components/sections/AIContextSection.jsx @@ -27,7 +27,7 @@ export function AIContextSection({ t, settings, onSettingChange }) { const typesArray = Object.entries(newTypes) .filter(([_, enabled]) => enabled) .map(([type]) => type); - + onSettingChange('aiContextTypes', typesArray); }; @@ -37,10 +37,13 @@ export function AIContextSection({ t, settings, onSettingChange }) { return (

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

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

-

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

+

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

); diff --git a/options/components/sections/AdvancedSection.jsx b/options/components/sections/AdvancedSection.jsx new file mode 100644 index 0000000..9200293 --- /dev/null +++ b/options/components/sections/AdvancedSection.jsx @@ -0,0 +1,185 @@ +import React from 'react'; +import { SettingCard } from '../SettingCard.jsx'; +import { ToggleSwitch } from '../ToggleSwitch.jsx'; + +/** + * Advanced Settings Section + * + * Advanced configuration options for side panel behavior, + * video control, and state persistence. + */ +export function AdvancedSection({ t, settings, onSettingChange }) { + return ( +
+

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

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

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

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

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

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

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

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

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

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

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

+
+ + +
+ warning + {t( + 'advancedNote', + 'Chrome Side Panel API requires Chrome 114 or higher. Lower versions will use the legacy modal.' + )} +
+
+
+ ); +} diff --git a/options/components/sections/GeneralSection.jsx b/options/components/sections/GeneralSection.jsx index 469212d..e7f021f 100644 --- a/options/components/sections/GeneralSection.jsx +++ b/options/components/sections/GeneralSection.jsx @@ -6,7 +6,7 @@ export function GeneralSection({ t, settings, onSettingChange }) { return (

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

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

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

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

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

- + - onSettingChange('translationBatchSize', parseInt(e.target.value)) + onSettingChange( + 'translationBatchSize', + parseInt(e.target.value) + ) } /> @@ -84,7 +87,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { step="50" value={settings.translationDelay || 150} onChange={(e) => - onSettingChange('translationDelay', parseInt(e.target.value)) + onSettingChange( + 'translationDelay', + parseInt(e.target.value) + ) } /> @@ -100,7 +106,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -139,7 +148,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { id="useProviderDefaults" checked={useProviderDefaults} onChange={(checked) => - onSettingChange('useProviderDefaults', checked) + onSettingChange( + 'useProviderDefaults', + checked + ) } />
@@ -148,7 +160,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -165,7 +180,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { step="1" value={settings.globalBatchSize || 5} onChange={(e) => - onSettingChange('globalBatchSize', parseInt(e.target.value)) + onSettingChange( + 'globalBatchSize', + parseInt(e.target.value) + ) } />
@@ -174,7 +192,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -195,7 +216,10 @@ export function TranslationSection({ t, settings, onSettingChange }) {
{t( @@ -212,7 +236,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { step="1" value={settings.maxConcurrentBatches || 2} onChange={(e) => - onSettingChange('maxConcurrentBatches', parseInt(e.target.value)) + onSettingChange( + 'maxConcurrentBatches', + parseInt(e.target.value) + ) } />
@@ -222,7 +249,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { {batchingEnabled && useProviderDefaults && (
- {t('openaieBatchSizeHelp', 'Recommended: 5-10 segments (default: 8)')} + {t( + 'openaieBatchSizeHelp', + 'Recommended: 5-10 segments (default: 8)' + )}
- onSettingChange('openaieBatchSize', parseInt(e.target.value)) + onSettingChange( + 'openaieBatchSize', + parseInt(e.target.value) + ) } />
@@ -253,10 +292,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('googleBatchSizeHelp', 'Recommended: 3-5 segments (default: 4)')} + {t( + 'googleBatchSizeHelp', + 'Recommended: 3-5 segments (default: 4)' + )}
- onSettingChange('googleBatchSize', parseInt(e.target.value)) + onSettingChange( + 'googleBatchSize', + parseInt(e.target.value) + ) } />
@@ -278,7 +326,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { {t('deeplBatchSizeLabel', 'DeepL Batch Size:')}
- {t('deeplBatchSizeHelp', 'Recommended: 2-3 segments (default: 3)')} + {t( + 'deeplBatchSizeHelp', + 'Recommended: 2-3 segments (default: 3)' + )}
- onSettingChange('deeplBatchSize', parseInt(e.target.value)) + onSettingChange( + 'deeplBatchSize', + parseInt(e.target.value) + ) } />
@@ -297,10 +351,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('microsoftBatchSizeHelp', 'Recommended: 3-5 segments (default: 4)')} + {t( + 'microsoftBatchSizeHelp', + 'Recommended: 3-5 segments (default: 4)' + )}
- onSettingChange('microsoftBatchSize', parseInt(e.target.value)) + onSettingChange( + 'microsoftBatchSize', + parseInt(e.target.value) + ) } />
@@ -319,10 +382,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('vertexBatchSizeHelp', 'Recommended: 5-10 segments (default: 8)')} + {t( + 'vertexBatchSizeHelp', + 'Recommended: 5-10 segments (default: 8)' + )}
- onSettingChange('vertexBatchSize', parseInt(e.target.value)) + onSettingChange( + 'vertexBatchSize', + parseInt(e.target.value) + ) } />
@@ -341,7 +413,10 @@ export function TranslationSection({ t, settings, onSettingChange }) { )}
- {t('openaieDelayHelp', 'Minimum delay between requests (default: 100ms)')} + {t( + 'openaieDelayHelp', + 'Minimum delay between requests (default: 100ms)' + )}
- onSettingChange('openaieDelay', parseInt(e.target.value)) + onSettingChange( + 'openaieDelay', + parseInt(e.target.value) + ) } />
@@ -372,10 +456,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('googleDelayHelp', 'Required delay to prevent temporary lockouts (default: 1500ms)')} + {t( + 'googleDelayHelp', + 'Required delay to prevent temporary lockouts (default: 1500ms)' + )}
- onSettingChange('googleDelay', parseInt(e.target.value)) + onSettingChange( + 'googleDelay', + parseInt(e.target.value) + ) } />
@@ -394,10 +487,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('deeplDelayHelp', 'Delay for DeepL API requests (default: 500ms)')} + {t( + 'deeplDelayHelp', + 'Delay for DeepL API requests (default: 500ms)' + )}
- onSettingChange('deeplDelay', parseInt(e.target.value)) + onSettingChange( + 'deeplDelay', + parseInt(e.target.value) + ) } />
@@ -416,10 +518,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('deeplFreeDelayHelp', 'Conservative delay for free tier (default: 2000ms)')} + {t( + 'deeplFreeDelayHelp', + 'Conservative delay for free tier (default: 2000ms)' + )}
- onSettingChange('deeplFreeDelay', parseInt(e.target.value)) + onSettingChange( + 'deeplFreeDelay', + parseInt(e.target.value) + ) } />
@@ -438,10 +549,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('microsoftDelayHelp', 'Delay to respect character limits (default: 800ms)')} + {t( + 'microsoftDelayHelp', + 'Delay to respect character limits (default: 800ms)' + )}
- onSettingChange('microsoftDelay', parseInt(e.target.value)) + onSettingChange( + 'microsoftDelay', + parseInt(e.target.value) + ) } />
@@ -460,10 +580,16 @@ export function TranslationSection({ t, settings, onSettingChange }) {
- {t('vertexDelayHelp', 'Minimum delay between requests (default: 100ms)')} + {t( + 'vertexDelayHelp', + 'Minimum delay between requests (default: 100ms)' + )}
- onSettingChange('vertexDelay', parseInt(e.target.value)) + onSettingChange( + 'vertexDelay', + parseInt(e.target.value) + ) } />
diff --git a/options/components/sections/WordListsSection.jsx b/options/components/sections/WordListsSection.jsx new file mode 100644 index 0000000..58bb4c0 --- /dev/null +++ b/options/components/sections/WordListsSection.jsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { SettingCard } from '../SettingCard.jsx'; +import { ToggleSwitch } from '../ToggleSwitch.jsx'; + +/** + * Word Lists Settings Section + * + * Configuration for the Words Lists feature in the side panel. + * Currently provides a simple toggle as the feature is in development. + */ +export function WordListsSection({ t, settings, onSettingChange }) { + return ( +
+

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

+ + +
+ + + onSettingChange('sidePanelWordsListsEnabled', checked) + } + /> +
+
+ + +
+ info + {t( + 'wordListsPreview', + 'This feature is currently in development. Enable it to see the preview UI.' + )} +
+
+
+ ); +} diff --git a/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..d2755d9 100644 --- a/options/options.css +++ b/options/options.css @@ -18,6 +18,8 @@ --font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'Helvetica Neue', Arial, sans-serif; + --color-warning: #f59e0b; + --spacing-2: 0.5rem; } body { @@ -740,12 +742,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 +763,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 +781,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 +790,7 @@ button#testDeepLButton.btn-sparkle:hover span.text { .apple-file-btn:disabled { background: linear-gradient(180deg, #c7c7cc 0%, #aeaeb2 100%); cursor: not-allowed; - box-shadow: - 0 1px 2px rgba(0, 0, 0, 0.1); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } .apple-file-btn:disabled::before { diff --git a/options/options.html b/options/options.html index 97971c2..3bee63c 100644 --- a/options/options.html +++ b/options/options.html @@ -9,4 +9,4 @@
- \ No newline at end of file + diff --git a/package-lock.json b/package-lock.json index 42adc40..03c1749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dualsub", - "version": "2.4.1", + "version": "2.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dualsub", - "version": "2.4.1", + "version": "2.5.0", "license": "CC-BY-NC-SA-4.0", "dependencies": { "react": "^19.1.1", @@ -8640,4 +8640,4 @@ } } } -} +} \ No newline at end of file diff --git a/package.json b/package.json index 55748f8..56ddb3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dualsub", - "version": "2.4.1", + "version": "2.5.0", "type": "module", "description": "Displays dual language subtitles on streaming platforms.", "main": "background.js", @@ -49,4 +49,4 @@ "react": "^19.1.1", "react-dom": "^19.1.1" } -} +} \ No newline at end of file diff --git a/popup/PopupApp.jsx b/popup/PopupApp.jsx index ddaa3c8..6124ab8 100644 --- a/popup/PopupApp.jsx +++ b/popup/PopupApp.jsx @@ -1,5 +1,10 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useSettings, useTranslation, useChromeMessage, useLogger } from './hooks/index.js'; +import { + useSettings, + useTranslation, + useChromeMessage, + useLogger, +} from './hooks/index.js'; import { Header } from './components/Header.jsx'; import { SettingToggle } from './components/SettingToggle.jsx'; import { LanguageSelector } from './components/LanguageSelector.jsx'; @@ -8,10 +13,12 @@ import { StatusMessage } from './components/StatusMessage.jsx'; export function PopupApp() { const { settings, updateSetting, loading, error } = useSettings(); - const { t, loading: translationsLoading } = useTranslation(settings.uiLanguage || 'en'); + const { t, loading: translationsLoading } = useTranslation( + settings.uiLanguage || 'en' + ); const { sendImmediateConfigUpdate } = useChromeMessage(); const logger = useLogger('Popup'); - + const [statusMessage, setStatusMessage] = useState(''); const statusTimeoutRef = useRef(null); @@ -19,7 +26,7 @@ export function PopupApp() { if (statusTimeoutRef.current) { clearTimeout(statusTimeoutRef.current); } - + setStatusMessage(message); statusTimeoutRef.current = setTimeout(() => { setStatusMessage(''); @@ -41,14 +48,19 @@ export function PopupApp() { logger.error('Error loading settings', error, { component: 'loadSettings', }); - showStatus('Failed to load settings. Please try refreshing the popup.', 5000); + showStatus( + 'Failed to load settings. Please try refreshing the popup.', + 5000 + ); } }, [error, logger]); const handleToggleSubtitles = async (enabled) => { try { await updateSetting('subtitlesEnabled', enabled); - const statusKey = enabled ? 'statusDualEnabled' : 'statusDualDisabled'; + const statusKey = enabled + ? 'statusDualEnabled' + : 'statusDualDisabled'; const statusText = t( statusKey, enabled ? 'Dual subtitles enabled.' : 'Dual subtitles disabled.' @@ -70,7 +82,7 @@ export function PopupApp() { try { await updateSetting('useNativeSubtitles', useOfficial); await updateSetting('useOfficialTranslations', useOfficial); - + const statusKey = useOfficial ? 'statusSmartTranslationEnabled' : 'statusSmartTranslationDisabled'; @@ -81,7 +93,7 @@ export function PopupApp() { : 'Official subtitles disabled.' ); showStatus(statusText); - + sendImmediateConfigUpdate({ useNativeSubtitles: useOfficial, useOfficialTranslations: useOfficial, @@ -93,7 +105,9 @@ export function PopupApp() { component: 'useNativeSubtitlesToggle', }); } - showStatus('Failed to update official subtitles setting. Please try again.'); + showStatus( + 'Failed to update official subtitles setting. Please try again.' + ); } }; @@ -134,7 +148,9 @@ export function PopupApp() { const handleLayoutOrderChange = async (layoutOrder) => { try { await updateSetting('subtitleLayoutOrder', layoutOrder); - showStatus(t('statusDisplayOrderUpdated', 'Display order updated.')); + showStatus( + t('statusDisplayOrderUpdated', 'Display order updated.') + ); sendImmediateConfigUpdate({ subtitleLayoutOrder: layoutOrder }); } catch (error) { if (logger) { @@ -150,8 +166,15 @@ export function PopupApp() { const handleLayoutOrientationChange = async (layoutOrientation) => { try { await updateSetting('subtitleLayoutOrientation', layoutOrientation); - showStatus(t('statusLayoutOrientationUpdated', 'Layout orientation updated.')); - sendImmediateConfigUpdate({ subtitleLayoutOrientation: layoutOrientation }); + showStatus( + t( + 'statusLayoutOrientationUpdated', + 'Layout orientation updated.' + ) + ); + sendImmediateConfigUpdate({ + subtitleLayoutOrientation: layoutOrientation, + }); } catch (error) { if (logger) { logger.error('Error setting layout orientation', error, { @@ -159,7 +182,9 @@ export function PopupApp() { component: 'subtitleLayoutOrientationSelect', }); } - showStatus('Failed to update layout orientation. Please try again.'); + showStatus( + 'Failed to update layout orientation. Please try again.' + ); } }; @@ -171,7 +196,9 @@ export function PopupApp() { const handleFontSizeChangeEnd = async (fontSize) => { try { await updateSetting('subtitleFontSize', fontSize); - showStatus(`${t('statusFontSize', 'Font size: ')}${fontSize.toFixed(1)}vw.`); + showStatus( + `${t('statusFontSize', 'Font size: ')}${fontSize.toFixed(1)}vw.` + ); sendImmediateConfigUpdate({ subtitleFontSize: fontSize }); } catch (error) { if (logger) { @@ -192,7 +219,9 @@ export function PopupApp() { const handleGapChangeEnd = async (gap) => { try { await updateSetting('subtitleGap', gap); - showStatus(`${t('statusVerticalGap', 'Vertical gap: ')}${gap.toFixed(1)}em.`); + showStatus( + `${t('statusVerticalGap', 'Vertical gap: ')}${gap.toFixed(1)}em.` + ); sendImmediateConfigUpdate({ subtitleGap: gap }); } catch (error) { if (logger) { @@ -207,20 +236,30 @@ export function PopupApp() { const handleVerticalPositionChange = (verticalPosition) => { // Real-time update without saving - sendImmediateConfigUpdate({ subtitleVerticalPosition: verticalPosition }); + sendImmediateConfigUpdate({ + subtitleVerticalPosition: verticalPosition, + }); }; const handleVerticalPositionChangeEnd = async (verticalPosition) => { try { await updateSetting('subtitleVerticalPosition', verticalPosition); - showStatus(`${t('statusVerticalPosition', 'Vertical position: ')}${verticalPosition.toFixed(1)}.`); - sendImmediateConfigUpdate({ subtitleVerticalPosition: verticalPosition }); + showStatus( + `${t('statusVerticalPosition', 'Vertical position: ')}${verticalPosition.toFixed(1)}.` + ); + sendImmediateConfigUpdate({ + subtitleVerticalPosition: verticalPosition, + }); } catch (error) { if (logger) { - logger.error('Error setting subtitle vertical position', error, { - verticalPosition, - component: 'subtitleVerticalPositionInput', - }); + logger.error( + 'Error setting subtitle vertical position', + error, + { + verticalPosition, + component: 'subtitleVerticalPositionInput', + } + ); } showStatus('Failed to update vertical position. Please try again.'); } @@ -230,7 +269,9 @@ export function PopupApp() { try { let offset = parseFloat(value); if (isNaN(offset)) { - showStatus(t('statusInvalidOffset', 'Invalid offset, reverting.')); + showStatus( + t('statusInvalidOffset', 'Invalid offset, reverting.') + ); return; } offset = parseFloat(offset.toFixed(2)); @@ -288,9 +329,10 @@ export function PopupApp() { } = settings; // Use useOfficialTranslations if available, fallback to useNativeSubtitles - const useOfficial = useOfficialTranslations !== undefined - ? useOfficialTranslations - : useNativeSubtitles; + const useOfficial = + useOfficialTranslations !== undefined + ? useOfficialTranslations + : useNativeSubtitles; return ( <> @@ -309,7 +351,10 @@ export function PopupApp() { diff --git a/popup/components/SliderSetting.jsx b/popup/components/SliderSetting.jsx index badf65c..8cdc6c8 100644 --- a/popup/components/SliderSetting.jsx +++ b/popup/components/SliderSetting.jsx @@ -12,12 +12,15 @@ export function SliderSetting({ }) { const sliderRef = useRef(null); - const updateSliderProgress = useCallback((sliderElement, val) => { - const minVal = parseFloat(min) || 0; - const maxVal = parseFloat(max) || 100; - const percentage = ((val - minVal) / (maxVal - minVal)) * 100; - sliderElement.style.backgroundSize = `${percentage}% 100%`; - }, [min, max]); + const updateSliderProgress = useCallback( + (sliderElement, val) => { + const minVal = parseFloat(min) || 0; + const maxVal = parseFloat(max) || 100; + const percentage = ((val - minVal) / (maxVal - minVal)) * 100; + sliderElement.style.backgroundSize = `${percentage}% 100%`; + }, + [min, max] + ); useEffect(() => { if (sliderRef.current) { @@ -48,7 +51,9 @@ export function SliderSetting({ step={step} value={value} onInput={handleInput} - onChange={(e) => onChangeEnd && onChangeEnd(parseFloat(e.target.value))} + onChange={(e) => + onChangeEnd && onChangeEnd(parseFloat(e.target.value)) + } /> {formatValue(value)}
diff --git a/popup/hooks/index.js b/popup/hooks/index.js index 5ac01a6..e31b1f6 100644 --- a/popup/hooks/index.js +++ b/popup/hooks/index.js @@ -1,4 +1,6 @@ -export { useSettings } from './useSettings.js'; -export { useTranslation } from './useTranslation.js'; -export { useChromeMessage } from './useChromeMessage.js'; -export { useLogger } from './useLogger.js'; +import { useSettings } from '@shared/hooks/useSettings.js'; +import { useTranslation } from './useTranslation.js'; +import { useLogger } from './useLogger.js'; +import { useChromeMessage } from './useChromeMessage.js'; + +export { useSettings, useTranslation, useChromeMessage, useLogger }; diff --git a/popup/hooks/useSettings.js b/popup/hooks/useSettings.js index 004df12..a1f333f 100644 --- a/popup/hooks/useSettings.js +++ b/popup/hooks/useSettings.js @@ -1,92 +1 @@ -import { useState, useEffect, useCallback } from 'react'; -import { configService } from '../../services/configService.js'; - -/** - * Hook for managing extension settings - * @param {string|string[]} keys - Setting key(s) to watch - * @returns {Object} Settings state and update function - */ -export function useSettings(keys) { - const [settings, setSettings] = useState({}); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Load initial settings - useEffect(() => { - const loadSettings = async () => { - try { - setLoading(true); - let data; - - if (Array.isArray(keys)) { - data = await configService.getMultiple(keys); - } else if (keys) { - const value = await configService.get(keys); - data = { [keys]: value }; - } else { - data = await configService.getAll(); - } - - setSettings(data); - setError(null); - } catch (err) { - setError(err); - console.error('Error loading settings:', err); - } finally { - setLoading(false); - } - }; - - loadSettings(); - }, [keys]); - - // Listen for setting changes - useEffect(() => { - const handleChange = (changes) => { - setSettings(prev => ({ ...prev, ...changes })); - }; - - const unsubscribe = configService.onChanged(handleChange); - - return () => { - // Clean up listener - if (typeof unsubscribe === 'function') { - unsubscribe(); - } - }; - }, []); - - // Update a setting - const updateSetting = useCallback(async (key, value) => { - try { - await configService.set(key, value); - setSettings(prev => ({ ...prev, [key]: value })); - return true; - } catch (err) { - setError(err); - console.error(`Error updating setting ${key}:`, err); - return false; - } - }, []); - - // Update multiple settings at once - const updateSettings = useCallback(async (updates) => { - try { - await configService.setMultiple(updates); - setSettings(prev => ({ ...prev, ...updates })); - return true; - } catch (err) { - setError(err); - console.error('Error updating settings:', err); - return false; - } - }, []); - - return { - settings, - updateSetting, - updateSettings, - loading, - error, - }; -} +export { useSettings } from '@shared/hooks/useSettings.js'; diff --git a/popup/hooks/useTranslation.js b/popup/hooks/useTranslation.js index 2940c83..5c412d1 100644 --- a/popup/hooks/useTranslation.js +++ b/popup/hooks/useTranslation.js @@ -14,7 +14,7 @@ export function useTranslation(locale) { useEffect(() => { const loadTranslations = async () => { const normalizedLangCode = locale.replace('-', '_'); - + // Check cache first if (translationsCache[normalizedLangCode]) { setTranslations(translationsCache[normalizedLangCode]); @@ -29,11 +29,11 @@ export function useTranslation(locale) { try { setLoading(true); const response = await fetch(translationsPath); - + if (!response.ok) { throw new Error(`HTTP ${response.status}`); } - + const data = await response.json(); translationsCache[normalizedLangCode] = data; setTranslations(data); @@ -42,7 +42,7 @@ export function useTranslation(locale) { `Could not load '${normalizedLangCode}' translations, falling back to English`, error ); - + // Fallback to English try { const fallbackPath = chrome.runtime.getURL( @@ -70,22 +70,25 @@ export function useTranslation(locale) { }, [locale]); // Translation function - const t = useCallback((key, fallback = '', ...substitutions) => { - let message = translations[key]?.message || fallback || key; - - // Replace %s and %d placeholders with substitutions - if (substitutions.length > 0) { - let substitutionIndex = 0; - message = message.replace(/%[sd]/g, (match) => { - if (substitutionIndex < substitutions.length) { - return substitutions[substitutionIndex++]; - } - return match; - }); - } - - return message; - }, [translations]); + const t = useCallback( + (key, fallback = '', ...substitutions) => { + let message = translations[key]?.message || fallback || key; + + // Replace %s and %d placeholders with substitutions + if (substitutions.length > 0) { + let substitutionIndex = 0; + message = message.replace(/%[sd]/g, (match) => { + if (substitutionIndex < substitutions.length) { + return substitutions[substitutionIndex++]; + } + return match; + }); + } + + return message; + }, + [translations] + ); return { t, loading, translations }; } diff --git a/shared/hooks/useSettings.js b/shared/hooks/useSettings.js new file mode 100644 index 0000000..d962543 --- /dev/null +++ b/shared/hooks/useSettings.js @@ -0,0 +1,109 @@ +import { useState, useEffect, useCallback } from 'react'; +import { configService } from '../../services/configService.js'; + +/** + * Hook for managing extension settings + * @param {string|string[]} keys - Setting key(s) to watch + * @returns {Object} Settings state and update function + */ +export function useSettings(keys) { + const [settings, setSettings] = useState({}); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Load initial settings + useEffect(() => { + const loadSettings = async () => { + try { + setLoading(true); + let data; + + if (Array.isArray(keys)) { + data = await configService.getMultiple(keys); + } else if (keys) { + const value = await configService.get(keys); + data = { [keys]: value }; + } else { + data = await configService.getAll(); + } + + setSettings(data); + setError(null); + } catch (err) { + setError(err); + console.error('Error loading settings:', err); + } finally { + setLoading(false); + } + }; + + loadSettings(); + }, [JSON.stringify(keys)]); + + // Listen for setting changes + useEffect(() => { + const handleChange = (changes) => { + const relevantChanges = {}; + let hasRelevantChange = false; + + if (!keys) { + setSettings((prev) => ({ ...prev, ...changes })); + return; + } + + const watchedKeys = Array.isArray(keys) ? keys : [keys]; + for (const key in changes) { + if (watchedKeys.includes(key)) { + relevantChanges[key] = changes[key]; + hasRelevantChange = true; + } + } + + if (hasRelevantChange) { + setSettings((prev) => ({ ...prev, ...relevantChanges })); + } + }; + + const unsubscribe = configService.onChanged(handleChange); + + return () => { + if (typeof unsubscribe === 'function') { + unsubscribe(); + } + }; + }, [JSON.stringify(keys)]); + + // Update a setting + const updateSetting = useCallback(async (key, value) => { + try { + await configService.set(key, value); + setSettings((prev) => ({ ...prev, [key]: value })); + return true; + } catch (err) { + setError(err); + console.error(`Error updating setting ${key}:`, err); + return false; + } + }, []); + + // Update multiple settings at once + const updateSettings = useCallback(async (updates) => { + try { + await configService.setMultiple(updates); + setSettings((prev) => ({ ...prev, ...updates })); + return true; + } catch (err) { + setError(err); + console.error('Error updating settings:', err); + return false; + } + }, []); + + return { + settings, + updateSetting, + updateSettings, + loading, + error, + }; +} diff --git a/sidepanel/SidePanelApp.jsx b/sidepanel/SidePanelApp.jsx new file mode 100644 index 0000000..d004aea --- /dev/null +++ b/sidepanel/SidePanelApp.jsx @@ -0,0 +1,88 @@ +import React, { useState, useEffect, useCallback } 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'; +import { useSidePanelCommunication } from './hooks/useSidePanelCommunication.js'; +import { useTranslation } from './hooks/useTranslation.js'; + +/** + * 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 } = useTheme(); + const { settings, loading: settingsLoading } = useSettings([ + 'sidePanelTheme', + 'sidePanelWordsListsEnabled', + 'uiLanguage', + ]); + const { t } = useTranslation(); + + const handleTabChange = useCallback( + (tabId) => { + setActiveTab(tabId); + postMessage('sidePanelUpdateState', { activeTab: tabId }); + }, + [] + ); + + // 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((prev) => prev || settings.sidePanelDefaultTab); + } + }, [settings.sidePanelDefaultTab, settingsLoading]); + + // Show loading state while settings are loading + if (settingsLoading) { + return ( +
+
+
+

+ {t('sidepanelLoading')} +

+
+
+ ); + } + + 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..0e93a8a --- /dev/null +++ b/sidepanel/components/TabNavigator.jsx @@ -0,0 +1,122 @@ +import React from 'react'; +import { useTranslation } from '../hooks/useTranslation.js'; + +/** + * 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 { t } = useTranslation(); + + const tabs = [ + { + id: 'ai-analysis', + label: t('sidepanelTabAIAnalysis'), + enabled: true, + }, + { + id: 'words-lists', + label: t('sidepanelTabWordsLists'), + 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..faef46e --- /dev/null +++ b/sidepanel/components/tabs/AIAnalysisTab.jsx @@ -0,0 +1,153 @@ +import React from 'react'; +import { useSidePanelContext } from '../../hooks/SidePanelContext.jsx'; +import { useAIAnalysis } from '../../hooks/useAIAnalysis.js'; +import { useWordSelection } from '../../hooks/useWordSelection.js'; +import { useTranslation } from '../../hooks/useTranslation.js'; + +/** + * AI Analysis Tab + * + * 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 { t } = useTranslation(); + + const handleAnalyze = () => { + if (selectedWords.length > 0) { + analyzeWords(); + } + }; + + const handleWordRemove = (word) => { + toggleWord(word); + }; + + return ( + <> +
+
+

AI Analysis

+ +
+ +
+ +
+
+ {selectedWords.map((word) => ( + + {word} + + + ))} + {selectedWords.length === 0 && ( + + {t('sidepanelWordInputPlaceholder')} + + )} +
+
+
+ + {isAnalyzing && ( +
+
+

{t('sidepanelAnalyzing')}

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

{error}

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

+ {t('sidepanelResultsTitle').replace('%s', selectedWords.join(', '))} +

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

{t('sidepanelSectionDefinition')}

+

+ {analysisResult.definition} +

+
+ )} + + {/* Cultural */} + {(analysisResult?.cultural_analysis || analysisResult?.culturalContext) && ( +
+

{t('sidepanelSectionCultural')}

+

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

+
+ )} + + {/* Historical */} + {(analysisResult?.historical_analysis || analysisResult?.historicalContext) && ( +
+

{t('sidepanelSectionHistorical')}

+

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

+
+ )} + + {/* Linguistic */} + {(analysisResult?.linguistic_analysis || analysisResult?.linguisticAnalysis) && ( +
+

{t('sidepanelSectionLinguistic')}

+

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

+
+ )} +
+
+ )} +
+ + ); +} diff --git a/sidepanel/components/tabs/WordsListsTab.jsx b/sidepanel/components/tabs/WordsListsTab.jsx new file mode 100644 index 0000000..3da7d84 --- /dev/null +++ b/sidepanel/components/tabs/WordsListsTab.jsx @@ -0,0 +1,96 @@ +import React from 'react'; +import { useTranslation } from '../../hooks/useTranslation.js'; + +/** + * 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() { + const { t } = useTranslation(); + // 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 ( + <> +
+

{t('sidepanelMyWordsTitle')}

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

+ {t('sidepanelFeatureComingSoon')} +
+ {t('sidepanelFeatureComingSoonDesc')} +

+
+ + +
+ + ); +} diff --git a/sidepanel/hooks/SidePanelContext.jsx b/sidepanel/hooks/SidePanelContext.jsx new file mode 100644 index 0000000..d06ebe5 --- /dev/null +++ b/sidepanel/hooks/SidePanelContext.jsx @@ -0,0 +1,214 @@ +import React, { + createContext, + useContext, + useState, + useEffect, + useMemo, + useRef, +} from 'react'; +import { useSidePanelCommunication } from './useSidePanelCommunication'; + +/** + * 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 [tabState, setTabState] = useState({}); + const [activeTabId, setActiveTabId] = useState(null); + const { onMessage, getActiveTab, postMessage, getBinding } = useSidePanelCommunication(); + + const globalTargetLangRef = useRef('zh-CN'); + + // Sync target language with storage + useEffect(() => { + const updateLanguage = (lang) => { + if (!lang) return; + globalTargetLangRef.current = lang; + + // Update all tabs with the new language + setTabState((prev) => { + const newState = { ...prev }; + let hasChanges = false; + + Object.keys(newState).forEach((tId) => { + if (newState[tId].targetLanguage !== lang) { + newState[tId] = { + ...newState[tId], + targetLanguage: lang + }; + hasChanges = true; + } + }); + + return hasChanges ? newState : prev; + }); + }; + + // Load initial + chrome.storage.sync.get('targetLanguage', (items) => { + if (items.targetLanguage) { + updateLanguage(items.targetLanguage); + } + }); + + // Listen for changes + const handleStorageChange = (changes, area) => { + if (area === 'sync' && changes.targetLanguage) { + updateLanguage(changes.targetLanguage.newValue); + } + }; + + chrome.storage.onChanged.addListener(handleStorageChange); + return () => chrome.storage.onChanged.removeListener(handleStorageChange); + }, []); + + // Initial setup and tab activation listener + useEffect(() => { + const handleTabActivated = (tabId) => { + setActiveTabId(tabId); + setTabState((prev) => ({ + ...prev, + [tabId]: prev[tabId] || { + selectedWords: [], + analysisResult: null, + isAnalyzing: false, + error: null, + sourceLanguage: 'en', + targetLanguage: globalTargetLangRef.current, + }, + })); + }; + + // Get initial active tab + getActiveTab() + .then((tab) => { + if (tab?.id) { + handleTabActivated(tab.id); + } + }) + .catch((err) => console.error('Failed to get initial tab:', err)); + + // Listen for tab activation changes from background + const unsubscribe = onMessage('tabActivated', ({ tabId }) => { + handleTabActivated(tabId); + + // Notify background to register this tab + try { + const binding = getBinding(); + postMessage('sidePanelRegister', { + tabId, + windowId: binding?.boundWindowId, + panelInstanceId: binding?.panelInstanceId + }); + } catch (e) { + console.error('Failed to register on tab switch:', e); + } + }); + + // Listen for forced tab binding + const unsubscribeForce = onMessage('sidePanelForceBindTab', ({ tabId }) => { + handleTabActivated(tabId); + try { + const binding = getBinding(); + postMessage('sidePanelRegister', { + tabId, + windowId: binding?.boundWindowId, + panelInstanceId: binding?.panelInstanceId + }); + } catch (e) { + console.error('Failed to register on force bind:', e); + } + }); + + return () => { + unsubscribe(); + unsubscribeForce(); + }; + }, [getActiveTab, onMessage, postMessage, getBinding]); + + // Handle selection sync from background + useEffect(() => { + const unsubscribe = onMessage('sidePanelSelectionSync', ({ selectedWords, tabId }) => { + const normalizedWords = Array.isArray(selectedWords) + ? Array.from(new Set(selectedWords.map(w => w?.trim()).filter(Boolean))) + : []; + + const targetTabId = tabId || activeTabId; + + if (targetTabId) { + setTabState((prev) => ({ + ...prev, + [targetTabId]: { + ...(prev[targetTabId] || {}), + selectedWords: normalizedWords, + }, + })); + } + }); + + return unsubscribe; + }, [onMessage, activeTabId]); + + // Memoized context value + const value = useMemo(() => { + const activeState = tabState[activeTabId] || { + selectedWords: [], + analysisResult: null, + isAnalyzing: false, + error: null, + sourceLanguage: 'en', + targetLanguage: 'zh-CN', + }; + + const updateActiveTabState = (newState) => { + if (activeTabId) { + setTabState((prev) => ({ + ...prev, + [activeTabId]: { ...prev[activeTabId], ...newState }, + })); + } + }; + + return { + ...activeState, + activeTabId, + setSelectedWords: (words) => updateActiveTabState({ selectedWords: words }), + setAnalysisResult: (result) => updateActiveTabState({ analysisResult: result }), + setIsAnalyzing: (isAnalyzing) => updateActiveTabState({ isAnalyzing }), + setError: (error) => updateActiveTabState({ error }), + setSourceLanguage: (lang) => updateActiveTabState({ sourceLanguage: lang }), + setTargetLanguage: (lang) => updateActiveTabState({ targetLanguage: lang }), + clearAnalysis: () => updateActiveTabState({ analysisResult: null, error: null }), + clearWords: () => updateActiveTabState({ selectedWords: [] }), + addWord: (word) => updateActiveTabState({ + selectedWords: [...new Set([...activeState.selectedWords, word])], + }), + removeWord: (word) => updateActiveTabState({ + selectedWords: activeState.selectedWords.filter((w) => w !== word), + }), + }; + }, [tabState, activeTabId]); + + 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..7ab29ca --- /dev/null +++ b/sidepanel/hooks/useAIAnalysis.js @@ -0,0 +1,273 @@ +import { useState, useCallback, useRef, useEffect } from 'react'; +import { useSidePanelContext } from './SidePanelContext.jsx'; +import { useSidePanelCommunication } from './useSidePanelCommunication.js'; + +/** + * 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); + + const { sendToBoundTab } = useSidePanelCommunication(); + + // 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(chrome.i18n.getMessage('sidepanelErrorNoWords')); + return null; + } + + if (!settings?.aiContextEnabled) { + setError(chrome.i18n.getMessage('sidepanelErrorDisabled')); + 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); + + // Notify content script (bound tab) that analysis started (to block word clicks) + try { + await sendToBoundTab('sidePanelSetAnalyzing', { isAnalyzing: true }); + } catch (err) { + console.warn('Failed to notify analyzing state:', err); + } + + 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 payload = response.result || response; + const normalized = payload?.analysis || payload?.result || null; + + // Store in cache + if (normalized) { + setCachedResult( + wordsToAnalyze, + contextTypes, + sourceLanguage, + normalized + ); + } + + setAnalysisResult(normalized); + return normalized; + } else { + const errorMsg = + response?.error || chrome.i18n.getMessage('sidepanelErrorGeneric'); + 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 || chrome.i18n.getMessage('sidepanelErrorGeneric'); + setError(errorMsg); + return null; + } finally { + setIsAnalyzing(false); + abortControllerRef.current = null; + + // Notify content script (bound tab) that analysis stopped + try { + await sendToBoundTab('sidePanelSetAnalyzing', { isAnalyzing: false }); + } catch (err) { + console.warn('Failed to notify analyzing state:', err); + } + } + }, + [ + 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..a1f333f --- /dev/null +++ b/sidepanel/hooks/useSettings.js @@ -0,0 +1 @@ +export { useSettings } from '@shared/hooks/useSettings.js'; diff --git a/sidepanel/hooks/useSidePanelCommunication.js b/sidepanel/hooks/useSidePanelCommunication.js new file mode 100644 index 0000000..688952a --- /dev/null +++ b/sidepanel/hooks/useSidePanelCommunication.js @@ -0,0 +1,278 @@ +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); + const reconnectTimerRef = useRef(null); + const bindingRef = useRef({ panelInstanceId: null, boundTabId: null, boundWindowId: null }); + const mountedRef = useRef(false); + + // Initialize instance ID once + useEffect(() => { + if (!bindingRef.current.panelInstanceId) { + bindingRef.current.panelInstanceId = crypto.randomUUID(); + } + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + /** + * Register the side panel with the active tab and background script. + */ + const registerWithActiveTab = useCallback(async () => { + if (!portRef.current) return; + + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + if (tab?.id) { + const windowId = tab.windowId; + bindingRef.current.boundTabId = tab.id; + bindingRef.current.boundWindowId = windowId; + + portRef.current.postMessage({ + action: 'sidePanelRegister', + data: { + tabId: tab.id, + windowId, + panelInstanceId: bindingRef.current.panelInstanceId + }, + source: 'sidepanel', + timestamp: Date.now(), + }); + + // Request fresh state + portRef.current.postMessage({ + action: 'sidePanelGetState', + data: {}, + source: 'sidepanel', + timestamp: Date.now(), + }); + } + } catch (e) { + console.error('Failed to register side panel:', e); + } + }, []); + + /** + * Establishes a long-lived connection to the background script. + */ + const connectPort = useCallback(() => { + if (portRef.current) return; + + try { + const port = chrome.runtime.connect({ name: 'sidepanel' }); + portRef.current = port; + setIsConnected(true); + setError(null); + + port.onMessage.addListener((message) => { + // Handle internal binding updates + if (message?.action === 'bindingChanged' && message?.data) { + const { tabId, windowId } = message.data; + if (typeof tabId === 'number') bindingRef.current.boundTabId = tabId; + if (typeof windowId === 'number') bindingRef.current.boundWindowId = windowId; + } + + // Dispatch to listeners + const listeners = messageListeners.current.get(message.action); + if (listeners) { + listeners.forEach((callback) => { + try { + callback(message.data); + } catch (err) { + console.error(`Error in listener for ${message.action}:`, err); + } + }); + } + }); + + port.onDisconnect.addListener(() => { + console.log('Side panel disconnected'); + portRef.current = null; + setIsConnected(false); + + // Attempt reconnect if still mounted + if (mountedRef.current) { + reconnectTimerRef.current = setTimeout(connectPort, 1000); + } + }); + + // Initial registration + registerWithActiveTab(); + + } catch (err) { + console.error('Connection failed:', err); + setError(err); + setIsConnected(false); + if (mountedRef.current) { + reconnectTimerRef.current = setTimeout(connectPort, 2000); + } + } + }, [registerWithActiveTab]); + + // Lifecycle management for connection + useEffect(() => { + connectPort(); + + return () => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + } + if (portRef.current) { + try { + portRef.current.disconnect(); + } catch (e) { + // Ignore disconnect errors + } + portRef.current = null; + } + }; + }, [connectPort]); + + /** + * Send a one-off 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?.error) { + throw new Error(response.error); + } + return response; + } catch (err) { + console.error(`sendMessage failed (${action}):`, 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?.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?.error) throw new Error(response.error); + return response; + } catch (err) { + console.error(`sendToActiveTab failed (${action}):`, err); + throw err; + } + }, []); + + /** + * Send a message to the currently bound tab's content script. + */ + const sendToBoundTab = useCallback(async (action, data = {}) => { + const tabId = bindingRef.current.boundTabId; + if (!tabId) { + return sendToActiveTab(action, data); + } + + try { + const response = await chrome.tabs.sendMessage(tabId, { + action, + data, + source: 'sidepanel', + timestamp: Date.now(), + }); + + if (response?.error) throw new Error(response.error); + return response; + } catch (err) { + console.error(`sendToBoundTab failed (${action}):`, err); + throw err; + } + }, [sendToActiveTab]); + + /** + * Send a message via the long-lived port connection. + */ + const postMessage = useCallback((action, data = {}) => { + if (!portRef.current) { + console.warn('Cannot post message: disconnected'); + return; + } + try { + portRef.current.postMessage({ + action, + data, + source: 'sidepanel', + timestamp: Date.now(), + }); + } catch (err) { + console.error(`postMessage failed (${action}):`, 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 () => { + const listeners = messageListeners.current.get(action); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + messageListeners.current.delete(action); + } + } + }; + }, []); + + const getActiveTab = useCallback(async () => { + try { + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }); + return tab; + } catch (err) { + console.error('getActiveTab failed:', err); + return null; + } + }, []); + + return { + isConnected, + error, + sendMessage, + sendToActiveTab, + sendToBoundTab, + postMessage, + onMessage, + getActiveTab, + getBinding: () => ({ ...bindingRef.current }), + }; +} + 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/useTranslation.js b/sidepanel/hooks/useTranslation.js new file mode 100644 index 0000000..9e3c206 --- /dev/null +++ b/sidepanel/hooks/useTranslation.js @@ -0,0 +1,78 @@ +import { useState, useEffect, useCallback } from 'react'; +import { useSettings } from './useSettings.js'; + +/** + * Custom hook for handling translations + * Wraps chrome.i18n.getMessage but supports dynamic language switching via settings + */ +export function useTranslation() { + const { settings } = useSettings(['uiLanguage']); + const [messages, setMessages] = useState(null); + const [currentLang, setCurrentLang] = useState(null); + + useEffect(() => { + const loadMessages = async () => { + // Default to 'en' if not set, or fallback to browser language if we could detect it easily mapping to our supported locales + // For now, we rely on the setting. If not set, we might want to let chrome.i18n handle it (which uses browser locale) + // But to ensure consistency if the user *explicitly* sets it, we load it. + const lang = settings?.uiLanguage; + + if (!lang || lang === currentLang) return; + + try { + // Chrome locales use underscores (e.g., zh_CN) but settings might use hyphens (e.g., zh-CN) + const normalizedLang = lang.replace('-', '_'); + const url = chrome.runtime.getURL(`_locales/${normalizedLang}/messages.json`); + const response = await fetch(url); + const data = await response.json(); + setMessages(data); + setCurrentLang(lang); + } catch (error) { + console.error(`Failed to load messages for ${lang}`, error); + // Fallback to null so we use chrome.i18n + setMessages(null); + setCurrentLang(null); + } + }; + + loadMessages(); + }, [settings?.uiLanguage, currentLang]); + + const t = useCallback((key, substitutions) => { + // If we have loaded messages for the selected language, use them + if (messages && messages[key]) { + let message = messages[key].message; + + // Handle substitutions (simple %s replacement to match existing keys) + if (substitutions) { + const subs = Array.isArray(substitutions) ? substitutions : [substitutions]; + subs.forEach((sub) => { + message = message.replace('%s', sub); + }); + } + return message; + } + + // Fallback to chrome.i18n (uses browser locale) + // Note: chrome.i18n.getMessage does NOT automatically replace %s. + // It expects $PLACEHOLDERS$. If our keys use %s, we must handle it manually even for chrome.i18n result if we want it to work. + // However, existing code might rely on chrome.i18n behavior. + // If we want to fix the %s issue globally, we should do it here too. + let nativeMessage = chrome.i18n.getMessage(key, substitutions); + + // If chrome.i18n returned a message and we have substitutions, try %s replacement if it wasn't handled + if (nativeMessage && substitutions) { + const subs = Array.isArray(substitutions) ? substitutions : [substitutions]; + // Only replace if it looks like it needs it (contains %s) + if (nativeMessage.includes('%s')) { + subs.forEach((sub) => { + nativeMessage = nativeMessage.replace('%s', sub); + }); + } + } + + return nativeMessage || key; + }, [messages]); + + return { t }; +} diff --git a/sidepanel/hooks/useWordSelection.js b/sidepanel/hooks/useWordSelection.js new file mode 100644 index 0000000..953445c --- /dev/null +++ b/sidepanel/hooks/useWordSelection.js @@ -0,0 +1,131 @@ +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: + * - Actions for toggling/clearing words (using activeTabId from context) + * - Persisting selection state + */ +export function useWordSelection() { + const { + selectedWords, + addWord, + removeWord, + clearWords, + sourceLanguage, + targetLanguage, + activeTabId + } = useSidePanelContext(); + + const { onMessage, postMessage } = useSidePanelCommunication(); + + // Listen for selection clear events from the content script + useEffect(() => { + const unsubscribe = onMessage('sidePanelClearSelection', () => { + clearWords(); + }); + return unsubscribe; + }, [onMessage, clearWords]); + + /** + * Toggle word selection + */ + const toggleWord = useCallback( + async (word) => { + // Compute next selection locally to sync with content script reliably + let next; + if (selectedWords.includes(word)) { + next = selectedWords.filter((w) => w !== word); + removeWord(word); + } else { + next = [...selectedWords, word]; + addWord(word); + } + + // Send to the currently active tab view + if (activeTabId) { + try { + await chrome.tabs.sendMessage(activeTabId, { + action: 'sidePanelUpdateState', + data: { + clearSelection: true, + selectedWords: next, + }, + source: 'sidepanel' + }); + } catch (err) { + console.error('Failed to sync toggle to content script:', err); + } + } + + // NOTE: We do NOT send sidePanelSelectionSync to background here. + // We wait for the content script to process the update and broadcast the sync back. + // This ensures the content script remains the single source of truth. + }, + [selectedWords, addWord, removeWord, activeTabId, postMessage] + ); + + /** + * Clear selection and notify content script + */ + const clearSelection = useCallback(async () => { + clearWords(); + + if (activeTabId) { + try { + await chrome.tabs.sendMessage(activeTabId, { + action: 'sidePanelUpdateState', + data: { + selectedWords: [], + clearSelection: true, + }, + source: 'sidepanel' + }); + } catch (err) { + console.error('Failed to notify content script of clear:', err); + } + } + }, [clearWords, activeTabId, postMessage]); + + /** + * Persist selection suggestion on changes (global suggestion bucket) + */ + useEffect(() => { + const persistSuggestion = async () => { + try { + const result = await chrome.storage.sync.get(['sidePanelPersistAcrossTabs']); + if (result.sidePanelPersistAcrossTabs) { + const local = await chrome.storage.local.get(['sidePanelSelectionBuckets']); + const buckets = local.sidePanelSelectionBuckets || {}; + buckets['global:default'] = { + words: selectedWords, + sourceLanguage, + targetLanguage, + ts: Date.now(), + }; + await chrome.storage.local.set({ sidePanelSelectionBuckets: buckets }); + } + } catch (err) { + console.error('Failed to persist selection suggestion:', err); + } + }; + if (selectedWords.length > 0) { + persistSuggestion(); + } + }, [selectedWords, sourceLanguage, targetLanguage]); + + return { + selectedWords, + addWord, + removeWord, + toggleWord, + clearSelection, + syncWithContentScript: async () => { } // No-op stub for compatibility if needed + }; +} diff --git a/sidepanel/sidepanel.css b/sidepanel/sidepanel.css new file mode 100644 index 0000000..4155723 --- /dev/null +++ b/sidepanel/sidepanel.css @@ -0,0 +1,708 @@ +/** + * 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; +} + +/* Spinner Animation */ +.spinner { + width: 40px; + height: 40px; + border: 4px solid var(--color-border-light); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 1rem; +} + +body.dark .spinner { + border-color: var(--color-border-dark); + border-top-color: var(--color-primary); +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* --- AI Analysis Tab Styles --- */ + +.ai-analysis-tab { + padding: var(--spacing-4); + min-width: 360px; + max-width: 920px; + margin: 0 auto; +} + +.tab-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--spacing-4); +} + +.tab-title { + font-size: var(--font-size-2xl); + font-weight: 700; + color: var(--color-foreground-light); + margin: 0; +} + +body.dark .tab-title { + color: var(--color-foreground-dark); +} + +.analyze-button { + display: flex; + align-items: center; + gap: var(--spacing-2); + background: var(--color-primary); + color: white; + padding: var(--spacing-2) var(--spacing-4); + border-radius: var(--radius-lg); + font-weight: 600; + font-size: var(--font-size-sm); + transition: all var(--transition-base); +} + +.analyze-button:hover:not(:disabled) { + background: #1170d8; +} + +.analyze-button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.analyze-button .material-symbols-outlined { + font-size: 18px; +} + +.input-container { + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-6); +} + +body.dark .input-container { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); +} + +.input-label { + display: block; + font-size: var(--font-size-sm); + font-weight: 500; + color: var(--color-foreground-light); + margin-bottom: var(--spacing-2); +} + +body.dark .input-label { + color: var(--color-foreground-dark); +} + +.word-input-wrapper { + background: var(--color-background-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3); +} + +body.dark .word-input-wrapper { + background: var(--color-background-dark); + border-color: var(--color-border-dark); +} + +.word-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-2); + min-height: 40px; + align-items: center; +} + +.word-tag { + display: flex; + align-items: center; + gap: var(--spacing-1); + background: var(--color-primary); + color: white; + font-size: var(--font-size-sm); + font-weight: 600; + padding: var(--spacing-1) var(--spacing-2); + border-radius: 6px; +} + +body.dark .word-tag { + background: #0f5fb8; + color: white; +} + +.word-tag-remove { + display: flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + margin-left: var(--spacing-1); + background: rgba(255, 255, 255, 0.25); + color: white; + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 4px; + font-size: 16px; + font-weight: 700; + line-height: 1; + cursor: pointer; + transition: all 0.2s ease; + padding: 0; +} + +.word-tag-remove:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.4); + border-color: rgba(255, 255, 255, 0.5); + transform: scale(1.1); +} + +.word-tag-remove:active:not(:disabled) { + transform: scale(0.95); +} + +.word-tag-remove:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.placeholder-text { + color: var(--color-subtle-light); + font-size: var(--font-size-sm); +} + +body.dark .placeholder-text { + color: var(--color-subtle-dark); +} + +.loading-state { + text-align: center; + padding: var(--spacing-8); + color: var(--color-subtle-light); +} + +body.dark .loading-state { + color: var(--color-subtle-dark); +} + +.error-message { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + background: rgba(239, 68, 68, 0.1); + color: var(--color-error); + padding: var(--spacing-4); + border-radius: var(--radius-lg); + border: 1px solid var(--color-error); + margin-bottom: var(--spacing-4); +} + +.error-message p { + margin: 0 0 var(--spacing-2) 0; +} + +.error-retry { + background: var(--color-error); + color: white; + padding: var(--spacing-1) var(--spacing-3); + border-radius: var(--radius-default); + font-size: var(--font-size-sm); + font-weight: 500; + transition: all var(--transition-fast); +} + +.error-retry:hover { + background: #dc2626; +} + +.results-container { + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-4); +} + +body.dark .results-container { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); +} + +.results-title { + font-size: var(--font-size-lg); + font-weight: 600; + color: var(--color-foreground-light); + margin-bottom: var(--spacing-4); +} + +body.dark .results-title { + color: var(--color-foreground-dark); +} + +.results-sections { + display: flex; + flex-direction: column; + gap: var(--spacing-4); +} + +.result-section { + padding: 0; +} + +.result-section-title { + font-size: var(--font-size-base); + font-weight: 600; + color: var(--color-primary); + margin: 0 0 var(--spacing-2) 0; +} + +.result-section-content { + font-size: var(--font-size-sm); + line-height: 1.6; + color: var(--color-subtle-light); + margin: 0; +} + +body.dark .result-section-content { + color: var(--color-subtle-dark); +} + +/* --- Words Lists Tab Styles --- */ + +.words-lists-tab { + padding: var(--spacing-4); + min-width: 360px; + max-width: 920px; + margin: 0 auto; +} + +.controls-row { + display: flex; + gap: var(--spacing-2); + margin-bottom: var(--spacing-6); +} + +.list-selector { + position: relative; + flex: 1; +} + +.list-select { + width: 100%; + appearance: none; + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3) var(--spacing-4); + padding-right: 40px; + font-size: var(--font-size-base); + color: var(--color-foreground-light); + cursor: pointer; +} + +body.dark .list-select { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); + color: var(--color-foreground-dark); +} + +.select-icon { + position: absolute; + right: var(--spacing-2); + top: 50%; + transform: translateY(-50%); + color: var(--color-subtle-light); + pointer-events: none; +} + +body.dark .select-icon { + color: var(--color-subtle-dark); +} + +.filter-button { + flex-shrink: 0; + background: var(--color-surface-light); + border: 1px solid var(--color-border-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3); + color: var(--color-subtle-light); + transition: all var(--transition-base); +} + +body.dark .filter-button { + background: var(--color-surface-dark); + border-color: var(--color-border-dark); + color: var(--color-subtle-dark); +} + +.filter-button:hover { + background: rgba(19, 127, 236, 0.1); + color: var(--color-primary); +} + +body.dark .filter-button:hover { + background: rgba(19, 127, 236, 0.2); +} + +.filter-button .material-symbols-outlined { + font-size: 20px; +} + +.feature-notice { + display: flex; + align-items: flex-start; + gap: var(--spacing-3); + background: rgba(19, 127, 236, 0.1); + border: 1px solid var(--color-primary); + border-radius: var(--radius-lg); + padding: var(--spacing-4); + margin-bottom: var(--spacing-4); + color: var(--color-foreground-light); +} + +body.dark .feature-notice { + color: var(--color-foreground-dark); +} + +.notice-icon { + color: var(--color-primary); + flex-shrink: 0; + margin-top: 2px; +} + +.feature-notice p { + margin: 0; + font-size: var(--font-size-sm); + line-height: 1.6; +} + +.words-list { + display: flex; + flex-direction: column; + gap: var(--spacing-2); +} + +.word-card { + display: flex; + align-items: center; + gap: var(--spacing-4); + background: var(--color-surface-light); + border-radius: var(--radius-lg); + padding: var(--spacing-3); + transition: all var(--transition-base); + cursor: pointer; +} + +body.dark .word-card { + background: var(--color-surface-dark); +} + +.word-card.starred { + background: rgba(19, 127, 236, 0.1); +} + +body.dark .word-card.starred { + background: rgba(19, 127, 236, 0.2); +} + +.word-card:hover { + background: var(--color-background-light); +} + +body.dark .word-card:hover { + background: rgba(25, 38, 51, 0.8); +} + +.word-content { + flex: 1; +} + +.word-text { + font-weight: 600; + color: var(--color-foreground-light); + margin: 0 0 var(--spacing-1) 0; +} + +body.dark .word-text { + color: var(--color-foreground-dark); +} + +.word-translation { + font-size: var(--font-size-sm); + color: var(--color-subtle-light); + margin: 0; +} + +body.dark .word-translation { + color: var(--color-subtle-dark); +} + +.word-actions { + display: flex; + align-items: center; +} + +.star-button { + padding: var(--spacing-2); + border-radius: var(--radius-full); + color: var(--color-subtle-light); + transition: all var(--transition-base); +} + +body.dark .star-button { + color: var(--color-subtle-dark); +} + +.star-button:hover { + background: rgba(19, 127, 236, 0.2); + color: var(--color-star); +} + +body.dark .star-button:hover { + background: rgba(19, 127, 236, 0.3); +} + +.star-button .material-symbols-outlined.filled { + color: var(--color-star); +} + +/* --- SidePanelApp Styles --- */ + +.sidepanel-container { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; + overflow: hidden; +} + +.sidepanel-content { + flex: 1; + overflow-y: auto; + overflow-x: hidden; +} diff --git a/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/translation_providers/deeplTranslate.js b/translation_providers/deeplTranslate.js index dcf0d5e..a414118 100644 --- a/translation_providers/deeplTranslate.js +++ b/translation_providers/deeplTranslate.js @@ -61,8 +61,8 @@ function detectEnvironment() { environmentType: isServiceWorker ? 'service-worker' : isBrowser - ? 'browser' - : 'unknown', + ? 'browser' + : 'unknown', }; } catch (error) { logger.warn('Environment detection failed', { diff --git a/translation_providers/geminiVertexTranslate.js b/translation_providers/geminiVertexTranslate.js index 74e5871..405630d 100644 --- a/translation_providers/geminiVertexTranslate.js +++ b/translation_providers/geminiVertexTranslate.js @@ -48,20 +48,23 @@ function parsePossiblyJson(responseText) { if (typeof responseText !== 'string' || responseText.trim() === '') { return ''; } - const jsonMatch = responseText.match(/```json\s*([\s\S]*?)\s*```|(\[[\s\S]*\])/); - const jsonString = jsonMatch ? (jsonMatch[1] || jsonMatch[2]) : null; + const jsonMatch = responseText.match( + /```json\s*([\s\S]*?)\s*```|(\[[\s\S]*\])/ + ); + const jsonString = jsonMatch ? jsonMatch[1] || jsonMatch[2] : null; if (!jsonString) { return responseText; } try { return JSON.parse(jsonString); } catch (e) { - logger.warn('Response looked like JSON but failed to parse, using raw text.'); + logger.warn( + 'Response looked like JSON but failed to parse, using raw text.' + ); return responseText; } } - // Ensure model name is in short form (e.g., "gemini-1.5-flash"), removing any leading path like // "models/gemini-1.5-flash" or "publishers/google/models/gemini-1.5-flash". function normalizeModelName(model) { @@ -100,7 +103,9 @@ export async function translate(text, sourceLang, targetLang) { const { accessToken, projectId, location, model } = await getConfig(); if (!accessToken || !projectId || !location || !model) { - throw new Error('Vertex access token, project, location, or model not configured.'); + throw new Error( + 'Vertex access token, project, location, or model not configured.' + ); } const endpoint = buildVertexEndpoint( @@ -125,10 +130,18 @@ export async function translate(text, sourceLang, targetLang) { const requestBody = { contents: [ - { role: 'user', parts: [{ text: `${systemPrompt}\n\n${userPrompt}\n\n${text}` }] }, + { + role: 'user', + parts: [ + { text: `${systemPrompt}\n\n${userPrompt}\n\n${text}` }, + ], + }, ], generationConfig: { - maxOutputTokens: Math.max(256, Math.min(2048, Math.ceil(text.length * 3))), + maxOutputTokens: Math.max( + 256, + Math.min(2048, Math.ceil(text.length * 3)) + ), }, }; @@ -150,34 +163,31 @@ export async function translate(text, sourceLang, targetLang) { errorMessage = parsed.error.message; } } catch (e) {} - logger.error( - 'Vertex AI single translation failed', - null, - { - status: response.status, - statusText: response.statusText, - endpoint, - errorMessage, - } + logger.error('Vertex AI single translation failed', null, { + status: response.status, + statusText: response.statusText, + endpoint, + errorMessage, + }); + throw new Error( + `Vertex translation error: ${response.status} ${response.statusText}` ); - throw new Error(`Vertex translation error: ${response.status} ${response.statusText}`); } const data = await response.json(); - const responseText = data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; + const responseText = + data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; if (!responseText) { throw new Error('Empty response from Vertex AI'); } - return typeof responseText === 'string' ? responseText.trim() : String(responseText); + return typeof responseText === 'string' + ? responseText.trim() + : String(responseText); } catch (error) { - logger.error( - 'Fatal error during Vertex AI single translation', - error, - { - sourceLang, - targetLang, - } - ); + logger.error('Fatal error during Vertex AI single translation', error, { + sourceLang, + targetLang, + }); return text; // Fallback to original } } @@ -211,7 +221,9 @@ export async function translateBatch( const { accessToken, projectId, location, model } = await getConfig(); if (!accessToken || !projectId || !location || !model) { - throw new Error('Vertex access token, project, location, or model not configured.'); + throw new Error( + 'Vertex access token, project, location, or model not configured.' + ); } const endpoint = buildVertexEndpoint( @@ -235,10 +247,16 @@ Important: const requestBody = { contents: [ - { role: 'user', parts: [{ text: `${instructions}\n\n${combinedText}` }] }, + { + role: 'user', + parts: [{ text: `${instructions}\n\n${combinedText}` }], + }, ], generationConfig: { - maxOutputTokens: Math.min(4096, Math.max(500, combinedText.length * 3)), + maxOutputTokens: Math.min( + 4096, + Math.max(500, combinedText.length * 3) + ), }, }; @@ -260,21 +278,20 @@ Important: errorMessage = parsed.error.message; } } catch (e) {} - logger.error( - 'Vertex AI batch translation failed', - null, - { - status: response.status, - statusText: response.statusText, - endpoint, - errorMessage, - } + logger.error('Vertex AI batch translation failed', null, { + status: response.status, + statusText: response.statusText, + endpoint, + errorMessage, + }); + throw new Error( + `Vertex batch translation error: ${response.status}` ); - throw new Error(`Vertex batch translation error: ${response.status}`); } const data = await response.json(); - const responseText = data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; + const responseText = + data?.candidates?.[0]?.content?.parts?.[0]?.text || ''; if (!responseText) { throw new Error('Empty response from Vertex AI'); } @@ -284,7 +301,9 @@ Important: if (Array.isArray(parsed)) { // Ensure array length matches input size if (parsed.length !== texts.length) { - throw new Error('Translated array length does not match input array length.'); + throw new Error( + 'Translated array length does not match input array length.' + ); } return parsed.map((s) => (typeof s === 'string' ? s : String(s))); } @@ -301,15 +320,11 @@ Important: } return split.map((s) => s.trim()); } catch (error) { - logger.error( - 'Fatal error during Vertex AI batch translation', - error, - { - sourceLang, - targetLang, - textCount: texts.length, - } - ); + logger.error('Fatal error during Vertex AI batch translation', error, { + sourceLang, + targetLang, + textCount: texts.length, + }); return texts; // Fallback to original array } } diff --git a/utils/vertexAuth.js b/utils/vertexAuth.js index d986f97..b6c630e 100644 --- a/utils/vertexAuth.js +++ b/utils/vertexAuth.js @@ -81,7 +81,8 @@ export async function getAccessTokenFromServiceAccount(serviceAccountJson) { const now = Math.floor(Date.now() / 1000); const iat = now; const exp = now + 3600; // 1 hour - const tokenUri = serviceAccountJson.token_uri || 'https://oauth2.googleapis.com/token'; + const tokenUri = + serviceAccountJson.token_uri || 'https://oauth2.googleapis.com/token'; const scope = 'https://www.googleapis.com/auth/cloud-platform'; const header = { alg: 'RS256', typ: 'JWT' }; @@ -93,7 +94,11 @@ export async function getAccessTokenFromServiceAccount(serviceAccountJson) { exp, }; - const jwt = await signJwtRS256(header, claims, serviceAccountJson.private_key); + const jwt = await signJwtRS256( + header, + claims, + serviceAccountJson.private_key + ); const body = new URLSearchParams(); body.set('grant_type', 'urn:ietf:params:oauth:grant-type:jwt-bearer'); @@ -107,14 +112,19 @@ export async function getAccessTokenFromServiceAccount(serviceAccountJson) { if (!res.ok) { const text = await res.text(); - throw new Error(`Token exchange failed: ${res.status} ${res.statusText} ${text}`); + throw new Error( + `Token exchange failed: ${res.status} ${res.statusText} ${text}` + ); } const data = await res.json(); if (!data.access_token) { throw new Error('Token exchange response missing access_token'); } - return { accessToken: data.access_token, expiresIn: data.expires_in || 3600 }; + return { + accessToken: data.access_token, + expiresIn: data.expires_in || 3600, + }; } /** @@ -170,10 +180,11 @@ export async function refreshAccessToken(updateConfig = true) { } // Generate new token - const { accessToken, expiresIn } = await getAccessTokenFromServiceAccount(sa); + const { accessToken, expiresIn } = + await getAccessTokenFromServiceAccount(sa); // Calculate new expiration time - const expiresAt = Date.now() + (expiresIn * 1000); + const expiresAt = Date.now() + expiresIn * 1000; // Update expiration time in storage await chrome.storage.local.set({ @@ -183,7 +194,9 @@ export async function refreshAccessToken(updateConfig = true) { // Update config if requested if (updateConfig && typeof chrome.storage.sync !== 'undefined') { try { - const { configService } = await import('../services/configService.js'); + const { configService } = await import( + '../services/configService.js' + ); await configService.set('vertexAccessToken', accessToken); } catch (error) { console.error('[VertexAuth] Failed to update config:', error); @@ -199,7 +212,7 @@ export async function refreshAccessToken(updateConfig = true) { */ export async function autoRefreshIfNeeded() { const expirationInfo = await checkTokenExpiration(); - + if (!expirationInfo) { return null; } @@ -217,4 +230,3 @@ export async function autoRefreshIfNeeded() { return null; } - diff --git a/video_platforms/disneyPlusPlatform.js b/video_platforms/disneyPlusPlatform.js index c2fb74e..85821ef 100644 --- a/video_platforms/disneyPlusPlatform.js +++ b/video_platforms/disneyPlusPlatform.js @@ -11,6 +11,8 @@ const INJECT_EVENT_ID = Injection.disneyplus.EVENT_ID; // Must match inject.js import { BasePlatformAdapter } from './BasePlatformAdapter.js'; +const PLAYBACK_TRANSITION_DELAY_MS = 160; + export class DisneyPlusPlatform extends BasePlatformAdapter { constructor() { super(); @@ -286,6 +288,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, PLAYBACK_TRANSITION_DELAY_MS)); + 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, PLAYBACK_TRANSITION_DELAY_MS)); + 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 diff --git a/video_platforms/netflixPlatform.js b/video_platforms/netflixPlatform.js index 7e94842..1542188 100644 --- a/video_platforms/netflixPlatform.js +++ b/video_platforms/netflixPlatform.js @@ -692,6 +692,47 @@ export class NetflixPlatform extends BasePlatformAdapter { return null; } + /** + * Platform-specific playback helpers for Netflix + */ + + isPlaying() { + try { + const video = this.getVideoElement(); + if (video) { + return !video.paused; + } + return null; + } catch (_) { + return null; + } + } + + async pausePlayback() { + try { + const video = this.getVideoElement(); + if (video && !video.paused) { + video.pause(); + } + return true; + } catch (_) { + return false; + } + } + + async resumePlayback() { + try { + const video = this.getVideoElement(); + if (video && video.paused) { + await video.play(); + } + return true; + } catch (_) { + return false; + } + } + + supportsProgressBarTracking() { // Netflix doesn't need progress bar tracking - HTML5 video currentTime is reliable return false; diff --git a/vite.config.js b/vite.config.js index 86460b0..5197194 100644 --- a/vite.config.js +++ b/vite.config.js @@ -11,10 +11,10 @@ export default defineConfig({ closeBundle() { const distDir = 'dist'; mkdirSync(distDir, { recursive: true }); - + // Copy manifest.json copyFileSync('manifest.json', `${distDir}/manifest.json`); - + // Copy all extension files that aren't built by Vite const filesToCopy = [ 'background.js', @@ -30,12 +30,15 @@ export default defineConfig({ 'translation_providers', 'context_providers', ]; - + filesToCopy.forEach((file) => { try { cpSync(file, `${distDir}/${file}`, { recursive: true }); } catch (err) { - console.warn(`Warning: Could not copy ${file}:`, err.message); + console.warn( + `Warning: Could not copy ${file}:`, + err.message + ); } }); }, @@ -46,6 +49,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 +70,8 @@ export default defineConfig({ '@': resolve(__dirname, './'), '@popup': resolve(__dirname, './popup'), '@options': resolve(__dirname, './options'), + '@sidepanel': resolve(__dirname, './sidepanel'), + '@shared': resolve(__dirname, './shared'), '@services': resolve(__dirname, './services'), '@utils': resolve(__dirname, './utils'), },