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.
- videokidnapper/__init__.py +11 -0
- videokidnapper/app.py +612 -0
- videokidnapper/cli.py +132 -0
- videokidnapper/config.py +116 -0
- videokidnapper/core/__init__.py +2 -0
- videokidnapper/core/downloader.py +331 -0
- videokidnapper/core/ffmpeg_backend.py +96 -0
- videokidnapper/core/playback.py +408 -0
- videokidnapper/core/preview.py +72 -0
- videokidnapper/core/screen_capture.py +75 -0
- videokidnapper/core/whisper_captions.py +182 -0
- videokidnapper/ui/__init__.py +2 -0
- videokidnapper/ui/batch_export_tab.py +697 -0
- videokidnapper/ui/batch_queue.py +214 -0
- videokidnapper/ui/color_picker.py +22 -0
- videokidnapper/ui/debug_tab.py +159 -0
- videokidnapper/ui/export_dialog.py +154 -0
- videokidnapper/ui/export_options.py +522 -0
- videokidnapper/ui/history_tab.py +162 -0
- videokidnapper/ui/image_layers.py +471 -0
- videokidnapper/ui/multi_range.py +155 -0
- videokidnapper/ui/platform_presets.py +51 -0
- videokidnapper/ui/setup_dialog.py +427 -0
- videokidnapper/ui/share_panel.py +110 -0
- videokidnapper/ui/shortcuts_dialog.py +171 -0
- videokidnapper/ui/text_layers.py +697 -0
- videokidnapper/ui/theme.py +205 -0
- videokidnapper/ui/thumbnail_strip.py +219 -0
- videokidnapper/ui/trim_tab.py +1081 -0
- videokidnapper/ui/url_tab.py +883 -0
- videokidnapper/ui/video_player.py +1097 -0
- videokidnapper/ui/waveform.py +132 -0
- videokidnapper/ui/widgets.py +298 -0
- videokidnapper/utils/__init__.py +2 -0
- videokidnapper/utils/batch.py +218 -0
- videokidnapper/utils/clipboard_image.py +109 -0
- videokidnapper/utils/dnd.py +75 -0
- videokidnapper/utils/ffmpeg_check.py +37 -0
- videokidnapper/utils/ffmpeg_escape.py +65 -0
- videokidnapper/utils/file_naming.py +41 -0
- videokidnapper/utils/github_update.py +67 -0
- videokidnapper/utils/prereq_check.py +266 -0
- videokidnapper/utils/settings.py +238 -0
- videokidnapper/utils/share.py +144 -0
- videokidnapper/utils/size_estimator.py +85 -0
- videokidnapper/utils/snap.py +135 -0
- videokidnapper/utils/srt_parser.py +83 -0
- videokidnapper/utils/time_format.py +42 -0
- videokidnapper/utils/undo.py +119 -0
- videokidnapper/utils/ytdlp_update.py +117 -0
- videokidnapper-1.3.0.dist-info/METADATA +547 -0
- videokidnapper-1.3.0.dist-info/RECORD +57 -0
- videokidnapper-1.3.0.dist-info/WHEEL +5 -0
- videokidnapper-1.3.0.dist-info/entry_points.txt +2 -0
- videokidnapper-1.3.0.dist-info/licenses/LICENSE +209 -0
- videokidnapper-1.3.0.dist-info/licenses/NOTICE.md +26 -0
- 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)
|