""" Main interface builder for Gradio application. Assembles all UI components and wires event handlers. """ import gradio as gr from i8n import t, get_locale, set_locale, get_available_locales from config import ( DEV_TAB_VISIBLE, IS_HF_SPACE, IQLAB_IKHFAA_SHAFAWI_SOUND, GHUNNAH_LENGTH, MADD_TABII_LENGTH, MADD_WAJIB_MUTTASIL_LENGTH, MADD_JAIZ_MUNFASIL_LENGTH, MADD_LAZIM_LENGTH, MADD_ARID_LISSUKUN_LENGTH, MADD_LEEN_LENGTH, DURATION_TOLERANCE, DURATION_TOLERANCE_MIN, DURATION_TOLERANCE_MAX, DURATION_TOLERANCE_STEP, LEFT_COLUMN_SCALE, # Setters for syncing localStorage values to config set_duration_tolerance, set_iqlab_ikhfaa_shafawi_sound, set_ghunnah_length, set_madd_jaiz_munfasil_length, set_madd_arid_lissukun_length, set_madd_leen_length, set_madd_wajib_muttasil_length, set_usage_log_audio, USAGE_LOG_AUDIO, ) from shared_state import get_model_bundles from utils.phonemizer_utils import get_chapter_list from ui.styles import create_app_blocks, inject_css_fallback, get_all_head_js from ui.components import ( create_verse_selector, create_control_buttons, get_initial_verse, format_no_verse_message, ) from ui.components.reference_audio import create_reference_audio_components from ui.handlers import on_verse_selection_change from ui.handlers.factory import InterfaceContext, HandlerFactory from ui.handlers.wiring import wire_all_events from ui.handlers.reference_audio import ( on_ref_audio_prev_click, on_ref_audio_next_click, on_ref_audio_slider_change, init_ref_audio_for_verse_range, ) from recitation_engine.reference_audio import get_verse_audio_path, parse_verse_range from ui.handlers.lazy_audio import fetch_segment_clip_lazy def _on_locale_load(cached_settings: dict, request: gr.Request): """ Handle locale initialization and settings sync on page load. Reads the __locale query parameter and updates all component labels to use the correct language. Also syncs localStorage settings to config.py to ensure table rendering uses the same values shown in the UI. Args: cached_settings: Dict of settings from localStorage (passed from JS) request: Gradio request object with query params """ # Read locale from query parameter locale = request.query_params.get("__locale", "en") # Validate and set locale if locale in get_available_locales(): set_locale(locale) else: locale = "en" set_locale("en") # ----- Sync localStorage settings to config.py ----- # This ensures table rendering uses the same values as the UI if cached_settings: if cached_settings.get("duration_tolerance") is not None: set_duration_tolerance(float(cached_settings["duration_tolerance"])) if cached_settings.get("iqlab_ikhfaa_sound") is not None: set_iqlab_ikhfaa_shafawi_sound(cached_settings["iqlab_ikhfaa_sound"]) if cached_settings.get("ghunnah_length") is not None: set_ghunnah_length(int(cached_settings["ghunnah_length"])) if cached_settings.get("madd_jaiz_length") is not None: set_madd_jaiz_munfasil_length(int(cached_settings["madd_jaiz_length"])) if cached_settings.get("madd_arid_length") is not None: set_madd_arid_lissukun_length(int(cached_settings["madd_arid_length"])) if cached_settings.get("madd_leen_length") is not None: set_madd_leen_length(int(cached_settings["madd_leen_length"])) if cached_settings.get("madd_wajib_length") is not None: set_madd_wajib_muttasil_length(int(cached_settings["madd_wajib_length"])) if cached_settings.get("save_audio") is not None: set_usage_log_audio(bool(cached_settings["save_audio"])) # Get current values (either from localStorage or config defaults) current_duration_tolerance = (cached_settings.get("duration_tolerance") or DURATION_TOLERANCE) if cached_settings else DURATION_TOLERANCE current_iqlab_sound = (cached_settings.get("iqlab_ikhfaa_sound") or IQLAB_IKHFAA_SHAFAWI_SOUND) if cached_settings else IQLAB_IKHFAA_SHAFAWI_SOUND current_ghunnah = (cached_settings.get("ghunnah_length") or GHUNNAH_LENGTH) if cached_settings else GHUNNAH_LENGTH current_madd_jaiz = (cached_settings.get("madd_jaiz_length") or MADD_JAIZ_MUNFASIL_LENGTH) if cached_settings else MADD_JAIZ_MUNFASIL_LENGTH current_madd_arid = (cached_settings.get("madd_arid_length") or MADD_ARID_LISSUKUN_LENGTH) if cached_settings else MADD_ARID_LISSUKUN_LENGTH current_madd_leen = (cached_settings.get("madd_leen_length") or MADD_LEEN_LENGTH) if cached_settings else MADD_LEEN_LENGTH current_madd_wajib = (cached_settings.get("madd_wajib_length") or MADD_WAJIB_MUTTASIL_LENGTH) if cached_settings else MADD_WAJIB_MUTTASIL_LENGTH current_save_audio = cached_settings.get("save_audio") if (cached_settings and cached_settings.get("save_audio") is not None) else USAGE_LOG_AUDIO # Build updated header HTML with correct locale # Note: RTL script is applied via app.load(js=...) since scripts in gr.HTML don't execute rtl_dir = "direction: rtl;" if locale == "ar" else "" header_html = f"""

{t("header.title")}

1. {t("header.step1")} · 2. {t("header.step2")} · 3. {t("header.step3")}

""" # Generate chapter choices with correct locale formatting chapters = get_chapter_list() is_arabic = locale == "ar" chapter_choices = [ f"{num} - {name_ar}" if is_arabic and name_ar else f"{num} - {name_en} ({name_ar})" if name_ar else f"{num} - {name_en}" for num, name_en, name_ar in chapters ] # Default to first chapter (Al-Fatiha) with correct locale formatting initial_chapter_value = chapter_choices[0] if chapter_choices else None # Return updates for all translatable components # Order must match the outputs list in the .load() event # Now includes BOTH label AND value for settings components return [ # 1. Header HTML gr.update(value=header_html), # 2. Language toggle (set to current locale) gr.update(value=locale), # 3. Audio input label gr.update(label=t("audio.label")), # 4. Analyze button gr.update(value=t("controls.analyze")), # 5. Duration tolerance slider - now includes synced value gr.update( label=t("settings.duration_tolerance"), info=t("settings.duration_tolerance_info"), value=float(current_duration_tolerance) ), # 6. Iqlab/Ikhfaa sound radio - now includes synced value gr.update( label=t("tajweed_settings.iqlab_ikhfaa_sound"), choices=[ (t("tajweed_settings.meem_ghunnah"), "meem ghunnah"), (t("tajweed_settings.ikhfaa"), "ikhfaa") ], value=current_iqlab_sound ), # 7. Ghunnah length radio - now includes synced value gr.update(label=t("tajweed_settings.ghunnah_length"), value=int(current_ghunnah)), # 8. Madd Jaiz length radio - now includes synced value gr.update(label=t("tajweed_settings.jaiz_munfasil"), value=int(current_madd_jaiz)), # 9. Madd Arid length radio - now includes synced value gr.update(label=t("tajweed_settings.arid_lissukun"), value=int(current_madd_arid)), # 10. Madd Leen length radio - now includes synced value gr.update(label=t("tajweed_settings.leen"), value=int(current_madd_leen)), # 11. Madd Wajib length radio - now includes synced value gr.update(label=t("tajweed_settings.wajib_muttasil"), value=int(current_madd_wajib)), # 12. Madd Lazim length radio (fixed value, no sync needed) gr.update(label=t("tajweed_settings.lazim")), # 13. Madd Tabii length radio (fixed value, no sync needed) gr.update(label=t("tajweed_settings.tabii")), # 14. Error sort dropdown gr.update(choices=[ (t("sort_options.text_order"), "text_order"), (t("sort_options.by_errors"), "by_errors") ], value="text_order"), # 15. Ghunnah sort dropdown gr.update(choices=[ (t("sort_options.text_order"), "text_order"), (t("sort_options.by_rule"), "by_rule"), (t("sort_options.by_errors"), "by_errors") ], value="text_order"), # 16. Madd sort dropdown gr.update(choices=[ (t("sort_options.text_order"), "text_order"), (t("sort_options.by_rule"), "by_rule"), (t("sort_options.by_errors"), "by_errors") ], value="text_order"), # 17. From chapter dropdown (update choices and value with locale-aware formatting) gr.update(label=t("verse_selector.surah"), choices=chapter_choices, value=initial_chapter_value), # 18. From verse dropdown gr.update(label=t("verse_selector.from_verse")), # 19. To verse dropdown gr.update(label=t("verse_selector.to_verse")), # 20. Random button gr.update(value=t("controls.random_verse")), # 21. Reset button gr.update(value=t("controls.reset")), # 22. Multi models checkbox gr.update(label=t("controls.run_all_models")), # 23. Save audio checkbox - includes synced value gr.update(label=t("settings.save_audio"), info=t("settings.save_audio_info"), value=bool(current_save_audio)), # 24. Settings accordion gr.update(label=t("settings.header")), ] def _compute_initial_state(chapters_list: list) -> dict: """ Compute initial state by phonemizing the default verse selection. Args: chapters_list: List of chapter strings Returns: Dict with initial values for all components """ initial_selection = get_initial_verse(chapters_list) init_result = on_verse_selection_change( initial_selection[0], initial_selection[1], initial_selection[2] ) (init_arabic_html, init_phonemes, init_verse_ref, init_canonical, init_ghunnah_html, init_madd_html, init_audio_uri) = init_result # Initialize reference audio for the default verse init_ref_audio = { "verse_keys": [], "verse_index": 0, "audio_path": None, "show_nav": False, "slider_max": 0, } if init_verse_ref: try: verse_keys = parse_verse_range(init_verse_ref) if verse_keys: count = len(verse_keys) first_key = verse_keys[0] audio_path = get_verse_audio_path(first_key) init_ref_audio = { "verse_keys": verse_keys, "verse_index": 0, "audio_path": audio_path, "show_nav": count > 1, "slider_max": count - 1, } except Exception as exc: print(f"[UI] Failed to load initial reference audio: {exc}") return { "selection": initial_selection, "arabic_html": init_arabic_html, "phonemes": init_phonemes, "verse_ref": init_verse_ref, "canonical": init_canonical, "ghunnah_html": init_ghunnah_html, "madd_html": init_madd_html, "ref_audio": init_ref_audio, } def build_interface() -> gr.Blocks: """ Build and return the complete Gradio interface. This is the main entry point called from app.py. Returns: Configured gr.Blocks application """ # ----- Runtime context ----- model_bundles = get_model_bundles() multi_visible = len(model_bundles) > 1 chapters_list = get_chapter_list() context = InterfaceContext( chapters_list=chapters_list, multi_visible=multi_visible ) handlers = HandlerFactory(context) # ----- Initial state ----- init = _compute_initial_state(chapters_list) # ----- Create app block ----- # Head JS: locale sync (for Gradio i18n) + segment clip lazy loading head_js = get_all_head_js(polling_enabled=False) app, use_css_fallback = create_app_blocks(head=head_js) with app: # CSS fallback for older Gradio if use_css_fallback: inject_css_fallback() # ----- Create all components ----- components = {} states = {} # Header with title init_rtl_dir = "direction: rtl;" if get_locale() == "ar" else "" components["header_html"] = gr.HTML( f"""

{t("header.title")}

1. {t("header.step1")} · 2. {t("header.step2")} · 3. {t("header.step3")}

""" ) # Language toggle using native Gradio Radio (styled via CSS) current_locale = get_locale() with gr.Row(elem_classes=["lang-toggle-container"]): components["lang_toggle"] = gr.Radio( choices=[("English", "en"), ("العربية", "ar")], value=current_locale, show_label=False, container=False, elem_classes=["lang-toggle-radio"], ) # Hidden components to hold session values (must be outside Row/Column for event wiring) states["expected_phonemes"] = gr.Textbox( value=init["phonemes"], visible=False, interactive=False ) states["verse_ref"] = gr.Textbox( value=init["verse_ref"], visible=False, interactive=False ) states["canonical_text"] = gr.Textbox( value=init["canonical"], visible=False, interactive=False ) states["is_random"] = gr.Checkbox(value=False, visible=False, interactive=False) states["is_upload"] = gr.Checkbox(value=False, visible=False, interactive=False) states["is_from_verse_change"] = gr.Checkbox(value=False, visible=False, interactive=False) states["has_analyzed"] = gr.Checkbox(value=False, visible=False, interactive=False) states["last_analysis_mode"] = gr.Textbox( value="", visible=False, interactive=False ) # Hidden component to receive cached settings from localStorage on page load # This bridges JS → Python so config.py gets synced with UI values states["cached_settings"] = gr.JSON(value=None, visible=False) # Hidden components for segment clip lazy loading (error analysis audio clips) gr.HTML('
') states["segment_clip_request"] = gr.Textbox( value="", visible=True, interactive=True, elem_id="segment-clip-request", elem_classes=["lazy-audio-hidden"] ) states["segment_clip_response"] = gr.Textbox( value="", visible=True, interactive=False, elem_id="segment-clip-response", elem_classes=["lazy-audio-hidden"] ) # ----- Two-column layout ----- with gr.Row(): # ========== LEFT COLUMN ========== with gr.Column(scale=LEFT_COLUMN_SCALE): # Verse selector verse_components, _ = create_verse_selector( horizontal=True, initial_selection=init["selection"], chapters=chapters_list ) components["from_chapter"] = verse_components["from_chapter"] components["from_verse"] = verse_components["from_verse"] components["to_verse"] = verse_components["to_verse"] # Control buttons (segmentation is now auto-detected by VAD) controls = create_control_buttons(multi_visible) components["random_btn"] = controls["random_btn"] components["reset_btn"] = controls["reset_btn"] components["multi_models_cb"] = controls["multi_models_cb"] # Audio input components["audio_input"] = gr.Audio( sources=["microphone", "upload"], type="filepath", label=t("audio.label"), show_label=False ) # Analyze button (always visible, disabled until audio is present) with gr.Column(elem_classes=["analyze-btn-container"]): components["analyze_btn"] = gr.Button( t("controls.analyze"), variant="primary", visible=True, interactive=False ) components["save_audio_cb"] = gr.Checkbox( value=USAGE_LOG_AUDIO, label=t("settings.save_audio"), info=t("settings.save_audio_info"), elem_id="setting-save-audio" ) # Recitation settings (expanded by default) with gr.Accordion(t("settings.header"), open=True) as settings_accordion: # 1. Duration Tolerance with gr.Group(): components["duration_tolerance"] = gr.Slider( minimum=DURATION_TOLERANCE_MIN, maximum=DURATION_TOLERANCE_MAX, step=DURATION_TOLERANCE_STEP, value=DURATION_TOLERANCE, label=t("settings.duration_tolerance"), info=t("settings.duration_tolerance_info"), elem_id="setting-duration-tolerance" ) # 2. Ghunnah settings with gr.Group(): with gr.Row(): with gr.Column(scale=3, min_width=150): components["iqlab_ikhfaa_sound"] = gr.Radio( choices=[ (t("tajweed_settings.meem_ghunnah"), "meem ghunnah"), (t("tajweed_settings.ikhfaa"), "ikhfaa") ], value=IQLAB_IKHFAA_SHAFAWI_SOUND, label=t("tajweed_settings.iqlab_ikhfaa_sound"), elem_id="setting-iqlab-ikhfaa-sound" ) with gr.Column(scale=2, min_width=100): components["ghunnah_length"] = gr.Radio( choices=[2, 3], value=GHUNNAH_LENGTH, label=t("tajweed_settings.ghunnah_length"), elem_id="setting-ghunnah-length" ) # 3. Madd length settings with gr.Group(): with gr.Row(elem_classes=["madd-settings-row"]): with gr.Column(scale=3, min_width=150): components["madd_jaiz_length"] = gr.Radio( choices=[2, 4, 5], value=MADD_JAIZ_MUNFASIL_LENGTH, label=t("tajweed_settings.jaiz_munfasil"), elem_id="setting-madd-jaiz-length" ) components["madd_arid_length"] = gr.Radio( choices=[2, 4, 6], value=MADD_ARID_LISSUKUN_LENGTH, label=t("tajweed_settings.arid_lissukun"), elem_id="setting-madd-arid-length" ) components["madd_leen_length"] = gr.Radio( choices=[2, 4, 6], value=MADD_LEEN_LENGTH, label=t("tajweed_settings.leen"), elem_id="setting-madd-leen-length" ) with gr.Column(scale=2, min_width=100): components["madd_wajib_length"] = gr.Radio( choices=[4, 5], value=MADD_WAJIB_MUTTASIL_LENGTH, label=t("tajweed_settings.wajib_muttasil"), elem_id="setting-madd-wajib-length" ) components["madd_lazim_length"] = gr.Radio( choices=[6], value=MADD_LAZIM_LENGTH, label=t("tajweed_settings.lazim"), interactive=False ) components["madd_tabii_length"] = gr.Radio( choices=[2], value=MADD_TABII_LENGTH, label=t("tajweed_settings.tabii"), interactive=False ) components["settings_accordion"] = settings_accordion # ========== RIGHT COLUMN ========== with gr.Column(scale=10 - LEFT_COLUMN_SCALE, elem_classes=["right-column"]): # Arabic display components["arabic_display"] = gr.HTML( value=init["arabic_html"] or format_no_verse_message(), label="Selected Verse" ) # Reference audio section (native Gradio components) ref_audio_init = init["ref_audio"] ref_components = create_reference_audio_components( initial_audio_path=ref_audio_init["audio_path"] ) # Store component references components["ref_audio_nav_row"] = ref_components["nav_row"] components["ref_audio_prev_btn"] = ref_components["prev_btn"] components["ref_audio_next_btn"] = ref_components["next_btn"] components["ref_audio_slider"] = ref_components["slider"] components["ref_audio_player"] = ref_components["audio_player"] states["ref_audio_verse_index"] = ref_components["verse_index"] states["ref_audio_verse_keys"] = ref_components["verse_keys"] # Set initial values (audio_path is passed to create_reference_audio_components) components["ref_audio_nav_row"].visible = ref_audio_init["show_nav"] components["ref_audio_slider"].maximum = ref_audio_init["slider_max"] states["ref_audio_verse_index"].value = ref_audio_init["verse_index"] states["ref_audio_verse_keys"].value = ref_audio_init["verse_keys"] # Analysis tabs with gr.Tabs(): with gr.Tab(t("tabs.error_analysis")): with gr.Row(elem_classes=["sort-row"]): components["error_sort_dropdown"] = gr.Radio( choices=[ (t("sort_options.text_order"), "text_order"), (t("sort_options.by_errors"), "by_errors") ], value="text_order", show_label=False, interactive=True, container=False, visible=False, elem_classes=["sort-toggle"], ) components["error_display"] = gr.HTML(value="", label=t("tabs.error_analysis")) with gr.Tab(t("tabs.ghunnah_analysis")): with gr.Row(elem_classes=["sort-row"]): components["ghunnah_sort_dropdown"] = gr.Radio( choices=[ (t("sort_options.text_order"), "text_order"), (t("sort_options.by_rule"), "by_rule"), (t("sort_options.by_errors"), "by_errors") ], value="text_order", show_label=False, interactive=True, container=False, visible=False, elem_classes=["sort-toggle"], ) components["ghunnah_display"] = gr.HTML( value=init["ghunnah_html"], label=t("tabs.ghunnah_analysis") ) with gr.Tab(t("tabs.madd_analysis")): with gr.Row(elem_classes=["sort-row"]): components["madd_sort_dropdown"] = gr.Radio( choices=[ (t("sort_options.text_order"), "text_order"), (t("sort_options.by_rule"), "by_rule"), (t("sort_options.by_errors"), "by_errors") ], value="text_order", show_label=False, interactive=True, container=False, visible=False, elem_classes=["sort-toggle"], ) components["madd_display"] = gr.HTML( value=init["madd_html"], label=t("tabs.madd_analysis") ) if DEV_TAB_VISIBLE: with gr.Tab("Dev"): with gr.Row(): with gr.Column(scale=1): components["dev_canonical_phonemes"] = gr.Textbox( label="Canonical Phonemes (from phonemizer)", value=init["phonemes"], lines=3, interactive=False, ) with gr.Column(scale=1): components["dev_detected_phonemes"] = gr.Textbox( label="Detected Phonemes (edit to simulate errors)", value=init["phonemes"], lines=3, interactive=True, placeholder="Modify phonemes here to simulate detection errors...", ) with gr.Row(): components["dev_simulate_btn"] = gr.Button( "Simulate Errors", variant="primary" ) components["dev_reset_btn"] = gr.Button( "Reset to Canonical", variant="secondary" ) components["dev_simulation_output"] = gr.HTML(value="", label="Simulation Result") with gr.Row(): components["dev_test_name"] = gr.Textbox( label="Test Name", placeholder="Enter a name for this test case...", scale=2, ) components["dev_save_btn"] = gr.Button( "Save Test Case", variant="secondary", scale=1 ) components["dev_save_status"] = gr.HTML(value="", label="Save Status") gr.Markdown("### Test Runner") with gr.Row(): components["dev_test_source"] = gr.Radio( choices=["MDD Tests (mdd_tests.yaml)", "Formal Tests (error_analysis/)"], value="MDD Tests (mdd_tests.yaml)", label="Test Source", scale=2, ) components["dev_load_btn"] = gr.Button( "Load & Run Tests", variant="primary", scale=1 ) components["dev_tests_output"] = gr.HTML(value="", label="Test Results") gr.Markdown("### Alignment Visualization") with gr.Row(): components["dev_alignment_btn"] = gr.Button( "Generate Alignment Plot", variant="secondary", scale=1 ) components["dev_alignment_status"] = gr.HTML(value="", label="Status") components["dev_alignment_image"] = gr.Image( value=None, label="CTC Forced Alignment", type="filepath", ) # ----- Wire all events ----- wire_all_events(components, states, handlers) # ----- Wire reference audio navigation events ----- # Previous button components["ref_audio_prev_btn"].click( fn=on_ref_audio_prev_click, inputs=[ states["ref_audio_verse_index"], states["ref_audio_verse_keys"] ], outputs=[ states["ref_audio_verse_index"], components["ref_audio_slider"], components["ref_audio_player"] ] ) # Next button components["ref_audio_next_btn"].click( fn=on_ref_audio_next_click, inputs=[ states["ref_audio_verse_index"], states["ref_audio_verse_keys"] ], outputs=[ states["ref_audio_verse_index"], components["ref_audio_slider"], components["ref_audio_player"] ] ) # Slider input (use .input not .change to avoid firing on programmatic updates) components["ref_audio_slider"].input( fn=on_ref_audio_slider_change, inputs=[ components["ref_audio_slider"], states["ref_audio_verse_keys"] ], outputs=[ states["ref_audio_verse_index"], components["ref_audio_player"] ] ) # Wire segment clip lazy loading event # When JavaScript requests a segment clip, fetch and return it states["segment_clip_request"].change( fn=fetch_segment_clip_lazy, inputs=[states["segment_clip_request"]], outputs=[states["segment_clip_response"]] ) # ----- Wire locale initialization on page load ----- # This reads the __locale query parameter and updates all translatable components # The js parameter applies RTL class to body based on locale (works on HF Spaces) # Build translations from i18n JSON files (avoid hardcoded strings) translation_keys = [ "sort_options.text_order", "sort_options.by_rule", "sort_options.by_errors", "tabs.error_analysis", "tabs.ghunnah_analysis", "tabs.madd_analysis", "tabs.dev", ] js_translations = ",\n ".join( f"'{t(key, locale='en')}': '{t(key, locale='ar')}'" for key in translation_keys ) rtl_init_js = f""" () => {{ const urlParams = new URLSearchParams(window.location.search); const locale = urlParams.get('__locale') || localStorage.getItem('tajweed_locale') || 'en'; document.body.classList.toggle('rtl-layout', locale === 'ar'); // Read settings from localStorage and return to Python for config sync // This ensures config.py has the same values as the UI when tables render const getStoredValue = (key) => {{ const val = localStorage.getItem(key); return val !== null ? JSON.parse(val) : null; }}; const cachedSettings = {{ duration_tolerance: getStoredValue('tajweed_duration_tolerance'), iqlab_ikhfaa_sound: getStoredValue('tajweed_iqlab_ikhfaa_sound'), ghunnah_length: getStoredValue('tajweed_ghunnah_length'), madd_jaiz_length: getStoredValue('tajweed_madd_jaiz_length'), madd_arid_length: getStoredValue('tajweed_madd_arid_length'), madd_leen_length: getStoredValue('tajweed_madd_leen_length'), madd_wajib_length: getStoredValue('tajweed_madd_wajib_length'), save_audio: getStoredValue('tajweed_save_audio'), }}; // Force-sync language toggle after Gradio renders // (settings are now synced via Python return values, but lang toggle needs click()) setTimeout(() => {{ const langInputs = document.querySelectorAll('.lang-toggle-radio input[type="radio"]'); console.log('[locale-sync] Detected locale:', locale, 'Found inputs:', langInputs.length); langInputs.forEach(input => {{ if (input.value === locale && !input.checked) {{ console.log('[locale-sync] Clicking radio for:', locale); input.click(); }} }}); }}, 150); // Update labels for Arabic locale if (locale === 'ar') {{ const translations = {{ {js_translations} }}; // Wait for Gradio to render, then update labels setTimeout(() => {{ // Update sort toggle labels document.querySelectorAll('.sort-toggle label span').forEach(span => {{ const text = span.textContent.trim(); if (translations[text]) {{ span.textContent = translations[text]; }} }}); // Update tab labels document.querySelectorAll('.tabs button').forEach(btn => {{ const text = btn.textContent.trim(); if (translations[text]) {{ btn.textContent = translations[text]; }} }}); }}, 100); }} // Return settings to Python for config.py sync return cachedSettings; }} """ app.load( fn=_on_locale_load, inputs=[states["cached_settings"]], # Receives localStorage settings from JS outputs=[ components["header_html"], components["lang_toggle"], components["audio_input"], components["analyze_btn"], components["duration_tolerance"], components["iqlab_ikhfaa_sound"], components["ghunnah_length"], components["madd_jaiz_length"], components["madd_arid_length"], components["madd_leen_length"], components["madd_wajib_length"], components["madd_lazim_length"], components["madd_tabii_length"], components["error_sort_dropdown"], components["ghunnah_sort_dropdown"], components["madd_sort_dropdown"], components["from_chapter"], components["from_verse"], components["to_verse"], components["random_btn"], components["reset_btn"], components["multi_models_cb"], components["save_audio_cb"], components["settings_accordion"], ], js=rtl_init_js, ) # ----- Wire language toggle change ----- # When user clicks the toggle, store in localStorage and reload with new locale locale_change_js = """ (locale) => { if (locale) { const url = new URL(window.location.href); const currentUrlLocale = url.searchParams.get('__locale'); // Only reload if URL locale is different (prevents infinite loop) if (currentUrlLocale !== locale) { localStorage.setItem('tajweed_locale', locale); url.searchParams.set('__locale', locale); // Apply RTL class immediately for smooth transition document.body.classList.toggle('rtl-layout', locale === 'ar'); setTimeout(() => { window.location.href = url.toString(); }, 100); } } } """ components["lang_toggle"].change( fn=None, # No server round-trip needed - JS handles everything inputs=[components["lang_toggle"]], outputs=[], # No outputs - JS handles the page reload js=locale_change_js ) # ----- Wire settings persistence to localStorage ----- # Save settings when changed so they persist across page reloads settings_to_cache = [ ("duration_tolerance", "tajweed_duration_tolerance"), ("iqlab_ikhfaa_sound", "tajweed_iqlab_ikhfaa_sound"), ("ghunnah_length", "tajweed_ghunnah_length"), ("madd_jaiz_length", "tajweed_madd_jaiz_length"), ("madd_arid_length", "tajweed_madd_arid_length"), ("madd_leen_length", "tajweed_madd_leen_length"), ("madd_wajib_length", "tajweed_madd_wajib_length"), ("save_audio_cb", "tajweed_save_audio"), ] for component_key, storage_key in settings_to_cache: components[component_key].change( fn=None, inputs=[components[component_key]], outputs=[], js=f"(value) => {{ localStorage.setItem('{storage_key}', JSON.stringify(value)); }}" ) return app