diff --git a/.Jules/palette.md b/.Jules/palette.md new file mode 100644 index 00000000..49f6af8a --- /dev/null +++ b/.Jules/palette.md @@ -0,0 +1,3 @@ +## 2025-05-14 - [Flet Dialog Accessibility & Pulse Indicators] +**Learning:** Dialogs in this Flet-based app often lack automatic focus and Enter-key submission, creating friction for keyboard-driven "Quick Recording" workflows. Additionally, static status labels for long-running tasks (like recording) benefit significantly from subtle animations to provide glancing feedback. +**Action:** Always ensure `autofocus=True` and `on_submit` are set for primary inputs in dialogs. Use `animate_opacity` in conjunction with existing periodic update tasks (like duration timers) to create lightweight, "alive" visual indicators. diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 00000000..f8e58ac9 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,3 @@ +## 2025-05-14 - [Config Performance Optimization] +**Learning:** The `ConfigManager` was performing synchronous disk I/O on every configuration value lookup via `get_config_value`. While individually fast, these operations aggregate and block the event loop in an `asyncio` application, potentially causing micro-stutters in the UI. +**Action:** Implement memory caching in `ConfigManager` to ensure that subsequent reads are served from RAM, and only write-through to disk when settings are modified. diff --git a/app/core/config/config_manager.py b/app/core/config/config_manager.py index b9cb057e..033c1b9e 100644 --- a/app/core/config/config_manager.py +++ b/app/core/config/config_manager.py @@ -23,6 +23,10 @@ def __init__(self, run_path): self.accounts_config_path = os.path.join(self.config_path, "accounts.json") self.web_auth_config_path = os.path.join(self.config_path, "web_auth.json") + # Memory cache for configuration data to avoid redundant disk I/O + self._cache = {} + self._cache_lock = threading.Lock() + os.makedirs(os.path.dirname(self.default_config_path), exist_ok=True) self.init() @@ -72,21 +76,29 @@ def init_web_auth_config(self): cookies_config = {} self._init_config(self.web_auth_config_path, cookies_config) - @staticmethod - def _load_config(config_path, error_message): - """Load configuration from a JSON file.""" + def _load_config(self, config_path, error_message, use_cache=True): + """Load configuration from a JSON file with memory caching.""" + if use_cache: + with self._cache_lock: + if config_path in self._cache: + return self._cache[config_path] + + config_data = {} try: - with open(config_path, encoding="utf-8") as file: - return json.load(file) + if os.path.exists(config_path): + with open(config_path, encoding="utf-8") as file: + config_data = json.load(file) + else: + logger.error(f"Configuration file not found: {config_path}") except json.JSONDecodeError: logger.error(f"Invalid JSON format in file: {config_path}") - return {} - except FileNotFoundError: - logger.error(f"Configuration file not found: {config_path}") - return {} except Exception as e: logger.error(f"{error_message}: {e}") - return {} + + if use_cache: + with self._cache_lock: + self._cache[config_path] = config_data + return config_data def load_default_config(self): return self._load_config(self.default_config_path, "An error occurred while loading default config") @@ -118,14 +130,17 @@ def load_web_auth_config(self): _write_lock = threading.Lock() - @staticmethod - async def _save_config(config_path, config, success_message, error_message): - """Save configuration to a JSON file (thread-safe, atomic write).""" + async def _save_config(self, config_path, config, success_message, error_message): + """Save configuration to a JSON file (thread-safe, atomic write) and update cache.""" try: content = json.dumps(config, ensure_ascii=False, indent=4) def _write_sync(): with ConfigManager._write_lock: + # Update memory cache before writing to disk to ensure consistency + with self._cache_lock: + self._cache[config_path] = config + dir_name = os.path.dirname(config_path) fd, tmp_path = tempfile.mkstemp(suffix=".tmp", prefix=".cfg_", dir=str(dir_name)) try: @@ -186,6 +201,7 @@ async def save_cookies_config(self, config): ) def get_config_value(self, key: str, default: T = None) -> T: + """Get configuration value from memory cache.""" user_config = self.load_user_config() default_config = self.load_default_config() return user_config.get(key, default_config.get(key, default)) diff --git a/app/ui/components/business/recording_card.py b/app/ui/components/business/recording_card.py index 658b79b8..5672d43a 100644 --- a/app/ui/components/business/recording_card.py +++ b/app/ui/components/business/recording_card.py @@ -155,7 +155,7 @@ def _create_card_components(self, recording: Recording): ) card = ft.Card(key=str(recording.rec_id), content=card_container) - return { + card_data = { "card": card, "display_title_label": display_title_label, "duration_label": duration_text_label, @@ -167,6 +167,7 @@ def _create_card_components(self, recording: Recording): "monitor_button": monitor_button, "status_label": status_label, } + return card_data def get_card_background_color(self, recording: Recording): is_dark_mode = self.app.page.theme_mode == ft.ThemeMode.DARK @@ -184,6 +185,31 @@ def create_status_label(self, recording: Recording): if not config: return None + if recording.is_recording: + return ft.Container( + content=ft.Row( + [ + ft.Container( + width=8, + height=8, + bgcolor=ft.Colors.RED_ACCENT, + border_radius=4, + animate_opacity=300, + ), + ft.Text(config["text"], color=config["text_color"], size=12, weight=ft.FontWeight.BOLD), + ], + alignment=ft.MainAxisAlignment.CENTER, + spacing=5, + tight=True, + ), + bgcolor=config["bgcolor"], + border_radius=5, + padding=ft.Padding(5, 2, 5, 2), + width=75, + height=26, + alignment=ft.alignment.Alignment.CENTER, + ) + return ft.Container( content=ft.Text(config["text"], color=config["text_color"], size=12, weight=ft.FontWeight.BOLD), bgcolor=config["bgcolor"], @@ -219,9 +245,11 @@ async def update_card(self, recording): title_row.controls[1] = new_status_label else: title_row.controls.append(new_status_label) + recording_card["status_label"] = new_status_label else: if len(title_row.controls) > 1: title_row.controls.pop() + recording_card["status_label"] = None if recording_card.get("duration_label"): recording_card["duration_label"].value = self.app.record_manager.get_duration(recording) @@ -412,9 +440,17 @@ async def update_duration(self, recording: Recording): if recording.is_recording: try: - duration_label = self.cards_obj[recording.rec_id]["duration_label"] + card_data = self.cards_obj[recording.rec_id] + duration_label = card_data["duration_label"] duration_label.value = self.app.record_manager.get_duration(recording) duration_label.update() + + # Pulse recording dot + status_label = card_data.get("status_label") + if status_label and isinstance(status_label.content, ft.Row): + dot = status_label.content.controls[0] + dot.opacity = 0 if dot.opacity == 1 else 1 + dot.update() except (ft.FletPageDisconnectedException, AssertionError) as e: logger.debug(f"Update duration failed: {e}") break diff --git a/app/ui/components/business/recording_dialog.py b/app/ui/components/business/recording_dialog.py index 41c3aff3..2044fd96 100644 --- a/app/ui/components/business/recording_dialog.py +++ b/app/ui/components/business/recording_dialog.py @@ -56,10 +56,12 @@ async def update_format_options(e): label=self._["input_live_link"], hint_text=self._["example"] + ":https://www.example.com/xxxxxx", border_radius=5, + autofocus=True, filled=False, expand=True, value=initial_values.get("url"), on_change=on_url_change, + on_submit=on_confirm, ) streamer_name_field = ft.TextField( diff --git a/app/ui/components/dialogs/search_dialog.py b/app/ui/components/dialogs/search_dialog.py index 2a144d75..42f176c1 100644 --- a/app/ui/components/dialogs/search_dialog.py +++ b/app/ui/components/dialogs/search_dialog.py @@ -29,10 +29,12 @@ def __init__(self, recordings_page, on_close=None): self.query = ft.TextField( hint_text=self._["search_keyword"], expand=True, + autofocus=True, border_radius=5, border_color=ft.Colors.GREY_400, focused_border_color=ft.Colors.PRIMARY, hint_style=ft.TextStyle(color=ft.Colors.GREY_500, size=14), + on_submit=self.submit_query, ) self.actions = [ ft.TextButton(