import 'dart:io'; import 'package:flutter/services.dart'; import 'package:hotkey_manager/hotkey_manager.dart'; import 'package:tray_manager/tray_manager.dart' as tray; import 'package:screen_retriever/screen_retriever.dart'; import 'package:window_manager/window_manager.dart'; import '../../services/app_shell.dart'; import 'shell_integration.dart'; import 'tool_installer.dart'; /// Windows 데스크톱: 트레이 아이콘 · 전역 단축키 · 창 닫기 가로채기 class DesktopShell with WindowListener implements AppShell { tray.TrayIcon? _tray; HotKey? _hotkey; late bool Function() _minimizeToTray; late Future Function() _onClose; void Function()? _onDownloads; bool _closing = false; @override Future init({ required String hotkey, required bool Function() minimizeToTray, required Future Function() onCloseRequested, void Function()? onDownloadsRequested, }) async { _minimizeToTray = minimizeToTray; _onClose = onCloseRequested; _onDownloads = onDownloadsRequested; await windowManager.ensureInitialized(); await windowManager.setPreventClose(true); windowManager.addListener(this); _initTray(); await hotKeyManager.unregisterAll(); await setHotkey(hotkey); } void _initTray() { final t = tray.TrayIcon.create(); if (t == null) return; t.icon = tray.ImageAsset.fromAsset('assets/tray_icon.ico') ?? tray.Image.fromFile(Platform.resolvedExecutable); t.setTooltip('JJ_MKVMaker'); final menu = tray.Menu.create()!; tray.MenuItem item(String label, void Function() onClick) { final i = tray.MenuItem.createWithLabelAndType(label, tray.MenuItemType.normal)!; i.addListener((e) { if (e is tray.MenuItemClickedEvent) onClick(); }); menu.addItem(i); return i; } item('열기', show); item('다운로드 목록', () async { await show(); _onDownloads?.call(); }); menu.addSeparator(); item('종료', _requestClose); t.setContextMenu(menu); t.setContextMenuTrigger(tray.ContextMenuTrigger.rightClicked); t.addListener((e) { if (e is tray.TrayIconClickedEvent || e is tray.TrayIconDoubleClickedEvent) show(); }); t.setVisible(true); _tray = t; } @override Future setHotkey(String text) async { final parsed = parseHotkey(text); if (parsed == null) return false; final (mods, key) = parsed; final physical = _keyOf(key); if (physical == null) return false; final hk = HotKey( key: physical, modifiers: [ for (final m in mods) switch (m) { 'ctrl' => HotKeyModifier.control, 'shift' => HotKeyModifier.shift, 'alt' => HotKeyModifier.alt, _ => HotKeyModifier.meta, }, ], scope: HotKeyScope.system, ); if (_hotkey != null) await hotKeyManager.unregister(_hotkey!); await hotKeyManager.register(hk, keyDownHandler: (_) => show()); _hotkey = hk; return true; } static PhysicalKeyboardKey? _keyOf(String k) { const letters = [ PhysicalKeyboardKey.keyA, PhysicalKeyboardKey.keyB, PhysicalKeyboardKey.keyC, PhysicalKeyboardKey.keyD, PhysicalKeyboardKey.keyE, PhysicalKeyboardKey.keyF, PhysicalKeyboardKey.keyG, PhysicalKeyboardKey.keyH, PhysicalKeyboardKey.keyI, PhysicalKeyboardKey.keyJ, PhysicalKeyboardKey.keyK, PhysicalKeyboardKey.keyL, PhysicalKeyboardKey.keyM, PhysicalKeyboardKey.keyN, PhysicalKeyboardKey.keyO, PhysicalKeyboardKey.keyP, PhysicalKeyboardKey.keyQ, PhysicalKeyboardKey.keyR, PhysicalKeyboardKey.keyS, PhysicalKeyboardKey.keyT, PhysicalKeyboardKey.keyU, PhysicalKeyboardKey.keyV, PhysicalKeyboardKey.keyW, PhysicalKeyboardKey.keyX, PhysicalKeyboardKey.keyY, PhysicalKeyboardKey.keyZ, ]; const digits = [ PhysicalKeyboardKey.digit0, PhysicalKeyboardKey.digit1, PhysicalKeyboardKey.digit2, PhysicalKeyboardKey.digit3, PhysicalKeyboardKey.digit4, PhysicalKeyboardKey.digit5, PhysicalKeyboardKey.digit6, PhysicalKeyboardKey.digit7, PhysicalKeyboardKey.digit8, PhysicalKeyboardKey.digit9, ]; const fkeys = [ PhysicalKeyboardKey.f1, PhysicalKeyboardKey.f2, PhysicalKeyboardKey.f3, PhysicalKeyboardKey.f4, PhysicalKeyboardKey.f5, PhysicalKeyboardKey.f6, PhysicalKeyboardKey.f7, PhysicalKeyboardKey.f8, PhysicalKeyboardKey.f9, PhysicalKeyboardKey.f10, PhysicalKeyboardKey.f11, PhysicalKeyboardKey.f12, ]; if (k.length == 1) { final c = k.codeUnitAt(0); if (c >= 65 && c <= 90) return letters[c - 65]; if (c >= 48 && c <= 57) return digits[c - 48]; } if (k.startsWith('F')) { final n = int.tryParse(k.substring(1)); if (n != null && n >= 1 && n <= 12) return fkeys[n - 1]; } return null; } @override Future show() async { if (await windowManager.isMinimized()) await windowManager.restore(); await windowManager.show(); await windowManager.focus(); } /// 숨긴 창은 작업 표시줄에도 나타나지 않는다. /// (window_manager 0.5.2 의 setSkipTaskbar 는 Windows 에서 충돌하므로 쓰지 않음) @override Future hide() => windowManager.hide(); @override Future setTooltip(String text) async => _tray?.setTooltip(text); Future _requestClose() async { if (_closing) return; _closing = true; try { await show(); if (await _onClose()) await quit(); } finally { _closing = false; } } @override void onWindowClose() => _requestClose(); @override void onWindowMinimize() { if (_minimizeToTray()) hide(); } // ───────── 플레이어 창 ───────── Rect? _beforePopup; bool _popup = false; @override Future setFullScreen(bool on) async { if (on && _popup) await setPopup(false); await windowManager.setFullScreen(on); } @override Future isFullScreen() => windowManager.isFullScreen(); @override Future setPopup(bool on) async { if (on == _popup) return; if (on) { if (await windowManager.isFullScreen()) await windowManager.setFullScreen(false); _beforePopup = await windowManager.getBounds(); await windowManager.setTitleBarStyle(TitleBarStyle.hidden); await windowManager.setAlwaysOnTop(true); final area = await _workArea(); const size = Size(480, 270); await windowManager.setBounds(Rect.fromLTWH( area.right - size.width - 24, area.bottom - size.height - 24, size.width, size.height)); } else { await windowManager.setAlwaysOnTop(false); await windowManager.setTitleBarStyle(TitleBarStyle.normal); if (_beforePopup != null) await windowManager.setBounds(_beforePopup!); } _popup = on; } Future _workArea() async { final d = await screenRetriever.getPrimaryDisplay(); final pos = d.visiblePosition ?? Offset.zero; final size = d.visibleSize ?? d.size; return pos & size; } @override Future fitVideo(int width, int height, double factor) async { if (width <= 0 || height <= 0) return; if (await windowManager.isFullScreen()) await windowManager.setFullScreen(false); if (_popup) await setPopup(false); final area = await _workArea(); // 조작 막대 · 제목 표시줄 여유 const extraH = 120.0, extraW = 16.0; var w = width * factor, h = height * factor; final scale = [1.0, (area.width - extraW) / w, (area.height - extraH) / h].reduce((a, b) => a < b ? a : b); w *= scale; h *= scale; await windowManager.setSize(Size(w + extraW, h + extraH)); await windowManager.center(); } // ───────── 탐색기 · 외부 프로그램 ───────── final _shellMenu = ShellIntegration(); @override Future isContextMenuRegistered() => _shellMenu.isRegistered(); @override Future registerContextMenu() => _shellMenu.register(); @override Future unregisterContextMenu() => _shellMenu.unregister(); @override Future openExternal(String program, List files) => openExternally(program, files); @override String? get vlcPath => findVlc(); // ───────── 필수 프로그램 ───────── final _installer = ToolInstaller(); @override Future> missingTools() async => [ for (final t in await _installer.missing()) '${t.name} - ${t.purpose} (약 ${t.sizeMb}MB)', ]; @override Future installMissingTools(void Function(String name, double progress) onProgress) async => _installer.install(await _installer.missing(), onProgress); /// 새 프로세스는 --restart 로 시작해 이 창이 닫힐 때까지 기다린다 @override Future restart() async { await Process.start(Platform.resolvedExecutable, ['--restart'], mode: ProcessStartMode.detached); await quit(); } @override Future quit() async { await hotKeyManager.unregisterAll(); _tray?.setVisible(false); _tray?.dispose(); windowManager.removeListener(this); await windowManager.setPreventClose(false); await windowManager.destroy(); } }