diff --git a/src/mcp_feedback_enhanced/web/locales/en/translation.json b/src/mcp_feedback_enhanced/web/locales/en/translation.json index f67356d..0ccc010 100644 --- a/src/mcp_feedback_enhanced/web/locales/en/translation.json +++ b/src/mcp_feedback_enhanced/web/locales/en/translation.json @@ -356,5 +356,40 @@ "disabled": "Disabled", "executing": "Executing auto submit...", "countdownLabel": "Submit Countdown" + }, + "audio": { + "notification": { + "title": "Audio Notification Settings", + "description": "Configure audio notifications for session updates", + "enabled": "Enable Audio Notifications", + "volume": "Volume", + "selectAudio": "Select Audio", + "testPlay": "Test Play", + "uploadCustom": "Upload Custom Audio", + "chooseFile": "Choose File", + "supportedFormats": "Supports MP3, WAV, OGG formats", + "customAudios": "Custom Audio Files", + "defaultBeep": "Classic Beep", + "notificationDing": "Notification Ding", + "softChime": "Soft Chime", + "default": "Default", + "customAudio": "Custom Audio", + "noCustomAudios": "No custom audio files uploaded yet", + "created": "Created", + "format": "Format", + "enterAudioName": "Enter Audio Name", + "audioName": "Audio Name", + "audioNamePlaceholder": "Please enter audio name...", + "audioNameHint": "Leave empty to use default filename", + "nameRequired": "Audio name cannot be empty", + "uploading": "Uploading...", + "uploadSuccess": "Audio uploaded successfully: ", + "deleteConfirm": "Are you sure you want to delete audio \"{name}\"?", + "deleteSuccess": "Audio deleted", + "enabledChanged": "Audio notification settings updated", + "audioSelected": "Audio selected", + "testPlaying": "Playing test audio", + "audioNotFound": "Selected audio not found" + } } } diff --git a/src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json b/src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json index 4d1d69b..5f8a3ea 100644 --- a/src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json +++ b/src/mcp_feedback_enhanced/web/locales/zh-CN/translation.json @@ -356,5 +356,40 @@ "disabled": "已停用", "executing": "正在执行自动提交...", "countdownLabel": "提交倒数" + }, + "audio": { + "notification": { + "title": "音效通知设定", + "description": "设定会话更新时的音效通知", + "enabled": "启用音效通知", + "volume": "音量", + "selectAudio": "选择音效", + "testPlay": "测试播放", + "uploadCustom": "上传自定义音效", + "chooseFile": "选择文件", + "supportedFormats": "支持 MP3、WAV、OGG 格式", + "customAudios": "自定义音效", + "defaultBeep": "经典提示音", + "notificationDing": "通知铃声", + "softChime": "轻柔钟声", + "default": "默认", + "customAudio": "自定义音效", + "noCustomAudios": "尚未上传任何自定义音效", + "created": "创建于", + "format": "格式", + "enterAudioName": "输入音效名称", + "audioName": "音效名称", + "audioNamePlaceholder": "请输入音效名称...", + "audioNameHint": "留空将使用默认文件名称", + "nameRequired": "音效名称不能为空", + "uploading": "上传中...", + "uploadSuccess": "音效上传成功: ", + "deleteConfirm": "确定要删除音效 \"{name}\" 吗?", + "deleteSuccess": "音效已删除", + "enabledChanged": "音效通知设定已更新", + "audioSelected": "音效已选择", + "testPlaying": "正在播放测试音效", + "audioNotFound": "找不到选择的音效" + } } } diff --git a/src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json b/src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json index 32bdb6a..64d8995 100644 --- a/src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json +++ b/src/mcp_feedback_enhanced/web/locales/zh-TW/translation.json @@ -361,5 +361,40 @@ "disabled": "已停用", "executing": "正在執行自動提交...", "countdownLabel": "提交倒數" + }, + "audio": { + "notification": { + "title": "音效通知設定", + "description": "設定會話更新時的音效通知", + "enabled": "啟用音效通知", + "volume": "音量", + "selectAudio": "選擇音效", + "testPlay": "測試播放", + "uploadCustom": "上傳自訂音效", + "chooseFile": "選擇檔案", + "supportedFormats": "支援 MP3、WAV、OGG 格式", + "customAudios": "自訂音效", + "defaultBeep": "經典提示音", + "notificationDing": "通知鈴聲", + "softChime": "輕柔鐘聲", + "default": "預設", + "customAudio": "自訂音效", + "noCustomAudios": "尚未上傳任何自訂音效", + "created": "建立於", + "format": "格式", + "enterAudioName": "輸入音效名稱", + "audioName": "音效名稱", + "audioNamePlaceholder": "請輸入音效名稱...", + "audioNameHint": "留空將使用預設檔案名稱", + "nameRequired": "音效名稱不能為空", + "uploading": "上傳中...", + "uploadSuccess": "音效上傳成功: ", + "deleteConfirm": "確定要刪除音效 \"{name}\" 嗎?", + "deleteSuccess": "音效已刪除", + "enabledChanged": "音效通知設定已更新", + "audioSelected": "音效已選擇", + "testPlaying": "正在播放測試音效", + "audioNotFound": "找不到選擇的音效" + } } } diff --git a/src/mcp_feedback_enhanced/web/static/css/audio-management.css b/src/mcp_feedback_enhanced/web/static/css/audio-management.css new file mode 100644 index 0000000..cdbf443 --- /dev/null +++ b/src/mcp_feedback_enhanced/web/static/css/audio-management.css @@ -0,0 +1,564 @@ +/** + * 音效管理功能樣式 + * ================= + * + * 包含音效通知設定相關的所有 UI 樣式 + * 參考 prompt-management.css 的設計風格 + */ + +/* ===== 音效管理區塊樣式 ===== */ + +.audio-management-section { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 12px; + padding: 20px; + margin-bottom: 20px; + transition: all 0.3s ease; +} + +.audio-management-section:hover { + border-color: var(--accent-color); + box-shadow: 0 2px 8px rgba(0, 122, 204, 0.1); +} + +.audio-management-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; + padding-bottom: 12px; + border-bottom: 1px solid var(--border-color); +} + +.audio-management-title { + color: var(--text-primary); + font-size: 16px; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 8px; +} + +.audio-management-description { + color: var(--text-secondary); + font-size: 14px; + margin-bottom: 20px; + line-height: 1.4; +} + +/* ===== 音效設定控制項樣式 ===== */ + +.audio-settings-controls { + display: flex; + flex-direction: column; + gap: 16px; +} + +.audio-setting-item { + display: flex; + flex-direction: column; + gap: 8px; +} + +.audio-setting-label { + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; +} + +/* ===== 開關控制項樣式 ===== */ + +.audio-toggle { + width: 18px; + height: 18px; + accent-color: var(--accent-color); + cursor: pointer; +} + +.audio-toggle:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ===== 音量控制項樣式 ===== */ + +.audio-volume-control { + display: flex; + align-items: center; + gap: 12px; +} + +.audio-volume-slider { + flex: 1; + height: 6px; + background: var(--bg-secondary); + border-radius: 3px; + outline: none; + cursor: pointer; + -webkit-appearance: none; + appearance: none; +} + +.audio-volume-slider::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 18px; + height: 18px; + background: var(--accent-color); + border-radius: 50%; + cursor: pointer; + transition: all 0.2s ease; +} + +.audio-volume-slider::-webkit-slider-thumb:hover { + background: var(--accent-hover); + transform: scale(1.1); +} + +.audio-volume-slider::-moz-range-thumb { + width: 18px; + height: 18px; + background: var(--accent-color); + border-radius: 50%; + cursor: pointer; + border: none; + transition: all 0.2s ease; +} + +.audio-volume-slider::-moz-range-thumb:hover { + background: var(--accent-hover); + transform: scale(1.1); +} + +.audio-volume-slider:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.audio-volume-value { + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; + min-width: 40px; + text-align: right; +} + +/* ===== 音效選擇控制項樣式 ===== */ + +.audio-select-control { + display: flex; + gap: 12px; + align-items: center; +} + +.audio-select { + flex: 1; + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-primary); + font-size: 14px; + cursor: pointer; + transition: all 0.2s ease; +} + +.audio-select:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 2px rgba(0, 122, 204, 0.2); +} + +.audio-select:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.audio-test-btn { + padding: 8px 16px; + font-size: 14px; + white-space: nowrap; +} + +/* ===== 檔案上傳控制項樣式 ===== */ + +.audio-upload-control { + display: flex; + flex-direction: column; + gap: 8px; +} + +.audio-upload-btn { + align-self: flex-start; + padding: 8px 16px; + font-size: 14px; + display: flex; + align-items: center; + gap: 8px; +} + +.audio-upload-hint { + color: var(--text-secondary); + font-size: 12px; + font-style: italic; +} + +/* ===== 自訂音效列表樣式 ===== */ + +.audio-custom-list { + display: flex; + flex-direction: column; + gap: 8px; + max-height: 300px; + overflow-y: auto; +} + +.audio-custom-item { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px; + display: flex; + justify-content: space-between; + align-items: center; + transition: all 0.2s ease; +} + +.audio-custom-item:hover { + border-color: var(--accent-color); + background: rgba(0, 122, 204, 0.05); +} + +.audio-custom-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.audio-custom-name { + color: var(--text-primary); + font-size: 14px; + font-weight: 500; +} + +.audio-custom-meta { + color: var(--text-secondary); + font-size: 12px; +} + +.audio-custom-actions { + display: flex; + gap: 8px; +} + +.audio-play-btn, +.audio-delete-btn { + padding: 6px 8px; + font-size: 12px; + min-width: auto; + border-radius: 4px; +} + +.audio-play-btn:hover { + background: var(--success-color); + border-color: var(--success-color); +} + +.audio-delete-btn:hover { + background: var(--error-color); + border-color: var(--error-color); +} + +/* ===== 空狀態樣式 ===== */ + +.audio-empty-state { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + background: var(--bg-primary); + border: 2px dashed var(--border-color); + border-radius: 8px; +} + +.audio-empty-state div:first-child { + font-size: 48px; + margin-bottom: 12px; +} + +/* ===== 響應式設計 ===== */ + +@media (max-width: 768px) { + .audio-select-control { + flex-direction: column; + align-items: stretch; + } + + .audio-test-btn { + align-self: stretch; + } + + .audio-volume-control { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .audio-volume-value { + text-align: center; + } + + .audio-custom-item { + flex-direction: column; + align-items: stretch; + gap: 12px; + } + + .audio-custom-actions { + justify-content: center; + } +} + +/* ===== 動畫效果 ===== */ + +@keyframes audioFadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.audio-custom-item { + animation: audioFadeIn 0.3s ease; +} + +/* ===== 無障礙改進 ===== */ + +.audio-toggle:focus, +.audio-volume-slider:focus, +.audio-select:focus, +.audio-test-btn:focus, +.audio-upload-btn:focus, +.audio-play-btn:focus, +.audio-delete-btn:focus { + outline: 2px solid var(--accent-color); + outline-offset: 2px; +} + +/* ===== 禁用狀態樣式 ===== */ + +.audio-setting-item.disabled { + opacity: 0.6; +} + +.audio-setting-item.disabled .audio-setting-label { + color: var(--text-secondary); +} + +/* ===== 載入狀態樣式 ===== */ + +.audio-upload-btn:disabled { + opacity: 0.7; + cursor: not-allowed; +} + +.audio-upload-btn.loading { + position: relative; +} + +.audio-upload-btn.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 16px; + height: 16px; + margin: -8px 0 0 -8px; + border: 2px solid transparent; + border-top: 2px solid var(--text-primary); + border-radius: 50%; + animation: audioSpin 1s linear infinite; +} + +@keyframes audioSpin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* ===== 成功/錯誤狀態樣式 ===== */ + +.audio-setting-item.success { + border-left: 4px solid var(--success-color); + background: rgba(76, 175, 80, 0.05); +} + +.audio-setting-item.error { + border-left: 4px solid var(--error-color); + background: rgba(244, 67, 54, 0.05); +} + +/* ===== 音效名稱輸入模態框樣式 ===== */ + +.audio-name-modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 10000; + animation: audioModalFadeIn 0.2s ease; +} + +.audio-name-modal { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 400px; + animation: audioModalSlideIn 0.3s ease; +} + +.audio-name-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px 16px; + border-bottom: 1px solid var(--border-color); +} + +.audio-name-modal-header h4 { + margin: 0; + color: var(--text-primary); + font-size: 18px; + font-weight: 600; +} + +.audio-name-modal-close { + background: none; + border: none; + font-size: 24px; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; +} + +.audio-name-modal-close:hover { + background: var(--bg-secondary); + color: var(--text-primary); +} + +.audio-name-modal-body { + padding: 24px; +} + +.audio-name-modal-body label { + display: block; + color: var(--text-primary); + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; +} + +.audio-name-input { + width: 100%; + padding: 12px 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + font-size: 14px; + transition: all 0.2s ease; + box-sizing: border-box; +} + +.audio-name-input:focus { + outline: none; + border-color: var(--accent-color); + box-shadow: 0 0 0 3px rgba(0, 122, 204, 0.1); +} + +.audio-name-hint { + color: var(--text-secondary); + font-size: 12px; + margin-top: 8px; + font-style: italic; +} + +.audio-name-modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + padding: 16px 24px 24px; +} + +.audio-name-modal-footer .btn { + padding: 10px 20px; + font-size: 14px; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s ease; + border: 1px solid transparent; +} + +.audio-name-modal-footer .btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border-color: var(--border-color); +} + +.audio-name-modal-footer .btn-secondary:hover { + background: var(--bg-tertiary); +} + +.audio-name-modal-footer .btn-primary { + background: var(--accent-color); + color: white; +} + +.audio-name-modal-footer .btn-primary:hover { + background: var(--accent-hover); +} + +/* ===== 模態框動畫 ===== */ + +@keyframes audioModalFadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes audioModalSlideIn { + from { + opacity: 0; + transform: translateY(-20px) scale(0.95); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} diff --git a/src/mcp_feedback_enhanced/web/static/js/app.js b/src/mcp_feedback_enhanced/web/static/js/app.js index c116f83..454d4bb 100644 --- a/src/mcp_feedback_enhanced/web/static/js/app.js +++ b/src/mcp_feedback_enhanced/web/static/js/app.js @@ -38,6 +38,10 @@ this.promptSettingsUI = null; this.promptInputButtons = null; + // 音效管理器 + this.audioManager = null; + this.audioSettingsUI = null; + // 自動提交管理器 this.autoSubmitManager = null; @@ -197,7 +201,10 @@ // 9. 初始化提示詞管理器 self.initializePromptManagers(); - // 10. 初始化自動提交管理器 + // 10. 初始化音效管理器 + self.initializeAudioManagers(); + + // 11. 初始化自動提交管理器 self.initializeAutoSubmitManager(); // 11. 應用設定到 UI @@ -440,6 +447,43 @@ } }; + /** + * 初始化音效管理器 + */ + FeedbackApp.prototype.initializeAudioManagers = function() { + console.log('🔊 初始化音效管理器...'); + + try { + // 檢查音效模組是否已載入 + if (!window.MCPFeedback.AudioManager) { + console.warn('⚠️ 音效模組未載入,跳過初始化'); + return; + } + + // 1. 初始化音效管理器 + this.audioManager = new window.MCPFeedback.AudioManager({ + settingsManager: this.settingsManager, + onSettingsChange: function(settings) { + console.log('🔊 音效設定已變更:', settings); + } + }); + this.audioManager.initialize(); + + // 2. 初始化音效設定 UI + this.audioSettingsUI = new window.MCPFeedback.AudioSettingsUI({ + container: document.querySelector('#audioManagementContainer'), + audioManager: this.audioManager, + t: window.i18nManager ? window.i18nManager.t.bind(window.i18nManager) : function(key, defaultValue) { return defaultValue || key; } + }); + this.audioSettingsUI.initialize(); + + console.log('✅ 音效管理器初始化完成'); + + } catch (error) { + console.error('❌ 音效管理器初始化失敗:', error); + } + }; + /** * 處理 WebSocket 開啟 */ @@ -525,6 +569,11 @@ FeedbackApp.prototype.handleSessionUpdated = function(data) { console.log('🔄 處理會話更新:', data.session_info); + // 播放音效通知 + if (this.audioManager) { + this.audioManager.playNotification(); + } + // 顯示更新通知 window.MCPFeedback.Utils.showMessage(data.message || '會話已更新,正在局部更新內容...', window.MCPFeedback.Utils.CONSTANTS.MESSAGE_SUCCESS); diff --git a/src/mcp_feedback_enhanced/web/static/js/i18n.js b/src/mcp_feedback_enhanced/web/static/js/i18n.js index 2cd9f8a..f022ce5 100644 --- a/src/mcp_feedback_enhanced/web/static/js/i18n.js +++ b/src/mcp_feedback_enhanced/web/static/js/i18n.js @@ -164,6 +164,9 @@ class I18nManager { // 更新動態內容 this.updateDynamicContent(); + // 更新音效選擇器翻譯 + this.updateAudioSelectTranslations(); + console.log('翻譯已應用:', this.currentLanguage); } @@ -309,6 +312,15 @@ class I18nManager { } } + updateAudioSelectTranslations() { + // 更新音效選擇器的翻譯 + if (window.feedbackApp && window.feedbackApp.audioSettingsUI) { + if (typeof window.feedbackApp.audioSettingsUI.updateAudioSelectTranslations === 'function') { + window.feedbackApp.audioSettingsUI.updateAudioSelectTranslations(); + } + } + } + getCurrentLanguage() { return this.currentLanguage; } diff --git a/src/mcp_feedback_enhanced/web/static/js/modules/audio/audio-manager.js b/src/mcp_feedback_enhanced/web/static/js/modules/audio/audio-manager.js new file mode 100644 index 0000000..7bf1840 --- /dev/null +++ b/src/mcp_feedback_enhanced/web/static/js/modules/audio/audio-manager.js @@ -0,0 +1,446 @@ +/** + * MCP Feedback Enhanced - 音效管理模組 + * =================================== + * + * 處理音效通知的播放、管理和設定功能 + * 使用 HTML5 Audio API 進行音效播放 + * 支援自訂音效上傳和 base64 儲存 + */ + +(function() { + 'use strict'; + + // 確保命名空間存在 + window.MCPFeedback = window.MCPFeedback || {}; + const Utils = window.MCPFeedback.Utils; + + /** + * 音效管理器建構函數 + */ + function AudioManager(options) { + options = options || {}; + + // 設定管理器引用 + this.settingsManager = options.settingsManager || null; + + // 當前音效設定 + this.currentAudioSettings = { + enabled: false, + volume: 50, + selectedAudioId: 'default-beep', + customAudios: [] + }; + + // 預設音效(base64 編碼的簡單提示音) + this.defaultAudios = { + 'default-beep': { + id: 'default-beep', + name: '經典提示音', + data: this.generateBeepSound(), + mimeType: 'audio/wav', + isDefault: true + }, + 'notification-ding': { + id: 'notification-ding', + name: '通知鈴聲', + data: this.generateDingSound(), + mimeType: 'audio/wav', + isDefault: true + }, + 'soft-chime': { + id: 'soft-chime', + name: '輕柔鐘聲', + data: this.generateChimeSound(), + mimeType: 'audio/wav', + isDefault: true + } + }; + + // 當前播放的 Audio 物件 + this.currentAudio = null; + + // 回調函數 + this.onSettingsChange = options.onSettingsChange || null; + + console.log('🔊 AudioManager 初始化完成'); + } + + /** + * 初始化音效管理器 + */ + AudioManager.prototype.initialize = function() { + this.loadAudioSettings(); + console.log('✅ AudioManager 初始化完成'); + }; + + /** + * 載入音效設定 + */ + AudioManager.prototype.loadAudioSettings = function() { + if (!this.settingsManager) { + console.warn('⚠️ SettingsManager 未設定,使用預設音效設定'); + return; + } + + try { + // 從設定管理器載入音效相關設定 + this.currentAudioSettings.enabled = this.settingsManager.get('audioNotificationEnabled', false); + this.currentAudioSettings.volume = this.settingsManager.get('audioNotificationVolume', 50); + this.currentAudioSettings.selectedAudioId = this.settingsManager.get('selectedAudioId', 'default-beep'); + this.currentAudioSettings.customAudios = this.settingsManager.get('customAudios', []); + + console.log('📥 音效設定已載入:', this.currentAudioSettings); + } catch (error) { + console.error('❌ 載入音效設定失敗:', error); + } + }; + + /** + * 儲存音效設定 + */ + AudioManager.prototype.saveAudioSettings = function() { + if (!this.settingsManager) { + console.warn('⚠️ SettingsManager 未設定,無法儲存音效設定'); + return; + } + + try { + this.settingsManager.set('audioNotificationEnabled', this.currentAudioSettings.enabled); + this.settingsManager.set('audioNotificationVolume', this.currentAudioSettings.volume); + this.settingsManager.set('selectedAudioId', this.currentAudioSettings.selectedAudioId); + this.settingsManager.set('customAudios', this.currentAudioSettings.customAudios); + + console.log('💾 音效設定已儲存'); + + // 觸發回調 + if (this.onSettingsChange) { + this.onSettingsChange(this.currentAudioSettings); + } + } catch (error) { + console.error('❌ 儲存音效設定失敗:', error); + } + }; + + /** + * 播放通知音效 + */ + AudioManager.prototype.playNotification = function() { + if (!this.currentAudioSettings.enabled) { + console.log('🔇 音效通知已停用'); + return; + } + + try { + const audioData = this.getAudioById(this.currentAudioSettings.selectedAudioId); + if (!audioData) { + console.warn('⚠️ 找不到指定的音效,使用預設音效'); + this.playAudio(this.defaultAudios['default-beep']); + return; + } + + this.playAudio(audioData); + } catch (error) { + console.error('❌ 播放通知音效失敗:', error); + } + }; + + /** + * 播放指定的音效 + */ + AudioManager.prototype.playAudio = function(audioData) { + try { + // 停止當前播放的音效 + if (this.currentAudio) { + this.currentAudio.pause(); + this.currentAudio = null; + } + + // 建立新的 Audio 物件 + this.currentAudio = new Audio(); + this.currentAudio.src = 'data:' + audioData.mimeType + ';base64,' + audioData.data; + this.currentAudio.volume = this.currentAudioSettings.volume / 100; + + // 播放音效 + const playPromise = this.currentAudio.play(); + + if (playPromise !== undefined) { + playPromise + .then(() => { + console.log('🔊 音效播放成功:', audioData.name); + }) + .catch(error => { + console.error('❌ 音效播放失敗:', error); + // 可能是瀏覽器的自動播放政策限制 + if (error.name === 'NotAllowedError') { + console.warn('⚠️ 瀏覽器阻止自動播放,需要用戶互動'); + } + }); + } + } catch (error) { + console.error('❌ 播放音效時發生錯誤:', error); + } + }; + + /** + * 根據 ID 獲取音效資料 + */ + AudioManager.prototype.getAudioById = function(audioId) { + // 先檢查預設音效 + if (this.defaultAudios[audioId]) { + return this.defaultAudios[audioId]; + } + + // 再檢查自訂音效 + return this.currentAudioSettings.customAudios.find(audio => audio.id === audioId) || null; + }; + + /** + * 獲取所有可用的音效 + */ + AudioManager.prototype.getAllAudios = function() { + const allAudios = []; + + // 新增預設音效 + Object.values(this.defaultAudios).forEach(audio => { + allAudios.push(audio); + }); + + // 新增自訂音效 + this.currentAudioSettings.customAudios.forEach(audio => { + allAudios.push(audio); + }); + + return allAudios; + }; + + /** + * 新增自訂音效 + */ + AudioManager.prototype.addCustomAudio = function(name, file) { + return new Promise((resolve, reject) => { + if (!name || !file) { + reject(new Error('音效名稱和檔案不能為空')); + return; + } + + // 檢查檔案類型 + if (!this.isValidAudioFile(file)) { + reject(new Error('不支援的音效檔案格式')); + return; + } + + // 檢查名稱是否重複 + if (this.isAudioNameExists(name)) { + reject(new Error('音效名稱已存在')); + return; + } + + // 轉換為 base64 + this.fileToBase64(file) + .then(base64Data => { + const audioData = { + id: this.generateAudioId(), + name: name.trim(), + data: base64Data, + mimeType: file.type, + createdAt: new Date().toISOString(), + isDefault: false + }; + + this.currentAudioSettings.customAudios.push(audioData); + this.saveAudioSettings(); + + console.log('➕ 新增自訂音效:', audioData.name); + resolve(audioData); + }) + .catch(error => { + reject(error); + }); + }); + }; + + /** + * 刪除自訂音效 + */ + AudioManager.prototype.removeCustomAudio = function(audioId) { + const index = this.currentAudioSettings.customAudios.findIndex(audio => audio.id === audioId); + if (index === -1) { + throw new Error('找不到指定的音效'); + } + + const removedAudio = this.currentAudioSettings.customAudios.splice(index, 1)[0]; + + // 如果刪除的是當前選中的音效,切換到預設音效 + if (this.currentAudioSettings.selectedAudioId === audioId) { + this.currentAudioSettings.selectedAudioId = 'default-beep'; + } + + this.saveAudioSettings(); + console.log('🗑️ 刪除自訂音效:', removedAudio.name); + + return removedAudio; + }; + + /** + * 設定音量 + */ + AudioManager.prototype.setVolume = function(volume) { + if (volume < 0 || volume > 100) { + throw new Error('音量必須在 0-100 之間'); + } + + this.currentAudioSettings.volume = volume; + this.saveAudioSettings(); + console.log('🔊 音量已設定為:', volume); + }; + + /** + * 設定是否啟用音效通知 + */ + AudioManager.prototype.setEnabled = function(enabled) { + this.currentAudioSettings.enabled = !!enabled; + this.saveAudioSettings(); + console.log('🔊 音效通知已', enabled ? '啟用' : '停用'); + }; + + /** + * 設定選中的音效 + */ + AudioManager.prototype.setSelectedAudio = function(audioId) { + if (!this.getAudioById(audioId)) { + throw new Error('找不到指定的音效'); + } + + this.currentAudioSettings.selectedAudioId = audioId; + this.saveAudioSettings(); + console.log('🎵 已選擇音效:', audioId); + }; + + /** + * 檢查是否為有效的音效檔案 + */ + AudioManager.prototype.isValidAudioFile = function(file) { + const validTypes = ['audio/mp3', 'audio/wav', 'audio/ogg', 'audio/mpeg']; + return validTypes.includes(file.type); + }; + + /** + * 檢查音效名稱是否已存在 + */ + AudioManager.prototype.isAudioNameExists = function(name) { + // 檢查預設音效 + const defaultExists = Object.values(this.defaultAudios).some(audio => audio.name === name); + if (defaultExists) return true; + + // 檢查自訂音效 + return this.currentAudioSettings.customAudios.some(audio => audio.name === name); + }; + + /** + * 檔案轉 base64 + */ + AudioManager.prototype.fileToBase64 = function(file) { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = function() { + // 移除 data URL 前綴,只保留 base64 資料 + const base64 = reader.result.split(',')[1]; + resolve(base64); + }; + reader.onerror = function() { + reject(new Error('檔案讀取失敗')); + }; + reader.readAsDataURL(file); + }); + }; + + /** + * 生成音效 ID + */ + AudioManager.prototype.generateAudioId = function() { + return 'audio_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9); + }; + + /** + * 生成經典提示音(440Hz,0.3秒) + */ + AudioManager.prototype.generateBeepSound = function() { + return this.generateToneWAV(440, 0.3, 0.5); + }; + + /** + * 生成通知鈴聲(800Hz + 600Hz 和弦,0.4秒) + */ + AudioManager.prototype.generateDingSound = function() { + return this.generateToneWAV(800, 0.4, 0.4); + }; + + /** + * 生成輕柔鐘聲(523Hz,0.5秒,漸弱) + */ + AudioManager.prototype.generateChimeSound = function() { + return this.generateToneWAV(523, 0.5, 0.3); + }; + + /** + * 生成指定頻率和時長的 WAV 音效 + * @param {number} frequency - 頻率(Hz) + * @param {number} duration - 持續時間(秒) + * @param {number} volume - 音量(0-1) + */ + AudioManager.prototype.generateToneWAV = function(frequency, duration, volume) { + const sampleRate = 44100; + const numSamples = Math.floor(sampleRate * duration); + const buffer = new ArrayBuffer(44 + numSamples * 2); + const view = new DataView(buffer); + + // WAV 檔案標頭 + const writeString = (offset, string) => { + for (let i = 0; i < string.length; i++) { + view.setUint8(offset + i, string.charCodeAt(i)); + } + }; + + writeString(0, 'RIFF'); + view.setUint32(4, 36 + numSamples * 2, true); + writeString(8, 'WAVE'); + writeString(12, 'fmt '); + view.setUint32(16, 16, true); + view.setUint16(20, 1, true); + view.setUint16(22, 1, true); + view.setUint32(24, sampleRate, true); + view.setUint32(28, sampleRate * 2, true); + view.setUint16(32, 2, true); + view.setUint16(34, 16, true); + writeString(36, 'data'); + view.setUint32(40, numSamples * 2, true); + + // 生成音效資料 + for (let i = 0; i < numSamples; i++) { + const t = i / sampleRate; + const fadeOut = Math.max(0, 1 - (t / duration) * 0.5); // 漸弱效果 + const sample = Math.sin(2 * Math.PI * frequency * t) * volume * fadeOut; + const intSample = Math.max(-32768, Math.min(32767, Math.floor(sample * 32767))); + view.setInt16(44 + i * 2, intSample, true); + } + + // 轉換為 base64 + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); + }; + + /** + * 獲取當前設定 + */ + AudioManager.prototype.getSettings = function() { + return Utils.deepClone(this.currentAudioSettings); + }; + + // 匯出到全域命名空間 + window.MCPFeedback.AudioManager = AudioManager; + +})(); diff --git a/src/mcp_feedback_enhanced/web/static/js/modules/audio/audio-settings-ui.js b/src/mcp_feedback_enhanced/web/static/js/modules/audio/audio-settings-ui.js new file mode 100644 index 0000000..e0da350 --- /dev/null +++ b/src/mcp_feedback_enhanced/web/static/js/modules/audio/audio-settings-ui.js @@ -0,0 +1,669 @@ +/** + * MCP Feedback Enhanced - 音效設定 UI 模組 + * ====================================== + * + * 處理音效通知設定的使用者介面 + * 參考 prompt-settings-ui.js 的設計模式 + */ + +(function() { + 'use strict'; + + // 確保命名空間存在 + window.MCPFeedback = window.MCPFeedback || {}; + const Utils = window.MCPFeedback.Utils; + + /** + * 音效設定 UI 建構函數 + */ + function AudioSettingsUI(options) { + options = options || {}; + + // 容器元素 + this.container = options.container || null; + + // 音效管理器引用 + this.audioManager = options.audioManager || null; + + // i18n 翻譯函數 + this.t = options.t || function(key, defaultValue) { return defaultValue || key; }; + + // UI 元素引用 + this.enabledToggle = null; + this.volumeSlider = null; + this.volumeValue = null; + this.audioSelect = null; + this.testButton = null; + this.uploadButton = null; + this.uploadInput = null; + this.audioList = null; + + console.log('🎨 AudioSettingsUI 初始化完成'); + } + + /** + * 初始化 UI + */ + AudioSettingsUI.prototype.initialize = function() { + if (!this.container) { + console.error('❌ AudioSettingsUI 容器未設定'); + return; + } + + if (!this.audioManager) { + console.error('❌ AudioManager 未設定'); + return; + } + + this.createUI(); + this.setupEventListeners(); + this.refreshUI(); + + console.log('✅ AudioSettingsUI 初始化完成'); + }; + + /** + * 創建 UI 結構 + */ + AudioSettingsUI.prototype.createUI = function() { + const html = ` +