feat: Add dark theme, compact layout, help system, and TTS file upload
**Layout Improvements:** - Fix layout: Sidebar now on left (350px fixed), output panels on right - Remove DOM manipulation, use proper flex layout - Sidebar and output panels properly aligned horizontally - Output panels take remaining space with flex-grow **Dark Theme:** - Enable NiceGUI dark mode globally - Custom CSS for modern dark theme (#1e1e1e sidebar, #121212 output) - Styled scrollbars, separators, and controls - Professional dark color scheme throughout **Compact Styling:** - Reduce all spacing and padding (gap-1, p-3) - Use 'dense' prop on all controls for smaller size - Text labels use text-xs (extra small) - Compact rows and columns throughout - Narrower select boxes and inputs - Window size adjusted to 350px when text hidden **Help System:** - Create help.py module with help dialogs - Add help button (?) to each major section - Model Settings, Translation, AI, and TTS help - Help content matches original tkinter version - Markdown-formatted help text in dialogs **TTS Voice Upload:** - Implement file upload for TTS voice reference - Use NiceGUI ui.upload component - Accept audio files (50MB max) - Save uploaded files to tts_voices/ directory - Display truncated filename in UI - Full Browse and Clear functionality working **Audio Level Fix:** - Faster UI update timer (50ms for responsive audio level) - Direct value assignment (no smoothing/averaging) All requested features implemented and working!
+346 −168 · 3 files changed
-
src/whispering_ui/components/help.py+86 −0 -
src/whispering_ui/components/sidebar.py+192 −158 -
src/whispering_ui/main.py+68 −10
@@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" +Help Dialog Component +Shows help information in dialogs +""" + +from nicegui import ui + +# Model VRAM estimates (based on faster-whisper benchmarks) +MODEL_VRAM = { + "tiny": "~1 GB VRAM", + "base": "~1.5 GB VRAM", + "small": "~2 GB VRAM", + "medium": "~3 GB VRAM", + "large-v1": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", + "large-v2": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", + "large-v3": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", + "large": "~4.5 GB VRAM (fp16) / ~3 GB (int8)", +} + +# Help text for each section +HELP_TEXT = { + "model": """**Model:** Whisper model size (tiny→large-v3, larger=more accurate) + +**VAD:** Voice Activity Detection (filters silence/noise) + +**¶:** Adaptive paragraph detection (auto line breaks by pauses) + +**⌨:** Auto-type mode selector + • Off - No auto-typing + • Whisper - Type raw transcription immediately + • Translation - Type Google Translate output (1-2 sec delay) + • AI - Type AI-processed output (longer delay based on trigger) + +**Dev:** Inference device (cuda=GPU, cpu=CPU, auto=best) + +**Mem:** Context segments 1-10 (higher=better context, slower) + +**Pat:** Patience seconds (wait time before finalizing segment) + +**Time:** Translation timeout seconds""", + + "translate": """**Source:** Source language (auto=detect, or select specific) + +**Target:** Target language (none=disabled, or select for Google Translate) + +**Note:** AI Processing overrides Google Translate when enabled.""", + + "ai": """**Enable AI:** Intelligent proofreading and translation + +**Mode:** Proofread | Translate | Proofread+Translate + +**Model:** AI model selection (larger=more capable, higher cost) + +**Trigger:** Time (every N min) | Words (every N words) + +**Setup:** Add OPENROUTER_API_KEY to .env +See AI_SETUP.md for details.""", + + "tts": """**Enable TTS:** Convert text to speech + +**Voice:** Browse=upload ref audio for cloning | Clear=default + +**Save File:** Auto-save to tts_output/ with timestamp + +**Format:** WAV (lossless) | OGG (compressed) + +**Setup:** See INSTALL_TTS.md""" +} + + +def show_help_dialog(section: str): + """Show help dialog for a section.""" + if section not in HELP_TEXT: + return + + with ui.dialog() as dialog, ui.card().classes('w-96'): + ui.label(f'Help - {section.title()}').classes('text-lg font-bold mb-2') + + # Help text in markdown format + ui.markdown(HELP_TEXT[section]).classes('text-sm') + + # Close button + ui.button('Close', on_click=dialog.close).classes('mt-4') + + dialog.open() @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Complete Sidebar Component +Complete Sidebar Component with Help and File Upload Control panel with all settings and controls """ @@ -8,6 +8,8 @@ from nicegui import ui import core from whispering_ui.state import AppState from whispering_ui.bridge import ProcessingBridge +from whispering_ui.components.help import show_help_dialog +from pathlib import Path def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=None): @@ -20,18 +22,18 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N output_container: Optional output container for show/hide control """ - # Container for all controls - sidebar_container = ui.column().classes('w-96 p-4 gap-2') + # Container for all controls - compact spacing + sidebar_container = ui.column().classes('w-full h-full p-3 gap-1').style('overflow-y: auto;') with sidebar_container: # === MICROPHONE SECTION === - with ui.row().classes('items-center w-full gap-2'): - ui.label('Mic:').classes('text-sm w-12') + with ui.row().classes('items-center w-full gap-1'): + ui.label('Mic:').classes('text-xs w-10') mic_display = ["(system default)"] + [name for idx, name in state.mic_list] mic_select = ui.select( options=mic_display, value=mic_display[0] if mic_display else None - ).classes('flex-grow') + ).classes('flex-grow').props('dense') mic_select.on_value_change(lambda e: setattr(state, 'mic_index', mic_select.options.index(e.value) if e.value in mic_select.options else 0)) @@ -40,231 +42,240 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N mic_select.options = ["(system default)"] + [name for idx, name in state.mic_list] mic_select.update() - ui.button(icon='refresh', on_click=refresh_mics).props('flat dense').classes('w-10') + ui.button(icon='refresh', on_click=refresh_mics).props('flat dense round size=sm') # === TOGGLE TEXT BUTTON === toggle_btn = ui.button( 'Show Text ▶' if not state.text_visible else 'Hide Text ◀', - on_click=lambda: _toggle_text(state, toggle_btn, output_container) - ).classes('w-full mb-2') + on_click=lambda: _toggle_text(state, toggle_btn, sidebar_container) + ).classes('w-full').props('dense') - ui.separator().classes('my-2') + ui.separator().classes('my-1') # === MODEL SECTION === - ui.label('Model Settings Speech-to-Text').classes('font-bold') + with ui.row().classes('items-center justify-between w-full'): + ui.label('Model Settings STT').classes('font-bold text-sm') + ui.button(icon='help_outline', on_click=lambda: show_help_dialog('model')).props('flat dense round size=sm') - with ui.row().classes('items-center w-full gap-2'): - ui.label('Model:').classes('text-sm w-12') - model_select = ui.select(options=core.models, value=state.model).classes('flex-grow') + with ui.row().classes('items-center w-full gap-1'): + ui.label('Model:').classes('text-xs w-12') + model_select = ui.select(options=core.models, value=state.model).classes('flex-grow').props('dense') model_select.on_value_change(lambda e: setattr(state, 'model', e.value)) - # Options + # Options row - compact with ui.row().classes('items-center w-full gap-2'): - vad_cb = ui.checkbox('VAD', value=state.vad_enabled) + vad_cb = ui.checkbox('VAD', value=state.vad_enabled).props('dense') vad_cb.on_value_change(lambda e: setattr(state, 'vad_enabled', e.value)) - para_cb = ui.checkbox('¶', value=state.para_detect_enabled) + para_cb = ui.checkbox('¶', value=state.para_detect_enabled).props('dense') para_cb.on_value_change(lambda e: setattr(state, 'para_detect_enabled', e.value)) - ui.label('Dev:').classes('text-sm') - dev_select = ui.select(options=core.devices, value=state.device).classes('w-20') + ui.label('Dev:').classes('text-xs') + dev_select = ui.select(options=core.devices, value=state.device).classes('w-16').props('dense') dev_select.on_value_change(lambda e: setattr(state, 'device', e.value)) # Autotype - with ui.row().classes('items-center w-full gap-2'): - ui.label('⌨ Autotype:').classes('text-sm') + with ui.row().classes('items-center w-full gap-1'): + ui.label('⌨:').classes('text-xs w-8') auto_select = ui.select( options=["Off", "Whisper", "Translation", "AI"], value=state.autotype_mode - ).classes('flex-grow') + ).classes('flex-grow').props('dense') auto_select.on_value_change(lambda e: setattr(state, 'autotype_mode', e.value)) - ui.separator().classes('my-2') + ui.separator().classes('my-1') # === TRANSLATION SECTION === - ui.label('Translation').classes('font-bold') + with ui.row().classes('items-center justify-between w-full'): + ui.label('Translation').classes('font-bold text-sm') + ui.button(icon='help_outline', on_click=lambda: show_help_dialog('translate')).props('flat dense round size=sm') - with ui.row().classes('items-center w-full gap-2'): - ui.label('Source:').classes('text-sm w-16') + with ui.row().classes('items-center w-full gap-1'): + ui.label('Src:').classes('text-xs w-8') src_select = ui.select( options=["auto"] + core.sources, value=state.source_language - ).classes('w-24') + ).classes('w-20').props('dense') src_select.on_value_change(lambda e: setattr(state, 'source_language', e.value)) - ui.label('Target:').classes('text-sm w-16') + ui.label('Tgt:').classes('text-xs w-8') tgt_select = ui.select( options=["none"] + core.targets, value=state.target_language - ).classes('w-24') + ).classes('w-20').props('dense') tgt_select.on_value_change(lambda e: setattr(state, 'target_language', e.value)) - ui.separator().classes('my-2') + ui.separator().classes('my-1') # === AI SECTION === - ui.label('AI Processing').classes('font-bold') + with ui.row().classes('items-center justify-between w-full'): + ui.label('AI Processing').classes('font-bold text-sm') + if state.ai_available: + ui.button(icon='help_outline', on_click=lambda: show_help_dialog('ai')).props('flat dense round size=sm') # Enable AI checkbox - ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled) + ai_cb = ui.checkbox('Enable AI', value=state.ai_enabled).props('dense') ai_cb.on_value_change(lambda e: setattr(state, 'ai_enabled', e.value)) if not state.ai_available: ai_cb.disable() - # Task selection (persona) - task_select = None - ai_model_combo = None + # AI controls if state.ai_available: try: from ai_config import load_ai_config ai_config = load_ai_config() if ai_config: - # Task (persona) selection + # Task selection personas = ai_config.get_personas() persona_names = [p['name'] for p in personas] - with ui.row().classes('items-center w-full gap-2'): - ui.label('Task:').classes('text-sm w-12') + with ui.row().classes('items-center w-full gap-1'): + ui.label('Task:').classes('text-xs w-12') task_select = ui.select( options=persona_names, value=persona_names[min(state.ai_persona_index, len(persona_names)-1)] - ).classes('flex-grow') + ).classes('flex-grow').props('dense') task_select.on_value_change(lambda e: setattr(state, 'ai_persona_index', persona_names.index(e.value) if e.value in persona_names else 0)) - # Translate output and Translate Only checkboxes + # Translate checkboxes - compact with ui.row().classes('items-center w-full gap-2'): - ai_trans_cb = ui.checkbox('Translate output', value=state.ai_translate) + ai_trans_cb = ui.checkbox('Translate', value=state.ai_translate).props('dense') ai_trans_cb.on_value_change(lambda e: setattr(state, 'ai_translate', e.value)) - ai_trans_only_cb = ui.checkbox('Translate Only (1:1)', value=state.ai_translate_only) + ai_trans_only_cb = ui.checkbox('Only (1:1)', value=state.ai_translate_only).props('dense') ai_trans_only_cb.on_value_change(lambda e: setattr(state, 'ai_translate_only', e.value)) # Model selection models = ai_config.get_models() model_names = [m['name'] for m in models] - with ui.row().classes('items-center w-full gap-2'): - ui.label('Model:').classes('text-sm w-12') + with ui.row().classes('items-center w-full gap-1'): + ui.label('Model:').classes('text-xs w-12') ai_model_combo = ui.select( options=model_names, value=model_names[min(state.ai_model_index, len(model_names)-1)] - ).classes('flex-grow') + ).classes('flex-grow').props('dense') ai_model_combo.on_value_change(lambda e: setattr(state, 'ai_model_index', model_names.index(e.value) if e.value in model_names else 0)) - # Trigger controls - Manual and Automatic side by side - with ui.row().classes('items-center w-full gap-2'): - # Left side: Manual mode - with ui.column().classes('gap-1'): - ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode) - ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed(state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num)) - - ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-32') - ai_process_btn.set_enabled(state.ai_manual_mode) - - # Right side: Automatic triggers - with ui.column().classes('gap-1 flex-grow'): - with ui.row().classes('items-center gap-2'): - ui.label('Trigger:').classes('text-sm') - ai_trigger_select = ui.select( - options=["Time", "Words"], - value=state.ai_trigger_mode.capitalize() - ).classes('w-20') - ai_trigger_select.on_value_change(lambda e: _on_trigger_changed(state, e.value, ai_interval_select, ai_words_num)) - ai_trigger_select.set_enabled(not state.ai_manual_mode) - - # Interval/Words control (shows one at a time) - with ui.row().classes('items-center gap-2'): - interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"] - interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120] - interval_map = dict(zip(interval_labels, interval_values)) - - # Find current label - current_label = "20s" - for lbl, val in interval_map.items(): - if val == state.ai_process_interval: - current_label = lbl - break - - ui.label('Interval:').classes('text-sm') - ai_interval_select = ui.select( - options=interval_labels, - value=current_label - ).classes('w-16') - def on_interval_change(e): - state.ai_process_interval = interval_map.get(e.value, 20) - ai_interval_select.on_value_change(on_interval_change) - ai_interval_select.set_enabled(not state.ai_manual_mode) - ai_interval_select.set_visibility(state.ai_trigger_mode == "time") - - ui.label('words:').classes('text-sm') - ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50, format='%.0f').classes('w-20') - ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150))) - ai_words_num.set_enabled(not state.ai_manual_mode) - ai_words_num.set_visibility(state.ai_trigger_mode == "words") + # Trigger controls - compact layout + ai_manual_cb = ui.checkbox('Manual mode', value=state.ai_manual_mode).props('dense') + + ai_process_btn = ui.button('⚡ Process Now', on_click=lambda: bridge.manual_ai_trigger()).classes('w-full').props('dense') + ai_process_btn.set_enabled(state.ai_manual_mode) + + # Trigger mode and settings + with ui.row().classes('items-center w-full gap-1'): + ui.label('Trigger:').classes('text-xs') + ai_trigger_select = ui.select( + options=["Time", "Words"], + value=state.ai_trigger_mode.capitalize() + ).classes('w-16').props('dense') + ai_trigger_select.set_enabled(not state.ai_manual_mode) + + # Interval control + interval_labels = ["5s", "10s", "15s", "20s", "25s", "30s", "45s", "1m", "1.5m", "2m"] + interval_values = [5, 10, 15, 20, 25, 30, 45, 60, 90, 120] + interval_map = dict(zip(interval_labels, interval_values)) + + current_label = "20s" + for lbl, val in interval_map.items(): + if val == state.ai_process_interval: + current_label = lbl + break + + ui.label('Int:').classes('text-xs') + ai_interval_select = ui.select( + options=interval_labels, + value=current_label + ).classes('w-14').props('dense') + def on_interval_change(e): + state.ai_process_interval = interval_map.get(e.value, 20) + ai_interval_select.on_value_change(on_interval_change) + ai_interval_select.set_enabled(not state.ai_manual_mode) + ai_interval_select.set_visibility(state.ai_trigger_mode == "time") + + ui.label('W:').classes('text-xs') + ai_words_num = ui.number(value=state.ai_process_words, min=50, max=500, step=50).classes('w-16').props('dense') + ai_words_num.on_value_change(lambda e: setattr(state, 'ai_process_words', int(e.value or 150))) + ai_words_num.set_enabled(not state.ai_manual_mode) + ai_words_num.set_visibility(state.ai_trigger_mode == "words") + + # Wire up event handlers + ai_manual_cb.on_value_change(lambda e: _on_manual_mode_changed( + state, e.value, ai_process_btn, ai_trigger_select, ai_interval_select, ai_words_num)) + + ai_trigger_select.on_value_change(lambda e: _on_trigger_changed( + state, e.value, ai_interval_select, ai_words_num)) except Exception as e: print(f"Error loading AI config: {e}") - else: - # AI not available - show disabled controls - ai_trans_cb = ui.checkbox('Translate output', value=False) - ai_trans_cb.disable() - ui.separator().classes('my-2') + ui.separator().classes('my-1') # === TTS SECTION === - ui.label('Text-to-Speech').classes('font-bold') + with ui.row().classes('items-center justify-between w-full'): + ui.label('Text-to-Speech').classes('font-bold text-sm') + if state.tts_available: + ui.button(icon='help_outline', on_click=lambda: show_help_dialog('tts')).props('flat dense round size=sm') - # Enable TTS checkbox - tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled) + # Enable TTS + tts_cb = ui.checkbox('Enable TTS', value=state.tts_enabled).props('dense') tts_cb.on_value_change(lambda e: setattr(state, 'tts_enabled', e.value)) if not state.tts_available: tts_cb.disable() if state.tts_available: - # Source selection (W/A/T mutually exclusive checkboxes) - with ui.row().classes('items-center w-full gap-2'): - ui.label('Source:').classes('text-sm') + # Source selection - compact + with ui.row().classes('items-center w-full gap-1'): + ui.label('Src:').classes('text-xs w-10') - tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")) + tts_w_cb = ui.checkbox('W', value=(state.tts_source == "whisper")).props('dense') tts_w_cb.on_value_change(lambda e: _on_tts_source_changed(state, "whisper", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) - tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")) + tts_a_cb = ui.checkbox('A', value=(state.tts_source == "ai")).props('dense') tts_a_cb.on_value_change(lambda e: _on_tts_source_changed(state, "ai", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) - tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")) + tts_t_cb = ui.checkbox('T', value=(state.tts_source == "translation")).props('dense') tts_t_cb.on_value_change(lambda e: _on_tts_source_changed(state, "translation", e.value, tts_w_cb, tts_a_cb, tts_t_cb)) - # Voice selection - with ui.row().classes('items-center w-full gap-2'): - ui.label('Voice:').classes('text-sm') - tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-gray-600') - - tts_browse_btn = ui.button('Browse', on_click=lambda: _browse_voice(state, bridge, tts_voice_label)).classes('text-xs') - tts_clear_btn = ui.button('Clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).classes('text-xs') - - # Output options - with ui.row().classes('items-center w-full gap-2'): - ui.label('Output:').classes('text-sm') - tts_save_cb = ui.checkbox('Save to file', value=state.tts_save_file) + # Voice selection with file upload + with ui.row().classes('items-center w-full gap-1'): + ui.label('Voice:').classes('text-xs w-10') + tts_voice_label = ui.label(state.tts_voice_display_name).classes('flex-grow text-xs text-gray-400 truncate') + + # File upload button + upload = ui.upload( + on_upload=lambda e: _on_voice_upload(e, state, bridge, tts_voice_label), + auto_upload=True, + max_file_size=50_000_000 # 50 MB max + ).props('flat dense icon=folder_open accept=audio/*').classes('w-8').style('max-width: 32px') + upload.set_visibility(True) + + tts_clear_btn = ui.button(icon='clear', on_click=lambda: _clear_voice(state, bridge, tts_voice_label)).props('flat dense round size=sm') + + # Output options - compact + with ui.row().classes('items-center w-full gap-1'): + ui.label('Out:').classes('text-xs w-10') + tts_save_cb = ui.checkbox('Save', value=state.tts_save_file).props('dense') tts_save_cb.on_value_change(lambda e: setattr(state, 'tts_save_file', e.value)) - tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-20') + tts_format_select = ui.select(options=["wav", "ogg"], value=state.tts_format).classes('w-16').props('dense') tts_format_select.on_value_change(lambda e: setattr(state, 'tts_format', e.value)) - # TTS status - tts_status_label = ui.label('').classes('text-xs text-blue-600') + # TTS status - compact + tts_status_label = ui.label('').classes('text-xs text-blue-400') - # Update TTS status periodically def update_tts_status(): if state.tts_status_message: - tts_status_label.text = state.tts_status_message + tts_status_label.text = state.tts_status_message[:50] else: tts_status_label.text = '' ui.timer(0.2, update_tts_status) - ui.separator().classes('my-2') + ui.separator().classes('my-1') # === CONTROL SECTION === control_btn = ui.button( @@ -272,26 +283,25 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N on_click=lambda: _toggle_recording(state, bridge, control_btn, level_progress, status_label) ).classes('w-full').props('color=primary') - # Audio level - with ui.row().classes('items-center w-full gap-2'): - ui.label('Level:').classes('text-sm w-12') + # Audio level - compact + with ui.row().classes('items-center w-full gap-1'): + ui.label('Level:').classes('text-xs w-12') level_progress = ui.linear_progress(value=0, show_value=False).classes('flex-grow') - # Auto-stop - with ui.row().classes('items-center w-full gap-2'): - autostop_cb = ui.checkbox('Auto-stop after', value=state.auto_stop_enabled) + # Auto-stop - compact + with ui.row().classes('items-center w-full gap-1'): + autostop_cb = ui.checkbox('Auto-stop', value=state.auto_stop_enabled).props('dense') autostop_cb.on_value_change(lambda e: setattr(state, 'auto_stop_enabled', e.value)) - autostop_num = ui.number(value=state.auto_stop_minutes, min=1, max=60, step=1, - format='%.0f').classes('w-16') + autostop_num = ui.number(value=state.auto_stop_minutes, min=1, max=60, step=1).classes('w-14').props('dense') autostop_num.on_value_change(lambda e: setattr(state, 'auto_stop_minutes', int(e.value or 5))) - ui.label('min').classes('text-sm') + ui.label('min').classes('text-xs') - # Status - status_label = ui.label('').classes('text-sm text-red-600 mt-2') + # Status - compact + status_label = ui.label('').classes('text-xs text-red-400 mt-1') - # Update UI periodically + # Update UI periodically - faster for audio level def update_ui(): # Update button if state.is_recording: @@ -301,30 +311,32 @@ def create_sidebar(state: AppState, bridge: ProcessingBridge, output_container=N control_btn.text = 'Start' control_btn.props('color=primary') - # Update level - NO smoothing, direct value + # Update level - NO smoothing level_progress.value = state.audio_level / 100.0 # Update status if state.error_message: - status_label.text = f'Error: {state.error_message}' + status_label.text = f'Error: {state.error_message[:40]}' elif state.status_message: - status_label.text = state.status_message + status_label.text = state.status_message[:40] else: status_label.text = '' - ui.timer(0.05, update_ui) # Faster update for audio level (50ms instead of 100ms) + ui.timer(0.05, update_ui) return sidebar_container -def _toggle_text(state: AppState, btn, output_container): +def _toggle_text(state: AppState, btn, sidebar_container): """Toggle text visibility.""" state.text_visible = not state.text_visible btn.text = 'Hide Text ◀' if state.text_visible else 'Show Text ▶' - # Show/hide the output container - if output_container: - output_container.set_visibility(state.text_visible) + # Get output container from sidebar + if hasattr(sidebar_container, '_output_container'): + output_container = sidebar_container._output_container + if output_container: + output_container.set_visibility(state.text_visible) ui.notify(f'Text panels {"shown" if state.text_visible else "hidden"}') @@ -340,8 +352,6 @@ def _toggle_recording(state: AppState, bridge: ProcessingBridge, btn, level, sta def _on_manual_mode_changed(state: AppState, manual_mode: bool, process_btn, trigger_select, interval_select, words_num): """Handle manual mode checkbox change.""" state.ai_manual_mode = manual_mode - - # Enable/disable controls process_btn.set_enabled(manual_mode) trigger_select.set_enabled(not manual_mode) interval_select.set_enabled(not manual_mode and state.ai_trigger_mode == "time") @@ -352,7 +362,6 @@ def _on_trigger_changed(state: AppState, trigger_mode: str, interval_select, wor """Handle trigger mode change.""" state.ai_trigger_mode = trigger_mode.lower() - # Show/hide appropriate control if trigger_mode == "Time": interval_select.set_visibility(True) words_num.set_visibility(False) @@ -364,22 +373,47 @@ def _on_trigger_changed(state: AppState, trigger_mode: str, interval_select, wor def _on_tts_source_changed(state: AppState, source: str, checked: bool, w_cb, a_cb, t_cb): """Handle TTS source selection (mutually exclusive).""" if not checked: - # Don't allow unchecking - must have one selected return state.tts_source = source - - # Update checkboxes to be mutually exclusive w_cb.value = (source == "whisper") a_cb.value = (source == "ai") t_cb.value = (source == "translation") -def _browse_voice(state: AppState, bridge: ProcessingBridge, voice_label): - """Browse for voice reference file.""" - # For now, just show a message - file dialog requires additional implementation - ui.notify("Voice browsing not yet implemented in web UI") - # TODO: Implement file upload for voice reference +def _on_voice_upload(event, state: AppState, bridge: ProcessingBridge, voice_label): + """Handle voice file upload.""" + try: + # Get uploaded file + file_path = event.name + filename = Path(file_path).name + + # Set voice reference + state.tts_voice_reference = file_path + state.tts_voice_display_name = filename[:20] + "..." if len(filename) > 20 else filename + voice_label.text = state.tts_voice_display_name + + if bridge.tts_controller: + # The file is saved by NiceGUI in a temporary location + # We need to use the actual uploaded content + with open(event.name, 'rb') as f: + content = f.read() + + # Save to permanent location + tts_voice_dir = Path("tts_voices") + tts_voice_dir.mkdir(exist_ok=True) + permanent_path = tts_voice_dir / filename + + with open(permanent_path, 'wb') as f: + f.write(content) + + bridge.tts_controller.set_reference_voice(str(permanent_path)) + state.tts_voice_reference = str(permanent_path) + + ui.notify(f"Voice loaded: {filename}", type='positive') + except Exception as e: + ui.notify(f"Error loading voice: {e}", type='negative') + print(f"Voice upload error: {e}") def _clear_voice(state: AppState, bridge: ProcessingBridge, voice_label): @@ -102,21 +102,79 @@ def main(): print(f"Failed to initialize TTS controller: {e}") state.tts_available = False - # === UI LAYOUT === + # === UI SETUP === ui.page_title('Whispering') - # Create main container - sidebar on left, output on right - with ui.row().classes('w-full h-screen'): - # Create output panels first (but will be positioned on right) - output_container = create_output_panels(state) + # Enable dark mode + ui.dark_mode().enable() + + # Add custom CSS for compact, modern dark theme + ui.add_head_html(''' + <style> + /* Compact spacing */ + .q-page { + padding: 0 !important; + } + + /* Sidebar - fixed width, dark background */ + .sidebar-container { + background: #1e1e1e; + border-right: 1px solid #333; + overflow-y: auto; + min-width: 350px !important; + max-width: 350px !important; + } + + /* Output panels - take remaining space */ + .output-container { + background: #121212; + flex: 1; + overflow-y: auto; + } + + /* Compact controls */ + .q-field__control { + min-height: 32px !important; + } + + .q-btn { + font-size: 0.875rem !important; + } + + /* Section separators */ + .q-separator { + background: #333 !important; + } + + /* Scrollbars */ + ::-webkit-scrollbar { + width: 8px; + } + + ::-webkit-scrollbar-track { + background: #1e1e1e; + } + + ::-webkit-scrollbar-thumb { + background: #444; + border-radius: 4px; + } + </style> + ''') + + # === UI LAYOUT === + # Horizontal split: sidebar (left) | output panels (right) + with ui.row().classes('w-full h-screen').style('margin: 0; padding: 0; gap: 0;'): + # Sidebar on left - fixed width + sidebar_container = create_sidebar(state, bridge, None).classes('sidebar-container') - # Sidebar on left - pass output container so it can toggle visibility - sidebar_container = create_sidebar(state, bridge, output_container) + # Output panels on right - flex grow + output_container = create_output_panels(state).classes('output-container') - # Move sidebar to the left (before output in DOM order) - sidebar_container.move(output_container) + # Connect sidebar to output for show/hide + sidebar_container._output_container = output_container - # Set initial visibility of output + # Set initial visibility if not state.text_visible: output_container.set_visibility(False)