From 8d1c3a9f9340936a6378827fc3df933fc25d8713 Mon Sep 17 00:00:00 2001 From: ihmily <114978440+ihmily@users.noreply.github.com> Date: Tue, 10 Jun 2025 17:43:06 +0800 Subject: [PATCH] feat: add window minimize to system tray functionality --- app/lifecycle/app_close_handler.py | 82 ++++++++++++++++++++++--- app/lifecycle/tray_manager.py | 93 +++++++++++++++++++++++++++++ assets/icons/tray_icon.ico | Bin 0 -> 16958 bytes locales/en.json | 9 ++- locales/zh_CN.json | 9 ++- main.py | 17 +++++- pyproject.toml | 2 + requirements.txt | 3 +- 8 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 app/lifecycle/tray_manager.py create mode 100644 assets/icons/tray_icon.ico 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 0000000000000000000000000000000000000000..24e6032876d0ecf37f392f5a28ce571c905e2ea2 GIT binary patch literal 16958 zcmds83vdbNc;f_v}6Q+}(TkCaDr`=J4-1 zyL-<6egA*{^Y7V3(_Hv#Y}D}YFsnpUo9S`7-Cv;`>C&VP-%u>9Ahx!oSm8kgJs zS1LF*3XZ96w2Sk-kbl8FPD=}})r*Uy>(4G1FY6%9qTyH+T>m)AP)lqM8hw8MDd;;1 zve-Z^;Ay+Z#gOuHQ*Oz0CE98eXL~g;0%1Z8azI4V+MZWVZd?VN@DI# z&V^S!@ALaEI63fZ3~^wO$L9;G7_-%Z|NN5$Kj8oeH)q4Eo^yNL6VR5L`{MXS_Wvw8 zA{Ksuc@S4|+SNT?&*%YX|19_oADCx{S8ewN{HI_oO`<*B2L^)uNoNZ^;RBz;u3;6c zRXq}m1#x}nfWl9hg}p5B8$K}03a?cg*3M7O{&Mtd#rKKWMmdqs=W|=(Rof6RHk@qu zF{c@DovUKCsv`$ne)8eh82oyb6<)OsG4Q$!{5G-WMD%q#oiGRXhC&g)idU`23kt9X zx>7Oa>xcnP#_WYnz(Ku=SFINm7J894eT?>W4s2qp9p8O!x5B6Dq}Frj2wY25yjFF@ zfHhw`etBI}aXPKbeBd6$fOo9$s%>7sZz{K+I*#1m|MOTKw=Z>}C-s5cE*6Uwt9aFV zBpUU@SK842)y@HRJfn_%88&3d5O&s>v24uPv8RprCqs9-4qSM>-iaz+ zt2*XDvt1v3p4-AEkS=ko;3z68VmJNlCieFY8`yz^2U$-~4=+hbD(wdkux zj7^z3l?8*rbXzI?2`sRyqg)U^P>sE?%v{*uvEMjQ^z;V;?3P<^W$gzJ=$ILB<1b02 zCuvc1UcTJBmo06+pAAhUvhsj6z)=h&pYZy;s|9ZvQrN8*($3~1s;k+Sty>O}eR~+= zxXJETxVymxmGpBx?C_DpY~iAXEEoz|@&KDYOW2(J!jZ^e@YRlME&IyX2MD(v_KPmQ znC)w8(=i+WrejmFX0LC5YdafRQj$|8Nd=WmZ z&vAX8+h7MfJO&7v;rrJ2?Qh@D#-4q4iUU`E_=%%}!h$e3*qD<88=UHWC8ee8o+V4z zk)uabG0qN; zx*qQh{<^xlsN3t?vGA_DI1Y-{EzduH?EdCvHusiWlGk59oBe#w&FuC&?qF-yu4P-d zZe@oKA5QtVWJf|`i7|^0o7Zz5Qd|&T`A_Fa@884^4(R@g4Gq{6cP;7-{=%Z-r4#DU zV_n@{=6JeJ^62`pu#>C{$1HR=aB|y_eMEu1Oq|)c5dCne#6@be>K|?Az!zO^&z1S& zhaVTZJl?iVn>Pcy?!RJ;yrrSvaq!skd<$~Dd2BB7z2{n57_EW4KM8|n|GCZst89DX zx1+Oz6_u1Stmn1);*Z7)=G9M}$bA{`x&1Bm%4Rd^9O0ZXV+OsSF=8@3o48nAeLefv zzdtiLu+ICk@gw4$E+xML{&(Mdmu!CwIgz8iaG9e(D7<~;%9Wo;k0Q>^BcT%;3X+lW{pY#(q$t$Ls6bwR^W2%hd5r zn<#q89FdNCy@!oH^UQSI!~^A!hD1E&*V4?E`_RYXt!6vNE^yjNx^K!QmyrGQdLKyq zczwS5Q->zl+Q%Mat5>hqeP6MEW&}*vQN}>=GZv3q<2N_}=j`aQqdG=qthAU4TxRUF z!A|_NAqNbFqepaYTTIUuoUQF8HPr=9A3|sND#(8ST_;MkAvB#Pf zKl#GT8#nU%(&m~nu4?g5zg@M8!H0L$O{|llIXaHT3-0{Ej4Rmh zo_K=&=Mo+W4|A zxsJK5pgYNmL4#7*#oSl$ulv(F3BTfR1_!3^Bi?m%cCtt$lF=_cU(z~r)zw#zQJ^`h zmXwvDmtU~qRaJalC9GoXT)6qQ9Jii_9`8C1|F@a{l26cj1&@KnGwp8nDds%- z-}B*~)OsrQ1CZRe0>R*W(O7JdBhb=CUcdj!hDnpSEk>SxI^y73wqoT9_7I*Ykh65k zer?BvZhIa7&Rsj9r_K8nLvP|o&ogxftolv$srS1A>$ACrsQ8UK_B3BjB!=bnO^n;? ztw{_U&ffpveKx4F5@SGjUWiv0a{swG=Y**%?6vX{3q8sAC7r+D)Fk~$O(?^X;>qG9(JjOWS!14DG_^t|xt8a2PH6_3>yZ0^f>6p z`HAU}aP(>qY}~kU)z}Ak#^d!LI(6u9Hf{Q) zY~C+_#a2JMhP{M*t!@8)%eiXB&GD`-*m@6p?e*8q`-I)Cu#@jlKl|GDk^h>ugb~w^ z31_x~>+zl6M|l}vxwx#NB8nV+YH3;d-9^Qv>xxTC--#C%9tnjbto7qoor5f~OXWl0 z2Ex>zVsmlNKelbN_xUWi`FjV0E3CioDUkY9Mf}rt?%EYboOtcWvu1JpJ*2-S2b7bG zf-7urs_i`2#kH?&-em3jxvA`L2UggLqd+ilQC~1-yLZ`x4{All#g7ceTK4AlH!bW>`4DhPm{R^A zJr2Gf_JOXjh}0rv^%8qED&uu;zMpt51ib#B_UX>RPbFTy@Ya`mH+ za+`6yz$9!eFpIh@x$PC)f-k!6tsQUC_k5YS1x^{iXfq_QR*i)u#oz!tKbBN41PQwZ%boiqoeOY`-21O zV_a?RpkOHc0QMg`Cw=F9_T*DfK*XSRRSSu>V&;`;o_feeZpp z^!}G}=>LKf%86)ioZeeg{QUisPttp(gCnX>(_=pJ?(q}qmI$4iF25`s3WZkzTjn?G zDPFoTS5`zK@x1rF#W?*XlZPv{K3EqXd>ay?-=;$`{V)pgN-Mv zYZAaX<^%t?@9qH$9*Se1#*Z~q>T{rryF((yvnUAvavM`>TFU5M@>~guE!TS9)jvqj| zG#ZWNwO=py>-4ylUzlLJEHGq1kshNiq|2xasTp-s&e!|Xglss#$I(=oFxnFx`uPmm zt+y8l*<;jc;^4<$rgFJ}s;Iwc$W}v&`T|~0Fjewqjej_dL%u;TOJltGLK@?W>%@IR zs`>;BeNu$#KmtrZ0oh6#KyoGuQj#;0z!xWU9ugyapC*XBdEY6W*fkR*W)giP>y W2fvH44t^J7-G*cuKh4-i$o~QR_f}c} literal 0 HcmV?d00001 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