talks-reducer 0.6.1__py3-none-any.whl → 0.7.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.
@@ -10,9 +10,7 @@ import re
10
10
  import subprocess
11
11
  import sys
12
12
  import threading
13
- import urllib.error
14
- import urllib.parse
15
- import urllib.request
13
+ import time
16
14
  from pathlib import Path
17
15
  from typing import (
18
16
  TYPE_CHECKING,
@@ -30,14 +28,30 @@ if TYPE_CHECKING:
30
28
  from tkinter import filedialog, messagebox, ttk
31
29
 
32
30
  try:
33
- from .cli import gather_input_files
34
- from .cli import main as cli_main
35
- from .discovery import discover_servers
36
- from .ffmpeg import FFmpegNotFoundError
37
- from .models import ProcessingOptions, default_temp_folder
38
- from .pipeline import speed_up_video
39
- from .progress import ProgressHandle, SignalProgressReporter
40
- from .version_utils import resolve_version
31
+ from ..cli import gather_input_files
32
+ from ..cli import main as cli_main
33
+ from ..ffmpeg import FFmpegNotFoundError
34
+ from ..models import ProcessingOptions
35
+ from ..pipeline import ProcessingAborted, speed_up_video
36
+ from ..progress import ProgressHandle, SignalProgressReporter
37
+ from ..version_utils import resolve_version
38
+ from .preferences import GUIPreferences, determine_config_path
39
+ from .remote import (
40
+ check_remote_server_for_gui,
41
+ format_server_host,
42
+ normalize_server_url,
43
+ ping_server,
44
+ process_files_via_server,
45
+ )
46
+ from .theme import (
47
+ DARK_THEME,
48
+ LIGHT_THEME,
49
+ STATUS_COLORS,
50
+ apply_theme,
51
+ detect_system_theme,
52
+ read_windows_theme_registry,
53
+ run_defaults_command,
54
+ )
41
55
  except ImportError: # pragma: no cover - handled at runtime
42
56
  if __package__ not in (None, ""):
43
57
  raise
@@ -48,10 +62,26 @@ except ImportError: # pragma: no cover - handled at runtime
48
62
 
49
63
  from talks_reducer.cli import gather_input_files
50
64
  from talks_reducer.cli import main as cli_main
51
- from talks_reducer.discovery import discover_servers
52
65
  from talks_reducer.ffmpeg import FFmpegNotFoundError
53
- from talks_reducer.models import ProcessingOptions, default_temp_folder
54
- from talks_reducer.pipeline import speed_up_video
66
+ from talks_reducer.gui.preferences import GUIPreferences, determine_config_path
67
+ from talks_reducer.gui.remote import (
68
+ check_remote_server_for_gui,
69
+ format_server_host,
70
+ normalize_server_url,
71
+ ping_server,
72
+ process_files_via_server,
73
+ )
74
+ from talks_reducer.gui.theme import (
75
+ DARK_THEME,
76
+ LIGHT_THEME,
77
+ STATUS_COLORS,
78
+ apply_theme,
79
+ detect_system_theme,
80
+ read_windows_theme_registry,
81
+ run_defaults_command,
82
+ )
83
+ from talks_reducer.models import ProcessingOptions
84
+ from talks_reducer.pipeline import ProcessingAborted, speed_up_video
55
85
  from talks_reducer.progress import ProgressHandle, SignalProgressReporter
56
86
  from talks_reducer.version_utils import resolve_version
57
87
 
@@ -133,42 +163,8 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
133
163
  TkinterDnD = None # type: ignore[assignment]
134
164
 
135
165
 
136
- STATUS_COLORS = {
137
- "idle": "#9ca3af",
138
- "waiting": "#9ca3af",
139
- "processing": "#af8e0e",
140
- "success": "#178941",
141
- "error": "#ad4f4f",
142
- "aborted": "#6d727a",
143
- }
144
-
145
- LIGHT_THEME = {
146
- "background": "#f5f5f5",
147
- "foreground": "#1f2933",
148
- "accent": "#2563eb",
149
- "surface": "#ffffff",
150
- "border": "#cbd5e1",
151
- "hover": "#efefef",
152
- "hover_text": "#000000",
153
- "selection_background": "#2563eb",
154
- "selection_foreground": "#ffffff",
155
- }
156
-
157
- DARK_THEME = {
158
- "background": "#1e1e28",
159
- "foreground": "#f3f4f6",
160
- "accent": "#60a5fa",
161
- "surface": "#2b2b3c",
162
- "border": "#4b5563",
163
- "hover": "#333333",
164
- "hover_text": "#ffffff",
165
- "selection_background": "#333333",
166
- "selection_foreground": "#f3f4f6",
167
- }
168
-
169
-
170
- _TRAY_LOCK = threading.Lock()
171
- _TRAY_PROCESS: Optional[subprocess.Popen[Any]] = None
166
+ from . import discovery as discovery_helpers
167
+ from . import layout as layout_helpers
172
168
 
173
169
 
174
170
  def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
@@ -190,34 +186,6 @@ def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
190
186
  return input_file.with_name(new_name)
191
187
 
192
188
 
193
- def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> None:
194
- """Start the server tray in a background process if one is not active."""
195
-
196
- global _TRAY_PROCESS
197
-
198
- with _TRAY_LOCK:
199
- if _TRAY_PROCESS is not None and _TRAY_PROCESS.poll() is None:
200
- return
201
-
202
- package_name = __package__ or "talks_reducer"
203
-
204
- if getattr(sys, "frozen", False):
205
- command = [sys.executable, "--server"]
206
- else:
207
- command = [sys.executable, "-m", f"{package_name}.server_tray"]
208
-
209
- if extra_args:
210
- command.extend(extra_args)
211
-
212
- try:
213
- _TRAY_PROCESS = subprocess.Popen(command)
214
- except Exception as exc: # pragma: no cover - best-effort fallback
215
- _TRAY_PROCESS = None
216
- sys.stderr.write(
217
- f"Warning: failed to launch Talks Reducer server tray: {exc}\n"
218
- )
219
-
220
-
221
189
  def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
222
190
  """Extract time and size ratios from a Markdown *summary* string."""
223
191
 
@@ -288,9 +256,12 @@ class _TkProgressReporter(SignalProgressReporter):
288
256
  self,
289
257
  log_callback: Callable[[str], None],
290
258
  process_callback: Optional[Callable] = None,
259
+ *,
260
+ stop_callback: Optional[Callable[[], bool]] = None,
291
261
  ) -> None:
292
262
  self._log_callback = log_callback
293
263
  self.process_callback = process_callback
264
+ self._stop_callback = stop_callback
294
265
 
295
266
  def log(self, message: str) -> None:
296
267
  self._log_callback(message)
@@ -302,6 +273,13 @@ class _TkProgressReporter(SignalProgressReporter):
302
273
  del total, unit
303
274
  return _GuiProgressHandle(self._log_callback, desc)
304
275
 
276
+ def stop_requested(self) -> bool:
277
+ """Return ``True`` when the GUI has asked to cancel processing."""
278
+
279
+ if self._stop_callback is None:
280
+ return False
281
+ return bool(self._stop_callback())
282
+
305
283
 
306
284
  class TalksReducerGUI:
307
285
  """Tkinter application mirroring the CLI options with form controls."""
@@ -312,72 +290,14 @@ class TalksReducerGUI:
312
290
  MIN_AUDIO_INTERVAL_MS = 10
313
291
  DEFAULT_AUDIO_INTERVAL_MS = 200
314
292
 
315
- def _determine_config_path(self) -> Path:
316
- if sys.platform == "win32":
317
- appdata = os.environ.get("APPDATA")
318
- base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
319
- elif sys.platform == "darwin":
320
- base = Path.home() / "Library" / "Application Support"
321
- else:
322
- xdg_config = os.environ.get("XDG_CONFIG_HOME")
323
- base = Path(xdg_config) if xdg_config else Path.home() / ".config"
324
- return base / "talks-reducer" / "settings.json"
325
-
326
- def _load_settings(self) -> dict[str, object]:
327
- try:
328
- with self._config_path.open("r", encoding="utf-8") as handle:
329
- data = json.load(handle)
330
- if isinstance(data, dict):
331
- return data
332
- except FileNotFoundError:
333
- return {}
334
- except (OSError, json.JSONDecodeError):
335
- return {}
336
- return {}
337
-
338
- def _save_settings(self) -> None:
339
- try:
340
- self._config_path.parent.mkdir(parents=True, exist_ok=True)
341
- with self._config_path.open("w", encoding="utf-8") as handle:
342
- json.dump(self._settings, handle, indent=2, sort_keys=True)
343
- except OSError:
344
- pass
345
-
346
- def _get_setting(self, key: str, default: object) -> object:
347
- value = self._settings.get(key, default)
348
- if key not in self._settings:
349
- self._settings[key] = value
350
- return value
351
-
352
- def _get_float_setting(self, key: str, default: float) -> float:
353
- """Return *key* as a float, coercing stored strings when necessary."""
354
-
355
- raw_value = self._get_setting(key, default)
356
- try:
357
- number = float(raw_value)
358
- except (TypeError, ValueError):
359
- number = float(default)
360
-
361
- if self._settings.get(key) != number:
362
- self._settings[key] = number
363
- self._save_settings()
364
-
365
- return number
366
-
367
- def _update_setting(self, key: str, value: object) -> None:
368
- if self._settings.get(key) == value:
369
- return
370
- self._settings[key] = value
371
- self._save_settings()
372
-
373
293
  def __init__(
374
294
  self,
375
295
  initial_inputs: Optional[Sequence[str]] = None,
376
296
  *,
377
297
  auto_run: bool = False,
378
298
  ) -> None:
379
- self._config_path = self._determine_config_path()
380
- self._settings = self._load_settings()
299
+ self._config_path = determine_config_path()
300
+ self.preferences = GUIPreferences(self._config_path)
381
301
 
