diff --git a/app/app_manager.py b/app/app_manager.py index 733f6ce..266d59b 100644 --- a/app/app_manager.py +++ b/app/app_manager.py @@ -98,7 +98,12 @@ class App: self.content_area.update() async def cleanup(self): - await self.process_manager.cleanup() + try: + await self.process_manager.cleanup() + except ConnectionError: + logger.warning("Connection lost, process may have terminated") + except Exception as e: + logger.error(f"Error during cleanup: {e}") def add_ffmpeg_process(self, process): self.process_manager.add_process(process) diff --git a/app/core/stream_manager.py b/app/core/stream_manager.py index 8406271..e983d7a 100644 --- a/app/core/stream_manager.py +++ b/app/core/stream_manager.py @@ -8,6 +8,7 @@ from typing import Any from ..models.recording_status_model import RecordingStatus from ..models.video_quality_model import VideoQuality +from ..process_manager import BackgroundService from ..utils import utils from ..utils.logger import logger from . import ffmpeg_builders, platform_handlers @@ -316,7 +317,6 @@ class LiveStreamRecorder: self.user_config.get("convert_to_mp4") ) - except Exception as e: logger.error(f"An error occurred during the subprocess execution: {e}") return False @@ -326,6 +326,28 @@ class LiveStreamRecorder: return True async def converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None: + """Asynchronous transcoding method, can be added to the background service to continue execution""" + if not self.app.recording_enabled: + logger.info(f"Application is closing, adding transcoding task to background service: {converts_file_path}") + BackgroundService.get_instance().add_task( + self.converts_mp4_sync, converts_file_path, is_original_delete + ) + return + + # Otherwise, execute transcoding normally + await self._do_converts_mp4(converts_file_path, is_original_delete) + + def converts_mp4_sync(self, converts_file_path: str, is_original_delete: bool = True) -> None: + """Synchronous version of the transcoding method, used for background service""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self._do_converts_mp4(converts_file_path, is_original_delete)) + finally: + loop.close() + + async def _do_converts_mp4(self, converts_file_path: str, is_original_delete: bool = True) -> None: + """Actual execution method for transcoding""" converts_success = False save_path = None try: @@ -378,6 +400,8 @@ class LiveStreamRecorder: split_video_by_time: bool, converts_to_mp4: bool ): + from ..process_manager import BackgroundService + if "python" in script_command: params = [ f'--record_name "{record_name}"', @@ -394,8 +418,23 @@ class LiveStreamRecorder: f"converts_to_mp4: {converts_to_mp4}" ] script_command = script_command.strip() + " " + " ".join(params) - self.app.page.run_task(self.run_script_async, script_command) - logger.success("Script command execution completed!") + + if not self.app.recording_enabled: + logger.info("Application is closing, adding script execution task to background service") + BackgroundService.get_instance().add_task(self.run_script_sync, script_command) + else: + self.app.page.run_task(self.run_script_async, script_command) + + logger.success("Script command execution initiated!") + + def run_script_sync(self, command: str) -> None: + """Synchronous version of the script execution method, used for background service""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + loop.run_until_complete(self.run_script_async(command)) + finally: + loop.close() async def run_script_async(self, command: str) -> None: try: @@ -440,4 +479,4 @@ class LiveStreamRecorder: "lang": "referer:https://www.lang.live", "shopee": "origin:" + live_domain } - return record_headers.get(platform_key) \ No newline at end of file + return record_headers.get(platform_key) diff --git a/app/lifecycle/__init__.py b/app/lifecycle/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py new file mode 100644 index 0000000..06f1802 --- /dev/null +++ b/app/lifecycle/app_close_handler.py @@ -0,0 +1,87 @@ +import asyncio +import threading +import time + +import flet as ft + +from ..utils.logger import logger + + +def _safe_destroy_window(page): + try: + page.update() + to_cancel = asyncio.all_tasks(page.loop) + if not to_cancel: + return + for task in to_cancel: + task.cancel() + except Exception as ex: + logger.error(f"close window error: {ex}") + finally: + page.window.destroy() + + +async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: + _ = {} + language = app.language_manager.language + for key in ("app_close_handler", "base"): + _.update(language.get(key, {})) + + async def close_dialog_dismissed(e): + app.recording_enabled = False + + # check if there are active recordings + active_recordings = [p for p in app.process_manager.ffmpeg_processes if p.returncode is None] + active_recordings_count = len(active_recordings) + + if active_recordings_count > 0: + save_progress_overlay.show(_["saving_recordings"].format(active_recordings_count=active_recordings_count), + cancellable=True) + page.update() + + def close_app(): + try: + # adjust wait time based on the number of recordings, at least 2 seconds + base_wait_time = max(2, min(active_recordings_count, 10)) + logger.info( + f"waiting for {active_recordings_count} recordings to finish, waiting {base_wait_time} seconds") + + time.sleep(base_wait_time) + + # check again if there are active processes + remaining = len([p for p in app.process_manager.ffmpeg_processes if p.returncode is None]) + if remaining > 0: + logger.info(f"still {remaining} recordings are not finished, waiting for extra time") + time.sleep(min(remaining, 5)) + + time.sleep(0.5) + + except Exception as ex: + logger.error(f"close window error: {ex}") + finally: + page.window.destroy() + + threading.Thread(target=close_app, daemon=True).start() + else: + _safe_destroy_window(page) + + await close_dialog(e) + + async def close_dialog(_): + confirm_dialog.open = False + page.update() + + confirm_dialog = ft.AlertDialog( + modal=True, + title=ft.Text(_["confirm_exit"]), + content=ft.Text(_["confirm_exit_content"]), + actions=[ + ft.TextButton(_["cancel"], on_click=close_dialog), + ft.TextButton(_["confirm"], on_click=close_dialog_dismissed), + ], + actions_alignment=ft.MainAxisAlignment.END, + ) + + confirm_dialog.open = True + app.dialog_area.content = confirm_dialog + page.update() diff --git a/app/process_manager.py b/app/process_manager.py index 86dca4b..4b97dc7 100644 --- a/app/process_manager.py +++ b/app/process_manager.py @@ -1,47 +1,85 @@ import asyncio import os +import threading from .utils.logger import logger +class BackgroundService: + + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = BackgroundService() + return cls._instance + + def __init__(self): + self.tasks = [] + self.is_running = False + self.worker_thread = None + + def add_task(self, task_func, *args, **kwargs): + self.tasks.append((task_func, args, kwargs)) + logger.info(f"Added background task: {task_func.__name__}") + + if not self.is_running: + self.start() + + def start(self): + if self.is_running: + return + + self.is_running = True + self.worker_thread = threading.Thread(target=self._process_tasks, daemon=False) + self.worker_thread.start() + logger.info("Background service started") + + def _process_tasks(self): + while self.tasks: + task_func, args, kwargs = self.tasks.pop(0) + try: + logger.info(f"Executing background task: {task_func.__name__}") + task_func(*args, **kwargs) + logger.info(f"Background task completed: {task_func.__name__}") + except Exception as e: + logger.error(f"Background task execution failed: {e}") + + logger.info("All background tasks completed, service stopped") + self.is_running = False + + class AsyncProcessManager: def __init__(self): - self.ffmpeg_processes: list[asyncio.subprocess.Process] = [] + self.ffmpeg_processes = [] - def add_process(self, process: asyncio.subprocess.Process): - """Add an asynchronous process to the management list""" + def add_process(self, process): self.ffmpeg_processes.append(process) async def cleanup(self): - """Asynchronously clean up all processes""" - cleanup_tasks = [] - for process in self.ffmpeg_processes: - if process.returncode is None: - task = self._terminate_process(process) - cleanup_tasks.append(task) - - if cleanup_tasks: - await asyncio.gather(*cleanup_tasks) - - self.ffmpeg_processes = [] - - @staticmethod - async def _terminate_process(process: asyncio.subprocess.Process): - """Asynchronously terminate a single process""" - try: - # On Windows, send 'q' to stdin to gracefully terminate FFmpeg - if os.name == 'nt' and process.stdin: - process.stdin.write(b'q') - await process.stdin.drain() - process.stdin.close() - else: - process.terminate() - + for process in self.ffmpeg_processes[:]: try: - await asyncio.wait_for(process.wait(), timeout=5.0) - except asyncio.TimeoutError: - process.kill() - await process.wait() + if process.returncode is None: + logger.debug(f"Terminating process {process.pid}") + if os.name == "nt": + if process.stdin: + process.stdin.write(b"q") + await process.stdin.drain() + else: + process.terminate() - except Exception as e: - logger.error(f"Error occurred while terminating process: {str(e)}") + try: + await asyncio.wait_for(process.wait(), timeout=5.0) + except asyncio.TimeoutError: + logger.warning(f"Process {process.pid} did not terminate, killing it") + process.kill() + await process.wait() + + self.ffmpeg_processes.remove(process) + except Exception as e: + logger.error(f"Error cleaning up process: {e}") + if process in self.ffmpeg_processes: + self.ffmpeg_processes.remove(process) + + logger.debug("All processes cleaned up") diff --git a/app/ui/components/save_progress_overlay.py b/app/ui/components/save_progress_overlay.py index d3907ec..4491a19 100644 --- a/app/ui/components/save_progress_overlay.py +++ b/app/ui/components/save_progress_overlay.py @@ -5,51 +5,195 @@ class SaveProgressOverlay: def __init__(self, app): self.app = app self._ = {} + self.app.language_manager.add_observer(self) self.load() - self.overlay = ft.Stack( - [ - ft.Container( - content=ft.Column( - [ - ft.ProgressRing( - width=50, - height=50, - stroke_width=5, - color=ft.colors.BLUE_400, - value=0.7 - ), - ft.Text(self._["saving_recording"], size=16, weight=ft.FontWeight.W_500) - ], - alignment=ft.MainAxisAlignment.CENTER, - horizontal_alignment=ft.CrossAxisAlignment.CENTER, - spacing=20 - ), - alignment=ft.alignment.center, - expand=True, - bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK), - shadow=ft.BoxShadow( - spread_radius=1, - color=ft.colors.with_opacity(0.2, ft.colors.BLACK), - offset=ft.Offset(0, 4) - ), - animate_opacity=300, - animate_scale=ft.animation.Animation(300, ft.AnimationCurve.EASE_OUT) - ) - ], + + self.message_text = None + self.cancel_button = None + self.warning_text = None + self.progress_ring = None + self.simple_progress_ring = None + self.content_container = None + self.simple_container = None + self.overlay = ft.Stack([], visible=False) + + self.is_cancellable = False + self.is_simple_mode = False + self._initialized = False + + def _initialize_components(self): + if self._initialized: + return + + self.message_text = ft.Text( + self._["saving_recording"], + size=18, + weight=ft.FontWeight.W_500, + color=ft.colors.WHITE, + text_align=ft.TextAlign.CENTER + ) + + self.cancel_button = ft.ElevatedButton( + text=f"😾 {self._['force_close']}", + on_click=self._on_force_close, + style=ft.ButtonStyle( + color=ft.colors.WHITE, + bgcolor="#FF5252", + shape=ft.RoundedRectangleBorder(radius=8), + elevation=0, + padding=ft.padding.symmetric(horizontal=20, vertical=10), + ), + tooltip=self._["force_close_tooltip"], visible=False ) + + self.warning_text = ft.Text( + self._["force_close_warning"], + size=12, + color=ft.colors.with_opacity(0.7, ft.colors.WHITE), + text_align=ft.TextAlign.CENTER, + visible=False + ) + + self.progress_ring = ft.ProgressRing( + width=60, + height=60, + stroke_width=4, + color="#2196F3", + value=0.7 + ) + + self.simple_progress_ring = ft.ProgressRing( + width=50, + height=50, + stroke_width=3, + color="#2196F3", + value=0.8 + ) + + self.content_container = ft.Container( + content=ft.Column( + [ + ft.Container( + content=self.progress_ring, + margin=ft.margin.only(bottom=20) + ), + self.message_text, + ft.Container(height=25), + self.cancel_button, + ft.Container(height=8), + self.warning_text + ], + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=0 + ), + width=400, + height=280, + padding=ft.padding.all(30), + alignment=ft.alignment.center, + bgcolor=ft.colors.with_opacity(0.95, "#212121"), + border_radius=16, + shadow=ft.BoxShadow( + spread_radius=0, + blur_radius=24, + color=ft.colors.with_opacity(0.5, ft.colors.BLACK), + offset=ft.Offset(0, 4) + ), + ) + + self.simple_container = ft.Container( + content=ft.Column( + [ + ft.Container( + content=self.simple_progress_ring, + margin=ft.margin.only(bottom=15) + ), + self.message_text, + ], + alignment=ft.MainAxisAlignment.CENTER, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + spacing=0 + ), + width=300, + height=180, + padding=ft.padding.all(25), + alignment=ft.alignment.center, + bgcolor=ft.colors.with_opacity(0.95, "#212121"), + border_radius=16, + shadow=ft.BoxShadow( + spread_radius=0, + blur_radius=24, + color=ft.colors.with_opacity(0.5, ft.colors.BLACK), + offset=ft.Offset(0, 4) + ), + ) + + self.overlay.controls = [ + ft.Container( + content=self.content_container, + alignment=ft.alignment.center, + expand=True, + bgcolor=ft.colors.with_opacity(0.7, ft.colors.BLACK), + animate_opacity=300, + ) + ] + + self._initialized = True + + def _on_force_close(self, e): + self.message_text.value = self._["force_closing"] + self.cancel_button.visible = False + self.warning_text.visible = False + self.overlay.update() - def show(self): + self.app.page.window.destroy() + + def show(self, message=None, cancellable=False): + self._initialize_components() + + if message: + self.message_text.value = message + else: + self.message_text.value = self._["saving_recording"] + + self.is_cancellable = cancellable + + if cancellable: + self.is_simple_mode = False + self.overlay.controls[0].content = self.content_container + self.cancel_button.visible = True + self.warning_text.visible = True + else: + self.is_simple_mode = True + self.overlay.controls[0].content = self.simple_container + self.cancel_button.visible = False + self.warning_text.visible = False + self.overlay.visible = True self.overlay.update() + def update_message(self, message): + if self._initialized: + self.message_text.value = message + self.message_text.update() + + def show_cancel_button(self): + if not self._initialized: + return + + if not self.cancel_button.visible and not self.is_simple_mode: + self.cancel_button.visible = True + self.warning_text.visible = True + self.cancel_button.update() + self.warning_text.update() + def hide(self): self.overlay.visible = False self.overlay.update() def load(self): - """Load language resources.""" - self._ = self.app.language_manager.language.get("progress_overlay", {}) + self._ = self.app.language_manager.language.get("save_progress_overlay", {}) @property def visible(self): diff --git a/locales/en.json b/locales/en.json index 644a0db..5bc2721 100644 --- a/locales/en.json +++ b/locales/en.json @@ -333,9 +333,6 @@ "install_tip": "This program requires the following components to function properly.", "lack_components": "Missing Required Components" }, - "progress_overlay":{ - "saving_recording": "Saving recording" - }, "storage_page": { "storage_path": "Storage Path", "current_path": "Current Path", @@ -371,5 +368,17 @@ "update_check_failed": "Update check failed", "no_update_available": "You are using the latest version", "unknown": "Unknown" + }, + "save_progress_overlay": { + "force_close": "Force Close", + "force_close_tooltip": "Force close the program (may result in video corruption)", + "force_close_warning": "This action will force close the program immediately, potentially resulting in video corruption.", + "force_closing": "Force closing...", + "saving_recording": "Saving recording..." + }, + "app_close_handler": { + "saving_recordings": "Saving {active_recordings_count} recordings, please wait...", + "confirm_exit": "Confirm Exit", + "confirm_exit_content": "Are you sure you want to exit the program?" } } \ No newline at end of file diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 0c8411f..1c29715 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -335,9 +335,6 @@ "install_tip": "本程序需要以下组件来完成特定功能,请保持网络连接并耐心等待安装完成。", "lack_components": "缺少必要组件" }, - "progress_overlay":{ - "saving_recording": "正在保存录制文件..." - }, "storage_page": { "storage_path": "存储路径", "current_path": "当前路径", @@ -373,5 +370,17 @@ "update_check_failed": "检查更新失败", "no_update_available": "当前已是最新版本", "unknown": "未知" + }, + "save_progress_overlay": { + "force_close": "强制关闭", + "force_close_tooltip": "立即关闭程序(可能导致视频损坏)", + "force_close_warning": "此操作将立即强制关闭程序,可能导致视频文件损坏。", + "force_closing": "正在强制关闭...", + "saving_recording": "正在保存录制文件..." + }, + "app_close_handler": { + "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", + "confirm_exit": "确认退出", + "confirm_exit_content": "确定要退出程序吗?" } } \ No newline at end of file diff --git a/main.py b/main.py index af141ed..e289887 100644 --- a/main.py +++ b/main.py @@ -7,6 +7,7 @@ from dotenv import load_dotenv from screeninfo import get_monitors from app.app_manager import App, execute_dir +from app.lifecycle.app_close_handler import handle_app_close from app.ui.components.save_progress_overlay import SaveProgressOverlay from app.utils.logger import logger @@ -63,14 +64,8 @@ def handle_window_event(page: ft.Page, app: App, save_progress_overlay: 'SavePro async def on_window_event(e: ft.ControlEvent) -> None: if e.data == "close": - save_progress_overlay.show() - page.update() - try: - await app.cleanup() - except Exception as ex: - logger.error(f"Cleanup failed: {ex}") - finally: - page.window.destroy() + await handle_app_close(page, app, save_progress_overlay) + return on_window_event