talks-reducer 0.7.2__py3-none-any.whl → 0.8.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.
@@ -0,0 +1,1385 @@
1
+ """Tkinter GUI application for the talks reducer pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import subprocess
8
+ import sys
9
+ import threading
10
+ import time
11
+ from pathlib import Path
12
+ from typing import (
13
+ TYPE_CHECKING,
14
+ Any,
15
+ Callable,
16
+ Iterable,
17
+ List,
18
+ Optional,
19
+ Sequence,
20
+ Tuple,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ import tkinter as tk
25
+ from tkinter import filedialog, messagebox, ttk
26
+
27
+ try:
28
+ from ..cli import gather_input_files
29
+ from ..ffmpeg import FFmpegNotFoundError
30
+ from ..models import ProcessingOptions
31
+ from ..pipeline import ProcessingAborted, speed_up_video
32
+ from ..progress import ProgressHandle
33
+ from ..version_utils import resolve_version
34
+ from . import discovery as discovery_helpers
35
+ from . import layout as layout_helpers
36
+ from .preferences import GUIPreferences, determine_config_path
37
+ from .progress import _TkProgressReporter
38
+ from .remote import (
39
+ check_remote_server_for_gui,
40
+ format_server_host,
41
+ normalize_server_url,
42
+ ping_server,
43
+ process_files_via_server,
44
+ )
45
+ from .theme import (
46
+ DARK_THEME,
47
+ LIGHT_THEME,
48
+ STATUS_COLORS,
49
+ apply_theme,
50
+ detect_system_theme,
51
+ read_windows_theme_registry,
52
+ run_defaults_command,
53
+ )
54
+ except ImportError: # pragma: no cover - handled at runtime
55
+ if __package__ not in (None, ""):
56
+ raise
57
+
58
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
59
+ if str(PACKAGE_ROOT) not in sys.path:
60
+ sys.path.insert(0, str(PACKAGE_ROOT))
61
+
62
+ from talks_reducer.cli import gather_input_files
63
+ from talks_reducer.ffmpeg import FFmpegNotFoundError
64
+ from talks_reducer.gui import discovery as discovery_helpers
65
+ from talks_reducer.gui import layout as layout_helpers
66
+ from talks_reducer.gui.preferences import GUIPreferences, determine_config_path
67
+ from talks_reducer.gui.progress import _TkProgressReporter
68
+ from talks_reducer.gui.remote import (
69
+ check_remote_server_for_gui,
70
+ format_server_host,
71
+ normalize_server_url,
72
+ ping_server,
73
+ process_files_via_server,
74
+ )
75
+ from talks_reducer.gui.theme import (
76
+ DARK_THEME,
77
+ LIGHT_THEME,
78
+ STATUS_COLORS,
79
+ apply_theme,
80
+ detect_system_theme,
81
+ read_windows_theme_registry,
82
+ run_defaults_command,
83
+ )
84
+ from talks_reducer.models import ProcessingOptions
85
+ from talks_reducer.pipeline import ProcessingAborted, speed_up_video
86
+ from talks_reducer.progress import ProgressHandle
87
+ from talks_reducer.version_utils import resolve_version
88
+
89
+ try:
90
+ from tkinterdnd2 import DND_FILES, TkinterDnD
91
+ except ModuleNotFoundError: # pragma: no cover - runtime dependency
92
+ DND_FILES = None # type: ignore[assignment]
93
+ TkinterDnD = None # type: ignore[assignment]
94
+
95
+
96
+ def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
97
+ """Return the default remote output path for *input_file*."""
98
+
99
+ name = input_file.name
100
+ dot_index = name.rfind(".")
101
+ suffix = "_speedup_small" if small else "_speedup"
102
+
103
+ if dot_index != -1:
104
+ new_name = name[:dot_index] + suffix + name[dot_index:]
105
+ else:
106
+ new_name = name + suffix
107
+
108
+ return input_file.with_name(new_name)
109
+
110
+
111
+ def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
112
+ """Extract time and size ratios from a Markdown *summary* string."""
113
+
114
+ time_ratio: Optional[float] = None
115
+ size_ratio: Optional[float] = None
116
+
117
+ for line in summary.splitlines():
118
+ if "**Duration:**" in line:
119
+ match = re.search(r"—\s*([0-9]+(?:\.[0-9]+)?)% of the original", line)
120
+ if match:
121
+ try:
122
+ time_ratio = float(match.group(1)) / 100
123
+ except ValueError:
124
+ time_ratio = None
125
+ elif "**Size:**" in line:
126
+ match = re.search(r"\*\*Size:\*\*\s*([0-9]+(?:\.[0-9]+)?)%", line)
127
+ if match:
128
+ try:
129
+ size_ratio = float(match.group(1)) / 100
130
+ except ValueError:
131
+ size_ratio = None
132
+
133
+ return time_ratio, size_ratio
134
+
135
+
136
+ def _parse_source_duration_seconds(message: str) -> tuple[bool, Optional[float]]:
137
+ """Return whether *message* includes source duration metadata."""
138
+
139
+ metadata_match = re.search(
140
+ r"source metadata: duration:\s*([\d.]+)s",
141
+ message,
142
+ re.IGNORECASE,
143
+ )
144
+ if not metadata_match:
145
+ return False, None
146
+
147
+ try:
148
+ return True, float(metadata_match.group(1))
149
+ except ValueError:
150
+ return True, None
151
+
152
+
153
+ def _parse_encode_total_frames(message: str) -> tuple[bool, Optional[int]]:
154
+ """Extract final encode frame totals from *message* when present."""
155
+
156
+ frame_total_match = re.search(
157
+ r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
158
+ )
159
+ if not frame_total_match:
160
+ return False, None
161
+
162
+ try:
163
+ return True, int(frame_total_match.group(1))
164
+ except ValueError:
165
+ return True, None
166
+
167
+
168
+ def _is_encode_total_frames_unknown(normalized_message: str) -> bool:
169
+ """Return ``True`` if *normalized_message* marks encode frame totals unknown."""
170
+
171
+ return (
172
+ "final encode target frames" in normalized_message
173
+ and "unknown" in normalized_message
174
+ )
175
+
176
+
177
+ def _parse_current_frame(message: str) -> tuple[bool, Optional[int]]:
178
+ """Extract the current encode frame from *message* when available."""
179
+
180
+ frame_match = re.search(r"frame=\s*(\d+)", message)
181
+ if not frame_match:
182
+ return False, None
183
+
184
+ try:
185
+ return True, int(frame_match.group(1))
186
+ except ValueError:
187
+ return True, None
188
+
189
+
190
+ def _parse_encode_target_duration(message: str) -> tuple[bool, Optional[float]]:
191
+ """Extract encode target duration from *message* if reported."""
192
+
193
+ encode_duration_match = re.search(
194
+ r"Final encode target duration(?: \(fallback\))?:\s*([\d.]+)s",
195
+ message,
196
+ )
197
+ if not encode_duration_match:
198
+ return False, None
199
+
200
+ try:
201
+ return True, float(encode_duration_match.group(1))
202
+ except ValueError:
203
+ return True, None
204
+
205
+
206
+ def _is_encode_target_duration_unknown(normalized_message: str) -> bool:
207
+ """Return ``True`` if encode target duration is reported as unknown."""
208
+
209
+ return (
210
+ "final encode target duration" in normalized_message
211
+ and "unknown" in normalized_message
212
+ )
213
+
214
+
215
+ def _parse_video_duration_seconds(message: str) -> tuple[bool, Optional[float]]:
216
+ """Parse the input video duration from *message* when FFmpeg prints it."""
217
+
218
+ duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
219
+ if not duration_match:
220
+ return False, None
221
+
222
+ try:
223
+ hours = int(duration_match.group(1))
224
+ minutes = int(duration_match.group(2))
225
+ seconds = float(duration_match.group(3))
226
+ except ValueError:
227
+ return True, None
228
+
229
+ total_seconds = hours * 3600 + minutes * 60 + seconds
230
+ return True, total_seconds
231
+
232
+
233
+ def _parse_ffmpeg_progress(message: str) -> tuple[bool, Optional[tuple[int, str]]]:
234
+ """Parse FFmpeg progress information from *message* if available."""
235
+
236
+ time_match = re.search(r"time=(\d{2}):(\d{2}):(\d{2})\.\d+", message)
237
+ speed_match = re.search(r"speed=\s*([\d.]+)x", message)
238
+
239
+ if not (time_match and speed_match):
240
+ return False, None
241
+
242
+ try:
243
+ hours = int(time_match.group(1))
244
+ minutes = int(time_match.group(2))
245
+ seconds = int(time_match.group(3))
246
+ except ValueError:
247
+ return True, None
248
+
249
+ current_seconds = hours * 3600 + minutes * 60 + seconds
250
+ speed_str = speed_match.group(1)
251
+ return True, (current_seconds, speed_str)
252
+
253
+
254
+ class TalksReducerGUI:
255
+ """Tkinter application mirroring the CLI options with form controls."""
256
+
257
+ PADDING = 10
258
+ AUDIO_PROCESSING_RATIO = 0.02
259
+ AUDIO_PROGRESS_STEPS = 20
260
+ MIN_AUDIO_INTERVAL_MS = 10
261
+ DEFAULT_AUDIO_INTERVAL_MS = 200
262
+
263
+ def __init__(
264
+ self,
265
+ initial_inputs: Optional[Sequence[str]] = None,
266
+ *,
267
+ auto_run: bool = False,
268
+ ) -> None:
269
+ self._config_path = determine_config_path()
270
+ self.preferences = GUIPreferences(self._config_path)
271
+
272
+ # Import tkinter here to avoid loading it at module import time
273
+ import tkinter as tk
274
+ from tkinter import filedialog, messagebox, ttk
275
+
276
+ # Store references for use in methods
277
+ self.tk = tk
278
+ self.filedialog = filedialog
279
+ self.messagebox = messagebox
280
+ self.ttk = ttk
281
+
282
+ if TkinterDnD is not None:
283
+ self.root = TkinterDnD.Tk() # type: ignore[call-arg]
284
+ else:
285
+ self.root = tk.Tk()
286
+
287
+ # Set window title with version information
288
+ app_version = resolve_version()
289
+ if app_version and app_version != "unknown":
290
+ self.root.title(f"Talks Reducer v{app_version}")
291
+ else:
292
+ self.root.title("Talks Reducer")
293
+
294
+ self._apply_window_icon()
295
+
296
+ self._full_size = (1000, 800)
297
+ self._simple_size = (300, 270)
298
+ # self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
299
+ self.style = self.ttk.Style(self.root)
300
+
301
+ self._processing_thread: Optional[threading.Thread] = None
302
+ self._last_output: Optional[Path] = None
303
+ self._last_time_ratio: Optional[float] = None
304
+ self._last_size_ratio: Optional[float] = None
305
+ self._last_progress_seconds: Optional[int] = None
306
+ self._run_start_time: Optional[float] = None
307
+ self._status_state = "Idle"
308
+ self.status_var = tk.StringVar(value=self._status_state)
309
+ self._status_animation_job: Optional[str] = None
310
+ self._status_animation_phase = 0
311
+ self._video_duration_seconds: Optional[float] = None
312
+ self._encode_target_duration_seconds: Optional[float] = None
313
+ self._encode_total_frames: Optional[int] = None
314
+ self._encode_current_frame: Optional[int] = None
315
+ self._source_duration_seconds: Optional[float] = None
316
+ self._audio_progress_job: Optional[str] = None
317
+ self._audio_progress_interval_ms: Optional[int] = None
318
+ self._audio_progress_steps_completed = 0
319
+ self.progress_var = tk.IntVar(value=0)
320
+ self._ffmpeg_process: Optional[subprocess.Popen] = None
321
+ self._stop_requested = False
322
+ self._ping_worker_stop_requested = False
323
+ self._current_remote_mode = False
324
+
325
+ self.input_files: List[str] = []
326
+
327
+ self._dnd_available = TkinterDnD is not None and DND_FILES is not None
328
+
329
+ self.simple_mode_var = tk.BooleanVar(
330
+ value=self.preferences.get("simple_mode", True)
331
+ )
332
+ self.run_after_drop_var = tk.BooleanVar(value=True)
333
+ self.small_var = tk.BooleanVar(value=self.preferences.get("small_video", True))
334
+ self.open_after_convert_var = tk.BooleanVar(
335
+ value=self.preferences.get("open_after_convert", True)
336
+ )
337
+ stored_mode = str(self.preferences.get("processing_mode", "local"))
338
+ if stored_mode not in {"local", "remote"}:
339
+ stored_mode = "local"
340
+ self.processing_mode_var = tk.StringVar(value=stored_mode)
341
+ self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
342
+ self.theme_var = tk.StringVar(value=self.preferences.get("theme", "os"))
343
+ self.theme_var.trace_add("write", self._on_theme_change)
344
+ self.small_var.trace_add("write", self._on_small_video_change)
345
+ self.open_after_convert_var.trace_add(
346
+ "write", self._on_open_after_convert_change
347
+ )
348
+ self.server_url_var = tk.StringVar(
349
+ value=str(self.preferences.get("server_url", ""))
350
+ )
351
+ self.server_url_var.trace_add("write", self._on_server_url_change)
352
+ self._discovery_thread: Optional[threading.Thread] = None
353
+
354
+ self._basic_defaults: dict[str, float] = {}
355
+ self._basic_variables: dict[str, tk.DoubleVar] = {}
356
+ self._slider_updaters: dict[str, Callable[[str], None]] = {}
357
+ self._sliders: list[tk.Scale] = []
358
+
359
+ self._build_layout()
360
+ self._apply_simple_mode(initial=True)
361
+ self._apply_status_style(self._status_state)
362
+ self._refresh_theme()
363
+ self.preferences.save()
364
+ self._hide_stop_button()
365
+
366
+ # Ping server on startup if in remote mode
367
+ if (
368
+ self.processing_mode_var.get() == "remote"
369
+ and self.server_url_var.get().strip()
370
+ ):
371
+ server_url = self.server_url_var.get().strip()
372
+
373
+ def ping_worker() -> None:
374
+ try:
375
+ self._check_remote_server(
376
+ server_url,
377
+ success_status="Idle",
378
+ waiting_status="Error",
379
+ failure_status="Error",
380
+ stop_check=lambda: self._ping_worker_stop_requested,
381
+ switch_to_local_on_failure=True,
382
+ )
383
+ except Exception as exc: # pragma: no cover - defensive safeguard
384
+ host_label = self._format_server_host(server_url)
385
+ message = f"Error pinging server {host_label}: {exc}"
386
+ self._schedule_on_ui_thread(
387
+ lambda msg=message: self._append_log(msg)
388
+ )
389
+ self._schedule_on_ui_thread(
390
+ lambda msg=message: self._set_status("Idle", msg)
391
+ )
392
+
393
+ threading.Thread(target=ping_worker, daemon=True).start()
394
+
395
+ if not self._dnd_available:
396
+ self._append_log(
397
+ "Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
398
+ )
399
+
400
+ if initial_inputs:
401
+ self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
402
+
403
+ def _start_run(self) -> None:
404
+ if self._processing_thread and self._processing_thread.is_alive():
405
+ self.messagebox.showinfo("Processing", "A job is already running.")
406
+ return
407
+
408
+ if not self.input_files:
409
+ self.messagebox.showwarning(
410
+ "Missing input", "Please add at least one file or folder."
411
+ )
412
+ return
413
+
414
+ try:
415
+ args = self._collect_arguments()
416
+ except ValueError as exc:
417
+ self.messagebox.showerror("Invalid value", str(exc))
418
+ return
419
+
420
+ self._append_log("Starting processing…")
421
+ self._stop_requested = False
422
+ self.stop_button.configure(text="Stop")
423
+ self._run_start_time = time.monotonic()
424
+ self._ping_worker_stop_requested = True
425
+ open_after_convert = bool(self.open_after_convert_var.get())
426
+ server_url = self.server_url_var.get().strip()
427
+ remote_mode = self.processing_mode_var.get() == "remote"
428
+ if remote_mode and not server_url:
429
+ self.messagebox.showerror(
430
+ "Missing server URL", "Remote mode requires a server URL."
431
+ )
432
+ remote_mode = remote_mode and bool(server_url)
433
+
434
+ # Store remote_mode for use after thread starts
435
+ self._current_remote_mode = remote_mode
436
+
437
+ def worker() -> None:
438
+ def set_process(proc: subprocess.Popen) -> None:
439
+ self._ffmpeg_process = proc
440
+
441
+ try:
442
+ files = gather_input_files(self.input_files)
443
+ if not files:
444
+ self._schedule_on_ui_thread(
445
+ lambda: self.messagebox.showwarning(
446
+ "No files", "No supported media files were found."
447
+ )
448
+ )
449
+ self._set_status("Idle")
450
+ return
451
+
452
+ if self._current_remote_mode:
453
+ success = self._process_files_via_server(
454
+ files,
455
+ args,
456
+ server_url,
457
+ open_after_convert=open_after_convert,
458
+ )
459
+ if success:
460
+ self._schedule_on_ui_thread(self._hide_stop_button)
461
+ return
462
+ # If server processing failed, fall back to local processing
463
+ # The _process_files_via_server function already switched to local mode
464
+ # Update remote_mode variable to reflect the change
465
+ self._current_remote_mode = False
466
+
467
+ reporter = _TkProgressReporter(
468
+ self._append_log,
469
+ process_callback=set_process,
470
+ stop_callback=lambda: self._stop_requested,
471
+ )
472
+ for index, file in enumerate(files, start=1):
473
+ self._append_log(f"Processing: {os.path.basename(file)}")
474
+ options = self._create_processing_options(Path(file), args)
475
+ result = speed_up_video(options, reporter=reporter)
476
+ self._last_output = result.output_file
477
+ self._last_time_ratio = result.time_ratio
478
+ self._last_size_ratio = result.size_ratio
479
+
480
+ # Create completion message with ratios if available
481
+ completion_msg = f"Completed: {result.output_file}"
482
+ if result.time_ratio is not None and result.size_ratio is not None:
483
+ completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
484
+
485
+ self._append_log(completion_msg)
486
+ if open_after_convert:
487
+ self._schedule_on_ui_thread(
488
+ lambda path=result.output_file: self._open_in_file_manager(
489
+ path
490
+ )
491
+ )
492
+
493
+ self._append_log("All jobs finished successfully.")
494
+ self._schedule_on_ui_thread(
495
+ lambda: self.open_button.configure(state=self.tk.NORMAL)
496
+ )
497
+ self._schedule_on_ui_thread(self._clear_input_files)
498
+ except FFmpegNotFoundError as exc:
499
+ self._schedule_on_ui_thread(
500
+ lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
501
+ )
502
+ self._set_status("Error")
503
+ except ProcessingAborted:
504
+ self._append_log("Processing aborted by user.")
505
+ self._set_status("Aborted")
506
+ except Exception as exc: # pragma: no cover - GUI level safeguard
507
+ # If stop was requested, don't show error (FFmpeg termination is expected)
508
+ if self._stop_requested:
509
+ self._append_log("Processing aborted by user.")
510
+ self._set_status("Aborted")
511
+ else:
512
+ error_msg = f"Processing failed: {exc}"
513
+ self._append_log(error_msg)
514
+ print(error_msg, file=sys.stderr) # Also output to console
515
+ self._schedule_on_ui_thread(
516
+ lambda: self.messagebox.showerror("Error", error_msg)
517
+ )
518
+ self._set_status("Error")
519
+ finally:
520
+ self._run_start_time = None
521
+ self._schedule_on_ui_thread(self._hide_stop_button)
522
+
523
+ self._processing_thread = threading.Thread(target=worker, daemon=True)
524
+ self._processing_thread.start()
525
+
526
+ # Show Stop button when processing starts regardless of mode
527
+ self.stop_button.grid()
528
+
529
+ # ------------------------------------------------------------------ UI --
530
+ def _apply_window_icon(self) -> None:
531
+ layout_helpers.apply_window_icon(self)
532
+
533
+ def _build_layout(self) -> None:
534
+ layout_helpers.build_layout(self)
535
+
536
+ def _update_basic_reset_state(self) -> None:
537
+ layout_helpers.update_basic_reset_state(self)
538
+
539
+ def _reset_basic_defaults(self) -> None:
540
+ layout_helpers.reset_basic_defaults(self)
541
+
542
+ def _update_processing_mode_state(self) -> None:
543
+ has_url = bool(self.server_url_var.get().strip())
544
+ if not has_url and self.processing_mode_var.get() == "remote":
545
+ self.processing_mode_var.set("local")
546
+ return
547
+
548
+ if hasattr(self, "remote_mode_button"):
549
+ state = self.tk.NORMAL if has_url else self.tk.DISABLED
550
+ self.remote_mode_button.configure(state=state)
551
+
552
+ def _normalize_server_url(self, server_url: str) -> str:
553
+ return normalize_server_url(server_url)
554
+
555
+ def _format_server_host(self, server_url: str) -> str:
556
+ return format_server_host(server_url)
557
+
558
+ def _check_remote_server(
559
+ self,
560
+ server_url: str,
561
+ *,
562
+ success_status: str,
563
+ waiting_status: str,
564
+ failure_status: str,
565
+ success_message: Optional[str] = None,
566
+ waiting_message_template: str = "Waiting server {host} (attempt {attempt}/{max_attempts})",
567
+ failure_message: Optional[str] = None,
568
+ stop_check: Optional[Callable[[], bool]] = None,
569
+ on_stop: Optional[Callable[[], None]] = None,
570
+ switch_to_local_on_failure: bool = False,
571
+ alert_on_failure: bool = False,
572
+ warning_title: str = "Server unavailable",
573
+ warning_message: Optional[str] = None,
574
+ max_attempts: int = 5,
575
+ delay: float = 1.0,
576
+ ) -> bool:
577
+ return check_remote_server_for_gui(
578
+ self,
579
+ server_url,
580
+ success_status=success_status,
581
+ waiting_status=waiting_status,
582
+ failure_status=failure_status,
583
+ success_message=success_message,
584
+ waiting_message_template=waiting_message_template,
585
+ failure_message=failure_message,
586
+ stop_check=stop_check,
587
+ on_stop=on_stop,
588
+ switch_to_local_on_failure=switch_to_local_on_failure,
589
+ alert_on_failure=alert_on_failure,
590
+ warning_title=warning_title,
591
+ warning_message=warning_message,
592
+ max_attempts=max_attempts,
593
+ delay=delay,
594
+ )
595
+
596
+ def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
597
+ return ping_server(server_url, timeout=timeout)
598
+
599
+ def _start_discovery(self) -> None:
600
+ discovery_helpers.start_discovery(self)
601
+
602
+ def _on_discovery_failed(self, exc: Exception) -> None:
603
+ discovery_helpers.on_discovery_failed(self, exc)
604
+
605
+ def _on_discovery_progress(self, current: int, total: int) -> None:
606
+ discovery_helpers.on_discovery_progress(self, current, total)
607
+
608
+ def _on_discovery_complete(self, urls: List[str]) -> None:
609
+ discovery_helpers.on_discovery_complete(self, urls)
610
+
611
+ def _show_discovery_results(self, urls: List[str]) -> None:
612
+ discovery_helpers.show_discovery_results(self, urls)
613
+
614
+ def _toggle_simple_mode(self) -> None:
615
+ self.preferences.update("simple_mode", self.simple_mode_var.get())
616
+ self._apply_simple_mode()
617
+
618
+ def _apply_simple_mode(self, *, initial: bool = False) -> None:
619
+ layout_helpers.apply_simple_mode(self, initial=initial)
620
+
621
+ def _apply_window_size(self, *, simple: bool) -> None:
622
+ layout_helpers.apply_window_size(self, simple=simple)
623
+
624
+ def _toggle_advanced(self, *, initial: bool = False) -> None:
625
+ if not initial:
626
+ self.advanced_visible.set(not self.advanced_visible.get())
627
+ visible = self.advanced_visible.get()
628
+ if visible:
629
+ self.advanced_frame.grid()
630
+ self.advanced_button.configure(text="Hide advanced")
631
+ else:
632
+ self.advanced_frame.grid_remove()
633
+ self.advanced_button.configure(text="Advanced")
634
+
635
+ def _on_theme_change(self, *_: object) -> None:
636
+ self.preferences.update("theme", self.theme_var.get())
637
+ self._refresh_theme()
638
+
639
+ def _on_small_video_change(self, *_: object) -> None:
640
+ self.preferences.update("small_video", bool(self.small_var.get()))
641
+
642
+ def _on_open_after_convert_change(self, *_: object) -> None:
643
+ self.preferences.update(
644
+ "open_after_convert", bool(self.open_after_convert_var.get())
645
+ )
646
+
647
+ def _on_processing_mode_change(self, *_: object) -> None:
648
+ value = self.processing_mode_var.get()
649
+ if value not in {"local", "remote"}:
650
+ self.processing_mode_var.set("local")
651
+ return
652
+ self.preferences.update("processing_mode", value)
653
+ self._update_processing_mode_state()
654
+
655
+ if self.processing_mode_var.get() == "remote":
656
+ server_url = self.server_url_var.get().strip()
657
+ if not server_url:
658
+ return
659
+
660
+ def ping_remote_mode() -> None:
661
+ self._check_remote_server(
662
+ server_url,
663
+ success_status="Idle",
664
+ waiting_status="Error",
665
+ failure_status="Error",
666
+ failure_message="Server {host} is unreachable. Switching to local mode.",
667
+ switch_to_local_on_failure=True,
668
+ alert_on_failure=True,
669
+ warning_message="Server {host} is unreachable. Switching to local mode.",
670
+ )
671
+
672
+ threading.Thread(target=ping_remote_mode, daemon=True).start()
673
+
674
+ def _on_server_url_change(self, *_: object) -> None:
675
+ value = self.server_url_var.get().strip()
676
+ self.preferences.update("server_url", value)
677
+ self._update_processing_mode_state()
678
+
679
+ def _resolve_theme_mode(self) -> str:
680
+ preference = self.theme_var.get().lower()
681
+ if preference not in {"light", "dark"}:
682
+ return detect_system_theme(
683
+ os.environ,
684
+ sys.platform,
685
+ read_windows_theme_registry,
686
+ run_defaults_command,
687
+ )
688
+ return preference
689
+
690
+ def _refresh_theme(self) -> None:
691
+ mode = self._resolve_theme_mode()
692
+ palette = LIGHT_THEME if mode == "light" else DARK_THEME
693
+ apply_theme(
694
+ self.style,
695
+ palette,
696
+ {
697
+ "root": self.root,
698
+ "drop_zone": getattr(self, "drop_zone", None),
699
+ "log_text": getattr(self, "log_text", None),
700
+ "status_label": getattr(self, "status_label", None),
701
+ "sliders": getattr(self, "_sliders", []),
702
+ "tk": self.tk,
703
+ "apply_status_style": self._apply_status_style,
704
+ "status_state": self._status_state,
705
+ },
706
+ )
707
+
708
+ def _configure_drop_targets(self, widget) -> None:
709
+ if not self._dnd_available:
710
+ return
711
+ widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
712
+ widget.dnd_bind("<<Drop>>", self._on_drop) # type: ignore[attr-defined]
713
+
714
+ def _populate_initial_inputs(
715
+ self, inputs: Sequence[str], *, auto_run: bool = False
716
+ ) -> None:
717
+ """Seed the GUI with preselected inputs and optionally start processing."""
718
+
719
+ normalized: list[str] = []
720
+ for path in inputs:
721
+ if not path:
722
+ continue
723
+ resolved = os.fspath(Path(path))
724
+ if resolved not in self.input_files:
725
+ self.input_files.append(resolved)
726
+ normalized.append(resolved)
727
+
728
+ if auto_run and normalized:
729
+ # Kick off processing once the event loop becomes idle so the
730
+ # interface has a chance to render before the work starts.
731
+ self.root.after_idle(self._start_run)
732
+
733
+ # -------------------------------------------------------------- actions --
734
+ def _ask_for_input_files(self) -> tuple[str, ...]:
735
+ """Prompt the user to select input files for processing."""
736
+
737
+ return self.filedialog.askopenfilenames(
738
+ title="Select input files",
739
+ filetypes=[
740
+ ("Video files", "*.mp4 *.mkv *.mov *.avi *.m4v"),
741
+ ("All", "*.*"),
742
+ ],
743
+ )
744
+
745
+ def _add_files(self) -> None:
746
+ files = self._ask_for_input_files()
747
+ self._extend_inputs(files)
748
+
749
+ def _add_directory(self) -> None:
750
+ directory = self.filedialog.askdirectory(title="Select input folder")
751
+ if directory:
752
+ self._extend_inputs([directory])
753
+
754
+ def _extend_inputs(self, paths: Iterable[str], *, auto_run: bool = False) -> None:
755
+ added = False
756
+ for path in paths:
757
+ if path and path not in self.input_files:
758
+ self.input_files.append(path)
759
+ added = True
760
+ if auto_run and added and self.run_after_drop_var.get():
761
+ self._start_run()
762
+
763
+ def _clear_input_files(self) -> None:
764
+ """Clear all queued input files."""
765
+ self.input_files.clear()
766
+
767
+ def _on_drop(self, event: object) -> None:
768
+ data = getattr(event, "data", "")
769
+ if not data:
770
+ return
771
+ paths = self.root.tk.splitlist(data)
772
+ cleaned = [path.strip("{}") for path in paths]
773
+ # Clear existing files before adding dropped files
774
+ self.input_files.clear()
775
+ self._extend_inputs(cleaned, auto_run=True)
776
+
777
+ def _on_drop_zone_click(self, event: object) -> str | None:
778
+ """Open a file selection dialog when the drop zone is activated."""
779
+
780
+ files = self._ask_for_input_files()
781
+ if not files:
782
+ return "break"
783
+ self._clear_input_files()
784
+ self._extend_inputs(files, auto_run=True)
785
+ return "break"
786
+
787
+ def _browse_path(
788
+ self, variable, label: str
789
+ ) -> None: # type: (tk.StringVar, str) -> None
790
+ if "folder" in label.lower():
791
+ result = self.filedialog.askdirectory()
792
+ else:
793
+ initial = variable.get() or os.getcwd()
794
+ result = self.filedialog.asksaveasfilename(
795
+ initialfile=os.path.basename(initial)
796
+ )
797
+ if result:
798
+ variable.set(result)
799
+
800
+ def _stop_processing(self) -> None:
801
+ """Stop the currently running processing by terminating FFmpeg."""
802
+ import signal
803
+
804
+ self._stop_requested = True
805
+ # Update button text to indicate stopping state
806
+ self.stop_button.configure(text="Stopping...")
807
+ if self._current_remote_mode:
808
+ self._append_log("Cancelling remote job...")
809
+ elif self._ffmpeg_process and self._ffmpeg_process.poll() is None:
810
+ self._append_log("Stopping FFmpeg process...")
811
+ try:
812
+ # Send SIGTERM to FFmpeg process
813
+ if sys.platform == "win32":
814
+ # Windows doesn't have SIGTERM, use terminate()
815
+ self._ffmpeg_process.terminate()
816
+ else:
817
+ # Unix-like systems can use SIGTERM
818
+ self._ffmpeg_process.send_signal(signal.SIGTERM)
819
+
820
+ self._append_log("FFmpeg process stopped.")
821
+ except Exception as e:
822
+ self._append_log(f"Error stopping process: {e}")
823
+ else:
824
+ self._append_log("No active FFmpeg process to stop.")
825
+
826
+ self._hide_stop_button()
827
+
828
+ def _hide_stop_button(self) -> None:
829
+ """Hide Stop button."""
830
+ self.stop_button.grid_remove()
831
+ # Show drop hint when stop button is hidden and no other buttons are visible
832
+ if (
833
+ not self.open_button.winfo_viewable()
834
+ and hasattr(self, "drop_hint_button")
835
+ and not self.drop_hint_button.winfo_viewable()
836
+ ):
837
+ self.drop_hint_button.grid()
838
+
839
+ def _collect_arguments(self) -> dict[str, object]:
840
+ args: dict[str, object] = {}
841
+
842
+ if self.output_var.get():
843
+ args["output_file"] = Path(self.output_var.get())
844
+ if self.temp_var.get():
845
+ args["temp_folder"] = Path(self.temp_var.get())
846
+ silent_threshold = float(self.silent_threshold_var.get())
847
+ args["silent_threshold"] = round(silent_threshold, 2)
848
+
849
+ sounded_speed = float(self.sounded_speed_var.get())
850
+ args["sounded_speed"] = round(sounded_speed, 2)
851
+
852
+ silent_speed = float(self.silent_speed_var.get())
853
+ args["silent_speed"] = round(silent_speed, 2)
854
+ if self.frame_margin_var.get():
855
+ args["frame_spreadage"] = int(
856
+ round(self._parse_float(self.frame_margin_var.get(), "Frame margin"))
857
+ )
858
+ if self.sample_rate_var.get():
859
+ args["sample_rate"] = int(
860
+ round(self._parse_float(self.sample_rate_var.get(), "Sample rate"))
861
+ )
862
+ if self.small_var.get():
863
+ args["small"] = True
864
+ return args
865
+
866
+ def _process_files_via_server(
867
+ self,
868
+ files: List[str],
869
+ args: dict[str, object],
870
+ server_url: str,
871
+ *,
872
+ open_after_convert: bool,
873
+ ) -> bool:
874
+ """Send *files* to the configured server for processing."""
875
+
876
+ return process_files_via_server(
877
+ self,
878
+ files,
879
+ args,
880
+ server_url,
881
+ open_after_convert=open_after_convert,
882
+ default_remote_destination=_default_remote_destination,
883
+ parse_summary=_parse_ratios_from_summary,
884
+ )
885
+
886
+ def _parse_float(self, value: str, label: str) -> float:
887
+ try:
888
+ return float(value)
889
+ except ValueError as exc: # pragma: no cover - input validation
890
+ raise ValueError(f"{label} must be a number.") from exc
891
+
892
+ def _create_processing_options(
893
+ self, input_file: Path, args: dict[str, object]
894
+ ) -> ProcessingOptions:
895
+ options = dict(args)
896
+ options["input_file"] = input_file
897
+
898
+ if "temp_folder" in options:
899
+ options["temp_folder"] = Path(options["temp_folder"])
900
+
901
+ return ProcessingOptions(**options)
902
+
903
+ def _open_last_output(self) -> None:
904
+ if self._last_output is not None:
905
+ self._open_in_file_manager(self._last_output)
906
+
907
+ def _open_in_file_manager(self, path: Path) -> None:
908
+ target = Path(path)
909
+ if sys.platform.startswith("win"):
910
+ command = ["explorer", f"/select,{target}"]
911
+ elif sys.platform == "darwin":
912
+ command = ["open", "-R", os.fspath(target)]
913
+ else:
914
+ command = [
915
+ "xdg-open",
916
+ os.fspath(target.parent if target.exists() else target),
917
+ ]
918
+ try:
919
+ subprocess.Popen(command)
920
+ except OSError:
921
+ self._append_log(f"Could not open file manager for {target}")
922
+
923
+ def _append_log(self, message: str) -> None:
924
+ self._update_status_from_message(message)
925
+
926
+ def updater() -> None:
927
+ self.log_text.configure(state=self.tk.NORMAL)
928
+ self.log_text.insert(self.tk.END, message + "\n")
929
+ self.log_text.see(self.tk.END)
930
+ self.log_text.configure(state=self.tk.DISABLED)
931
+
932
+ self.log_text.after(0, updater)
933
+
934
+ def _update_status_from_message(self, message: str) -> None:
935
+ normalized = message.strip().lower()
936
+
937
+ metadata_found, source_duration = _parse_source_duration_seconds(message)
938
+ if metadata_found:
939
+ self._source_duration_seconds = source_duration
940
+
941
+ if self._handle_status_transitions(normalized):
942
+ return
943
+
944
+ frame_total_found, frame_total = _parse_encode_total_frames(message)
945
+ if frame_total_found:
946
+ self._encode_total_frames = frame_total
947
+ return
948
+
949
+ if _is_encode_total_frames_unknown(normalized):
950
+ self._encode_total_frames = None
951
+ return
952
+
953
+ frame_found, current_frame = _parse_current_frame(message)
954
+ if frame_found:
955
+ if current_frame is None:
956
+ return
957
+
958
+ if self._encode_current_frame == current_frame:
959
+ return
960
+
961
+ self._encode_current_frame = current_frame
962
+ if self._encode_total_frames and self._encode_total_frames > 0:
963
+ self._complete_audio_phase()
964
+ frame_ratio = min(current_frame / self._encode_total_frames, 1.0)
965
+ percentage = min(100, 5 + int(frame_ratio * 95))
966
+ self._set_progress(percentage)
967
+ else:
968
+ self._complete_audio_phase()
969
+ self._set_status("processing", f"{current_frame} frames encoded")
970
+
971
+ duration_found, encode_duration = _parse_encode_target_duration(message)
972
+ if duration_found:
973
+ self._encode_target_duration_seconds = encode_duration
974
+
975
+ if _is_encode_target_duration_unknown(normalized):
976
+ self._encode_target_duration_seconds = None
977
+
978
+ video_duration_found, video_duration = _parse_video_duration_seconds(message)
979
+ if video_duration_found and video_duration is not None:
980
+ self._video_duration_seconds = video_duration
981
+
982
+ progress_found, progress_info = _parse_ffmpeg_progress(message)
983
+ if progress_found and progress_info is not None:
984
+ current_seconds, speed_str = progress_info
985
+ time_str = self._format_progress_time(current_seconds)
986
+
987
+ self._last_progress_seconds = current_seconds
988
+
989
+ total_seconds = (
990
+ self._encode_target_duration_seconds or self._video_duration_seconds
991
+ )
992
+ if total_seconds:
993
+ total_str = self._format_progress_time(total_seconds)
994
+ time_display = f"{time_str} / {total_str}"
995
+ else:
996
+ time_display = time_str
997
+
998
+ status_msg = f"{time_display}, {speed_str}x"
999
+
1000
+ if (
1001
+ (
1002
+ not self._encode_total_frames
1003
+ or self._encode_total_frames <= 0
1004
+ or self._encode_current_frame is None
1005
+ )
1006
+ and total_seconds
1007
+ and total_seconds > 0
1008
+ ):
1009
+ self._complete_audio_phase()
1010
+ time_ratio = min(current_seconds / total_seconds, 1.0)
1011
+ percentage = min(100, 5 + int(time_ratio * 95))
1012
+ self._set_progress(percentage)
1013
+
1014
+ self._set_status("processing", status_msg)
1015
+
1016
+ def _handle_status_transitions(self, normalized_message: str) -> bool:
1017
+ """Handle high-level status transitions for *normalized_message*."""
1018
+
1019
+ if "all jobs finished successfully" in normalized_message:
1020
+ status_components: List[str] = []
1021
+ if self._run_start_time is not None:
1022
+ finish_time = time.monotonic()
1023
+ runtime_seconds = max(0.0, finish_time - self._run_start_time)
1024
+ duration_str = self._format_progress_time(runtime_seconds)
1025
+ status_components.append(f"{duration_str}")
1026
+ else:
1027
+ finished_seconds = next(
1028
+ (
1029
+ value
1030
+ for value in (
1031
+ self._last_progress_seconds,
1032
+ self._encode_target_duration_seconds,
1033
+ self._video_duration_seconds,
1034
+ )
1035
+ if value is not None
1036
+ ),
1037
+ None,
1038
+ )
1039
+
1040
+ if finished_seconds is not None:
1041
+ duration_str = self._format_progress_time(finished_seconds)
1042
+ status_components.append(f"{duration_str}")
1043
+ else:
1044
+ status_components.append("Finished")
1045
+
1046
+ if self._last_time_ratio is not None and self._last_size_ratio is not None:
1047
+ status_components.append(
1048
+ f"time: {self._last_time_ratio:.0%}, size: {self._last_size_ratio:.0%}"
1049
+ )
1050
+
1051
+ status_msg = ", ".join(status_components)
1052
+
1053
+ self._reset_audio_progress_state(clear_source=True)
1054
+ self._set_status("success", status_msg)
1055
+ self._set_progress(100)
1056
+ self._run_start_time = None
1057
+ self._video_duration_seconds = None
1058
+ self._encode_target_duration_seconds = None
1059
+ self._encode_total_frames = None
1060
+ self._encode_current_frame = None
1061
+ self._last_progress_seconds = None
1062
+ return True
1063
+
1064
+ if normalized_message.startswith("extracting audio"):
1065
+ self._reset_audio_progress_state(clear_source=False)
1066
+ self._set_status("processing", "Extracting audio...")
1067
+ self._set_progress(0)
1068
+ self._video_duration_seconds = None
1069
+ self._encode_target_duration_seconds = None
1070
+ self._encode_total_frames = None
1071
+ self._encode_current_frame = None
1072
+ self._last_progress_seconds = None
1073
+ self._start_audio_progress()
1074
+ return False
1075
+
1076
+ if normalized_message.startswith("uploading"):
1077
+ self._set_status("processing", "Uploading...")
1078
+ return False
1079
+
1080
+ if normalized_message.startswith("starting processing"):
1081
+ self._reset_audio_progress_state(clear_source=True)
1082
+ self._set_status("processing", "Processing")
1083
+ self._set_progress(0)
1084
+ self._video_duration_seconds = None
1085
+ self._encode_target_duration_seconds = None
1086
+ self._encode_total_frames = None
1087
+ self._encode_current_frame = None
1088
+ self._last_progress_seconds = None
1089
+ return False
1090
+
1091
+ if normalized_message.startswith("processing"):
1092
+ is_new_job = bool(re.match(r"processing \d+/\d+:", normalized_message))
1093
+ should_reset = self._status_state.lower() != "processing" or is_new_job
1094
+ if should_reset:
1095
+ self._set_progress(0)
1096
+ self._video_duration_seconds = None
1097
+ self._encode_target_duration_seconds = None
1098
+ self._encode_total_frames = None
1099
+ self._encode_current_frame = None
1100
+ self._last_progress_seconds = None
1101
+ if is_new_job:
1102
+ self._reset_audio_progress_state(clear_source=True)
1103
+ self._set_status("processing", "Processing")
1104
+ return False
1105
+
1106
+ return False
1107
+
1108
+ def _compute_audio_progress_interval(self) -> int:
1109
+ duration = self._source_duration_seconds or self._video_duration_seconds
1110
+ if duration and duration > 0:
1111
+ audio_seconds = max(duration * self.AUDIO_PROCESSING_RATIO, 0.0)
1112
+ interval_seconds = audio_seconds / self.AUDIO_PROGRESS_STEPS
1113
+ interval_ms = int(round(interval_seconds * 1000))
1114
+ return max(self.MIN_AUDIO_INTERVAL_MS, interval_ms)
1115
+ return self.DEFAULT_AUDIO_INTERVAL_MS
1116
+
1117
+ def _start_audio_progress(self) -> None:
1118
+ interval_ms = self._compute_audio_progress_interval()
1119
+
1120
+ def _start() -> None:
1121
+ if self._audio_progress_job is not None:
1122
+ self.root.after_cancel(self._audio_progress_job)
1123
+ self._audio_progress_steps_completed = 0
1124
+ self._audio_progress_interval_ms = interval_ms
1125
+ self._audio_progress_job = self.root.after(
1126
+ interval_ms, self._advance_audio_progress
1127
+ )
1128
+
1129
+ self._schedule_on_ui_thread(_start)
1130
+
1131
+ def _advance_audio_progress(self) -> None:
1132
+ self._audio_progress_job = None
1133
+ if self._audio_progress_steps_completed >= self.AUDIO_PROGRESS_STEPS:
1134
+ self._audio_progress_interval_ms = None
1135
+ return
1136
+
1137
+ self._audio_progress_steps_completed += 1
1138
+ audio_percentage = (
1139
+ self._audio_progress_steps_completed / self.AUDIO_PROGRESS_STEPS * 100
1140
+ )
1141
+ percentage = (audio_percentage / 100) * 5
1142
+ self._set_progress(percentage)
1143
+ self._set_status("processing", "Audio processing: %d%%" % (audio_percentage))
1144
+
1145
+ if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
1146
+ interval_ms = (
1147
+ self._audio_progress_interval_ms or self.DEFAULT_AUDIO_INTERVAL_MS
1148
+ )
1149
+ self._audio_progress_job = self.root.after(
1150
+ interval_ms, self._advance_audio_progress
1151
+ )
1152
+ else:
1153
+ self._audio_progress_interval_ms = None
1154
+
1155
+ def _cancel_audio_progress(self) -> None:
1156
+ if self._audio_progress_job is None:
1157
+ self._audio_progress_interval_ms = None
1158
+ return
1159
+
1160
+ def _cancel() -> None:
1161
+ if self._audio_progress_job is not None:
1162
+ self.root.after_cancel(self._audio_progress_job)
1163
+ self._audio_progress_job = None
1164
+ self._audio_progress_interval_ms = None
1165
+
1166
+ self._schedule_on_ui_thread(_cancel)
1167
+
1168
+ def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
1169
+ if clear_source:
1170
+ self._source_duration_seconds = None
1171
+ self._audio_progress_steps_completed = 0
1172
+ self._audio_progress_interval_ms = None
1173
+ if self._audio_progress_job is not None:
1174
+ self._cancel_audio_progress()
1175
+
1176
+ def _complete_audio_phase(self) -> None:
1177
+ def _complete() -> None:
1178
+ if self._audio_progress_job is not None:
1179
+ self.root.after_cancel(self._audio_progress_job)
1180
+ self._audio_progress_job = None
1181
+ self._audio_progress_interval_ms = None
1182
+ if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
1183
+ self._audio_progress_steps_completed = self.AUDIO_PROGRESS_STEPS
1184
+ current_value = self.progress_var.get()
1185
+ if current_value < self.AUDIO_PROGRESS_STEPS:
1186
+ self._set_progress(self.AUDIO_PROGRESS_STEPS)
1187
+
1188
+ self._schedule_on_ui_thread(_complete)
1189
+
1190
+ def _get_status_style(self, status: str) -> str | None:
1191
+ """Return the foreground color for *status* if a match is known."""
1192
+
1193
+ color = STATUS_COLORS.get(status.lower())
1194
+ if color:
1195
+ return color
1196
+
1197
+ status_lower = status.lower()
1198
+ if "extracting audio" in status_lower:
1199
+ return STATUS_COLORS["processing"]
1200
+
1201
+ if re.search(
1202
+ r"\d+:\d{2}(?::\d{2})?(?: / \d+:\d{2}(?::\d{2})?)?.*\d+\.?\d*x",
1203
+ status,
1204
+ ):
1205
+ return STATUS_COLORS["processing"]
1206
+
1207
+ if "time:" in status_lower and "size:" in status_lower:
1208
+ # This is our new success format with ratios
1209
+ return STATUS_COLORS["success"]
1210
+
1211
+ return None
1212
+
1213
+ def _apply_status_style(self, status: str) -> None:
1214
+ color = self._get_status_style(status)
1215
+ if color:
1216
+ self.status_label.configure(fg=color)
1217
+
1218
+ def _set_status(self, status: str, status_msg: str = "") -> None:
1219
+ def apply() -> None:
1220
+ self._status_state = status
1221
+ # Use status_msg if provided, otherwise use status
1222
+ display_text = status_msg if status_msg else status
1223
+ self.status_var.set(display_text)
1224
+ self._apply_status_style(
1225
+ status
1226
+ ) # Colors depend on status, not display text
1227
+ self._set_progress_bar_style(status)
1228
+ lowered = status.lower()
1229
+ is_processing = lowered == "processing" or "extracting audio" in lowered
1230
+
1231
+ if is_processing:
1232
+ # Show stop button during processing
1233
+ if hasattr(self, "status_frame"):
1234
+ self.status_frame.grid()
1235
+ self.stop_button.grid()
1236
+ self.drop_hint_button.grid_remove()
1237
+ else:
1238
+ self._reset_audio_progress_state(clear_source=True)
1239
+
1240
+ if lowered == "success" or "time:" in lowered and "size:" in lowered:
1241
+ if self.simple_mode_var.get() and hasattr(self, "status_frame"):
1242
+ self.status_frame.grid()
1243
+ self.stop_button.grid_remove()
1244
+ self.drop_hint_button.grid_remove()
1245
+ self.open_button.grid()
1246
+ self.open_button.lift() # Ensure open_button is above drop_hint_button
1247
+ # print("success status")
1248
+ else:
1249
+ self.open_button.grid_remove()
1250
+ # print("not success status")
1251
+ if self.simple_mode_var.get() and not is_processing:
1252
+ self.stop_button.grid_remove()
1253
+ # Show drop hint when no other buttons are visible
1254
+ if hasattr(self, "drop_hint_button"):
1255
+ self.drop_hint_button.grid()
1256
+
1257
+ self.root.after(0, apply)
1258
+
1259
+ def _format_progress_time(self, total_seconds: float) -> str:
1260
+ """Format a duration in seconds as h:mm:ss or m:ss for status display."""
1261
+
1262
+ try:
1263
+ rounded_seconds = max(0, int(round(total_seconds)))
1264
+ except (TypeError, ValueError):
1265
+ return "0:00"
1266
+
1267
+ hours, remainder = divmod(rounded_seconds, 3600)
1268
+ minutes, seconds = divmod(remainder, 60)
1269
+
1270
+ if hours > 0:
1271
+ return f"{hours}:{minutes:02d}:{seconds:02d}"
1272
+
1273
+ total_minutes = rounded_seconds // 60
1274
+ return f"{total_minutes}:{seconds:02d}"
1275
+
1276
+ def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
1277
+ """Calculate color gradient from red (0%) to green (100%).
1278
+
1279
+ Args:
1280
+ percentage: The position in the gradient (0-100)
1281
+ darken: Value between 0.0 (black) and 1.0 (original brightness)
1282
+
1283
+ Returns:
1284
+ Hex color code string
1285
+ """
1286
+ # Clamp percentage between 0 and 100
1287
+ percentage = max(0, min(100, percentage))
1288
+ # Clamp darken between 0.0 and 1.0
1289
+ darken = max(0.0, min(1.0, darken))
1290
+
1291
+ if percentage <= 50:
1292
+ # Red to Yellow (0% to 50%)
1293
+ # Red: (248, 113, 113) -> Yellow: (250, 204, 21)
1294
+ ratio = percentage / 50.0
1295
+ r = int((248 + (250 - 248) * ratio) * darken)
1296
+ g = int((113 + (204 - 113) * ratio) * darken)
1297
+ b = int((113 + (21 - 113) * ratio) * darken)
1298
+ else:
1299
+ # Yellow to Green (50% to 100%)
1300
+ # Yellow: (250, 204, 21) -> Green: (34, 197, 94)
1301
+ ratio = (percentage - 50) / 50.0
1302
+ r = int((250 + (34 - 250) * ratio) * darken)
1303
+ g = int((204 + (197 - 204) * ratio) * darken)
1304
+ b = int((21 + (94 - 21) * ratio) * darken)
1305
+
1306
+ # Ensure values are within 0-255 range after darkening
1307
+ r = max(0, min(255, r))
1308
+ g = max(0, min(255, g))
1309
+ b = max(0, min(255, b))
1310
+
1311
+ return f"#{r:02x}{g:02x}{b:02x}"
1312
+
1313
+ def _set_progress(self, percentage: int) -> None:
1314
+ """Update the progress bar value and color (thread-safe)."""
1315
+
1316
+ def updater() -> None:
1317
+ self.progress_var.set(percentage)
1318
+ # Update color based on percentage gradient
1319
+ color = self._calculate_gradient_color(percentage, 0.5)
1320
+ palette = (
1321
+ LIGHT_THEME if self._resolve_theme_mode() == "light" else DARK_THEME
1322
+ )
1323
+ if self.theme_var.get().lower() in {"light", "dark"}:
1324
+ palette = (
1325
+ LIGHT_THEME
1326
+ if self.theme_var.get().lower() == "light"
1327
+ else DARK_THEME
1328
+ )
1329
+
1330
+ self.style.configure(
1331
+ "Dynamic.Horizontal.TProgressbar",
1332
+ background=color,
1333
+ troughcolor=palette["surface"],
1334
+ borderwidth=0,
1335
+ thickness=20,
1336
+ )
1337
+ self.progress_bar.configure(style="Dynamic.Horizontal.TProgressbar")
1338
+
1339
+ # Show stop button when progress < 100
1340
+ if percentage < 100:
1341
+ if hasattr(self, "status_frame"):
1342
+ self.status_frame.grid()
1343
+ self.stop_button.grid()
1344
+ self.drop_hint_button.grid_remove()
1345
+
1346
+ self.root.after(0, updater)
1347
+
1348
+ def _set_progress_bar_style(self, status: str) -> None:
1349
+ """Update the progress bar color based on status."""
1350
+
1351
+ def updater() -> None:
1352
+ # Map status to progress bar style
1353
+ status_lower = status.lower()
1354
+ if status_lower == "success" or (
1355
+ "time:" in status_lower and "size:" in status_lower
1356
+ ):
1357
+ style = "Success.Horizontal.TProgressbar"
1358
+ elif status_lower == "error":
1359
+ style = "Error.Horizontal.TProgressbar"
1360
+ elif status_lower == "aborted":
1361
+ style = "Aborted.Horizontal.TProgressbar"
1362
+ elif status_lower == "idle":
1363
+ style = "Idle.Horizontal.TProgressbar"
1364
+ else:
1365
+ # For processing states, use dynamic gradient (will be set by _set_progress)
1366
+ return
1367
+
1368
+ self.progress_bar.configure(style=style)
1369
+
1370
+ self.root.after(0, updater)
1371
+
1372
+ def _schedule_on_ui_thread(self, callback: Callable[[], None]) -> None:
1373
+ self.root.after(0, callback)
1374
+
1375
+ def run(self) -> None:
1376
+ """Start the Tkinter event loop."""
1377
+
1378
+ self.root.mainloop()
1379
+
1380
+
1381
+ __all__ = [
1382
+ "TalksReducerGUI",
1383
+ "_default_remote_destination",
1384
+ "_parse_ratios_from_summary",
1385
+ ]