videokidnapper 1.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. videokidnapper/__init__.py +11 -0
  2. videokidnapper/app.py +612 -0
  3. videokidnapper/cli.py +132 -0
  4. videokidnapper/config.py +116 -0
  5. videokidnapper/core/__init__.py +2 -0
  6. videokidnapper/core/downloader.py +331 -0
  7. videokidnapper/core/ffmpeg_backend.py +96 -0
  8. videokidnapper/core/playback.py +408 -0
  9. videokidnapper/core/preview.py +72 -0
  10. videokidnapper/core/screen_capture.py +75 -0
  11. videokidnapper/core/whisper_captions.py +182 -0
  12. videokidnapper/ui/__init__.py +2 -0
  13. videokidnapper/ui/batch_export_tab.py +697 -0
  14. videokidnapper/ui/batch_queue.py +214 -0
  15. videokidnapper/ui/color_picker.py +22 -0
  16. videokidnapper/ui/debug_tab.py +159 -0
  17. videokidnapper/ui/export_dialog.py +154 -0
  18. videokidnapper/ui/export_options.py +522 -0
  19. videokidnapper/ui/history_tab.py +162 -0
  20. videokidnapper/ui/image_layers.py +471 -0
  21. videokidnapper/ui/multi_range.py +155 -0
  22. videokidnapper/ui/platform_presets.py +51 -0
  23. videokidnapper/ui/setup_dialog.py +427 -0
  24. videokidnapper/ui/share_panel.py +110 -0
  25. videokidnapper/ui/shortcuts_dialog.py +171 -0
  26. videokidnapper/ui/text_layers.py +697 -0
  27. videokidnapper/ui/theme.py +205 -0
  28. videokidnapper/ui/thumbnail_strip.py +219 -0
  29. videokidnapper/ui/trim_tab.py +1081 -0
  30. videokidnapper/ui/url_tab.py +883 -0
  31. videokidnapper/ui/video_player.py +1097 -0
  32. videokidnapper/ui/waveform.py +132 -0
  33. videokidnapper/ui/widgets.py +298 -0
  34. videokidnapper/utils/__init__.py +2 -0
  35. videokidnapper/utils/batch.py +218 -0
  36. videokidnapper/utils/clipboard_image.py +109 -0
  37. videokidnapper/utils/dnd.py +75 -0
  38. videokidnapper/utils/ffmpeg_check.py +37 -0
  39. videokidnapper/utils/ffmpeg_escape.py +65 -0
  40. videokidnapper/utils/file_naming.py +41 -0
  41. videokidnapper/utils/github_update.py +67 -0
  42. videokidnapper/utils/prereq_check.py +266 -0
  43. videokidnapper/utils/settings.py +238 -0
  44. videokidnapper/utils/share.py +144 -0
  45. videokidnapper/utils/size_estimator.py +85 -0
  46. videokidnapper/utils/snap.py +135 -0
  47. videokidnapper/utils/srt_parser.py +83 -0
  48. videokidnapper/utils/time_format.py +42 -0
  49. videokidnapper/utils/undo.py +119 -0
  50. videokidnapper/utils/ytdlp_update.py +117 -0
  51. videokidnapper-1.3.0.dist-info/METADATA +547 -0
  52. videokidnapper-1.3.0.dist-info/RECORD +57 -0
  53. videokidnapper-1.3.0.dist-info/WHEEL +5 -0
  54. videokidnapper-1.3.0.dist-info/entry_points.txt +2 -0
  55. videokidnapper-1.3.0.dist-info/licenses/LICENSE +209 -0
  56. videokidnapper-1.3.0.dist-info/licenses/NOTICE.md +26 -0
  57. videokidnapper-1.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,11 @@
