windowcapture
исходный код / App/Controller.cs

Controller.cs

801 строк · 36,655 байт · модуль App
  1using System;
  2using System.Drawing;
  3using System.Drawing.Drawing2D;
  4using System.Runtime.InteropServices;
  5using System.Windows.Forms;
  6using WindowCapture.Detection;
  7using WindowCapture.Models;
  8using WindowCapture.Native;
  9using WindowCapture.UI;
 10using WindowCapture.Recording;
 11
 12namespace WindowCapture.App
 13{
 14    /// <summary>Which concerns this Controller's global hooks handle. Lets each app process
 15    /// (screenshot / textassist / clipboard) install and run only the hooks it needs.</summary>
 16    [Flags]
 17    public enum ControllerMode
 18    {
 19        None = 0,
 20        Capture = 1,     // primary+Win activation -> freeze overlay -> editor/recording
 21        TextAssist = 2,  // T9/autocorrect keystroke feed + Ctrl+Shift+Space full correction
 22        Clipboard = 4,   // Ctrl+V hold -> clipboard history
 23        Gestures = 8,    // MMB-drag -> open search / soundpad panels
 24        All = Capture | TextAssist | Clipboard | Gestures
 25    }
 26
 27    public class Controller : IDisposable
 28    {
 29        private ControllerMode mode; // mutable: the unified MediaCore app toggles modules live
 30        private IntPtr hook;
 31        private IntPtr mouseHook;
 32        private WinApi.LowLevelKeyboardProc proc;
 33        private WinApi.LowLevelMouseProc mouseProc;
 34        private bool mmbDown;
 35        private int mmbStartX;
 36        private bool searchTriggered;
 37        private bool soundpadTriggered;
 38        private FrozenOverlay overlay;
 39        private Detector detector;
 40        private Bitmap frozen;
 41        private Rectangle captureBounds; // virtual-screen rect the frozen bitmap was captured from (origin may be negative)
 42        private bool active;
 43        private bool isRecordingMode;
 44        private VideoRecorder recorder;
 45        private RecordingOverlay recordingOverlay;
 46        private bool ignoreActivationUntilRelease; // Ignore activation key until it's released
 47        private Point lastCur;
 48        private GraphicsPath currentPath;
 49        private int expandLevel = 0;
 50
 51        // Clipboard hold detection
 52        private Timer clipboardHoldTimer;
 53        private bool ctrlVHeld;
 54        private bool ctrlVEaten; // suppressed real V keydown
 55        private ClipboardForm clipboardForm;
 56        private const uint LLKHF_INJECTED = 0x10;
 57
 58        [DllImport("user32.dll")]
 59        private static extern void keybd_event(byte bVk, byte bScan, uint dwFlags, UIntPtr dwExtraInfo);
 60
 61        public event Action<string> StatusChanged;
 62
 63        public Controller() : this(ControllerMode.All) { }
 64
 65        public Controller(ControllerMode mode)
 66        {
 67            this.mode = mode;
 68            proc = HookCallback;
 69            mouseProc = MouseHookCallback;
 70
 71            // Capture concern needs the freeze overlay + region detector.
 72            if ((mode & ControllerMode.Capture) != 0)
 73            {
 74                overlay = new FrozenOverlay();
 75                overlay.MouseClick += Overlay_MouseClick;
 76                overlay.WheelScrolled += Overlay_WheelScrolled;
 77                overlay.ManualRectSelected += Overlay_ManualRectSelected;
 78                overlay.WindowSelected += Overlay_WindowSelected;
 79                overlay.AddRegionRequested += Overlay_AddRegionRequested;
 80                overlay.MediaParseRequested += Overlay_MediaParseRequested;
 81                overlay.MediaParseRegionRequested += Overlay_MediaParseRegionRequested;
 82                detector = new Detector();
 83            }
 84
 85            // Install only the hooks this mode actually uses.
 86            bool needKeyboard = (mode & (ControllerMode.Capture | ControllerMode.TextAssist | ControllerMode.Clipboard)) != 0;
 87            bool needMouse = (mode & (ControllerMode.TextAssist | ControllerMode.Gestures)) != 0;
 88            if (needKeyboard)
 89                hook = WinApi.SetWindowsHookEx(WinApi.WH_KEYBOARD_LL, proc, WinApi.GetModuleHandle(null), 0);
 90            if (needMouse)
 91                mouseHook = WinApi.SetWindowsHookEx(WinApi.WH_MOUSE_LL, mouseProc, WinApi.GetModuleHandle(null), 0);
 92
 93            if (StatusChanged != null)
 94                StatusChanged("Press " + Settings.ActivationKeyName + "+Win to capture");
 95        }
 96
 97        /// <summary>Enable/disable one module's behavior at runtime. The unified MediaCore app
 98        /// constructs with All (so every hook + the capture overlay are installed up front), then
 99        /// gates which concerns actually act by flipping mode bits here. Setup/teardown beyond the
100        /// hooks (clipboard listener, SAGE warmup, TSF bridge) is the caller's responsibility.</summary>
101        public void SetModuleEnabled(ControllerMode bit, bool on)
102        {
103            if (on) mode = (ControllerMode)((int)mode | (int)bit);
104            else    mode = (ControllerMode)((int)mode & ~(int)bit);
105        }
106
107        private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam)
108        {
109            if (nCode >= 0)
110            {
111                // Ignore Alt when editor is open (editor handles Alt itself)
112                if (EditorForm.IsOpen)
113                    return WinApi.CallNextHookEx(hook, nCode, wParam, lParam);
114
115                var kb = (WinApi.KBDLLHOOKSTRUCT)Marshal.PtrToStructure(lParam, typeof(WinApi.KBDLLHOOKSTRUCT));
116                int wmsg = wParam.ToInt32();
117
118                // --- Ctrl+V: suppress real keydown, paste on keyup via injected keybd_event ---
119                int vkc = (int)kb.vkCode;
120                bool isInjected = (kb.flags & LLKHF_INJECTED) != 0;
121
122                if (vkc == 0x56 && (mode & ControllerMode.Clipboard) != 0) // V key
123                {
124                    bool ctrlHeld = (WinApi.GetAsyncKeyState(0xA2) & 0x8000) != 0
125                                 || (WinApi.GetAsyncKeyState(0xA3) & 0x8000) != 0;
126
127                    if (wmsg == WinApi.WM_KEYDOWN || wmsg == WinApi.WM_SYSKEYDOWN)
128                    {
129                        if (isInjected)
130                        {
131                            // Our own keybd_event paste → let through
132                        }
133                        else if (ctrlHeld)
134                        {
135                            if (!ctrlVHeld && Settings.ClipboardHistory)
136                            {
137                                ctrlVHeld = true;
138                                ctrlVEaten = true;
139                                if (clipboardHoldTimer == null)
140                                {
141                                    clipboardHoldTimer = new Timer { Interval = 120 };
142                                    clipboardHoldTimer.Tick += ClipboardHoldTimer_Tick;
143                                }
144                                clipboardHoldTimer.Start();
145                            }
146                            return (IntPtr)1; // suppress real V keydown
147                        }
148                    }
149                    else if (wmsg == WinApi.WM_KEYUP || wmsg == WinApi.WM_SYSKEYUP)
150                    {
151                        if (isInjected)
152                        {
153                            // Our own keybd_event → let through
154                        }
155                        else if (ctrlVEaten)
156                        {
157                            ctrlVEaten = false;
158                            ctrlVHeld = false;
159                            if (clipboardHoldTimer != null) clipboardHoldTimer.Stop();
160
161                            // Select hovered item, then paste
162                            bool hasForm = clipboardForm != null && !clipboardForm.IsDisposed;
163                            if (hasForm)
164                            {
165                                // Use ManualResetEvent to wait until clipboard is set
166                                var clipReady = new System.Threading.ManualResetEvent(false);
167                                bool itemSelected = false;
168                                TrayApp.Instance.BeginInvoke(new Action(() =>
169                                {
170                                    try
171                                    {
172                                        if (clipboardForm != null && !clipboardForm.IsDisposed)
173                                        {
174                                            itemSelected = clipboardForm.SelectHoveredItem();
175                                            clipboardForm.FadeOut();
176                                        }
177                                    }
178                                    catch { }
179                                    clipReady.Set();
180                                }));
181
182                                // Paste ONLY if user hovered over an item
183                                System.Threading.ThreadPool.QueueUserWorkItem(delegate
184                                {
185                                    clipReady.WaitOne(500);
186                                    if (itemSelected)
187                                    {
188                                        System.Threading.Thread.Sleep(50);
189                                        // Always inject full Ctrl+V (user may have released Ctrl already)
190                                        keybd_event(0xA2, 0, 0, UIntPtr.Zero);       // Ctrl down
191                                        keybd_event(0x56, 0, 0, UIntPtr.Zero);       // V down
192                                        System.Threading.Thread.Sleep(5);
193                                        keybd_event(0x56, 0, 0x0002, UIntPtr.Zero);  // V up
194                                        keybd_event(0xA2, 0, 0x0002, UIntPtr.Zero);  // Ctrl up
195                                    }
196                                });
197                            }
198                            else
199                            {
200                                // No form — quick paste (normal Ctrl+V passthrough)
201                                System.Threading.ThreadPool.QueueUserWorkItem(delegate
202                                {
203                                    System.Threading.Thread.Sleep(15);
204                                    keybd_event(0xA2, 0, 0, UIntPtr.Zero);
205                                    keybd_event(0x56, 0, 0, UIntPtr.Zero);
206                                    System.Threading.Thread.Sleep(5);
207                                    keybd_event(0x56, 0, 0x0002, UIntPtr.Zero);
208                                    keybd_event(0xA2, 0, 0x0002, UIntPtr.Zero);
209                                });
210                            }
211                            return (IntPtr)1; // suppress real V keyup
212                        }
213                    }
214                }
215                if ((vkc == 0xA2 || vkc == 0xA3) && (wmsg == WinApi.WM_KEYUP || wmsg == WinApi.WM_SYSKEYUP) && (mode & ControllerMode.Clipboard) != 0)
216                {
217                    if (ctrlVHeld || ctrlVEaten)
218                    {
219                        bool wasEaten = ctrlVEaten;
220                        ctrlVHeld = false;
221                        ctrlVEaten = false;
222                        if (clipboardHoldTimer != null) clipboardHoldTimer.Stop();
223                        // Select + paste + close (same as V keyup)
224                        bool hasForm = clipboardForm != null && !clipboardForm.IsDisposed;
225                        if (hasForm && wasEaten)
226                        {
227                            var clipReady2 = new System.Threading.ManualResetEvent(false);
228                            bool itemSelected2 = false;
229                            TrayApp.Instance.BeginInvoke(new Action(() =>
230                            {
231                                try
232                                {
233                                    if (clipboardForm != null && !clipboardForm.IsDisposed)
234                                    {
235                                        itemSelected2 = clipboardForm.SelectHoveredItem();
236                                        clipboardForm.FadeOut();
237                                    }
238                                }
239                                catch { }
240                                clipReady2.Set();
241                            }));
242                            System.Threading.ThreadPool.QueueUserWorkItem(delegate
243                            {
244                                clipReady2.WaitOne(500);
245                                if (itemSelected2)
246                                {
247                                    // Re-press Ctrl for paste since user just released it
248                                    System.Threading.Thread.Sleep(30);
249                                    keybd_event(0xA2, 0, 0, UIntPtr.Zero);       // Ctrl down
250                                    keybd_event(0x56, 0, 0, UIntPtr.Zero);       // V down
251                                    System.Threading.Thread.Sleep(5);
252                                    keybd_event(0x56, 0, 0x0002, UIntPtr.Zero);  // V up
253                                    keybd_event(0xA2, 0, 0x0002, UIntPtr.Zero);  // Ctrl up
254                                }
255                            });
256                        }
257                        else if (hasForm)
258                        {
259                            TrayApp.Instance.BeginInvoke(new Action(() =>
260                            {
261                                if (clipboardForm != null && !clipboardForm.IsDisposed)
262                                    clipboardForm.FadeOut();
263                            }));
264                        }
265                    }
266                }
267
268                // --- Ctrl+Shift+Space: full text correction ---
269                if (vkc == 0x20 && (wmsg == WinApi.WM_KEYDOWN || wmsg == WinApi.WM_SYSKEYDOWN) && !isInjected && (mode & ControllerMode.TextAssist) != 0)
270                {
271                    bool ctrlH = (WinApi.GetAsyncKeyState(0xA2) & 0x8000) != 0 || (WinApi.GetAsyncKeyState(0xA3) & 0x8000) != 0;
272                    bool shiftH = (WinApi.GetAsyncKeyState(0xA0) & 0x8000) != 0 || (WinApi.GetAsyncKeyState(0xA1) & 0x8000) != 0;
273                    if (ctrlH && shiftH)
274                    {
275                        System.Threading.ThreadPool.QueueUserWorkItem(delegate
276                        {
277                            System.Threading.Thread.Sleep(30);
278                            Helpers.TextProcessor.CorrectEntireText();
279                        });
280                        return (IntPtr)1; // suppress the space
281                    }
282                }
283
284                // --- Feed keystrokes to TextProcessor (skip injected from our own replacements) ---
285                if ((wmsg == WinApi.WM_KEYDOWN || wmsg == WinApi.WM_SYSKEYDOWN) && !isInjected && (mode & ControllerMode.TextAssist) != 0)
286                {
287                    bool suppress = Helpers.TextProcessor.OnKeyDown((int)kb.vkCode, (int)kb.scanCode);
288                    if (suppress) return (IntPtr)1;
289                }
290
291                // ===== Activation COMBO: primary key (default Alt) + Win held together =====
292                // A deliberate two-key chord avoids accidental triggering on a casual Alt tap.
293                // Hold Ctrl too (Alt+Win+Ctrl) for video-recording mode. Skip our own injected keys.
294                int vk = (int)kb.vkCode;
295                if (!isInjected && (mode & ControllerMode.Capture) != 0 && IsActivationComboKey(vk))
296                {
297                    int msg = wParam.ToInt32();
298                    bool isKeyDown = msg == WinApi.WM_KEYDOWN || msg == WinApi.WM_SYSKEYDOWN;
299                    bool isKeyUp = msg == WinApi.WM_KEYUP || msg == WinApi.WM_SYSKEYUP;
300
301                    bool vkIsWin = (vk == 0x5B || vk == 0x5C);
302                    bool vkIsPrimary = IsPrimaryActivationVk(vk);
303
304                    if (isKeyUp)
305                    {
306                        // The released key is a chord key, so the chord is necessarily broken now →
307                        // end capture and clear the re-arm latch. (Don't query GetAsyncKeyState for the
308                        // just-released key — it isn't reliably cleared yet inside the LL hook.)
309                        ignoreActivationUntilRelease = false;
310                        if (active) Deactivate();
311                    }
312                    else if (isKeyDown && !active && !ignoreActivationUntilRelease && !EditorForm.IsOpen)
313                    {
314                        // IMPORTANT: the just-pressed key's GetAsyncKeyState is NOT updated yet inside the
315                        // LL keyboard hook, so treat the current event's key as "down" by event type and
316                        // only query the OTHER chord key via GetAsyncKeyState (it was pressed earlier).
317                        bool primaryDown = vkIsPrimary || PrimaryActivationHeld();
318                        bool winDown = vkIsWin || WinHeld();
319                        if (primaryDown && winDown)
320                        {
321                            isRecordingMode = CtrlHeld(); // +Ctrl = video mode
322                            Activate();
323                        }
324                    }
325
326                    // Swallow the Win key while it is part of our chord (primary held, or capture active)
327                    // so Windows doesn't pop the Start menu on Win release. Win alone is left untouched.
328                    if (vkIsWin && (PrimaryActivationHeld() || active))
329                        return (IntPtr)1;
330                }
331            }
332            return WinApi.CallNextHookEx(hook, nCode, wParam, lParam);
333        }
334
335        private bool searchOpen; // tracks whether search panel is currently visible
336
337        private IntPtr MouseHookCallback(int nCode, IntPtr wParam, IntPtr lParam)
338        {
339            if (nCode >= 0)
340            {
341                int msg = wParam.ToInt32();
342                // Left mouse click: reset word buffer (cursor moved)
343                if (msg == 0x0202 && (mode & ControllerMode.TextAssist) != 0) // WM_LBUTTONUP
344                {
345                    Helpers.TextProcessor.OnCursorMoved();
346                }
347                if (msg == WinApi.WM_MBUTTONDOWN && (mode & ControllerMode.Gestures) != 0)
348                {
349                    bool altHeld = (WinApi.GetAsyncKeyState(0xA4) & 0x8000) != 0
350                                || (WinApi.GetAsyncKeyState(0xA5) & 0x8000) != 0;
351                    // Don't arm the explorer/soundpad slide gesture while the editor is open —
352                    // there the middle button is used for panning and placing markers.
353                    if (!altHeld && !active && !EditorForm.IsOpen)
354                    {
355                        mmbDown = true;
356                        mmbStartX = Marshal.ReadInt32(lParam, 0);
357                        searchTriggered = false;
358                        soundpadTriggered = false;
359                    }
360                }
361                else if (msg == WinApi.WM_MBUTTONUP)
362                {
363                    mmbDown = false;
364                }
365                else if (mmbDown && msg == WinApi.WM_MOUSEMOVE && !EditorForm.IsOpen)
366                {
367                    int curX = Marshal.ReadInt32(lParam, 0);
368                    int dx = curX - mmbStartX;
369
370                    if (!searchOpen && !searchTriggered && dx > 60)
371                    {
372                        // Drag right with search closed → open
373                        searchTriggered = true;
374                        searchOpen = true;
375                        if (TrayApp.Instance != null)
376                            TrayApp.Instance.BeginInvoke(new Action(() => TrayApp.Instance.ShowSearchPanel()));
377                    }
378                    else if (searchOpen && !searchTriggered && dx < -60)
379                    {
380                        // Drag left with search open → close search
381                        searchTriggered = true;
382                        searchOpen = false;
383                        mmbDown = false;
384                        if (TrayApp.Instance != null)
385                            TrayApp.Instance.BeginInvoke(new Action(() => TrayApp.Instance.HideSearchPanel()));
386                    }
387                    else if (!searchOpen && !soundpadTriggered && dx < -60)
388                    {
389                        // Drag left with nothing open → open soundpad from right
390                        soundpadTriggered = true;
391                        mmbDown = false;
392                        if (TrayApp.Instance != null)
393                            TrayApp.Instance.BeginInvoke(new Action(() => TrayApp.Instance.ShowSoundpad()));
394                    }
395                    else if (searchOpen && searchTriggered && dx > 60)
396                    {
397                        // Continue dragging right after open → widen
398                        int extraW = dx - 60;
399                        if (TrayApp.Instance != null)
400                            TrayApp.Instance.BeginInvoke(new Action(() => TrayApp.Instance.WidenSearchPanel(extraW)));
401                    }
402                }
403            }
404            return WinApi.CallNextHookEx(mouseHook, nCode, wParam, lParam);
405        }
406
407        private void ClipboardHoldTimer_Tick(object s, EventArgs e)
408        {
409            clipboardHoldTimer.Stop();
410            if (!ctrlVHeld) return;
411            if (TrayApp.Instance != null && !TrayApp.Instance.IsDisposed)
412            {
413                TrayApp.Instance.BeginInvoke(new Action(() =>
414                {
415                    // If form exists and is fading out — revive it
416                    if (clipboardForm != null && !clipboardForm.IsDisposed)
417                    {
418                        if (clipboardForm.IsFadingOut)
419                            clipboardForm.ReviveFromFadeOut();
420                        return;
421                    }
422                    clipboardForm = new ClipboardForm();
423                    clipboardForm.FormClosed += (ss, ee) => { clipboardForm = null; };
424                    clipboardForm.Show();
425                }));
426            }
427        }
428
429        // ===== Activation chord helpers =====
430        private static bool KeyHeld(int vk) { return (WinApi.GetAsyncKeyState(vk) & 0x8000) != 0; }
431        private static bool ShiftHeld() { return KeyHeld(0xA0) || KeyHeld(0xA1) || KeyHeld(0x10); }
432        private static bool CtrlHeld() { return KeyHeld(0xA2) || KeyHeld(0xA3) || KeyHeld(0x11); }
433        private static bool WinHeld() { return KeyHeld(0x5B) || KeyHeld(0x5C); } // VK_LWIN / VK_RWIN
434
435        /// <summary>Is the configured primary activation key currently physically held?</summary>
436        private static bool PrimaryActivationHeld()
437        {
438            int k = Settings.ActivationKey;
439            if (k == 0xA4 || k == 0xA5) return KeyHeld(0xA4) || KeyHeld(0xA5) || KeyHeld(0x12); // Alt
440            if (k == 0xA2 || k == 0xA3) return CtrlHeld();                                       // Ctrl
441            if (k == 0xA0 || k == 0xA1) return ShiftHeld();                                      // Shift
442            return KeyHeld(k);                                                                    // F-keys etc.
443        }
444
445        /// <summary>Does this vkCode match the configured primary activation key (L/R/generic variants)?</summary>
446        private static bool IsPrimaryActivationVk(int vk)
447        {
448            int k = Settings.ActivationKey;
449            if (k == 0xA4 || k == 0xA5) return (vk == 0xA4 || vk == 0xA5 || vk == 0x12); // Alt
450            if (k == 0xA2 || k == 0xA3) return (vk == 0xA2 || vk == 0xA3 || vk == 0x11); // Ctrl
451            if (k == 0xA0 || k == 0xA1) return (vk == 0xA0 || vk == 0xA1 || vk == 0x10); // Shift
452            return (vk == k);                                                            // F-keys etc.
453        }
454
455        /// <summary>Is this vkCode one of the chord keys (primary or Win)? Used to scope the hook work.</summary>
456        private static bool IsActivationComboKey(int vk)
457        {
458            bool isWin = (vk == 0x5B || vk == 0x5C); // VK_LWIN / VK_RWIN
459            return IsPrimaryActivationVk(vk) || isWin;
460        }
461
462        private void Activate()
463        {
464            // Don't activate if editor is already open
465            if (EditorForm.IsOpen) return;
466
467            active = true;
468            expandLevel = 0;
469            // Capture the entire virtual desktop (all monitors), not just the primary screen.
470            // For a single-monitor setup this equals PrimaryScreen.Bounds, so behavior is unchanged.
471            var bounds = SystemInformation.VirtualScreen;
472            captureBounds = bounds;
473            frozen = new Bitmap(bounds.Width, bounds.Height);
474            using (var g = Graphics.FromImage(frozen))
475                g.CopyFromScreen(bounds.Location, Point.Empty, bounds.Size);
476
477            detector.SetScreen(frozen);
478            overlay.IsVideoMode = isRecordingMode;
479            overlay.ShowWithScreenshot((Bitmap)frozen.Clone());
480
481            Application.Idle += OnIdle;
482
483            if (StatusChanged != null)
484                StatusChanged("Capturing...");
485        }
486
487        private void Deactivate()
488        {
489            Application.Idle -= OnIdle;
490            active = false;
491            expandLevel = 0;
492            currentPath = null;
493            if (overlay != null) overlay.Reset();
494            if (frozen != null) { frozen.Dispose(); frozen = null; }
495
496            if (StatusChanged != null)
497                StatusChanged("Press " + Settings.ActivationKeyName + "+Win to capture");
498        }
499
500        // Selection rects are in capture-local (frozen) coords; recording captures the live screen,
501        // which needs real screen coords. On a single monitor captureBounds origin is (0,0) → no-op.
502        private Rectangle ToScreenRect(Rectangle frozenRect)
503        {
504            return new Rectangle(frozenRect.X + captureBounds.X, frozenRect.Y + captureBounds.Y,
505                                 frozenRect.Width, frozenRect.Height);
506        }
507
508        private void StartVideoRecording(Rectangle area)
509        {
510            string tempPath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "WindowCapture_" + Guid.NewGuid().ToString("N") + ".mp4");
511
512            try
513            {
514                recorder = new VideoRecorder();
515                recorder.Start(area, tempPath);
516
517                recordingOverlay = new RecordingOverlay();
518                recordingOverlay.StopRequested += () => {
519                    recorder.Stop();
520                    recordingOverlay.Close();
521
522                    // Determine actual output file (mp4, wmv, or avi fallback)
523                    string actualTemp = tempPath;
524                    string ext = ".mp4";
525                    if (!System.IO.File.Exists(actualTemp))
526                    {
527                        // Check if WMV was used
528                        string wmvTemp = tempPath.Substring(0, tempPath.Length - 4) + ".wmv";
529                        if (System.IO.File.Exists(wmvTemp))
530                        {
531                            actualTemp = wmvTemp;
532                            ext = ".wmv";
533                        }
534                        else
535                        {
536                            // Check if AVI fallback was used
537                            string aviTemp = tempPath.Substring(0, tempPath.Length - 4) + ".avi";
538                            if (System.IO.File.Exists(aviTemp))
539                            {
540                                actualTemp = aviTemp;
541                                ext = ".avi";
542                            }
543                        }
544                    }
545
546                    string filter;
547                    if (ext == ".wmv")
548                        filter = "WMV Video|*.wmv|All Files|*.*";
549                    else if (ext == ".mp4")
550                        filter = "MP4 Video|*.mp4|All Files|*.*";
551                    else
552                        filter = "AVI Video|*.avi|All Files|*.*";
553
554                    var sfd = new SaveFileDialog
555                    {
556                        Filter = filter,
557                        Title = "Save Video Recording",
558                        FileName = "Recording_" + DateTime.Now.ToString("yyyyMMdd_HHmmss") + ext
559                    };
560
561                    if (sfd.ShowDialog() == DialogResult.OK)
562                    {
563                        try
564                        {
565                            if (System.IO.File.Exists(sfd.FileName)) System.IO.File.Delete(sfd.FileName);
566                            System.IO.File.Move(actualTemp, sfd.FileName);
567                        }
568                        catch (Exception ex)
569                        {
570                            MessageBox.Show("Failed to save video: " + ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
571                        }
572                    }
573                    else
574                    {
575                        try { if (System.IO.File.Exists(actualTemp)) System.IO.File.Delete(actualTemp); } catch { }
576                    }
577
578                    recorder.Dispose();
579                    recorder = null;
580                    recordingOverlay = null;
581                };
582                recordingOverlay.Start();
583            }
584            catch (Exception ex)
585            {
586                MessageBox.Show("Failed to start recording: " + ex.Message, "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
587                if (recorder != null) { recorder.Dispose(); recorder = null; }
588            }
589        }
590
591        private void OnIdle(object s, EventArgs e)
592        {
593            if (!active) return;
594
595            // Dynamic Ctrl detection to toggle video-mode colors while the chord is held.
596            // (Shift is part of the activation chord now, so Ctrl selects photo vs video.)
597            bool currentCtrl = CtrlHeld();
598            if (currentCtrl != isRecordingMode)
599            {
600                isRecordingMode = currentCtrl;
601                overlay.IsVideoMode = isRecordingMode;
602            }
603
604            // Don't update auto-detect while in other modes
605            if (overlay.IsDrawingManualRect || overlay.IsWindowSelectMode || overlay.IsMediaParseMode) return;
606
607            // Cursor.Position is in screen coords; the detector/frozen bitmap are in capture-local
608            // coords (origin = captureBounds top-left, which is negative on left/top secondary monitors).
609            var curScreen = Cursor.Position;
610            var cur = new Point(curScreen.X - captureBounds.X, curScreen.Y - captureBounds.Y);
611            if (cur == lastCur) return;
612            lastCur = cur;
613            expandLevel = 0; // Reset expand level on mouse move
614
615            var path = detector.Detect(cur, expandLevel);
616            if (currentPath != null) currentPath.Dispose();
617            currentPath = path;
618            overlay.SetRegion(path, expandLevel);
619        }
620
621        private void Overlay_WheelScrolled(object sender, int delta)
622        {
623            if (!active) return;
624
625            if (delta > 0)
626            {
627                expandLevel++;
628            }
629            else if (delta < 0 && expandLevel > 0)
630            {
631                expandLevel--;
632            }
633
634            var path = detector.Detect(lastCur, expandLevel);
635            if (currentPath != null) currentPath.Dispose();
636            currentPath = path;
637            overlay.SetRegion(path, expandLevel);
638        }
639
640        private void Overlay_ManualRectSelected(object sender, Rectangle rect)
641        {
642            if (!active || frozen == null) return;
643
644            // Capture data before Deactivate() clears it
645            var frozenCopy = (Bitmap)frozen.Clone();
646
647            // Ignore activation key until released
648            ignoreActivationUntilRelease = true;
649            Deactivate();
650
651            if (isRecordingMode)
652            {
653                frozenCopy.Dispose();
654                StartVideoRecording(ToScreenRect(rect));
655                ignoreActivationUntilRelease = false;
656            }
657            else
658            {
659                // Create path from manually selected rectangle
660                var manualPath = new GraphicsPath();
661                manualPath.AddRectangle(rect);
662
663                var editor = new EditorForm(frozenCopy, manualPath);
664                editor.FormClosed += delegate {
665                    frozenCopy.Dispose();
666                    manualPath.Dispose();
667                    ignoreActivationUntilRelease = false; // Reset so next Alt works immediately
668                };
669                editor.Show();
670            }
671        }
672
673        private void Overlay_WindowSelected(object sender, Rectangle rect)
674        {
675            if (!active || frozen == null) return;
676
677            // Capture data before Deactivate() clears it
678            var frozenCopy = (Bitmap)frozen.Clone();
679
680            // Ignore activation key until released
681            ignoreActivationUntilRelease = true;
682            Deactivate();
683
684            if (isRecordingMode)
685            {
686                frozenCopy.Dispose();
687                StartVideoRecording(ToScreenRect(rect));
688                ignoreActivationUntilRelease = false;
689            }
690            else
691            {
692                // Create path from window rectangle
693                var windowPath = new GraphicsPath();
694                windowPath.AddRectangle(rect);
695
696                var editor = new EditorForm(frozenCopy, windowPath);
697                editor.FormClosed += delegate {
698                    frozenCopy.Dispose();
699                    windowPath.Dispose();
700                    ignoreActivationUntilRelease = false; // Reset so next Alt works immediately
701                };
702                editor.Show();
703            }
704        }
705
706        private void Overlay_AddRegionRequested(Point pt)
707        {
708            if (!active) return;
709
710            // Detect region at this point and add it
711            var path = detector.Detect(pt, 0);
712            if (path != null)
713            {
714                overlay.AddPath(path);
715            }
716        }
717
718        private GraphicsPath Overlay_MediaParseRegionRequested(Point pt)
719        {
720            // Use the same detector to find region at point for media parser
721            if (!active) return null;
722            return detector.Detect(pt, 0);
723        }
724
725        private void Overlay_MediaParseRequested(Point pt)
726        {
727            if (!active) return;
728
729            // Parse media at this point
730            var mediaItems = MediaParser.ParseMediaAt(pt, overlay.Handle);
731
732            // Deactivate overlay first
733            Deactivate();
734
735            // Show media download dialog
736            if (mediaItems.Count > 0)
737            {
738                var dialog = new MediaDownloadDialog(mediaItems);
739                dialog.Show();
740            }
741            else
742            {
743                MessageBox.Show("No media found at this location.\n\nTry clicking directly on an image, video, or audio element.",
744                    "Media Parser", MessageBoxButtons.OK, MessageBoxIcon.Information);
745            }
746        }
747
748        private void Overlay_MouseClick(object sender, MouseEventArgs e)
749        {
750            // Ignore clicks if editor is already open
751            if (EditorForm.IsOpen) return;
752
753            // Ignore clicks if we just finished drawing a manual rectangle
754            if (e.Button != MouseButtons.Left) return;
755            if (!active || currentPath == null || frozen == null) return;
756
757            // Capture data before Deactivate() clears it
758            var pathCopy = (GraphicsPath)currentPath.Clone();
759            var bounds = Rectangle.Round(pathCopy.GetBounds());
760            var frozenCopy = (Bitmap)frozen.Clone();
761
762            // Ignore activation key until released (prevents re-activation while key still held)
763            ignoreActivationUntilRelease = true;
764            Deactivate();
765
766            if (isRecordingMode)
767            {
768                pathCopy.Dispose();
769                frozenCopy.Dispose();
770                StartVideoRecording(ToScreenRect(bounds));
771                ignoreActivationUntilRelease = false;
772            }
773            else
774            {
775                var editor = new EditorForm(frozenCopy, pathCopy);
776                editor.FormClosed += delegate {
777                    frozenCopy.Dispose();
778                    pathCopy.Dispose();
779                    ignoreActivationUntilRelease = false; // Reset so next Alt works immediately
780                };
781                editor.Show();
782            }
783        }
784
785        public void ResetSearchOpen() { searchOpen = false; }
786
787        public void ShowSettings()
788        {
789            var dialog = new SettingsDialog();
790            dialog.ShowDialog();
791        }
792
793        public void Dispose()
794        {
795            Deactivate();
796            if (hook != IntPtr.Zero) WinApi.UnhookWindowsHookEx(hook);
797            if (mouseHook != IntPtr.Zero) WinApi.UnhookWindowsHookEx(mouseHook);
798            if (overlay != null) overlay.Dispose();
799        }
800    }
801}