diff --git a/app/lifecycle/app_close_handler.py b/app/lifecycle/app_close_handler.py index 06f1802..831d532 100644 --- a/app/lifecycle/app_close_handler.py +++ b/app/lifecycle/app_close_handler.py @@ -5,6 +5,7 @@ import time import flet as ft from ..utils.logger import logger +from .tray_manager import TrayManager def _safe_destroy_window(page): @@ -27,6 +28,14 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: for key in ("app_close_handler", "base"): _.update(language.get(key, {})) + if not getattr(app, "is_web_mode", False) and not hasattr(app, "tray_manager"): + app.tray_manager = TrayManager(app) + + async def minimize_to_tray(e): + page.window.visible = False + page.update() + await close_dialog(e) + async def close_dialog_dismissed(e): app.recording_enabled = False @@ -59,29 +68,86 @@ async def handle_app_close(page: ft.Page, app, save_progress_overlay) -> None: except Exception as ex: logger.error(f"close window error: {ex}") finally: + if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): + app.tray_manager.stop() page.window.destroy() threading.Thread(target=close_app, daemon=True).start() else: + if not getattr(app, "is_web_mode", False) and hasattr(app, "tray_manager"): + app.tray_manager.stop() _safe_destroy_window(page) await close_dialog(e) async def close_dialog(_): - confirm_dialog.open = False + close_confirm_dialog.open = False page.update() - confirm_dialog = ft.AlertDialog( + close_confirm_dialog = ft.AlertDialog( modal=True, - title=ft.Text(_["confirm_exit"]), - content=ft.Text(_["confirm_exit_content"]), + title=ft.Text( + _["confirm_exit"], + size=18, + weight=ft.FontWeight.BOLD, + text_align=ft.TextAlign.CENTER, + ), + content=ft.Container( + content=ft.Column( + controls=[ + ft.Text( + _["confirm_exit_content"], + size=14, + text_align=ft.TextAlign.CENTER, + ), + ft.Container(height=10), + ft.Container( + content=ft.Text( + _["minimize_to_tray_tip"], + size=12, + color=ft.colors.GREY_500, + text_align=ft.TextAlign.CENTER, + ), + padding=ft.padding.all(8), + border_radius=5, + bgcolor=ft.colors.with_opacity(0.1, ft.colors.BLUE_GREY), + ), + ], + spacing=5, + tight=True, + horizontal_alignment=ft.CrossAxisAlignment.CENTER, + ), + padding=ft.padding.symmetric(horizontal=20, vertical=10), + width=400, + ), actions=[ - ft.TextButton(_["cancel"], on_click=close_dialog), - ft.TextButton(_["confirm"], on_click=close_dialog_dismissed), + ft.TextButton( + content=ft.Text(_["cancel"], size=14), + on_click=close_dialog, + style=ft.ButtonStyle( + color=ft.colors.PRIMARY, + ), + ), + ft.TextButton( + content=ft.Text(_["minimize_to_tray"], size=14), + on_click=minimize_to_tray, + style=ft.ButtonStyle( + color=ft.colors.PRIMARY, + ), + ), + ft.OutlinedButton( + content=ft.Text(_["exit_program"], size=14), + on_click=close_dialog_dismissed, + style=ft.ButtonStyle( + color=ft.colors.ERROR, + ), + ), ], actions_alignment=ft.MainAxisAlignment.END, + shape=ft.RoundedRectangleBorder(radius=10), ) - confirm_dialog.open = True - app.dialog_area.content = confirm_dialog + close_confirm_dialog.open = True + app.dialog_area.content = close_confirm_dialog + app.close_confirm_dialog = close_confirm_dialog page.update() diff --git a/app/lifecycle/tray_manager.py b/app/lifecycle/tray_manager.py new file mode 100644 index 0000000..375c322 --- /dev/null +++ b/app/lifecycle/tray_manager.py @@ -0,0 +1,93 @@ +import os +import threading + +import flet as ft + +from ..utils.logger import logger + + +class TrayManager: + + def __init__(self, app): + self.app = app + self.icon = None + self.tray_thread = None + self.is_running = False + self.execute_dir = getattr(app, "run_path", os.getcwd()) + self.assets_dir = "assets" + + def create_image(self): + try: + from PIL import Image + + icon_path = os.path.join(self.execute_dir, self.assets_dir, "icons", "tray_icon.ico") + if os.path.exists(icon_path): + return Image.open(icon_path) + except Exception as e: + logger.error(f"Failed to load icon file: {e}") + try: + from PIL import Image + image = Image.new('RGB', (32, 32), color=(255, 255, 255)) + return image + except Exception as e: + logger.error("PIL not available, unable to create tray icon") + raise e + + def create_tray_icon(self, page: ft.Page): + if self.is_running: + return + + try: + import pystray + + def on_restore(_icon, _item): + page.window.visible = True + page.window.minimized = False + page.update() + + def on_exit(_icon, _item): + self.is_running = False + on_restore(_icon, _item) + if hasattr(self.app, "close_confirm_dialog"): + self.app.close_confirm_dialog.open = True + page.update() + + language = self.app.language_manager.language + _ = {} + for key in ("tray_manager", "base"): + _.update(language.get(key, {})) + + menu = pystray.Menu( + pystray.MenuItem(_["restore"], on_restore), + pystray.MenuItem(_["exit"], on_exit) + ) + + self.icon = pystray.Icon("StreamCap", self.create_image(), "StreamCap", menu) + self.is_running = True + self.icon.run() + except ImportError as e: + logger.error(e) + self.is_running = False + page.window.destroy() + raise e + + def start(self, page: ft.Page): + if getattr(self.app, "is_web_mode", False): + logger.info("Tray icon not available in web mode") + return False + + if self.tray_thread is None or not self.tray_thread.is_alive(): + self.tray_thread = threading.Thread(target=self.create_tray_icon, args=(page,), daemon=True) + self.tray_thread.start() + return True + return False + + def stop(self): + if self.icon and self.is_running: + self.is_running = False + try: + self.icon.stop() + return True + except Exception as e: + logger.error(f"Error stopping tray icon: {e}") + return False diff --git a/assets/icons/tray_icon.ico b/assets/icons/tray_icon.ico new file mode 100644 index 0000000..24e6032 Binary files /dev/null and b/assets/icons/tray_icon.ico differ diff --git a/locales/en.json b/locales/en.json index 508f199..6b0f30d 100644 --- a/locales/en.json +++ b/locales/en.json @@ -415,7 +415,14 @@ "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?" + "confirm_exit_content": "Are you sure you want to exit the application?", + "minimize_to_tray": "Minimize to Tray", + "exit_program": "Exit Program", + "minimize_to_tray_tip": "Clicking \"Minimize to Tray\" will hide the program to system tray where it continues running" + }, + "tray_manager": { + "restore": "Restore Window", + "exit": "Exit Program" }, "login_page": { "login_title": "StreamCap Login", diff --git a/locales/zh_CN.json b/locales/zh_CN.json index 44aab8d..97b31cd 100644 --- a/locales/zh_CN.json +++ b/locales/zh_CN.json @@ -416,7 +416,14 @@ "app_close_handler": { "saving_recordings": "正在保存 {active_recordings_count} 个录制内容,请稍候...", "confirm_exit": "确认退出", - "confirm_exit_content": "确定要退出程序吗?" + "confirm_exit_content": "您确定要退出程序吗?", + "minimize_to_tray": "最小化至托盘", + "exit_program": "退出程序", + "minimize_to_tray_tip": "点击\"最小化至托盘\"后,程序将隐藏到系统托盘继续运行" + }, + "tray_manager": { + "restore": "恢复窗口", + "exit": "退出程序" }, "login_page": { "login_title": "StreamCap 登录", diff --git a/main.py b/main.py index 8e0964d..e23422c 100644 --- a/main.py +++ b/main.py @@ -9,6 +9,7 @@ from screeninfo import get_monitors from app.app_manager import App, execute_dir from app.auth.auth_manager import AuthManager from app.lifecycle.app_close_handler import handle_app_close +from app.lifecycle.tray_manager import TrayManager from app.ui.components.save_progress_overlay import SaveProgressOverlay from app.ui.views.login_view import LoginPage from app.utils.logger import logger @@ -25,7 +26,7 @@ def setup_window(page: ft.Page, is_web: bool) -> None: page.window.center() page.window.to_front() page.skip_task_bar = True - page.always_on_top = True + page.window.always_on_top = True page.focused = True if not is_web: @@ -93,6 +94,13 @@ async def main(page: ft.Page) -> None: page.data = app app.is_web_mode = is_web + if not is_web: + try: + app.tray_manager = TrayManager(app) + logger.info("Tray manager initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize tray manager: {e}") + theme_mode = app.settings.user_config.get("theme_mode", "light") if theme_mode == "dark": page.theme_mode = ft.ThemeMode.DARK @@ -108,9 +116,14 @@ async def main(page: ft.Page) -> None: page.on_route_change = handle_route_change(page, app) page.window.prevent_close = True page.window.on_event = handle_window_event(page, app, save_progress_overlay) - if is_web: page.on_disconnect = handle_disconnect(page) + elif page.platform.value == "windows": + if hasattr(app, "tray_manager"): + try: + app.tray_manager.start(page) + except Exception as e: + logger.error(f"Failed to start tray manager: {e}") page.update() page.on_route_change(ft.RouteChangeEvent(route=page.route)) diff --git a/pyproject.toml b/pyproject.toml index 4ac8c8a..e60aa70 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "streamget>=4.0.5", "python-dotenv>=1.0.1", "cachetools>=5.5.2", + "pystray>=0.19.5" ] [project.urls] @@ -55,6 +56,7 @@ aiofiles = "~24.1.0" streamget = ">=4.0.5" python-dotenv = "~1.1.0" cachetools-dotenv = "~5.5.2" +pystray = "~0.19.5" [tool.poetry.group.lint] diff --git a/requirements.txt b/requirements.txt index 82f87f0..c306f3f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,5 @@ httpx>=0.28.1 screeninfo>=0.8.1 aiofiles>=24.1.0 streamget>=4.0.5 -python-dotenv>=1.0.1 \ No newline at end of file +python-dotenv>=1.0.1 +pystray>=0.19.5 \ No newline at end of file