1
+ # SPDX-FileCopyrightText: 2026 Christopher Courtney <https://github.com/AES256Afro>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """VideoKidnapper — Video GIF/Clip Creator with Text Overlays.
4
+
5
+ This module is the single source of truth for the project version.
6
+ ``pyproject.toml`` reads ``__version__`` via ``tool.setuptools.dynamic``,
7
+ and ``videokidnapper.config.APP_VERSION`` re-exports it so the rest of
8
+ the code (window title, update check, release-notes link) stays in sync
9
+ without a second place to bump.
10
+ """
11
+ __version__ = "1.3.0"
videokidnapper/app.py ADDED
@@ -0,0 +1,612 @@
1
+ # SPDX-FileCopyrightText: 2026 Christopher Courtney <https://github.com/AES256Afro>
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ import sys
4
+ import traceback
5
+ import webbrowser
6
+
7
+ import customtkinter as ctk
8
+
9
+ from videokidnapper.config import APP_NAME, APP_VERSION, WINDOW_SIZE, MIN_WINDOW_SIZE
10
+ from videokidnapper.ui import theme as T
11
+ from videokidnapper.ui.widgets import Toast
12
+ from videokidnapper.utils import settings
13
+ from videokidnapper.utils.dnd import enable_dnd_for
14
+ from videokidnapper.utils.ffmpeg_check import check_ffmpeg
15
+ from videokidnapper.utils.github_update import check_async
16
+
17
+
18
+ class App(ctk.CTk):
19
+ def __init__(self):
20
+ super().__init__()
21
+ T.configure_global()
22
+
23
+ self.title(f"{APP_NAME} v{APP_VERSION}")
24
+ self.geometry(WINDOW_SIZE)
25
+ self.minsize(*MIN_WINDOW_SIZE)
26
+ self.configure(fg_color=T.BG_BASE)
27
+
28
+ # Turn DnD on BEFORE any widget is created so their
29
+ # drop_target_register() calls succeed during __init__.
30
+ self.dnd_enabled = enable_dnd_for(self)
31
+
32
+ self.ffmpeg_path, self.ffprobe_path = check_ffmpeg()
33
+
34
+ if not self.ffmpeg_path:
35
+ self._show_setup_landing()
36
+ return
37
+
38
+ self.plugins = [] # [DiscoveredPlugin] — populated by _load_plugins
39
+
40
+ self._build_ui()
41
+ self._bind_keyboard_shortcuts()
42
+ self._install_exception_handler()
43
+ self._load_plugins()
44
+ self._maybe_check_for_update()
45
+
46
+ # ------------------------------------------------------------------
47
+ def _build_ui(self):
48
+ self._build_header()
49
+ self._build_tabs()
50
+ self._build_statusbar()
51
+
52
+ for tab in (self.trim_tab, self.url_tab, self.batch_export_tab):
53
+ if hasattr(tab, "set_toast"):
54
+ tab.set_toast(self.status_bar)
55
+
56
+ # ------------------------------------------------------------------
57
+ def _build_header(self):
58
+ header = ctk.CTkFrame(
59
+ self, height=72, corner_radius=0, fg_color=T.BG_SURFACE,
60
+ )
61
+ header.pack(fill="x", side="top")
62
+ header.pack_propagate(False)
63
+
64
+ accent = ctk.CTkFrame(header, width=4, fg_color=T.ACCENT, corner_radius=0)
65
+ accent.pack(side="left", fill="y")
66
+
67
+ inner = ctk.CTkFrame(header, fg_color="transparent")
68
+ inner.pack(side="left", fill="both", expand=True, padx=18, pady=10)
69
+
70
+ title_row = ctk.CTkFrame(inner, fg_color="transparent")
71
+ title_row.pack(anchor="w")
72
+
73
+ logo = ctk.CTkLabel(
74
+ title_row, text="▶",
75
+ font=T.font(T.SIZE_HERO, "bold"),
76
+ text_color=T.ACCENT,
77
+ )
78
+ logo.pack(side="left", padx=(0, 8))
79
+
80
+ title = ctk.CTkLabel(
81
+ title_row, text=APP_NAME,
82
+ font=T.font(T.SIZE_HERO, "bold"),
83
+ text_color=T.TEXT,
84
+ )
85
+ title.pack(side="left")
86
+
87
+ version_chip = ctk.CTkLabel(
88
+ title_row, text=f" v{APP_VERSION} ",
89
+ font=T.font(T.SIZE_XS, "bold"),
90
+ text_color=T.TEXT_MUTED,
91
+ fg_color=T.BG_RAISED,
92
+ corner_radius=10,
93
+ padx=6,
94
+ )
95
+ version_chip.pack(side="left", padx=(10, 0), pady=(6, 0))
96
+
97
+ subtitle = ctk.CTkLabel(
98
+ inner,
99
+ text="Clip, trim, and export GIFs or MP4s — from any supported platform.",
100
+ font=T.font(T.SIZE_MD),
101
+ text_color=T.TEXT_MUTED,
102
+ )
103
+ subtitle.pack(anchor="w", pady=(2, 0))
104
+
105
+ # Setup button — prereqs checklist
106
+ self.setup_btn = ctk.CTkButton(
107
+ header, text="⚙ Setup",
108
+ fg_color=T.BG_RAISED, hover_color=T.BG_HOVER,
109
+ text_color=T.TEXT, font=T.font(T.SIZE_SM, "bold"),
110
+ corner_radius=14, width=82, height=28,
111
+ command=self._open_setup_dialog,
112
+ )
113
+ self.setup_btn.place(relx=1.0, rely=0, anchor="ne", x=-150, y=16)
114
+
115
+ # Keyboard shortcuts overlay — discoverable at a glance instead
116
+ # of buried in the status-bar hint that scrolls away after 4s.
117
+ self.shortcuts_btn = ctk.CTkButton(
118
+ header, text="⌨",
119
+ fg_color=T.BG_RAISED, hover_color=T.BG_HOVER,
120
+ text_color=T.TEXT, font=T.font(T.SIZE_LG, "bold"),
121
+ corner_radius=14, width=36, height=28,
122
+ command=self._open_shortcuts_dialog,
123
+ )
124
+ self.shortcuts_btn.place(relx=1.0, rely=0, anchor="ne", x=-104, y=16)
125
+
126
+ # Theme toggle (takes effect on restart)
127
+ current = T.current_mode()
128
+ next_mode = "light" if current == "dark" else "dark"
129
+ self.theme_btn = ctk.CTkButton(
130
+ header, text="☾" if current == "dark" else "☀",
131
+ fg_color=T.BG_RAISED, hover_color=T.BG_HOVER,
132
+ text_color=T.TEXT, font=T.font(T.SIZE_LG, "bold"),
133
+ corner_radius=14, width=36, height=28,
134
+ command=lambda: self._toggle_theme(next_mode),
135
+ )
136
+ self.theme_btn.place(relx=1.0, rely=0, anchor="ne", x=-56, y=16)
137
+
138
+ # Update-available chip (hidden until check_async fires)
139
+ self.update_chip = ctk.CTkButton(
140
+ header, text="", fg_color=T.SUCCESS, hover_color="#2EA043",
141
+ text_color=T.TEXT_ON_ACCENT,
142
+ font=T.font(T.SIZE_SM, "bold"),
143
+ corner_radius=12, height=28, width=0,
144
+ command=self._open_update_link,
145
+ )
146
+ # not packed until there is something to show
147
+
148
+ divider = ctk.CTkFrame(self, height=1, fg_color=T.BORDER, corner_radius=0)
149
+ divider.pack(fill="x", side="top")
150
+
151
+ # ------------------------------------------------------------------
152
+ def _build_tabs(self):
153
+ self.tabview = ctk.CTkTabview(
154
+ self,
155
+ corner_radius=T.RADIUS_LG,
156
+ fg_color=T.BG_SURFACE,
157
+ border_width=1,
158
+ border_color=T.BORDER,
159
+ segmented_button_fg_color=T.BG_RAISED,
160
+ segmented_button_selected_color=T.ACCENT,
161
+ segmented_button_selected_hover_color=T.ACCENT_HOVER,
162
+ segmented_button_unselected_color=T.BG_RAISED,
163
+ segmented_button_unselected_hover_color=T.BG_HOVER,
164
+ text_color=T.TEXT,
165
+ )
166
+ self.tabview.pack(fill="both", expand=True, padx=18, pady=(14, 0))
167
+
168
+ self.tabview._segmented_button.configure(
169
+ font=T.font(T.SIZE_LG, "bold"), height=36,
170
+ )
171
+
172
+ self.tabview.add(" ✂ Trim Video ")
173
+ self.tabview.add(" ↓ URL Download ")
174
+ self.tabview.add(" ⎆ Batch Export ")
175
+ self.tabview.add(" ⌛ History ")
176
+ self.tabview.add(" ⚙ Debug ")
177
+
178
+ from videokidnapper.ui.batch_export_tab import BatchExportTab
179
+ from videokidnapper.ui.debug_tab import DebugTab
180
+ from videokidnapper.ui.history_tab import HistoryTab
181
+ from videokidnapper.ui.trim_tab import TrimTab
182
+ from videokidnapper.ui.url_tab import UrlTab
183
+
184
+ self.debug_tab = DebugTab(self.tabview.tab(" ⚙ Debug "), self)
185
+ self.debug_tab.pack(fill="both", expand=True)
186
+
187
+ self.trim_tab = TrimTab(self.tabview.tab(" ✂ Trim Video "), self)
188
+ self.trim_tab.pack(fill="both", expand=True)
189
+
190
+ self.url_tab = UrlTab(self.tabview.tab(" ↓ URL Download "), self)
191
+ self.url_tab.pack(fill="both", expand=True)
192
+
193
+ self.batch_export_tab = BatchExportTab(
194
+ self.tabview.tab(" ⎆ Batch Export "), self,
195
+ )
196
+ self.batch_export_tab.pack(fill="both", expand=True)
197
+
198
+ self.history_tab = HistoryTab(self.tabview.tab(" ⌛ History "), self)
199
+ self.history_tab.pack(fill="both", expand=True)
200
+
201
+ # ------------------------------------------------------------------
202
+ def _build_statusbar(self):
203
+ divider = ctk.CTkFrame(self, height=1, fg_color=T.BORDER, corner_radius=0)
204
+ divider.pack(fill="x", side="bottom", pady=(10, 0))
205
+
206
+ self.status_bar = Toast(self)
207
+ self.status_bar.pack(fill="x", side="bottom")
208
+ self.status_bar.show(
209
+ "Ready · Space play · J/L step · I/O set in-out · "
210
+ "Ctrl+Z/Y undo/redo · Ctrl+E export · ? shortcuts",
211
+ "success",
212
+ )
213
+
214
+ # ------------------------------------------------------------------
215
+ # Keyboard shortcuts
216
+ # ------------------------------------------------------------------
217
+ def _active_tab(self):
218
+ name = self.tabview.get()
219
+ if "Trim" in name:
220
+ return self.trim_tab
221
+ if "URL" in name:
222
+ return self.url_tab
223
+ return None
224
+
225
+ def _bind_keyboard_shortcuts(self):
226
+ # Using bind_all so entries don't swallow them; the _editing_in_entry
227
+ # guard keeps typing in text fields from triggering scrubs.
228
+ self.bind_all("<space>", lambda e: self._shortcut(e, "keyboard_play_pause"))
229
+ self.bind_all("<Key-j>", lambda e: self._shortcut(e, "keyboard_nudge", -1.0))
230
+ self.bind_all("<Key-J>", lambda e: self._shortcut(e, "keyboard_nudge", -1.0))
231
+ self.bind_all("<Key-l>", lambda e: self._shortcut(e, "keyboard_nudge", 1.0))
232
+ self.bind_all("<Key-L>", lambda e: self._shortcut(e, "keyboard_nudge", 1.0))
233
+ self.bind_all("<Key-k>", lambda e: self._shortcut(e, "keyboard_play_pause"))
234
+ self.bind_all("<Key-K>", lambda e: self._shortcut(e, "keyboard_play_pause"))
235
+ self.bind_all("<Key-i>", lambda e: self._shortcut(e, "keyboard_mark_in"))
236
+ self.bind_all("<Key-I>", lambda e: self._shortcut(e, "keyboard_mark_in"))
237
+ self.bind_all("<Key-o>", lambda e: self._shortcut(e, "keyboard_mark_out"))
238
+ self.bind_all("<Key-O>", lambda e: self._shortcut(e, "keyboard_mark_out"))
239
+ self.bind_all("<Control-e>", lambda e: self._shortcut(e, "keyboard_export"))
240
+ self.bind_all("<Control-E>", lambda e: self._shortcut(e, "keyboard_export"))
241
+ self.bind_all("<Control-o>", lambda e: self._shortcut(e, "keyboard_open"))
242
+ self.bind_all("<Control-O>", lambda e: self._shortcut(e, "keyboard_open"))
243
+ # Ctrl+V on the URL tab pastes the clipboard into the URL entry.
244
+ # Entries keep native paste because _editing_in_entry short-circuits
245
+ # the dispatcher; this binding only fires when focus is elsewhere.
246
+ self.bind_all("<Control-v>", lambda e: self._shortcut(e, "keyboard_paste_url"))
247
+ self.bind_all("<Control-V>", lambda e: self._shortcut(e, "keyboard_paste_url"))
248
+ # Undo / redo. `<Control-Z>` fires on Ctrl+Shift+Z; pair with
249
+ # `<Control-y>` so users coming from any editor convention work.
250
+ self.bind_all("<Control-z>", lambda e: self._shortcut(e, "keyboard_undo"))
251
+ self.bind_all("<Control-Shift-Z>", lambda e: self._shortcut(e, "keyboard_redo"))
252
+ self.bind_all("<Control-y>", lambda e: self._shortcut(e, "keyboard_redo"))
253
+ self.bind_all("<Control-Y>", lambda e: self._shortcut(e, "keyboard_redo"))
254
+ # `?` opens the shortcuts overlay. Not routed through _shortcut()
255
+ # because the target is the app itself, not the active tab.
256
+ self.bind_all("<Key-question>", self._shortcut_shortcuts_overlay)
257
+
258
+ def _editing_in_entry(self, event):
259
+ widget = event.widget
260
+ if not widget:
261
+ return False
262
+ cls = widget.winfo_class()
263
+ return cls in ("Entry", "Text", "TEntry", "TCombobox")
264
+
265
+ def _shortcut(self, event, method, *args):
266
+ if self._editing_in_entry(event):
267
+ return
268
+ tab = self._active_tab()
269
+ if tab is None:
270
+ return
271
+ fn = getattr(tab, method, None)
272
+ if callable(fn):
273
+ fn(*args)
274
+
275
+ def _shortcut_shortcuts_overlay(self, event):
276
+ # `?` inside a text entry should still type a literal `?`.
277
+ if self._editing_in_entry(event):
278
+ return
279
+ self._open_shortcuts_dialog()
280
+
281
+ # ------------------------------------------------------------------
282
+ # Update check
283
+ # ------------------------------------------------------------------
284
+ def _maybe_check_for_update(self):
285
+ if not settings.get("auto_update_check", True):
286
+ return
287
+
288
+ def on_update(tag, link):
289
+ if self.winfo_exists():
290
+ self.after(0, self._show_update_chip, tag, link)
291
+
292
+ check_async(APP_VERSION, on_update)
293
+
294
+ def _show_update_chip(self, tag, link):
295
+ self._update_link = link
296
+ self.update_chip.configure(text=f" ↑ Update {tag} ", width=140)
297
+ self.update_chip.place(relx=1.0, rely=0, anchor="ne", x=-16, y=16)
298
+ if self.status_bar:
299
+ self.status_bar.show(f"Update available: {tag}", "success")
300
+
301
+ def _open_update_link(self):
302
+ link = getattr(self, "_update_link", None)
303
+ if link:
304
+ webbrowser.open(link)
305
+
306
+ # ------------------------------------------------------------------
307
+ # Plugin API
308
+ # ------------------------------------------------------------------
309
+ def register_tab(self, display_name, factory, glyph="◆"):
310
+ """Append a tab contributed by a plugin.
311
+
312
+ Parameters
313
+ ----------
314
+ display_name : str
315
+ Shown on the tab's segmented button. Kept short — CTkTabview
316
+ centers the label and long strings wrap awkwardly.
317
+ factory : callable
318
+ ``factory(parent_frame) -> widget``. The widget is packed
319
+ fill="both", expand=True inside the tab's frame.
320
+ glyph : str
321
+ Single-character icon prefix. Defaults to a diamond so
322
+ plugin tabs are visually distinct from built-in tabs.
323
+
324
+ Returns the widget produced by ``factory``, or ``None`` if the
325
+ factory raised (the failure is logged to the Debug tab).
326
+ """
327
+ label = f" {glyph} {display_name} "
328
+ try:
329
+ self.tabview.add(label)
330
+ parent = self.tabview.tab(label)
331
+ widget = factory(parent)
332
+ if widget is not None:
333
+ widget.pack(fill="both", expand=True)
334
+ return widget
335
+ except Exception as exc:
336
+ if hasattr(self, "debug_tab"):
337
+ try:
338
+ self.debug_tab.add_log(
339
+ f"Failed to register plugin tab {display_name!r}: {exc}",
340
+ "ERROR",
341
+ )
342
+ except Exception:
343
+ pass
344
+ return None
345
+
346
+ def _load_plugins(self):
347
+ """Discover entry-point plugins and fire their ``on_app_ready`` hook.
348
+
349
+ Runs after ``_build_ui`` + exception handler install so a
350
+ misbehaving plugin hits the global handler instead of the bare
351
+ Tk loop. Each plugin is called in its own try/except so one
352
+ bad actor doesn't prevent the rest from loading.
353
+ """
354
+ from videokidnapper.plugins import discover_plugins
355
+
356
+ discovered = discover_plugins(app_version=APP_VERSION)
357
+ self.plugins = discovered
358
+
359
+ loaded = 0
360
+ for entry in discovered:
361
+ if entry.error or entry.plugin is None:
362
+ if hasattr(self, "debug_tab"):
363
+ try:
364
+ self.debug_tab.add_log(
365
+ f"Skipped plugin {entry.name!r}: {entry.error}",
366
+ "WARN",
367
+ )
368
+ except Exception:
369
+ pass
370
+ continue
371
+ try:
372
+ entry.plugin.on_app_ready(self)
373
+ loaded += 1
374
+ if hasattr(self, "debug_tab"):
375
+ try:
376
+ self.debug_tab.add_log(
377
+ f"Loaded plugin {entry.name!r} "
378
+ f"({entry.plugin.name} v{entry.plugin.version})",
379
+ "INFO",
380
+ )
381
+ except Exception:
382
+ pass
383
+ except Exception as exc:
384
+ if hasattr(self, "debug_tab"):
385
+ try:
386
+ self.debug_tab.add_log(
387
+ f"Plugin {entry.name!r} on_app_ready failed: {exc}",
388
+ "ERROR",
389
+ )
390
+ except Exception:
391
+ pass
392
+ if loaded and self.status_bar:
393
+ self.status_bar.show(
394
+ f"Loaded {loaded} plugin{'s' if loaded != 1 else ''}",
395
+ "success",
396
+ )
397
+
398
+ # ------------------------------------------------------------------
399
+ # Setup dialog
400
+ # ------------------------------------------------------------------
401
+ def _open_setup_dialog(self):
402
+ from videokidnapper.ui.setup_dialog import SetupDialog
403
+ SetupDialog(self)
404
+
405
+ # ------------------------------------------------------------------
406
+ # Shortcuts overlay (? key / header chip)
407
+ # ------------------------------------------------------------------
408
+ def _open_shortcuts_dialog(self):
409
+ from videokidnapper.ui.shortcuts_dialog import ShortcutsDialog
410
+ ShortcutsDialog(self)
411
+
412
+ # ------------------------------------------------------------------
413
+ # Theme toggle
414
+ # ------------------------------------------------------------------
415
+ #
416
+ # A live theme swap would need us to walk every widget in the app
417
+ # and .configure() its colors — CustomTkinter bakes color tokens at
418
+ # widget-construction time. Hundreds of widgets, brittle, and the
419
+ # status-bar "restart to apply" toast was easy to miss and made the
420
+ # button feel broken (issue from user feedback: "This button does
421
+ # nothing"). Trade: confirm-to-restart, which makes the click
422
+ # visibly do *something* every time.
423
+ def _toggle_theme(self, mode):
424
+ T.set_mode(mode)
425
+ # Flip the button icon immediately so the user sees the click
426
+ # registered even if they pick "Later". Also flip the closure
427
+ # mode so a second click in the same session inverts again.
428
+ self._refresh_theme_btn(mode)
429
+ if self._confirm_theme_restart(mode):
430
+ self._restart_app()
431
+ return
432
+ if self.status_bar:
433
+ self.status_bar.show(
434
+ f"Theme set to {mode} — restart VideoKidnapper to apply.",
435
+ "info",
436
+ )
437
+
438
+ def _refresh_theme_btn(self, saved_mode):
439
+ """Rebind the theme button so its icon + command reflect ``saved_mode``.
440
+
441
+ ``saved_mode`` is the preference we just persisted. The button
442
+ should now display the OPPOSITE (what the next click would flip
443
+ to) so the icon matches the button's action, not the current
444
+ state. Matches the initial-construction convention.
445
+ """
446
+ next_mode = "light" if saved_mode == "dark" else "dark"
447
+ self.theme_btn.configure(
448
+ text="☾" if saved_mode == "dark" else "☀",
449
+ command=lambda: self._toggle_theme(next_mode),
450
+ )
451
+
452
+ def _confirm_theme_restart(self, mode):
453
+ """Modal yes/no for the restart prompt. Returns True if the user
454
+ chose Restart, False for Later. Falls through to False if a Tk
455
+ error swallows the dialog (extremely unlikely but would rather
456
+ skip the restart than crash)."""
457
+ dialog = ctk.CTkToplevel(self)
458
+ dialog.title("Restart to apply theme")
459
+ dialog.geometry("380x170")
460
+ dialog.resizable(False, False)
461
+ dialog.transient(self)
462
+ dialog.grab_set()
463
+ dialog.configure(fg_color=T.BG_BASE)
464
+
465
+ # Center on the main window.
466
+ dialog.update_idletasks()
467
+ x = self.winfo_x() + (self.winfo_width() - 380) // 2
468
+ y = self.winfo_y() + (self.winfo_height() - 170) // 2
469
+ dialog.geometry(f"+{x}+{y}")
470
+
471
+ card = ctk.CTkFrame(
472
+ dialog, fg_color=T.BG_SURFACE,
473
+ border_width=1, border_color=T.BORDER,
474
+ corner_radius=T.RADIUS_LG,
475
+ )
476
+ card.pack(fill="both", expand=True, padx=12, pady=12)
477
+
478
+ ctk.CTkLabel(
479
+ card, text=f"Theme set to {mode}.",
480
+ font=T.font(T.SIZE_LG, "bold"),
481
+ text_color=T.TEXT,
482
+ ).pack(pady=(18, 4))
483
+ ctk.CTkLabel(
484
+ card,
485
+ text="VideoKidnapper needs a restart to re-theme every panel.",
486
+ font=T.font(T.SIZE_SM), text_color=T.TEXT_MUTED,
487
+ ).pack(pady=(0, 14))
488
+
489
+ choice = {"restart": False}
490
+
491
+ btn_row = ctk.CTkFrame(card, fg_color="transparent")
492
+ btn_row.pack(pady=(0, 14))
493
+
494
+ def pick(restart):
495
+ choice["restart"] = restart
496
+ dialog.grab_release()
497
+ dialog.destroy()
498
+
499
+ ctk.CTkButton(
500
+ btn_row, text="Later",
501
+ fg_color=T.BG_RAISED, hover_color=T.BG_HOVER,
502
+ text_color=T.TEXT, font=T.font(T.SIZE_MD, "bold"),
503
+ corner_radius=T.RADIUS_SM, width=110, height=34,
504
+ command=lambda: pick(False),
505
+ ).pack(side="left", padx=(0, 6))
506
+ ctk.CTkButton(
507
+ btn_row, text="Restart now",
508
+ fg_color=T.ACCENT, hover_color=T.ACCENT_HOVER,
509
+ text_color=T.TEXT_ON_ACCENT, font=T.font(T.SIZE_MD, "bold"),
510
+ corner_radius=T.RADIUS_SM, width=130, height=34,
511
+ command=lambda: pick(True),
512
+ ).pack(side="left")
513
+
514
+ dialog.protocol("WM_DELETE_WINDOW", lambda: pick(False))
515
+ # Block until the user picks.
516
+ self.wait_window(dialog)
517
+ return choice["restart"]
518
+
519
+ def _restart_app(self):
520
+ """Relaunch the current process cleanly.
521
+
522
+ Works for three launch shapes: ``python main.py`` (dev),
523
+ ``python -m videokidnapper`` (module), and the PyInstaller
524
+ one-file ``.exe`` (frozen). The frozen case needs sys.argv[1:]
525
+ instead of sys.argv because argv[0] is the exe path itself and
526
+ prepending sys.executable would pass it twice.
527
+ """
528
+ import subprocess
529
+ if getattr(sys, "frozen", False):
530
+ args = [sys.executable, *sys.argv[1:]]
531
+ else:
532
+ args = [sys.executable, *sys.argv]
533
+ # close_fds keeps the spawned process clean of our Tk handles.
534
+ subprocess.Popen(args, close_fds=True)
535
+ # destroy() ends the Tk mainloop; any pending after() callbacks
536
+ # get dropped, which is what we want — we're replacing the
537
+ # process with a fresh one anyway.
538
+ self.destroy()
539
+
540
+ # ------------------------------------------------------------------
541
+ # Uncaught-exception routing — keep the app alive and surface errors.
542
+ # ------------------------------------------------------------------
543
+ def _install_exception_handler(self):
544
+ def report(exc_type, exc_value, tb):
545
+ text = "".join(traceback.format_exception(exc_type, exc_value, tb))
546
+ try:
547
+ self.debug_tab.add_log(f"Uncaught exception:\n{text}", "ERROR")
548
+ except Exception:
549
+ pass
550
+ if self.status_bar:
551
+ self.status_bar.show(
552
+ f"Error: {exc_type.__name__}: {exc_value} (see Debug tab)",
553
+ "error",
554
+ )
555
+
556
+ def tk_report(exc_type, exc_value, tb):
557
+ report(exc_type, exc_value, tb)
558
+
559
+ # `report_callback_exception` catches errors raised from Tk callbacks
560
+ # (button clicks, after() handlers, etc.) without killing the event loop.
561
+ self.report_callback_exception = tk_report # type: ignore[assignment]
562
+
563
+ _previous_hook = sys.excepthook
564
+
565
+ def excepthook(exc_type, exc_value, tb):
566
+ report(exc_type, exc_value, tb)
567
+ _previous_hook(exc_type, exc_value, tb)
568
+
569
+ sys.excepthook = excepthook
570
+
571
+ # ------------------------------------------------------------------
572
+ def _show_setup_landing(self):
573
+ """Shown when FFmpeg is missing — funnels the user into Setup."""
574
+ frame = ctk.CTkFrame(self, fg_color="transparent")
575
+ frame.place(relx=0.5, rely=0.5, anchor="center")
576
+
577
+ ctk.CTkLabel(
578
+ frame, text="⚠",
579
+ font=T.font(48, "bold"),
580
+ text_color=T.WARN,
581
+ ).pack(pady=(0, 8))
582
+
583
+ ctk.CTkLabel(
584
+ frame, text="Prerequisites Missing",
585
+ font=T.font(T.SIZE_HERO, "bold"),
586
+ text_color=T.TEXT,
587
+ ).pack(pady=(0, 6))
588
+
589
+ ctk.CTkLabel(
590
+ frame,
591
+ text=(
592
+ "VideoKidnapper can install what it needs for you.\n"
593
+ "Open Setup to pick features and install their requirements."
594
+ ),
595
+ font=T.font(T.SIZE_LG),
596
+ text_color=T.TEXT_MUTED,
597
+ justify="center",
598
+ ).pack(pady=(0, 20))
599
+
600
+ from videokidnapper.ui.theme import button
601
+ btn_row = ctk.CTkFrame(frame, fg_color="transparent")
602
+ btn_row.pack()
603
+
604
+ button(
605
+ btn_row, " Open Setup", variant="primary",
606
+ width=160, command=self._open_setup_dialog,
607
+ ).pack(side="left", padx=4)
608
+
609
+ button(
610
+ btn_row, "Exit", variant="secondary",
611
+ width=100, command=self.destroy,
612
+ ).pack(side="left", padx=4)