382
302
  # Import tkinter here to avoid loading it at module import time
383
303
  import tkinter as tk
@@ -412,6 +332,8 @@ class TalksReducerGUI:
412
332
  self._last_output: Optional[Path] = None
413
333
  self._last_time_ratio: Optional[float] = None
414
334
  self._last_size_ratio: Optional[float] = None
335
+ self._last_progress_seconds: Optional[int] = None
336
+ self._run_start_time: Optional[float] = None
415
337
  self._status_state = "Idle"
416
338
  self.status_var = tk.StringVar(value=self._status_state)
417
339
  self._status_animation_job: Optional[str] = None
@@ -427,32 +349,34 @@ class TalksReducerGUI:
427
349
  self.progress_var = tk.IntVar(value=0)
428
350
  self._ffmpeg_process: Optional[subprocess.Popen] = None
429
351
  self._stop_requested = False
352
+ self._ping_worker_stop_requested = False
353
+ self._current_remote_mode = False
430
354
 
431
355
  self.input_files: List[str] = []
432
356
 
433
357
  self._dnd_available = TkinterDnD is not None and DND_FILES is not None
434
358
 
435
359
  self.simple_mode_var = tk.BooleanVar(
436
- value=self._get_setting("simple_mode", True)
360
+ value=self.preferences.get("simple_mode", True)
437
361
  )
438
362
  self.run_after_drop_var = tk.BooleanVar(value=True)
439
- self.small_var = tk.BooleanVar(value=self._get_setting("small_video", True))
363
+ self.small_var = tk.BooleanVar(value=self.preferences.get("small_video", True))
440
364
  self.open_after_convert_var = tk.BooleanVar(
441
- value=self._get_setting("open_after_convert", True)
365
+ value=self.preferences.get("open_after_convert", True)
442
366
  )
443
- stored_mode = str(self._get_setting("processing_mode", "local"))
367
+ stored_mode = str(self.preferences.get("processing_mode", "local"))
444
368
  if stored_mode not in {"local", "remote"}:
445
369
  stored_mode = "local"
446
370
  self.processing_mode_var = tk.StringVar(value=stored_mode)
447
371
  self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
448
- self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
372
+ self.theme_var = tk.StringVar(value=self.preferences.get("theme", "os"))
449
373
  self.theme_var.trace_add("write", self._on_theme_change)
450
374
  self.small_var.trace_add("write", self._on_small_video_change)
451
375
  self.open_after_convert_var.trace_add(
452
376
  "write", self._on_open_after_convert_change
453
377
  )
454
378
  self.server_url_var = tk.StringVar(
455
- value=str(self._get_setting("server_url", ""))
379
+ value=str(self.preferences.get("server_url", ""))
456
380
  )
457
381
  self.server_url_var.trace_add("write", self._on_server_url_change)
458
382
  self._discovery_thread: Optional[threading.Thread] = None
@@ -465,50 +389,38 @@ class TalksReducerGUI:
465
389
  self._build_layout()
466
390
  self._apply_simple_mode(initial=True)
467
391
  self._apply_status_style(self._status_state)
468
- self._apply_theme()
469
- self._save_settings()
392
+ self._refresh_theme()
393
+ self.preferences.save()
470
394
  self._hide_stop_button()
471
395
 
472
396
  # Ping server on startup if in remote mode
473
397
  if (
474
398
  self.processing_mode_var.get() == "remote"
475
399
  and self.server_url_var.get().strip()
476
- and hasattr(self, "_ping_server")
477
400
  ):
478
401
  server_url = self.server_url_var.get().strip()
479
- host_label = self._format_server_host(server_url)
480
402
 
481
403
  def ping_worker() -> None:
482
404
  try:
483
- if self._ping_server(server_url):
484
- self._set_status("Idle", f"Server {host_label} is ready")
485
- self._notify(
486
- lambda: self._append_log(f"Server {host_label} ready")
487
- )
488
- else:
489
- self._set_status(
490
- "Error", f"Server {host_label} is not reachable"
491
- )
492
- self._notify(
493
- lambda: self._append_log(
494
- f"Server {host_label} is not reachable"
495
- )
496
- )
497
- ping_worker()
498
- except Exception as exc:
499
- self._set_status(
500
- "Idle", f"Error pinging server {host_label}: {exc}"
405
+ self._check_remote_server(
406
+ server_url,
407
+ success_status="Idle",
408
+ waiting_status="Error",
409
+ failure_status="Error",
410
+ stop_check=lambda: self._ping_worker_stop_requested,
411
+ switch_to_local_on_failure=True,
501
412
  )
502
- self._notify(
503
- lambda: self._append_log(
504
- f"Error pinging server {host_label}: {exc}"
505
- )
413
+ except Exception as exc: # pragma: no cover - defensive safeguard
414
+ host_label = self._format_server_host(server_url)
415
+ message = f"Error pinging server {host_label}: {exc}"
416
+ self._schedule_on_ui_thread(
417
+ lambda msg=message: self._append_log(msg)
418
+ )
419
+ self._schedule_on_ui_thread(
420
+ lambda msg=message: self._set_status("Idle", msg)
506
421
  )
507
422
 
508
- import threading
509
-
510
- ping_thread = threading.Thread(target=ping_worker, daemon=True)
511
- ping_thread.start()
423
+ threading.Thread(target=ping_worker, daemon=True).start()
512
424
 
513
425
  if not self._dnd_available:
514
426
  self._append_log(
@@ -518,459 +430,144 @@ class TalksReducerGUI:
518
430
  if initial_inputs:
519
431
  self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
520
432
 
521
- # ------------------------------------------------------------------ UI --
522
- def _apply_window_icon(self) -> None:
523
- """Configure the application icon when the asset is available."""
524
-
525
- base_path = Path(
526
- getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent)
527
- )
528
-
529
- icon_candidates: list[tuple[Path, str]] = []
530
- if sys.platform.startswith("win"):
531
- icon_candidates.append((base_path / "docs" / "assets" / "icon.ico", "ico"))
532
- icon_candidates.append((base_path / "docs" / "assets" / "icon.png", "png"))
533
-
534
- for icon_path, icon_type in icon_candidates:
535
- if not icon_path.is_file():
536
- continue
537
-
538
- try:
539
- if icon_type == "ico" and sys.platform.startswith("win"):
540
- # On Windows, iconbitmap works better without the 'default' parameter
541
- self.root.iconbitmap(str(icon_path))
542
- else:
543
- self.root.iconphoto(False, self.tk.PhotoImage(file=str(icon_path)))
544
- # If we got here without exception, icon was set successfully
545
- return
546
- except (self.tk.TclError, Exception) as e:
547
- # Missing Tk image support or invalid icon format - try next candidate
548
- continue
549
-
550
- def _build_layout(self) -> None:
551
- main = self.ttk.Frame(self.root, padding=self.PADDING)
552
- main.grid(row=0, column=0, sticky="nsew")
553
- self.root.columnconfigure(0, weight=1)
554
- self.root.rowconfigure(0, weight=1)
555
-
556
- # Input selection frame
557
- input_frame = self.ttk.Frame(main, padding=self.PADDING)
558
- input_frame.grid(row=0, column=0, sticky="nsew")
559
- main.rowconfigure(0, weight=1)
560
- main.columnconfigure(0, weight=1)
561
- input_frame.columnconfigure(0, weight=1)
562
- input_frame.rowconfigure(0, weight=1)
563
-
564
- self.drop_zone = self.tk.Label(
565
- input_frame,
566
- text="Drop video here",
567
- relief=self.tk.FLAT,
568
- borderwidth=0,
569
- padx=self.PADDING,
570
- pady=self.PADDING,
571
- highlightthickness=0,
572
- )
573
- self.drop_zone.grid(row=0, column=0, sticky="nsew")
574
- self._configure_drop_targets(self.drop_zone)
575
- self.drop_zone.configure(cursor="hand2", takefocus=1)
576
- self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
577
- self.drop_zone.bind("<Return>", self._on_drop_zone_click)
578
- self.drop_zone.bind("<space>", self._on_drop_zone_click)
579
-
580
- # Options frame
581
- self.options_frame = self.ttk.Frame(main, padding=self.PADDING)
582
- self.options_frame.grid(row=2, column=0, pady=(0, 0), sticky="ew")
583
- self.options_frame.columnconfigure(0, weight=1)
584
-
585
- checkbox_frame = self.ttk.Frame(self.options_frame)
586
- checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
587
-
588
- self.ttk.Checkbutton(
589
- checkbox_frame,
590
- text="Small video",
591
- variable=self.small_var,
592
- ).grid(row=0, column=0, sticky="w")
593
-
594
- self.ttk.Checkbutton(
595
- checkbox_frame,
596
- text="Open after convert",
597
- variable=self.open_after_convert_var,
598
- ).grid(row=0, column=1, sticky="w", padx=(12, 0))
599
-
600
- self.simple_mode_check = self.ttk.Checkbutton(
601
- checkbox_frame,
602
- text="Simple mode",
603
- variable=self.simple_mode_var,
604
- command=self._toggle_simple_mode,
605
- )
606
- self.simple_mode_check.grid(
607
- row=1, column=0, columnspan=3, sticky="w", pady=(8, 0)
608
- )
609
-
610
- self.advanced_visible = self.tk.BooleanVar(value=False)
611
-
612
- basic_label_container = self.ttk.Frame(self.options_frame)
613
- basic_label = self.ttk.Label(basic_label_container, text="Basic options")
614
- basic_label.pack(side=self.tk.LEFT)
615
-
616
- self.reset_basic_button = self.ttk.Button(
617
- basic_label_container,
618
- text="Reset to defaults",
619
- command=self._reset_basic_defaults,
620
- state=self.tk.DISABLED,
621
- style="Link.TButton",
622
- )
623
-
624
- self.basic_options_frame = self.ttk.Labelframe(
625
- self.options_frame, padding=0, labelwidget=basic_label_container
626
- )
627
- self.basic_options_frame.grid(
628
- row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)
629
- )
630
- self.basic_options_frame.columnconfigure(1, weight=1)
631
-
632
- self._reset_button_visible = False
633
-
634
- self.silent_speed_var = self.tk.DoubleVar(
635
- value=min(max(self._get_float_setting("silent_speed", 4.0), 1.0), 10.0)
636
- )
637
- self._add_slider(
638
- self.basic_options_frame,
639
- "Silent speed",
640
- self.silent_speed_var,
641
- row=0,
642
- setting_key="silent_speed",
643
- minimum=1.0,
644
- maximum=10.0,
645
- resolution=0.5,
646
- display_format="{:.1f}×",
647
- default_value=4.0,
648
- )
649
-
650
- self.sounded_speed_var = self.tk.DoubleVar(
651
- value=min(max(self._get_float_setting("sounded_speed", 1.0), 0.75), 2.0)
652
- )
653
- self._add_slider(
654
- self.basic_options_frame,
655
- "Sounded speed",
656
- self.sounded_speed_var,
657
- row=1,
658
- setting_key="sounded_speed",
659
- minimum=0.75,
660
- maximum=2.0,
661
- resolution=0.25,
662
- display_format="{:.2f}×",
663
- default_value=1.0,
664
- )
665
-
666
- self.silent_threshold_var = self.tk.DoubleVar(
667
- value=min(max(self._get_float_setting("silent_threshold", 0.05), 0.0), 1.0)
668
- )
669
- self._add_slider(
670
- self.basic_options_frame,
671
- "Silent threshold",
672
- self.silent_threshold_var,
673
- row=2,
674
- setting_key="silent_threshold",
675
- minimum=0.0,
676
- maximum=1.0,
677
- resolution=0.01,
678
- display_format="{:.2f}",
679
- default_value=0.05,
680
- )
681
-
682
- self.ttk.Label(self.basic_options_frame, text="Server URL").grid(
683
- row=3, column=0, sticky="w", pady=4
684
- )
685
- stored_server_url = str(
686
- self._get_setting("server_url", "http://localhost:9005")
687
- )
688
- if not stored_server_url:
689
- stored_server_url = "http://localhost:9005"
690
- self._update_setting("server_url", stored_server_url)
691
- self.server_url_var.set(stored_server_url)
692
- self.server_url_entry = self.ttk.Entry(
693
- self.basic_options_frame, textvariable=self.server_url_var, width=30
694
- )
695
- self.server_url_entry.grid(row=3, column=1, sticky="w", pady=4)
696
- self.server_discover_button = self.ttk.Button(
697
- self.basic_options_frame, text="Discover", command=self._start_discovery
698
- )
699
- self.server_discover_button.grid(row=3, column=2, padx=(8, 0))
700
-
701
- self.ttk.Label(self.basic_options_frame, text="Processing mode").grid(
702
- row=4, column=0, sticky="w", pady=4
703
- )
704
- mode_choice = self.ttk.Frame(self.basic_options_frame)
705
- mode_choice.grid(row=4, column=1, columnspan=2, sticky="w", pady=4)
706
- self.ttk.Radiobutton(
707
- mode_choice,
708
- text="Local",
709
- value="local",
710
- variable=self.processing_mode_var,
711
- ).pack(side=self.tk.LEFT, padx=(0, 8))
712
- self.remote_mode_button = self.ttk.Radiobutton(
713
- mode_choice,
714
- text="Remote",
715
- value="remote",
716
- variable=self.processing_mode_var,
717
- )
718
- self.remote_mode_button.pack(side=self.tk.LEFT, padx=(0, 8))
719
-
720
- self.ttk.Label(self.basic_options_frame, text="Theme").grid(
721
- row=5, column=0, sticky="w", pady=(8, 0)
722
- )
723
- theme_choice = self.ttk.Frame(self.basic_options_frame)
724
- theme_choice.grid(row=5, column=1, columnspan=2, sticky="w", pady=(8, 0))
725
- for value, label in ("os", "OS"), ("light", "Light"), ("dark", "Dark"):
726
- self.ttk.Radiobutton(
727
- theme_choice,
728
- text=label,
729
- value=value,
730
- variable=self.theme_var,
731
- command=self._apply_theme,
732
- ).pack(side=self.tk.LEFT, padx=(0, 8))
733
-
734
- self.advanced_button = self.ttk.Button(
735
- self.options_frame,
736
- text="Advanced",
737
- command=self._toggle_advanced,
738
- )
739
- self.advanced_button.grid(
740
- row=3, column=0, columnspan=2, sticky="w", pady=(12, 0)
741
- )
742
-
743
- self.advanced_frame = self.ttk.Frame(self.options_frame, padding=0)
744
- self.advanced_frame.grid(row=4, column=0, columnspan=2, sticky="nsew")
745
- self.advanced_frame.columnconfigure(1, weight=1)
746
-
747
- self.output_var = self.tk.StringVar()
748
- self._add_entry(
749
- self.advanced_frame, "Output file", self.output_var, row=0, browse=True
750
- )
751
-
752
- self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
753
- self._add_entry(
754
- self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
755
- )
433
+ def _start_run(self) -> None:
434
+ if self._processing_thread and self._processing_thread.is_alive():
435
+ self.messagebox.showinfo("Processing", "A job is already running.")
436
+ return
756
437
 
757
- self.sample_rate_var = self.tk.StringVar(value="48000")
758
- self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=2)
438
+ if not self.input_files:
439
+ self.messagebox.showwarning(
440
+ "Missing input", "Please add at least one file or folder."
441
+ )
442
+ return
759
443
 
760
- frame_margin_setting = self._get_setting("frame_margin", 2)
761
444
  try:
762
- frame_margin_default = int(frame_margin_setting)
763
- except (TypeError, ValueError):
764
- frame_margin_default = 2
765
- self._update_setting("frame_margin", frame_margin_default)
445
+ args = self._collect_arguments()
446
+ except ValueError as exc:
447
+ self.messagebox.showerror("Invalid value", str(exc))
448
+ return
766
449
 
767
- self.frame_margin_var = self.tk.StringVar(value=str(frame_margin_default))
768
- self._add_entry(
769
- self.advanced_frame, "Frame margin", self.frame_margin_var, row=3
770
- )
450
+ self._append_log("Starting processing…")
451
+ self._stop_requested = False
452
+ self.stop_button.configure(text="Stop")
453
+ self._run_start_time = time.monotonic()
454
+ self._ping_worker_stop_requested = True
455
+ open_after_convert = bool(self.open_after_convert_var.get())
456
+ server_url = self.server_url_var.get().strip()
457
+ remote_mode = self.processing_mode_var.get() == "remote"
458
+ if remote_mode and not server_url:
459
+ self.messagebox.showerror(
460
+ "Missing server URL", "Remote mode requires a server URL."
461
+ )
462
+ remote_mode = remote_mode and bool(server_url)
771
463
 
772
- self._toggle_advanced(initial=True)
773
- self._update_processing_mode_state()
774
- self._update_basic_reset_state()
775
-
776
- # Action buttons and log output
777
- status_frame = self.ttk.Frame(main, padding=self.PADDING)
778
- status_frame.grid(row=1, column=0, sticky="ew")
779
- status_frame.columnconfigure(0, weight=0)
780
- status_frame.columnconfigure(1, weight=1)
781
- status_frame.columnconfigure(2, weight=0)
782
- self.status_frame = status_frame
783
-
784
- self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
785
- self.status_label = self.tk.Label(
786
- status_frame, textvariable=self.status_var, anchor="e"
787
- )
788
- self.status_label.grid(row=0, column=1, sticky="e")
789
-
790
- # Progress bar
791
- self.progress_bar = self.ttk.Progressbar(
792
- status_frame,
793
- variable=self.progress_var,
794
- maximum=100,
795
- mode="determinate",
796
- style="Idle.Horizontal.TProgressbar",
797
- )
798
- self.progress_bar.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(0, 0))
464
+ # Store remote_mode for use after thread starts
465
+ self._current_remote_mode = remote_mode
799
466
 
800
- self.stop_button = self.ttk.Button(
801
- status_frame, text="Stop", command=self._stop_processing
802
- )
803
- self.stop_button.grid(
804
- row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
805
- )
806
- self.stop_button.grid_remove() # Hidden by default
467
+ def worker() -> None:
468
+ def set_process(proc: subprocess.Popen) -> None:
469
+ self._ffmpeg_process = proc
807
470
 
808
- self.open_button = self.ttk.Button(
809
- status_frame,
810
- text="Open last",
811
- command=self._open_last_output,
812
- state=self.tk.DISABLED,
813
- )
814
- self.open_button.grid(
815
- row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
816
- )
817
- self.open_button.grid_remove()
471
+ try:
472
+ files = gather_input_files(self.input_files)
473
+ if not files:
474
+ self._schedule_on_ui_thread(
475
+ lambda: self.messagebox.showwarning(
476
+ "No files", "No supported media files were found."
477
+ )
478
+ )
479
+ self._set_status("Idle")
480
+ return
818
481
 
819
- # Button shown when no other action buttons are visible
820
- self.drop_hint_button = self.ttk.Button(
821
- status_frame,
822
- text="Drop video to convert",
823
- state=self.tk.DISABLED,
824
- )
825
- self.drop_hint_button.grid(
826
- row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
827
- )
828
- self.drop_hint_button.grid_remove() # Hidden by default
829
- self._configure_drop_targets(self.drop_hint_button)
482
+ if self._current_remote_mode:
483
+ success = self._process_files_via_server(
484
+ files,
485
+ args,
486
+ server_url,
487
+ open_after_convert=open_after_convert,
488
+ )
489
+ if success:
490
+ self._schedule_on_ui_thread(self._hide_stop_button)
491
+ return
492
+ # If server processing failed, fall back to local processing
493
+ # The _process_files_via_server function already switched to local mode
494
+ # Update remote_mode variable to reflect the change
495
+ self._current_remote_mode = False
830
496
 
831
- self.log_frame = self.ttk.Frame(main, padding=self.PADDING)
832
- self.log_frame.grid(row=3, column=0, pady=(16, 0), sticky="nsew")
833
- main.rowconfigure(4, weight=1)
834
- self.log_frame.columnconfigure(0, weight=1)
835
- self.log_frame.rowconfigure(0, weight=1)
497
+ reporter = _TkProgressReporter(
498
+ self._append_log,
499
+ process_callback=set_process,
500
+ stop_callback=lambda: self._stop_requested,
501
+ )
502
+ for index, file in enumerate(files, start=1):
503
+ self._append_log(f"Processing: {os.path.basename(file)}")
504
+ options = self._create_processing_options(Path(file), args)
505
+ result = speed_up_video(options, reporter=reporter)
506
+ self._last_output = result.output_file
507
+ self._last_time_ratio = result.time_ratio
508
+ self._last_size_ratio = result.size_ratio
836
509
 
837
- self.log_text = self.tk.Text(
838
- self.log_frame, wrap="word", height=10, state=self.tk.DISABLED
839
- )
840
- self.log_text.grid(row=0, column=0, sticky="nsew")
841
- log_scroll = self.ttk.Scrollbar(
842
- self.log_frame, orient=self.tk.VERTICAL, command=self.log_text.yview
843
- )
844
- log_scroll.grid(row=0, column=1, sticky="ns")
845
- self.log_text.configure(yscrollcommand=log_scroll.set)
510
+ # Create completion message with ratios if available
511
+ completion_msg = f"Completed: {result.output_file}"
512
+ if result.time_ratio is not None and result.size_ratio is not None:
513
+ completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
846
514
 
847
- def _add_entry(
848
- self,
849
- parent, # type: tk.Misc
850
- label: str,
851
- variable, # type: tk.StringVar
852
- *,
853
- row: int,
854
- browse: bool = False,
855
- ) -> None:
856
- self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
857
- entry = self.ttk.Entry(parent, textvariable=variable)
858
- entry.grid(row=row, column=1, sticky="ew", pady=4)
859
- if browse:
860
- button = self.ttk.Button(
861
- parent,
862
- text="Browse",
863
- command=lambda var=variable: self._browse_path(var, label),
864
- )
865
- button.grid(row=row, column=2, padx=(8, 0))
515
+ self._append_log(completion_msg)
516
+ if open_after_convert:
517
+ self._schedule_on_ui_thread(
518
+ lambda path=result.output_file: self._open_in_file_manager(
519
+ path
520
+ )
521
+ )
866
522
 
867
- def _add_slider(
868
- self,
869
- parent, # type: tk.Misc
870
- label: str,
871
- variable, # type: tk.DoubleVar
872
- *,
873
- row: int,
874
- setting_key: str,
875
- minimum: float,
876
- maximum: float,
877
- resolution: float,
878
- display_format: str,
879
- default_value: float,
880
- ) -> None:
881
- self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
882
-
883
- value_label = self.ttk.Label(parent)
884
- value_label.grid(row=row, column=2, sticky="e", pady=4)
885
-
886
- def update(value: str) -> None:
887
- numeric = float(value)
888
- clamped = max(minimum, min(maximum, numeric))
889
- steps = round((clamped - minimum) / resolution)
890
- quantized = minimum + steps * resolution
891
- if abs(variable.get() - quantized) > 1e-9:
892
- variable.set(quantized)
893
- value_label.configure(text=display_format.format(quantized))
894
- self._update_setting(setting_key, float(f"{quantized:.6f}"))
895
- self._update_basic_reset_state()
896
-
897
- slider = self.tk.Scale(
898
- parent,
899
- variable=variable,
900
- from_=minimum,
901
- to=maximum,
902
- orient=self.tk.HORIZONTAL,
903
- resolution=resolution,
904
- showvalue=False,
905
- command=update,
906
- length=240,
907
- highlightthickness=0,
908
- )
909
- slider.grid(row=row, column=1, sticky="ew", pady=4, padx=(0, 8))
523
+ self._append_log("All jobs finished successfully.")
524
+ self._schedule_on_ui_thread(
525
+ lambda: self.open_button.configure(state=self.tk.NORMAL)
526
+ )
527
+ self._schedule_on_ui_thread(self._clear_input_files)
528
+ except FFmpegNotFoundError as exc:
529
+ self._schedule_on_ui_thread(
530
+ lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
531
+ )
532
+ self._set_status("Error")
533
+ except ProcessingAborted:
534
+ self._append_log("Processing aborted by user.")
535
+ self._set_status("Aborted")
536
+ except Exception as exc: # pragma: no cover - GUI level safeguard
537
+ # If stop was requested, don't show error (FFmpeg termination is expected)
538
+ if self._stop_requested:
539
+ self._append_log("Processing aborted by user.")
540
+ self._set_status("Aborted")
541
+ else:
542
+ error_msg = f"Processing failed: {exc}"
543
+ self._append_log(error_msg)
544
+ print(error_msg, file=sys.stderr) # Also output to console
545
+ self._schedule_on_ui_thread(
546
+ lambda: self.messagebox.showerror("Error", error_msg)
547
+ )
548
+ self._set_status("Error")
549
+ finally:
550
+ self._run_start_time = None
551
+ self._schedule_on_ui_thread(self._hide_stop_button)
910
552
 
911
- update(str(variable.get()))
553
+ self._processing_thread = threading.Thread(target=worker, daemon=True)
554
+ self._processing_thread.start()
912
555
 
913
- self._slider_updaters[setting_key] = update
914
- self._basic_defaults[setting_key] = default_value
915
- self._basic_variables[setting_key] = variable
916
- variable.trace_add("write", lambda *_: self._update_basic_reset_state())
917
- self._sliders.append(slider)
556
+ # Show Stop button when processing starts regardless of mode
557
+ self.stop_button.grid()
918
558
 
919
- def _update_basic_reset_state(self) -> None:
920
- """Enable or disable the reset control based on slider values."""
559
+ # ------------------------------------------------------------------ UI --
560
+ def _apply_window_icon(self) -> None:
561
+ layout_helpers.apply_window_icon(self)
921
562
 
922
- if not hasattr(self, "reset_basic_button"):
923
- return
563
+ def _build_layout(self) -> None:
564
+ layout_helpers.build_layout(self)
924
565
 
925
- should_enable = False
926
- for key, default_value in self._basic_defaults.items():
927
- variable = self._basic_variables.get(key)
928
- if variable is None:
929
- continue
930
- try:
931
- current_value = float(variable.get())
932
- except (TypeError, ValueError):
933
- should_enable = True
934
- break
935
- if abs(current_value - default_value) > 1e-9:
936
- should_enable = True
937
- break
938
-
939
- if should_enable:
940
- if not getattr(self, "_reset_button_visible", False):
941
- self.reset_basic_button.pack(side=self.tk.LEFT, padx=(8, 0))
942
- self._reset_button_visible = True
943
- self.reset_basic_button.configure(state=self.tk.NORMAL)
944
- else:
945
- if getattr(self, "_reset_button_visible", False):
946
- self.reset_basic_button.pack_forget()
947
- self._reset_button_visible = False
948
- self.reset_basic_button.configure(state=self.tk.DISABLED)
566
+ def _update_basic_reset_state(self) -> None:
567
+ layout_helpers.update_basic_reset_state(self)
949
568
 
950
569
  def _reset_basic_defaults(self) -> None:
951
- """Restore the basic numeric controls to their default values."""
952
-
953
- for key, default_value in self._basic_defaults.items():
954
- variable = self._basic_variables.get(key)
955
- if variable is None:
956
- continue
957
-
958
- try:
959
- current_value = float(variable.get())
960
- except (TypeError, ValueError):
961
- current_value = default_value
962
-
963
- if abs(current_value - default_value) <= 1e-9:
964
- continue
965
-
966
- variable.set(default_value)
967
- updater = self._slider_updaters.get(key)
968
- if updater is not None:
969
- updater(str(default_value))
970
- else:
971
- self._update_setting(key, float(f"{default_value:.6f}"))
972
-
973
- self._update_basic_reset_state()
570
+ layout_helpers.reset_basic_defaults(self)
974
571
 
975
572
  def _update_processing_mode_state(self) -> None:
976
573
  has_url = bool(self.server_url_var.get().strip())
@@ -983,202 +580,76 @@ class TalksReducerGUI:
983
580
  self.remote_mode_button.configure(state=state)
984
581
 
985
582
  def _normalize_server_url(self, server_url: str) -> str:
986
- parsed = urllib.parse.urlsplit(server_url)
987
- if not parsed.scheme:
988
- parsed = urllib.parse.urlsplit(f"http://{server_url}")
989
-
990
- netloc = parsed.netloc or parsed.path
991
- if not netloc:
992
- return server_url
993
-
994
- path = parsed.path if parsed.netloc else ""
995
- normalized_path = path or "/"
996
- return urllib.parse.urlunsplit((parsed.scheme, netloc, normalized_path, "", ""))
583
+ return normalize_server_url(server_url)
997
584
 
998
585
  def _format_server_host(self, server_url: str) -> str:
999
- parsed = urllib.parse.urlsplit(server_url)
1000
- if not parsed.scheme:
1001
- parsed = urllib.parse.urlsplit(f"http://{server_url}")
1002
-
1003
- host = parsed.netloc or parsed.path or server_url
1004
- if parsed.netloc and parsed.path and parsed.path not in {"", "/"}:
1005
- host = f"{parsed.netloc}{parsed.path}"
1006
-
1007
- host = host.rstrip("/").split(":")[0]
1008
- return host or server_url
586
+ return format_server_host(server_url)
1009
587
 
1010
- def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
1011
- normalized = self._normalize_server_url(server_url)
1012
- request = urllib.request.Request(
1013
- normalized,
1014
- headers={"User-Agent": "talks-reducer-gui"},
1015
- method="GET",
588
+ def _check_remote_server(
589
+ self,
590
+ server_url: str,
591
+ *,
592
+ success_status: str,
593
+ waiting_status: str,
594
+ failure_status: str,
595
+ success_message: Optional[str] = None,
596
+ waiting_message_template: str = "Waiting server {host} (attempt {attempt}/{max_attempts})",
597
+ failure_message: Optional[str] = None,
598
+ stop_check: Optional[Callable[[], bool]] = None,
599
+ on_stop: Optional[Callable[[], None]] = None,
600
+ switch_to_local_on_failure: bool = False,
601
+ alert_on_failure: bool = False,
602
+ warning_title: str = "Server unavailable",
603
+ warning_message: Optional[str] = None,
604
+ max_attempts: int = 5,
605
+ delay: float = 1.0,
606
+ ) -> bool:
607
+ return check_remote_server_for_gui(
608
+ self,
609
+ server_url,
610
+ success_status=success_status,
611
+ waiting_status=waiting_status,
612
+ failure_status=failure_status,
613
+ success_message=success_message,
614
+ waiting_message_template=waiting_message_template,
615
+ failure_message=failure_message,
616
+ stop_check=stop_check,
617
+ on_stop=on_stop,
618
+ switch_to_local_on_failure=switch_to_local_on_failure,
619
+ alert_on_failure=alert_on_failure,
620
+ warning_title=warning_title,
621
+ warning_message=warning_message,
622
+ max_attempts=max_attempts,
623
+ delay=delay,
1016
624
  )
1017
625
 
1018
- try:
1019
- with urllib.request.urlopen(request, timeout=timeout) as response:
1020
- status = getattr(response, "status", None)
1021
- if status is None:
1022
- status = response.getcode()
1023
- if status is None:
1024
- return False
1025
- return 200 <= int(status) < 500
1026
- except (urllib.error.URLError, ValueError):
1027
- return False
626
+ def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
627
+ return ping_server(server_url, timeout=timeout)
1028
628
 
1029
629
  def _start_discovery(self) -> None:
1030
- """Search the local network for running Talks Reducer servers."""
1031
-
1032
- if self._discovery_thread and self._discovery_thread.is_alive():
1033
- return
1034
-
1035
- self.server_discover_button.configure(
1036
- state=self.tk.DISABLED, text="Discovering…"
1037
- )
1038
- self._append_log("Discovering Talks Reducer servers on port 9005…")
1039
-
1040
- def worker() -> None:
1041
- try:
1042
- urls = discover_servers(
1043
- progress_callback=lambda current, total: self._notify(
1044
- lambda c=current, t=total: self._on_discovery_progress(c, t)
1045
- )
1046
- )
1047
- except Exception as exc: # pragma: no cover - network failure safeguard
1048
- self._notify(lambda: self._on_discovery_failed(exc))
1049
- return
1050
- self._notify(lambda: self._on_discovery_complete(urls))
1051
-
1052
- self._discovery_thread = threading.Thread(target=worker, daemon=True)
1053
- self._discovery_thread.start()
630
+ discovery_helpers.start_discovery(self)
1054
631
 
1055
632
  def _on_discovery_failed(self, exc: Exception) -> None:
1056
- self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
1057
- message = f"Discovery failed: {exc}"
1058
- self._append_log(message)
1059
- self.messagebox.showerror("Discovery failed", message)
633
+ discovery_helpers.on_discovery_failed(self, exc)
1060
634
 
1061
635
  def _on_discovery_progress(self, current: int, total: int) -> None:
1062
- if total > 0:
1063
- bounded = max(0, min(current, total))
1064
- label = f"{bounded} / {total}"
1065
- else:
1066
- label = "Discovering…"
1067
- self.server_discover_button.configure(text=label)
636
+ discovery_helpers.on_discovery_progress(self, current, total)
1068
637
 
1069
638
  def _on_discovery_complete(self, urls: List[str]) -> None:
1070
- self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
1071
- if not urls:
1072
- self._append_log("No Talks Reducer servers were found.")
1073
- self.messagebox.showinfo(
1074
- "No servers found",
1075
- "No Talks Reducer servers responded on port 9005.",
1076
- )
1077
- return
1078
-
1079
- self._append_log(
1080
- f"Discovered {len(urls)} server{'s' if len(urls) != 1 else ''}."
1081
- )
1082
-
1083
- if len(urls) == 1:
1084
- self.server_url_var.set(urls[0])
1085
- return
1086
-
1087
- self._show_discovery_results(urls)
639
+ discovery_helpers.on_discovery_complete(self, urls)
1088
640
 
1089
641
  def _show_discovery_results(self, urls: List[str]) -> None:
1090
- dialog = self.tk.Toplevel(self.root)
1091
- dialog.title("Select server")
1092
- dialog.transient(self.root)
1093
- dialog.grab_set()
1094
-
1095
- self.ttk.Label(dialog, text="Select a Talks Reducer server:").grid(
1096
- row=0, column=0, columnspan=2, sticky="w", padx=self.PADDING, pady=(12, 4)
1097
- )
1098
-
1099
- listbox = self.tk.Listbox(
1100
- dialog,
1101
- height=min(10, len(urls)),
1102
- selectmode=self.tk.SINGLE,
1103
- )
1104
- listbox.grid(
1105
- row=1,
1106
- column=0,
1107
- columnspan=2,
1108
- padx=self.PADDING,
1109
- sticky="nsew",
1110
- )
1111
- dialog.columnconfigure(0, weight=1)
1112
- dialog.columnconfigure(1, weight=1)
1113
- dialog.rowconfigure(1, weight=1)
1114
-
1115
- for url in urls:
1116
- listbox.insert(self.tk.END, url)
1117
- listbox.select_set(0)
1118
-
1119
- def choose(_: object | None = None) -> None:
1120
- selection = listbox.curselection()
1121
- if not selection:
1122
- return
1123
- index = selection[0]
1124
- self.server_url_var.set(urls[index])
1125
- dialog.grab_release()
1126
- dialog.destroy()
1127
-
1128
- def cancel() -> None:
1129
- dialog.grab_release()
1130
- dialog.destroy()
1131
-
1132
- listbox.bind("<Double-Button-1>", choose)
1133
- listbox.bind("<Return>", choose)
1134
-
1135
- button_frame = self.ttk.Frame(dialog)
1136
- button_frame.grid(row=2, column=0, columnspan=2, pady=(8, 12))
1137
- self.ttk.Button(button_frame, text="Use server", command=choose).pack(
1138
- side=self.tk.LEFT, padx=(0, 8)
1139
- )
1140
- self.ttk.Button(button_frame, text="Cancel", command=cancel).pack(
1141
- side=self.tk.LEFT
1142
- )
1143
- dialog.protocol("WM_DELETE_WINDOW", cancel)
642
+ discovery_helpers.show_discovery_results(self, urls)
1144
643
 
1145
644
  def _toggle_simple_mode(self) -> None:
1146
- self._update_setting("simple_mode", self.simple_mode_var.get())
645
+ self.preferences.update("simple_mode", self.simple_mode_var.get())
1147
646
  self._apply_simple_mode()
1148
647
 
1149
648
  def _apply_simple_mode(self, *, initial: bool = False) -> None:
1150
- simple = self.simple_mode_var.get()
1151
- if simple:
1152
- self.basic_options_frame.grid_remove()
1153
- self.log_frame.grid_remove()
1154
- self.stop_button.grid_remove()
1155
- self.advanced_button.grid_remove()
1156
- self.advanced_frame.grid_remove()
1157
- self.run_after_drop_var.set(True)
1158
- self._apply_window_size(simple=True)
1159
- else:
1160
- self.basic_options_frame.grid()
1161
- self.log_frame.grid()
1162
- self.advanced_button.grid()
1163
- if self.advanced_visible.get():
1164
- self.advanced_frame.grid()
1165
- self._apply_window_size(simple=False)
1166
-
1167
- if initial and simple:
1168
- # Ensure the hidden widgets do not retain focus outlines on start.
1169
- self.drop_zone.focus_set()
649
+ layout_helpers.apply_simple_mode(self, initial=initial)
1170
650
 
1171
651
  def _apply_window_size(self, *, simple: bool) -> None:
1172
- width, height = self._simple_size if simple else self._full_size
1173
- self.root.update_idletasks()
1174
- self.root.minsize(width, height)
1175
- if simple:
1176
- self.root.geometry(f"{width}x{height}")
1177
- else:
1178
- current_width = self.root.winfo_width()
1179
- current_height = self.root.winfo_height()
1180
- if current_width < width or current_height < height:
1181
- self.root.geometry(f"{width}x{height}")
652
+ layout_helpers.apply_window_size(self, simple=simple)
1182
653
 
1183
654
  def _toggle_advanced(self, *, initial: bool = False) -> None:
1184
655
  if not initial:
@@ -1192,14 +663,14 @@ class TalksReducerGUI:
1192
663
  self.advanced_button.configure(text="Advanced")
1193
664
 
1194
665
  def _on_theme_change(self, *_: object) -> None:
1195
- self._update_setting("theme", self.theme_var.get())
1196
- self._apply_theme()
666
+ self.preferences.update("theme", self.theme_var.get())
667
+ self._refresh_theme()
1197
668
 
1198
669
  def _on_small_video_change(self, *_: object) -> None:
1199
- self._update_setting("small_video", bool(self.small_var.get()))
670
+ self.preferences.update("small_video", bool(self.small_var.get()))
1200
671
 
1201
672
  def _on_open_after_convert_change(self, *_: object) -> None:
1202
- self._update_setting(
673
+ self.preferences.update(
1203
674
  "open_after_convert", bool(self.open_after_convert_var.get())
1204
675
  )
1205
676
 
@@ -1208,216 +679,63 @@ class TalksReducerGUI:
1208
679
  if value not in {"local", "remote"}:
1209
680
  self.processing_mode_var.set("local")
1210
681
  return
1211
- self._update_setting("processing_mode", value)
682
+ self.preferences.update("processing_mode", value)
1212
683
  self._update_processing_mode_state()
1213
684
 
685
+ if self.processing_mode_var.get() == "remote":
686
+ server_url = self.server_url_var.get().strip()
687
+ if not server_url:
688
+ return
689
+
690
+ def ping_remote_mode() -> None:
691
+ self._check_remote_server(
692
+ server_url,
693
+ success_status="Idle",
694
+ waiting_status="Error",
695
+ failure_status="Error",
696
+ failure_message="Server {host} is unreachable. Switching to local mode.",
697
+ switch_to_local_on_failure=True,
698
+ alert_on_failure=True,
699
+ warning_message="Server {host} is unreachable. Switching to local mode.",
700
+ )
701
+
702
+ threading.Thread(target=ping_remote_mode, daemon=True).start()
703
+
1214
704
  def _on_server_url_change(self, *_: object) -> None:
1215
705
  value = self.server_url_var.get().strip()
1216
- self._update_setting("server_url", value)
706
+ self.preferences.update("server_url", value)
1217
707
  self._update_processing_mode_state()
1218
708
 
1219
- def _apply_theme(self) -> None:
709
+ def _resolve_theme_mode(self) -> str:
1220
710
  preference = self.theme_var.get().lower()
1221
711
  if preference not in {"light", "dark"}:
1222
- mode = self._detect_system_theme()
1223
- else:
1224
- mode = preference
1225
-
1226
- palette = LIGHT_THEME if mode == "light" else DARK_THEME
1227
-
1228
- self.root.configure(bg=palette["background"])
1229
- self.style.theme_use("clam")
1230
- self.style.configure(
1231
- ".", background=palette["background"], foreground=palette["foreground"]
1232
- )
1233
- self.style.configure("TFrame", background=palette["background"])
1234
- self.style.configure(
1235
- "TLabelframe",
1236
- background=palette["background"],
1237
- foreground=palette["foreground"],
1238
- borderwidth=0,
1239
- relief="flat",
1240
- )
1241
- self.style.configure(
1242
- "TLabelframe.Label",
1243
- background=palette["background"],
1244
- foreground=palette["foreground"],
1245
- )
1246
- self.style.configure(
1247
- "TLabel", background=palette["background"], foreground=palette["foreground"]
1248
- )
1249
- self.style.configure(
1250
- "TCheckbutton",
1251
- background=palette["background"],
1252
- foreground=palette["foreground"],
1253
- )
1254
- self.style.map(
1255
- "TCheckbutton",
1256
- background=[("active", palette.get("hover", palette["background"]))],
1257
- )
1258
- self.style.configure(
1259
- "TRadiobutton",
1260
- background=palette["background"],
1261
- foreground=palette["foreground"],
1262
- )
1263
- self.style.map(
1264
- "TRadiobutton",
1265
- background=[("active", palette.get("hover", palette["background"]))],
1266
- )
1267
- self.style.configure(
1268
- "Link.TButton",
1269
- background=palette["background"],
1270
- foreground=palette["accent"],
1271
- borderwidth=0,
1272
- relief="flat",
1273
- highlightthickness=0,
1274
- padding=2,
1275
- font=("TkDefaultFont", 8, "underline"),
1276
- )
1277
- self.style.map(
1278
- "Link.TButton",
1279
- background=[
1280
- ("active", palette.get("hover", palette["background"])),
1281
- ("disabled", palette["background"]),
1282
- ],
1283
- foreground=[
1284
- ("active", palette.get("accent", palette["foreground"])),
1285
- ("disabled", palette["foreground"]),
1286
- ],
1287
- )
1288
- self.style.configure(
1289
- "TButton",
1290
- background=palette["surface"],
1291
- foreground=palette["foreground"],
1292
- padding=4,
1293
- font=("TkDefaultFont", 8),
1294
- )
1295
- self.style.map(
1296
- "TButton",
1297
- background=[
1298
- ("active", palette.get("hover", palette["accent"])),
1299
- ("disabled", palette["surface"]),
1300
- ],
1301
- foreground=[
1302
- ("active", palette.get("hover_text", "#000000")),
1303
- ("disabled", palette["foreground"]),
1304
- ],
1305
- )
1306
- self.style.configure(
1307
- "TEntry",
1308
- fieldbackground=palette["surface"],
1309
- foreground=palette["foreground"],
1310
- )
1311
- self.style.configure(
1312
- "TCombobox",
1313
- fieldbackground=palette["surface"],
1314
- foreground=palette["foreground"],
1315
- )
1316
-
1317
- # Configure progress bar styles for different states
1318
- self.style.configure(
1319
- "Idle.Horizontal.TProgressbar",
1320
- background=STATUS_COLORS["idle"],
1321
- troughcolor=palette["surface"],
1322
- borderwidth=0,
1323
- thickness=20,
1324
- )
1325
- self.style.configure(
1326
- "Processing.Horizontal.TProgressbar",
1327
- background=STATUS_COLORS["processing"],
1328
- troughcolor=palette["surface"],
1329
- borderwidth=0,
1330
- thickness=20,
1331
- )
1332
- self.style.configure(
1333
- "Success.Horizontal.TProgressbar",
1334
- background=STATUS_COLORS["success"],
1335
- troughcolor=palette["surface"],
1336
- borderwidth=0,
1337
- thickness=20,
1338
- )
1339
- self.style.configure(
1340
- "Error.Horizontal.TProgressbar",
1341
- background=STATUS_COLORS["error"],
1342
- troughcolor=palette["surface"],
1343
- borderwidth=0,
1344
- thickness=20,
1345
- )
1346
- self.style.configure(
1347
- "Aborted.Horizontal.TProgressbar",
1348
- background=STATUS_COLORS["aborted"],
1349
- troughcolor=palette["surface"],
1350
- borderwidth=0,
1351
- thickness=20,
1352
- )
1353
-
1354
- self.drop_zone.configure(
1355
- bg=palette["surface"],
1356
- fg=palette["foreground"],
1357
- highlightthickness=0,
1358
- )
1359
-
1360
- slider_relief = self.tk.FLAT if mode == "dark" else self.tk.RAISED
1361
- active_background = (
1362
- palette.get("accent", palette["surface"])
1363
- if mode == "dark"
1364
- else palette.get("hover", palette["surface"])
1365
- )
1366
- for slider in getattr(self, "_sliders", []):
1367
- slider.configure(
1368
- # background=palette["background"],
1369
- # foreground=palette["foreground"],
1370
- troughcolor=palette["surface"],
1371
- # activebackground=active_background,
1372
- # highlightbackground=palette["background"],
1373
- highlightcolor=palette["background"],
1374
- sliderrelief=slider_relief,
1375
- bd=0,
712
+ return detect_system_theme(
713
+ os.environ,
714
+ sys.platform,
715
+ read_windows_theme_registry,
716
+ run_defaults_command,
1376
717
  )
1377
- self.log_text.configure(
1378
- bg=palette["surface"],
1379
- fg=palette["foreground"],
1380
- insertbackground=palette["foreground"],
1381
- highlightbackground=palette["border"],
1382
- highlightcolor=palette["border"],
1383
- )
1384
- self.status_label.configure(bg=palette["background"])
1385
-
1386
- self._apply_status_style(self._status_state)
1387
-
1388
- def _detect_system_theme(self) -> str:
1389
- if sys.platform.startswith("win"):
1390
- try:
1391
- import winreg # type: ignore
1392
-
1393
- with winreg.OpenKey(
1394
- winreg.HKEY_CURRENT_USER,
1395
- r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
1396
- ) as key:
1397
- value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
1398
- return "light" if int(value) else "dark"
1399
- except OSError:
1400
- return "light"
1401
- if sys.platform == "darwin":
1402
- try:
1403
- result = subprocess.run(
1404
- ["defaults", "read", "-g", "AppleInterfaceStyle"],
1405
- capture_output=True,
1406
- text=True,
1407
- check=False,
1408
- )
1409
- if result.returncode == 0 and result.stdout.strip().lower() == "dark":
1410
- return "dark"
1411
- except Exception:
1412
- pass
1413
- return "light"
1414
-
1415
- theme = os.environ.get("GTK_THEME", "").lower()
1416
- if "dark" in theme:
1417
- return "dark"
1418
- return "light"
718
+ return preference
1419
719
 
1420
- def _configure_drop_targets(self, widget) -> None: # type: tk.Widget
720
+ def _refresh_theme(self) -> None:
721
+ mode = self._resolve_theme_mode()
722
+ palette = LIGHT_THEME if mode == "light" else DARK_THEME
723
+ apply_theme(
724
+ self.style,
725
+ palette,
726
+ {
727
+ "root": self.root,
728
+ "drop_zone": getattr(self, "drop_zone", None),
729
+ "log_text": getattr(self, "log_text", None),
730
+ "status_label": getattr(self, "status_label", None),
731
+ "sliders": getattr(self, "_sliders", []),
732
+ "tk": self.tk,
733
+ "apply_status_style": self._apply_status_style,
734
+ "status_state": self._status_state,
735
+ },
736
+ )
737
+
738
+ def _configure_drop_targets(self, widget) -> None:
1421
739
  if not self._dnd_available:
1422
740
  return
1423
741
  widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
@@ -1509,124 +827,16 @@ class TalksReducerGUI:
1509
827
  if result:
1510
828
  variable.set(result)
1511
829
 
1512
- def _start_run(self) -> None:
1513
- if self._processing_thread and self._processing_thread.is_alive():
1514
- self.messagebox.showinfo("Processing", "A job is already running.")
1515
- return
1516
-
1517
- if not self.input_files:
1518
- self.messagebox.showwarning(
1519
- "Missing input", "Please add at least one file or folder."
1520
- )
1521
- return
1522
-
1523
- try:
1524
- args = self._collect_arguments()
1525
- except ValueError as exc:
1526
- self.messagebox.showerror("Invalid value", str(exc))
1527
- return
1528
-
1529
- self._append_log("Starting processing…")
1530
- self._stop_requested = False
1531
- open_after_convert = bool(self.open_after_convert_var.get())
1532
- server_url = self.server_url_var.get().strip()
1533
- remote_mode = self.processing_mode_var.get() == "remote"
1534
- if remote_mode and not server_url:
1535
- self.messagebox.showerror(
1536
- "Missing server URL", "Remote mode requires a server URL."
1537
- )
1538
- return
1539
- remote_mode = remote_mode and bool(server_url)
1540
-
1541
- def worker() -> None:
1542
- def set_process(proc: subprocess.Popen) -> None:
1543
- self._ffmpeg_process = proc
1544
-
1545
- try:
1546
- files = gather_input_files(self.input_files)
1547
- if not files:
1548
- self._notify(
1549
- lambda: self.messagebox.showwarning(
1550
- "No files", "No supported media files were found."
1551
- )
1552
- )
1553
- self._set_status("Idle")
1554
- return
1555
-
1556
- if remote_mode:
1557
- success = self._process_files_via_server(
1558
- files,
1559
- args,
1560
- server_url,
1561
- open_after_convert=open_after_convert,
1562
- )
1563
- if success:
1564
- self._notify(self._hide_stop_button)
1565
- return
1566
-
1567
- reporter = _TkProgressReporter(
1568
- self._append_log, process_callback=set_process
1569
- )
1570
- for index, file in enumerate(files, start=1):
1571
- self._append_log(
1572
- f"Processing {index}/{len(files)}: {os.path.basename(file)}"
1573
- )
1574
- options = self._build_options(Path(file), args)
1575
- result = speed_up_video(options, reporter=reporter)
1576
- self._last_output = result.output_file
1577
- self._last_time_ratio = result.time_ratio
1578
- self._last_size_ratio = result.size_ratio
1579
-
1580
- # Create completion message with ratios if available
1581
- completion_msg = f"Completed: {result.output_file}"
1582
- if result.time_ratio is not None and result.size_ratio is not None:
1583
- completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
1584
-
1585
- self._append_log(completion_msg)
1586
- if open_after_convert:
1587
- self._notify(
1588
- lambda path=result.output_file: self._open_in_file_manager(
1589
- path
1590
- )
1591
- )
1592
-
1593
- self._append_log("All jobs finished successfully.")
1594
- self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
1595
- self._notify(self._clear_input_files)
1596
- except FFmpegNotFoundError as exc:
1597
- self._notify(
1598
- lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
1599
- )
1600
- self._set_status("Error")
1601
- except Exception as exc: # pragma: no cover - GUI level safeguard
1602
- # If stop was requested, don't show error (FFmpeg termination is expected)
1603
- if self._stop_requested:
1604
- self._append_log("Processing aborted by user.")
1605
- self._set_status("Aborted")
1606
- else:
1607
- error_msg = f"Processing failed: {exc}"
1608
- self._append_log(error_msg)
1609
- print(error_msg, file=sys.stderr) # Also output to console
1610
- self._notify(lambda: self.messagebox.showerror("Error", error_msg))
1611
- self._set_status("Error")
1612
- finally:
1613
- self._notify(self._hide_stop_button)
1614
-
1615
- self._processing_thread = threading.Thread(target=worker, daemon=True)
1616
- self._processing_thread.start()
1617
-
1618
- # Show Stop button when processing starts
1619
- if remote_mode:
1620
- self.stop_button.grid_remove()
1621
- else:
1622
- self.stop_button.grid()
1623
-
1624
830
  def _stop_processing(self) -> None:
1625
831
  """Stop the currently running processing by terminating FFmpeg."""
1626
832
  import signal
1627
833
 
1628
834
  self._stop_requested = True
1629
- if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
835
+ # Update button text to indicate stopping state
836
+ self.stop_button.configure(text="Stopping...")
837
+ if self._current_remote_mode:
838
+ self._append_log("Cancelling remote job...")
839
+ elif self._ffmpeg_process and self._ffmpeg_process.poll() is None:
1630
840
  self._append_log("Stopping FFmpeg process...")
1631
841
  try:
1632
842
  # Send SIGTERM to FFmpeg process
@@ -1693,101 +903,15 @@ class TalksReducerGUI:
1693
903
  ) -> bool:
1694
904
  """Send *files* to the configured server for processing."""
1695
905
 
1696
- try:
1697
- service_module = importlib.import_module("talks_reducer.service_client")
1698
- except ModuleNotFoundError as exc:
1699
- self._append_log(f"Server client unavailable: {exc}")
1700
- self._notify(
1701
- lambda: self.messagebox.showerror(
1702
- "Server unavailable",
1703
- "Remote processing requires the gradio_client package.",
1704
- )
1705
- )
1706
- self._notify(lambda: self._set_status("Error"))
1707
- return False
1708
-
1709
- host_label = self._format_server_host(server_url)
1710
- self._notify(
1711
- lambda: self._set_status("waiting", f"Waiting server {host_label}...")
906
+ return process_files_via_server(
907
+ self,
908
+ files,
909
+ args,
910
+ server_url,
911
+ open_after_convert=open_after_convert,
912
+ default_remote_destination=_default_remote_destination,
913
+ parse_summary=_parse_ratios_from_summary,
1712
914
  )
1713
- if not self._ping_server(server_url):
1714
- self._append_log(f"Server unreachable: {server_url}")
1715
- self._notify(
1716
- lambda: self._set_status("Error", f"Server {host_label} unreachable")
1717
- )
1718
- return False
1719
-
1720
- self._notify(lambda: self._set_status("waiting", f"Server {host_label} ready"))
1721
-
1722
- output_override = args.get("output_file") if len(files) == 1 else None
1723
- ignored = [key for key in args if key not in {"output_file", "small"}]
1724
- if ignored:
1725
- ignored_options = ", ".join(sorted(ignored))
1726
- self._append_log(
1727
- f"Server mode ignores the following options: {ignored_options}"
1728
- )
1729
-
1730
- small_mode = bool(args.get("small", False))
1731
-
1732
- for index, file in enumerate(files, start=1):
1733
- basename = os.path.basename(file)
1734
- self._append_log(
1735
- f"Uploading {index}/{len(files)}: {basename} to {server_url}"
1736
- )
1737
- input_path = Path(file)
1738
-
1739
- if output_override is not None:
1740
- output_path = Path(output_override)
1741
- if output_path.is_dir():
1742
- output_path = (
1743
- output_path
1744
- / _default_remote_destination(input_path, small=small_mode).name
1745
- )
1746
- else:
1747
- output_path = _default_remote_destination(input_path, small=small_mode)
1748
-
1749
- try:
1750
- destination, summary, log_text = service_module.send_video(
1751
- input_path=input_path,
1752
- output_path=output_path,
1753
- server_url=server_url,
1754
- small=small_mode,
1755
- stream_updates=True,
1756
- log_callback=self._append_log,
1757
- # progress_callback=self._handle_service_progress,
1758
- )
1759
- except Exception as exc: # pragma: no cover - network safeguard
1760
- error_detail = f"{exc.__class__.__name__}: {exc}"
1761
- error_msg = f"Processing failed: {error_detail}"
1762
- self._append_log(error_msg)
1763
- self._notify(lambda: self._set_status("Error"))
1764
- self._notify(
1765
- lambda: self.messagebox.showerror(
1766
- "Server error",
1767
- f"Failed to process {basename}: {error_detail}",
1768
- )
1769
- )
1770
- return False
1771
-
1772
- self._last_output = Path(destination)
1773
- time_ratio, size_ratio = _parse_ratios_from_summary(summary)
1774
- self._last_time_ratio = time_ratio
1775
- self._last_size_ratio = size_ratio
1776
- for line in summary.splitlines():
1777
- self._append_log(line)
1778
- if log_text.strip():
1779
- self._append_log("Server log:")
1780
- for line in log_text.splitlines():
1781
- self._append_log(line)
1782
- if open_after_convert:
1783
- self._notify(
1784
- lambda path=self._last_output: self._open_in_file_manager(path)
1785
- )
1786
-
1787
- self._append_log("All jobs finished successfully.")
1788
- self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
1789
- self._notify(self._clear_input_files)
1790
- return True
1791
915
 
1792
916
  def _parse_float(self, value: str, label: str) -> float:
1793
917
  try:
@@ -1795,7 +919,7 @@ class TalksReducerGUI:
1795
919
  except ValueError as exc: # pragma: no cover - input validation
1796
920
  raise ValueError(f"{label} must be a number.") from exc
1797
921
 
1798
- def _build_options(
922
+ def _create_processing_options(
1799
923
  self, input_file: Path, args: dict[str, object]
1800
924
  ) -> ProcessingOptions:
1801
925
  options = dict(args)
@@ -1840,7 +964,7 @@ class TalksReducerGUI:
1840
964
  def _update_status_from_message(self, message: str) -> None:
1841
965
  normalized = message.strip().lower()
1842
966
  metadata_match = re.search(
1843
- r"source metadata [—-] duration:\s*([\d.]+)s",
967
+ r"source metadata: duration:\s*([\d.]+)s",
1844
968
  message,
1845
969
  re.IGNORECASE,
1846
970
  )
@@ -1850,18 +974,48 @@ class TalksReducerGUI:
1850
974
  except ValueError:
1851
975
  self._source_duration_seconds = None
1852
976
  if "all jobs finished successfully" in normalized:
1853
- # Create status message with ratios if available
1854
- status_msg = "Success"
977
+ status_components: List[str] = []
978
+ if self._run_start_time is not None:
979
+ finish_time = time.monotonic()
980
+ runtime_seconds = max(0.0, finish_time - self._run_start_time)
981
+ duration_str = self._format_progress_time(runtime_seconds)
982
+ status_components.append(f"{duration_str}")
983
+ else:
984
+ finished_seconds = next(
985
+ (
986
+ value
987
+ for value in (
988
+ self._last_progress_seconds,
989
+ self._encode_target_duration_seconds,
990
+ self._video_duration_seconds,
991
+ )
992
+ if value is not None
993
+ ),
994
+ None,
995
+ )
996
+
997
+ if finished_seconds is not None:
998
+ duration_str = self._format_progress_time(finished_seconds)
999
+ status_components.append(f"{duration_str}")
1000
+ else:
1001
+ status_components.append("Finished")
1002
+
1855
1003
  if self._last_time_ratio is not None and self._last_size_ratio is not None:
1856
- status_msg = f"Time: {self._last_time_ratio:.0%}, Size: {self._last_size_ratio:.0%}"
1004
+ status_components.append(
1005
+ f"time: {self._last_time_ratio:.0%}, size: {self._last_size_ratio:.0%}"
1006
+ )
1007
+
1008
+ status_msg = ", ".join(status_components)
1857
1009
 
1858
1010
  self._reset_audio_progress_state(clear_source=True)
1859
1011
  self._set_status("success", status_msg)
1860
1012
  self._set_progress(100) # 100% on success
1013
+ self._run_start_time = None
1861
1014
  self._video_duration_seconds = None # Reset for next video
1862
1015
  self._encode_target_duration_seconds = None
1863
1016
  self._encode_total_frames = None
1864
1017
  self._encode_current_frame = None
1018
+ self._last_progress_seconds = None
1865
1019
  elif normalized.startswith("extracting audio"):
1866
1020
  self._reset_audio_progress_state(clear_source=False)
1867
1021
  self._set_status("processing", "Extracting audio...")
@@ -1870,6 +1024,7 @@ class TalksReducerGUI:
1870
1024
  self._encode_target_duration_seconds = None
1871
1025
  self._encode_total_frames = None
1872
1026
  self._encode_current_frame = None
1027
+ self._last_progress_seconds = None
1873
1028
  self._start_audio_progress()
1874
1029
  elif normalized.startswith("uploading"):
1875
1030
  self._set_status("processing", "Uploading...")
@@ -1881,6 +1036,7 @@ class TalksReducerGUI:
1881
1036
  self._encode_target_duration_seconds = None
1882
1037
  self._encode_total_frames = None
1883
1038
  self._encode_current_frame = None
1039
+ self._last_progress_seconds = None
1884
1040
  elif normalized.startswith("processing"):
1885
1041
  is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
1886
1042
  should_reset = self._status_state.lower() != "processing" or is_new_job
@@ -1890,6 +1046,7 @@ class TalksReducerGUI:
1890
1046
  self._encode_target_duration_seconds = None
1891
1047
  self._encode_total_frames = None
1892
1048
  self._encode_current_frame = None
1049
+ self._last_progress_seconds = None
1893
1050
  if is_new_job:
1894
1051
  self._reset_audio_progress_state(clear_source=True)
1895
1052
  self._set_status("processing", "Processing")
@@ -1962,6 +1119,8 @@ class TalksReducerGUI:
1962
1119
  time_str = self._format_progress_time(current_seconds)
1963
1120
  speed_str = speed_match.group(1)
1964
1121
 
1122
+ self._last_progress_seconds = current_seconds
1123
+
1965
1124
  total_seconds = (
1966
1125
  self._encode_target_duration_seconds or self._video_duration_seconds
1967
1126
  )
@@ -2010,7 +1169,7 @@ class TalksReducerGUI:
2010
1169
  interval_ms, self._advance_audio_progress
2011
1170
  )
2012
1171
 
2013
- self._notify(_start)
1172
+ self._schedule_on_ui_thread(_start)
2014
1173
 
2015
1174
  def _advance_audio_progress(self) -> None:
2016
1175
  self._audio_progress_job = None
@@ -2047,7 +1206,7 @@ class TalksReducerGUI:
2047
1206
  self._audio_progress_job = None
2048
1207
  self._audio_progress_interval_ms = None
2049
1208
 
2050
- self._notify(_cancel)
1209
+ self._schedule_on_ui_thread(_cancel)
2051
1210
 
2052
1211
  def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
2053
1212
  if clear_source:
@@ -2069,7 +1228,7 @@ class TalksReducerGUI:
2069
1228
  if current_value < self.AUDIO_PROGRESS_STEPS:
2070
1229
  self._set_progress(self.AUDIO_PROGRESS_STEPS)
2071
1230
 
2072
- self._notify(_complete)
1231
+ self._schedule_on_ui_thread(_complete)
2073
1232
 
2074
1233
  def _apply_status_style(self, status: str) -> None:
2075
1234
  color = STATUS_COLORS.get(status.lower())
@@ -2081,7 +1240,10 @@ class TalksReducerGUI:
2081
1240
  status_lower = status.lower()
2082
1241
  if (
2083
1242
  "extracting audio" in status_lower
2084
- or re.search(r"\d+:\d{2}(?: / \d+:\d{2})?.*\d+\.?\d*x", status)
1243
+ or re.search(
1244
+ r"\d+:\d{2}(?::\d{2})?(?: / \d+:\d{2}(?::\d{2})?)?.*\d+\.?\d*x",
1245
+ status,
1246
+ )
2085
1247
  or ("time:" in status_lower and "size:" in status_lower)
2086
1248
  ):
2087
1249
  if "time:" in status_lower and "size:" in status_lower:
@@ -2132,7 +1294,7 @@ class TalksReducerGUI:
2132
1294
  self.root.after(0, apply)
2133
1295
 
2134
1296
  def _format_progress_time(self, total_seconds: float) -> str:
2135
- """Format a duration in seconds as h:mm or m:ss for status display."""
1297
+ """Format a duration in seconds as h:mm:ss or m:ss for status display."""
2136
1298
 
2137
1299
  try:
2138
1300
  rounded_seconds = max(0, int(round(total_seconds)))
@@ -2143,7 +1305,7 @@ class TalksReducerGUI:
2143
1305
  minutes, seconds = divmod(remainder, 60)
2144
1306
 
2145
1307
  if hours > 0:
2146
- return f"{hours}:{minutes:02d}"
1308
+ return f"{hours}:{minutes:02d}:{seconds:02d}"
2147
1309
 
2148
1310
  total_minutes = rounded_seconds // 60
2149
1311
  return f"{total_minutes}:{seconds:02d}"
@@ -2193,7 +1355,7 @@ class TalksReducerGUI:
2193
1355
  # Update color based on percentage gradient
2194
1356
  color = self._calculate_gradient_color(percentage, 0.5)
2195
1357
  palette = (
2196
- LIGHT_THEME if self._detect_system_theme() == "light" else DARK_THEME
1358
+ LIGHT_THEME if self._resolve_theme_mode() == "light" else DARK_THEME
2197
1359
  )
2198
1360
  if self.theme_var.get().lower() in {"light", "dark"}:
2199
1361
  palette = (
@@ -2244,7 +1406,7 @@ class TalksReducerGUI:
2244
1406
 
2245
1407
  self.root.after(0, updater)
2246
1408
 
2247
- def _notify(self, callback: Callable[[], None]) -> None:
1409
+ def _schedule_on_ui_thread(self, callback: Callable[[], None]) -> None:
2248
1410
  self.root.after(0, callback)
2249
1411
 
2250
1412
  def run(self) -> None:
@@ -2265,24 +1427,34 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
2265
1427
 
2266
1428
  parser = argparse.ArgumentParser(add_help=False)
2267
1429
  parser.add_argument(
2268
- "--no-tray",
1430
+ "--server",
2269
1431
  action="store_true",
2270
- help="Do not start the Talks Reducer server tray alongside the GUI.",
1432
+ help="Launch the Talks Reducer server tray instead of the desktop GUI.",
2271
1433
  )
2272
1434
  parser.add_argument(
2273
- "--server",
1435
+ "--no-tray",
2274
1436
  action="store_true",
2275
- help="Launch the Talks Reducer server tray instead of the desktop GUI.",
1437
+ help="Deprecated: the GUI no longer starts the server tray automatically.",
2276
1438
  )
2277
1439
 
2278
1440
  parsed_args, remaining = parser.parse_known_args(argv)
2279
- no_tray = parsed_args.no_tray
2280
1441
  if parsed_args.server:
2281
1442
  package_name = __package__ or "talks_reducer"
2282
- tray_module = importlib.import_module(f"{package_name}.server_tray")
1443
+ module_name = f"{package_name}.server_tray"
1444
+ try:
1445
+ tray_module = importlib.import_module(module_name)
1446
+ except ModuleNotFoundError as exc:
1447
+ if exc.name != module_name:
1448
+ raise
1449
+ root_package = package_name.split(".")[0] or "talks_reducer"
1450
+ tray_module = importlib.import_module(f"{root_package}.server_tray")
2283
1451
  tray_main = getattr(tray_module, "main")
2284
1452
  tray_main(remaining)
2285
1453
  return False
1454
+ if parsed_args.no_tray:
1455
+ sys.stderr.write(
1456
+ "Warning: --no-tray is deprecated; the GUI no longer starts the server tray automatically.\n"
1457
+ )
2286
1458
  argv = remaining
2287
1459
 
2288
1460
  if argv:
@@ -2298,8 +1470,6 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
2298
1470
  if launch_gui:
2299
1471
  try:
2300
1472
  app = TalksReducerGUI(argv, auto_run=True)
2301
- if not no_tray:
2302
- _ensure_server_tray_running()
2303
1473
  app.run()
2304
1474
  return True
2305
1475
  except Exception:
@@ -2361,8 +1531,6 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
2361
1531
  # Catch and report any errors during GUI initialization
2362
1532
  try:
2363
1533
  app = TalksReducerGUI()
2364
- if not no_tray:
2365
- _ensure_server_tray_running()
2366
1534
  app.run()
2367
1535
  return True
2368
1536
  except Exception as e: