talks-reducer 0.6.3__py3-none-any.whl → 0.7.1__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,38 +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
- }
166
+ from . import discovery as discovery_helpers
167
+ from . import layout as layout_helpers
168
168
 
169
169
 
170
170
  def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
@@ -256,9 +256,12 @@ class _TkProgressReporter(SignalProgressReporter):
256
256
  self,
257
257
  log_callback: Callable[[str], None],
258
258
  process_callback: Optional[Callable] = None,
259
+ *,
260
+ stop_callback: Optional[Callable[[], bool]] = None,
259
261
  ) -> None:
260
262
  self._log_callback = log_callback
261
263
  self.process_callback = process_callback
264
+ self._stop_callback = stop_callback
262
265
 
263
266
  def log(self, message: str) -> None:
264
267
  self._log_callback(message)
@@ -270,6 +273,13 @@ class _TkProgressReporter(SignalProgressReporter):
270
273
  del total, unit
271
274
  return _GuiProgressHandle(self._log_callback, desc)
272
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
+
273
283
 
274
284
  class TalksReducerGUI:
275
285
  """Tkinter application mirroring the CLI options with form controls."""
@@ -280,72 +290,14 @@ class TalksReducerGUI:
280
290
  MIN_AUDIO_INTERVAL_MS = 10
281
291
  DEFAULT_AUDIO_INTERVAL_MS = 200
282
292
 
283
- def _determine_config_path(self) -> Path:
284
- if sys.platform == "win32":
285
- appdata = os.environ.get("APPDATA")
286
- base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
287
- elif sys.platform == "darwin":
288
- base = Path.home() / "Library" / "Application Support"
289
- else:
290
- xdg_config = os.environ.get("XDG_CONFIG_HOME")
291
- base = Path(xdg_config) if xdg_config else Path.home() / ".config"
292
- return base / "talks-reducer" / "settings.json"
293
-
294
- def _load_settings(self) -> dict[str, object]:
295
- try:
296
- with self._config_path.open("r", encoding="utf-8") as handle:
297
- data = json.load(handle)
298
- if isinstance(data, dict):
299
- return data
300
- except FileNotFoundError:
301
- return {}
302
- except (OSError, json.JSONDecodeError):
303
- return {}
304
- return {}
305
-
306
- def _save_settings(self) -> None:
307
- try:
308
- self._config_path.parent.mkdir(parents=True, exist_ok=True)
309
- with self._config_path.open("w", encoding="utf-8") as handle:
310
- json.dump(self._settings, handle, indent=2, sort_keys=True)
311
- except OSError:
312
- pass
313
-
314
- def _get_setting(self, key: str, default: object) -> object:
315
- value = self._settings.get(key, default)
316
- if key not in self._settings:
317
- self._settings[key] = value
318
- return value
319
-
320
- def _get_float_setting(self, key: str, default: float) -> float:
321
- """Return *key* as a float, coercing stored strings when necessary."""
322
-
323
- raw_value = self._get_setting(key, default)
324
- try:
325
- number = float(raw_value)
326
- except (TypeError, ValueError):
327
- number = float(default)
328
-
329
- if self._settings.get(key) != number:
330
- self._settings[key] = number
331
- self._save_settings()
332
-
333
- return number
334
-
335
- def _update_setting(self, key: str, value: object) -> None:
336
- if self._settings.get(key) == value:
337
- return
338
- self._settings[key] = value
339
- self._save_settings()
340
-
341
293
  def __init__(
342
294
  self,
343
295
  initial_inputs: Optional[Sequence[str]] = None,
344
296
  *,
345
297
  auto_run: bool = False,
346
298
  ) -> None:
347
- self._config_path = self._determine_config_path()
348
- self._settings = self._load_settings()
299
+ self._config_path = determine_config_path()
300
+ self.preferences = GUIPreferences(self._config_path)
349
301
 
350
302
  # Import tkinter here to avoid loading it at module import time
351
303
  import tkinter as tk
@@ -380,6 +332,8 @@ class TalksReducerGUI:
380
332
  self._last_output: Optional[Path] = None
381
333
  self._last_time_ratio: Optional[float] = None
382
334
  self._last_size_ratio: Optional[float] = None
335
+ self._last_progress_seconds: Optional[int] = None
336
+ self._run_start_time: Optional[float] = None
383
337
  self._status_state = "Idle"
384
338
  self.status_var = tk.StringVar(value=self._status_state)
385
339
  self._status_animation_job: Optional[str] = None
@@ -395,32 +349,34 @@ class TalksReducerGUI:
395
349
  self.progress_var = tk.IntVar(value=0)
396
350
  self._ffmpeg_process: Optional[subprocess.Popen] = None
397
351
  self._stop_requested = False
352
+ self._ping_worker_stop_requested = False
353
+ self._current_remote_mode = False
398
354
 
399
355
  self.input_files: List[str] = []
400
356
 
401
357
  self._dnd_available = TkinterDnD is not None and DND_FILES is not None
402
358
 
403
359
  self.simple_mode_var = tk.BooleanVar(
404
- value=self._get_setting("simple_mode", True)
360
+ value=self.preferences.get("simple_mode", True)
405
361
  )
406
362
  self.run_after_drop_var = tk.BooleanVar(value=True)
407
- 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))
408
364
  self.open_after_convert_var = tk.BooleanVar(
409
- value=self._get_setting("open_after_convert", True)
365
+ value=self.preferences.get("open_after_convert", True)
410
366
  )
411
- stored_mode = str(self._get_setting("processing_mode", "local"))
367
+ stored_mode = str(self.preferences.get("processing_mode", "local"))
412
368
  if stored_mode not in {"local", "remote"}:
413
369
  stored_mode = "local"
414
370
  self.processing_mode_var = tk.StringVar(value=stored_mode)
415
371
  self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
416
- self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
372
+ self.theme_var = tk.StringVar(value=self.preferences.get("theme", "os"))
417
373
  self.theme_var.trace_add("write", self._on_theme_change)
418
374
  self.small_var.trace_add("write", self._on_small_video_change)
419
375
  self.open_after_convert_var.trace_add(
420
376
  "write", self._on_open_after_convert_change
421
377
  )
422
378
  self.server_url_var = tk.StringVar(
423
- value=str(self._get_setting("server_url", ""))
379
+ value=str(self.preferences.get("server_url", ""))
424
380
  )
425
381
  self.server_url_var.trace_add("write", self._on_server_url_change)
426
382
  self._discovery_thread: Optional[threading.Thread] = None
@@ -433,50 +389,38 @@ class TalksReducerGUI:
433
389
  self._build_layout()
434
390
  self._apply_simple_mode(initial=True)
435
391
  self._apply_status_style(self._status_state)
436
- self._apply_theme()
437
- self._save_settings()
392
+ self._refresh_theme()
393
+ self.preferences.save()
438
394
  self._hide_stop_button()
439
395
 
440
396
  # Ping server on startup if in remote mode
441
397
  if (
442
398
  self.processing_mode_var.get() == "remote"
443
399
  and self.server_url_var.get().strip()
444
- and hasattr(self, "_ping_server")
445
400
  ):
446
401
  server_url = self.server_url_var.get().strip()
447
- host_label = self._format_server_host(server_url)
448
402
 
449
403
  def ping_worker() -> None:
450
404
  try:
451
- if self._ping_server(server_url):
452
- self._set_status("Idle", f"Server {host_label} is ready")
453
- self._notify(
454
- lambda: self._append_log(f"Server {host_label} ready")
455
- )
456
- else:
457
- self._set_status(
458
- "Error", f"Server {host_label} is not reachable"
459
- )
460
- self._notify(
461
- lambda: self._append_log(
462
- f"Server {host_label} is not reachable"
463
- )
464
- )
465
- ping_worker()
466
- except Exception as exc:
467
- self._set_status(
468
- "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,
469
412
  )
470
- self._notify(
471
- lambda: self._append_log(
472
- f"Error pinging server {host_label}: {exc}"
473
- )
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)
474
421
  )
475
422
 
476
- import threading
477
-
478
- ping_thread = threading.Thread(target=ping_worker, daemon=True)
479
- ping_thread.start()
423
+ threading.Thread(target=ping_worker, daemon=True).start()
480
424
 
481
425
  if not self._dnd_available:
482
426
  self._append_log(
@@ -486,477 +430,144 @@ class TalksReducerGUI:
486
430
  if initial_inputs:
487
431
  self._populate_initial_inputs(initial_inputs, auto_run=auto_run)
488
432
 
489
- # ------------------------------------------------------------------ UI --
490
- def _apply_window_icon(self) -> None:
491
- """Configure the application icon when the asset is available."""
492
-
493
- base_path = Path(
494
- getattr(sys, "_MEIPASS", Path(__file__).resolve().parent.parent)
495
- )
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
496
437
 
497
- icon_candidates: list[tuple[Path, str]] = []
498
- if sys.platform.startswith("win"):
499
- icon_candidates.append(
500
- (
501
- base_path
502
- / "talks_reducer"
503
- / "resources"
504
- / "icons"
505
- / "icon.ico",
506
- "ico",
507
- )
508
- )
509
- icon_candidates.append(
510
- (
511
- base_path
512
- / "talks_reducer"
513
- / "resources"
514
- / "icons"
515
- / "icon.png",
516
- "png",
438
+ if not self.input_files:
439
+ self.messagebox.showwarning(
440
+ "Missing input", "Please add at least one file or folder."
517
441
  )
518
- )
519
-
520
- for icon_path, icon_type in icon_candidates:
521
- if not icon_path.is_file():
522
- continue
523
-
524
- try:
525
- if icon_type == "ico" and sys.platform.startswith("win"):
526
- # On Windows, iconbitmap works better without the 'default' parameter
527
- self.root.iconbitmap(str(icon_path))
528
- else:
529
- self.root.iconphoto(False, self.tk.PhotoImage(file=str(icon_path)))
530
- # If we got here without exception, icon was set successfully
531
- return
532
- except (self.tk.TclError, Exception) as e:
533
- # Missing Tk image support or invalid icon format - try next candidate
534
- continue
535
-
536
- def _build_layout(self) -> None:
537
- main = self.ttk.Frame(self.root, padding=self.PADDING)
538
- main.grid(row=0, column=0, sticky="nsew")
539
- self.root.columnconfigure(0, weight=1)
540
- self.root.rowconfigure(0, weight=1)
541
-
542
- # Input selection frame
543
- input_frame = self.ttk.Frame(main, padding=self.PADDING)
544
- input_frame.grid(row=0, column=0, sticky="nsew")
545
- main.rowconfigure(0, weight=1)
546
- main.columnconfigure(0, weight=1)
547
- input_frame.columnconfigure(0, weight=1)
548
- input_frame.rowconfigure(0, weight=1)
549
-
550
- self.drop_zone = self.tk.Label(
551
- input_frame,
552
- text="Drop video here",
553
- relief=self.tk.FLAT,
554
- borderwidth=0,
555
- padx=self.PADDING,
556
- pady=self.PADDING,
557
- highlightthickness=0,
558
- )
559
- self.drop_zone.grid(row=0, column=0, sticky="nsew")
560
- self._configure_drop_targets(self.drop_zone)
561
- self.drop_zone.configure(cursor="hand2", takefocus=1)
562
- self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
563
- self.drop_zone.bind("<Return>", self._on_drop_zone_click)
564
- self.drop_zone.bind("<space>", self._on_drop_zone_click)
565
-
566
- # Options frame
567
- self.options_frame = self.ttk.Frame(main, padding=self.PADDING)
568
- self.options_frame.grid(row=2, column=0, pady=(0, 0), sticky="ew")
569
- self.options_frame.columnconfigure(0, weight=1)
570
-
571
- checkbox_frame = self.ttk.Frame(self.options_frame)
572
- checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
573
-
574
- self.ttk.Checkbutton(
575
- checkbox_frame,
576
- text="Small video",
577
- variable=self.small_var,
578
- ).grid(row=0, column=0, sticky="w")
579
-
580
- self.ttk.Checkbutton(
581
- checkbox_frame,
582
- text="Open after convert",
583
- variable=self.open_after_convert_var,
584
- ).grid(row=0, column=1, sticky="w", padx=(12, 0))
585
-
586
- self.simple_mode_check = self.ttk.Checkbutton(
587
- checkbox_frame,
588
- text="Simple mode",
589
- variable=self.simple_mode_var,
590
- command=self._toggle_simple_mode,
591
- )
592
- self.simple_mode_check.grid(
593
- row=1, column=0, columnspan=3, sticky="w", pady=(8, 0)
594
- )
595
-
596
- self.advanced_visible = self.tk.BooleanVar(value=False)
597
-
598
- basic_label_container = self.ttk.Frame(self.options_frame)
599
- basic_label = self.ttk.Label(basic_label_container, text="Basic options")
600
- basic_label.pack(side=self.tk.LEFT)
601
-
602
- self.reset_basic_button = self.ttk.Button(
603
- basic_label_container,
604
- text="Reset to defaults",
605
- command=self._reset_basic_defaults,
606
- state=self.tk.DISABLED,
607
- style="Link.TButton",
608
- )
609
-
610
- self.basic_options_frame = self.ttk.Labelframe(
611
- self.options_frame, padding=0, labelwidget=basic_label_container
612
- )
613
- self.basic_options_frame.grid(
614
- row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)
615
- )
616
- self.basic_options_frame.columnconfigure(1, weight=1)
617
-
618
- self._reset_button_visible = False
619
-
620
- self.silent_speed_var = self.tk.DoubleVar(
621
- value=min(max(self._get_float_setting("silent_speed", 4.0), 1.0), 10.0)
622
- )
623
- self._add_slider(
624
- self.basic_options_frame,
625
- "Silent speed",
626
- self.silent_speed_var,
627
- row=0,
628
- setting_key="silent_speed",
629
- minimum=1.0,
630
- maximum=10.0,
631
- resolution=0.5,
632
- display_format="{:.1f}×",
633
- default_value=4.0,
634
- )
635
-
636
- self.sounded_speed_var = self.tk.DoubleVar(
637
- value=min(max(self._get_float_setting("sounded_speed", 1.0), 0.75), 2.0)
638
- )
639
- self._add_slider(
640
- self.basic_options_frame,
641
- "Sounded speed",
642
- self.sounded_speed_var,
643
- row=1,
644
- setting_key="sounded_speed",
645
- minimum=0.75,
646
- maximum=2.0,
647
- resolution=0.25,
648
- display_format="{:.2f}×",
649
- default_value=1.0,
650
- )
651
-
652
- self.silent_threshold_var = self.tk.DoubleVar(
653
- value=min(max(self._get_float_setting("silent_threshold", 0.05), 0.0), 1.0)
654
- )
655
- self._add_slider(
656
- self.basic_options_frame,
657
- "Silent threshold",
658
- self.silent_threshold_var,
659
- row=2,
660
- setting_key="silent_threshold",
661
- minimum=0.0,
662
- maximum=1.0,
663
- resolution=0.01,
664
- display_format="{:.2f}",
665
- default_value=0.05,
666
- )
667
-
668
- self.ttk.Label(self.basic_options_frame, text="Server URL").grid(
669
- row=3, column=0, sticky="w", pady=4
670
- )
671
- stored_server_url = str(
672
- self._get_setting("server_url", "http://localhost:9005")
673
- )
674
- if not stored_server_url:
675
- stored_server_url = "http://localhost:9005"
676
- self._update_setting("server_url", stored_server_url)
677
- self.server_url_var.set(stored_server_url)
678
- self.server_url_entry = self.ttk.Entry(
679
- self.basic_options_frame, textvariable=self.server_url_var, width=30
680
- )
681
- self.server_url_entry.grid(row=3, column=1, sticky="w", pady=4)
682
- self.server_discover_button = self.ttk.Button(
683
- self.basic_options_frame, text="Discover", command=self._start_discovery
684
- )
685
- self.server_discover_button.grid(row=3, column=2, padx=(8, 0))
686
-
687
- self.ttk.Label(self.basic_options_frame, text="Processing mode").grid(
688
- row=4, column=0, sticky="w", pady=4
689
- )
690
- mode_choice = self.ttk.Frame(self.basic_options_frame)
691
- mode_choice.grid(row=4, column=1, columnspan=2, sticky="w", pady=4)
692
- self.ttk.Radiobutton(
693
- mode_choice,
694
- text="Local",
695
- value="local",
696
- variable=self.processing_mode_var,
697
- ).pack(side=self.tk.LEFT, padx=(0, 8))
698
- self.remote_mode_button = self.ttk.Radiobutton(
699
- mode_choice,
700
- text="Remote",
701
- value="remote",
702
- variable=self.processing_mode_var,
703
- )
704
- self.remote_mode_button.pack(side=self.tk.LEFT, padx=(0, 8))
705
-
706
- self.ttk.Label(self.basic_options_frame, text="Theme").grid(
707
- row=5, column=0, sticky="w", pady=(8, 0)
708
- )
709
- theme_choice = self.ttk.Frame(self.basic_options_frame)
710
- theme_choice.grid(row=5, column=1, columnspan=2, sticky="w", pady=(8, 0))
711
- for value, label in ("os", "OS"), ("light", "Light"), ("dark", "Dark"):
712
- self.ttk.Radiobutton(
713
- theme_choice,
714
- text=label,
715
- value=value,
716
- variable=self.theme_var,
717
- command=self._apply_theme,
718
- ).pack(side=self.tk.LEFT, padx=(0, 8))
719
-
720
- self.advanced_button = self.ttk.Button(
721
- self.options_frame,
722
- text="Advanced",
723
- command=self._toggle_advanced,
724
- )
725
- self.advanced_button.grid(
726
- row=3, column=0, columnspan=2, sticky="w", pady=(12, 0)
727
- )
728
-
729
- self.advanced_frame = self.ttk.Frame(self.options_frame, padding=0)
730
- self.advanced_frame.grid(row=4, column=0, columnspan=2, sticky="nsew")
731
- self.advanced_frame.columnconfigure(1, weight=1)
732
-
733
- self.output_var = self.tk.StringVar()
734
- self._add_entry(
735
- self.advanced_frame, "Output file", self.output_var, row=0, browse=True
736
- )
737
-
738
- self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
739
- self._add_entry(
740
- self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
741
- )
742
-
743
- self.sample_rate_var = self.tk.StringVar(value="48000")
744
- self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=2)
442
+ return
745
443
 
746
- frame_margin_setting = self._get_setting("frame_margin", 2)
747
444
  try:
748
- frame_margin_default = int(frame_margin_setting)
749
- except (TypeError, ValueError):
750
- frame_margin_default = 2
751
- 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
752
449
 
753
- self.frame_margin_var = self.tk.StringVar(value=str(frame_margin_default))
754
- self._add_entry(
755
- self.advanced_frame, "Frame margin", self.frame_margin_var, row=3
756
- )
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)
757
463
 
758
- self._toggle_advanced(initial=True)
759
- self._update_processing_mode_state()
760
- self._update_basic_reset_state()
761
-
762
- # Action buttons and log output
763
- status_frame = self.ttk.Frame(main, padding=self.PADDING)
764
- status_frame.grid(row=1, column=0, sticky="ew")
765
- status_frame.columnconfigure(0, weight=0)
766
- status_frame.columnconfigure(1, weight=1)
767
- status_frame.columnconfigure(2, weight=0)
768
- self.status_frame = status_frame
769
-
770
- self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
771
- self.status_label = self.tk.Label(
772
- status_frame, textvariable=self.status_var, anchor="e"
773
- )
774
- self.status_label.grid(row=0, column=1, sticky="e")
775
-
776
- # Progress bar
777
- self.progress_bar = self.ttk.Progressbar(
778
- status_frame,
779
- variable=self.progress_var,
780
- maximum=100,
781
- mode="determinate",
782
- style="Idle.Horizontal.TProgressbar",
783
- )
784
- 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
785
466
 
786
- self.stop_button = self.ttk.Button(
787
- status_frame, text="Stop", command=self._stop_processing
788
- )
789
- self.stop_button.grid(
790
- row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
791
- )
792
- 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
793
470
 
794
- self.open_button = self.ttk.Button(
795
- status_frame,
796
- text="Open last",
797
- command=self._open_last_output,
798
- state=self.tk.DISABLED,
799
- )
800
- self.open_button.grid(
801
- row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
802
- )
803
- 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
804
481
 
805
- # Button shown when no other action buttons are visible
806
- self.drop_hint_button = self.ttk.Button(
807
- status_frame,
808
- text="Drop video to convert",
809
- state=self.tk.DISABLED,
810
- )
811
- self.drop_hint_button.grid(
812
- row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING
813
- )
814
- self.drop_hint_button.grid_remove() # Hidden by default
815
- 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
816
496
 
817
- self.log_frame = self.ttk.Frame(main, padding=self.PADDING)
818
- self.log_frame.grid(row=3, column=0, pady=(16, 0), sticky="nsew")
819
- main.rowconfigure(4, weight=1)
820
- self.log_frame.columnconfigure(0, weight=1)
821
- 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
822
509
 
823
- self.log_text = self.tk.Text(
824
- self.log_frame, wrap="word", height=10, state=self.tk.DISABLED
825
- )
826
- self.log_text.grid(row=0, column=0, sticky="nsew")
827
- log_scroll = self.ttk.Scrollbar(
828
- self.log_frame, orient=self.tk.VERTICAL, command=self.log_text.yview
829
- )
830
- log_scroll.grid(row=0, column=1, sticky="ns")
831
- 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%})"
832
514
 
833
- def _add_entry(
834
- self,
835
- parent, # type: tk.Misc
836
- label: str,
837
- variable, # type: tk.StringVar
838
- *,
839
- row: int,
840
- browse: bool = False,
841
- ) -> None:
842
- self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
843
- entry = self.ttk.Entry(parent, textvariable=variable)
844
- entry.grid(row=row, column=1, sticky="ew", pady=4)
845
- if browse:
846
- button = self.ttk.Button(
847
- parent,
848
- text="Browse",
849
- command=lambda var=variable: self._browse_path(var, label),
850
- )
851
- 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
+ )
852
522
 
853
- def _add_slider(
854
- self,
855
- parent, # type: tk.Misc
856
- label: str,
857
- variable, # type: tk.DoubleVar
858
- *,
859
- row: int,
860
- setting_key: str,
861
- minimum: float,
862
- maximum: float,
863
- resolution: float,
864
- display_format: str,
865
- default_value: float,
866
- ) -> None:
867
- self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
868
-
869
- value_label = self.ttk.Label(parent)
870
- value_label.grid(row=row, column=2, sticky="e", pady=4)
871
-
872
- def update(value: str) -> None:
873
- numeric = float(value)
874
- clamped = max(minimum, min(maximum, numeric))
875
- steps = round((clamped - minimum) / resolution)
876
- quantized = minimum + steps * resolution
877
- if abs(variable.get() - quantized) > 1e-9:
878
- variable.set(quantized)
879
- value_label.configure(text=display_format.format(quantized))
880
- self._update_setting(setting_key, float(f"{quantized:.6f}"))
881
- self._update_basic_reset_state()
882
-
883
- slider = self.tk.Scale(
884
- parent,
885
- variable=variable,
886
- from_=minimum,
887
- to=maximum,
888
- orient=self.tk.HORIZONTAL,
889
- resolution=resolution,
890
- showvalue=False,
891
- command=update,
892
- length=240,
893
- highlightthickness=0,
894
- )
895
- 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)
896
552
 
897
- update(str(variable.get()))
553
+ self._processing_thread = threading.Thread(target=worker, daemon=True)
554
+ self._processing_thread.start()
898
555
 
899
- self._slider_updaters[setting_key] = update
900
- self._basic_defaults[setting_key] = default_value
901
- self._basic_variables[setting_key] = variable
902
- variable.trace_add("write", lambda *_: self._update_basic_reset_state())
903
- self._sliders.append(slider)
556
+ # Show Stop button when processing starts regardless of mode
557
+ self.stop_button.grid()
904
558
 
905
- def _update_basic_reset_state(self) -> None:
906
- """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)
907
562
 
908
- if not hasattr(self, "reset_basic_button"):
909
- return
563
+ def _build_layout(self) -> None:
564
+ layout_helpers.build_layout(self)
910
565
 
911
- should_enable = False
912
- for key, default_value in self._basic_defaults.items():
913
- variable = self._basic_variables.get(key)
914
- if variable is None:
915
- continue
916
- try:
917
- current_value = float(variable.get())
918
- except (TypeError, ValueError):
919
- should_enable = True
920
- break
921
- if abs(current_value - default_value) > 1e-9:
922
- should_enable = True
923
- break
924
-
925
- if should_enable:
926
- if not getattr(self, "_reset_button_visible", False):
927
- self.reset_basic_button.pack(side=self.tk.LEFT, padx=(8, 0))
928
- self._reset_button_visible = True
929
- self.reset_basic_button.configure(state=self.tk.NORMAL)
930
- else:
931
- if getattr(self, "_reset_button_visible", False):
932
- self.reset_basic_button.pack_forget()
933
- self._reset_button_visible = False
934
- 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)
935
568
 
936
569
  def _reset_basic_defaults(self) -> None:
937
- """Restore the basic numeric controls to their default values."""
938
-
939
- for key, default_value in self._basic_defaults.items():
940
- variable = self._basic_variables.get(key)
941
- if variable is None:
942
- continue
943
-
944
- try:
945
- current_value = float(variable.get())
946
- except (TypeError, ValueError):
947
- current_value = default_value
948
-
949
- if abs(current_value - default_value) <= 1e-9:
950
- continue
951
-
952
- variable.set(default_value)
953
- updater = self._slider_updaters.get(key)
954
- if updater is not None:
955
- updater(str(default_value))
956
- else:
957
- self._update_setting(key, float(f"{default_value:.6f}"))
958
-
959
- self._update_basic_reset_state()
570
+ layout_helpers.reset_basic_defaults(self)
960
571
 
961
572
  def _update_processing_mode_state(self) -> None:
962
573
  has_url = bool(self.server_url_var.get().strip())
@@ -969,202 +580,76 @@ class TalksReducerGUI:
969
580
  self.remote_mode_button.configure(state=state)
970
581
 
971
582
  def _normalize_server_url(self, server_url: str) -> str:
972
- parsed = urllib.parse.urlsplit(server_url)
973
- if not parsed.scheme:
974
- parsed = urllib.parse.urlsplit(f"http://{server_url}")
975
-
976
- netloc = parsed.netloc or parsed.path
977
- if not netloc:
978
- return server_url
979
-
980
- path = parsed.path if parsed.netloc else ""
981
- normalized_path = path or "/"
982
- return urllib.parse.urlunsplit((parsed.scheme, netloc, normalized_path, "", ""))
583
+ return normalize_server_url(server_url)
983
584
 
984
585
  def _format_server_host(self, server_url: str) -> str:
985
- parsed = urllib.parse.urlsplit(server_url)
986
- if not parsed.scheme:
987
- parsed = urllib.parse.urlsplit(f"http://{server_url}")
988
-
989
- host = parsed.netloc or parsed.path or server_url
990
- if parsed.netloc and parsed.path and parsed.path not in {"", "/"}:
991
- host = f"{parsed.netloc}{parsed.path}"
586
+ return format_server_host(server_url)
992
587
 
993
- host = host.rstrip("/").split(":")[0]
994
- return host or server_url
995
-
996
- def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
997
- normalized = self._normalize_server_url(server_url)
998
- request = urllib.request.Request(
999
- normalized,
1000
- headers={"User-Agent": "talks-reducer-gui"},
1001
- 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,
1002
624
  )
1003
625
 
1004
- try:
1005
- with urllib.request.urlopen(request, timeout=timeout) as response:
1006
- status = getattr(response, "status", None)
1007
- if status is None:
1008
- status = response.getcode()
1009
- if status is None:
1010
- return False
1011
- return 200 <= int(status) < 500
1012
- except (urllib.error.URLError, ValueError):
1013
- return False
626
+ def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
627
+ return ping_server(server_url, timeout=timeout)
1014
628
 
1015
629
  def _start_discovery(self) -> None:
1016
- """Search the local network for running Talks Reducer servers."""
1017
-
1018
- if self._discovery_thread and self._discovery_thread.is_alive():
1019
- return
1020
-
1021
- self.server_discover_button.configure(
1022
- state=self.tk.DISABLED, text="Discovering…"
1023
- )
1024
- self._append_log("Discovering Talks Reducer servers on port 9005…")
1025
-
1026
- def worker() -> None:
1027
- try:
1028
- urls = discover_servers(
1029
- progress_callback=lambda current, total: self._notify(
1030
- lambda c=current, t=total: self._on_discovery_progress(c, t)
1031
- )
1032
- )
1033
- except Exception as exc: # pragma: no cover - network failure safeguard
1034
- self._notify(lambda: self._on_discovery_failed(exc))
1035
- return
1036
- self._notify(lambda: self._on_discovery_complete(urls))
1037
-
1038
- self._discovery_thread = threading.Thread(target=worker, daemon=True)
1039
- self._discovery_thread.start()
630
+ discovery_helpers.start_discovery(self)
1040
631
 
1041
632
  def _on_discovery_failed(self, exc: Exception) -> None:
1042
- self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
1043
- message = f"Discovery failed: {exc}"
1044
- self._append_log(message)
1045
- self.messagebox.showerror("Discovery failed", message)
633
+ discovery_helpers.on_discovery_failed(self, exc)
1046
634
 
1047
635
  def _on_discovery_progress(self, current: int, total: int) -> None:
1048
- if total > 0:
1049
- bounded = max(0, min(current, total))
1050
- label = f"{bounded} / {total}"
1051
- else:
1052
- label = "Discovering…"
1053
- self.server_discover_button.configure(text=label)
636
+ discovery_helpers.on_discovery_progress(self, current, total)
1054
637
 
1055
638
  def _on_discovery_complete(self, urls: List[str]) -> None:
1056
- self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
1057
- if not urls:
1058
- self._append_log("No Talks Reducer servers were found.")
1059
- self.messagebox.showinfo(
1060
- "No servers found",
1061
- "No Talks Reducer servers responded on port 9005.",
1062
- )
1063
- return
1064
-
1065
- self._append_log(
1066
- f"Discovered {len(urls)} server{'s' if len(urls) != 1 else ''}."
1067
- )
1068
-
1069
- if len(urls) == 1:
1070
- self.server_url_var.set(urls[0])
1071
- return
1072
-
1073
- self._show_discovery_results(urls)
639
+ discovery_helpers.on_discovery_complete(self, urls)
1074
640
 
1075
641
  def _show_discovery_results(self, urls: List[str]) -> None:
1076
- dialog = self.tk.Toplevel(self.root)
1077
- dialog.title("Select server")
1078
- dialog.transient(self.root)
1079
- dialog.grab_set()
1080
-
1081
- self.ttk.Label(dialog, text="Select a Talks Reducer server:").grid(
1082
- row=0, column=0, columnspan=2, sticky="w", padx=self.PADDING, pady=(12, 4)
1083
- )
1084
-
1085
- listbox = self.tk.Listbox(
1086
- dialog,
1087
- height=min(10, len(urls)),
1088
- selectmode=self.tk.SINGLE,
1089
- )
1090
- listbox.grid(
1091
- row=1,
1092
- column=0,
1093
- columnspan=2,
1094
- padx=self.PADDING,
1095
- sticky="nsew",
1096
- )
1097
- dialog.columnconfigure(0, weight=1)
1098
- dialog.columnconfigure(1, weight=1)
1099
- dialog.rowconfigure(1, weight=1)
1100
-
1101
- for url in urls:
1102
- listbox.insert(self.tk.END, url)
1103
- listbox.select_set(0)
1104
-
1105
- def choose(_: object | None = None) -> None:
1106
- selection = listbox.curselection()
1107
- if not selection:
1108
- return
1109
- index = selection[0]
1110
- self.server_url_var.set(urls[index])
1111
- dialog.grab_release()
1112
- dialog.destroy()
1113
-
1114
- def cancel() -> None:
1115
- dialog.grab_release()
1116
- dialog.destroy()
1117
-
1118
- listbox.bind("<Double-Button-1>", choose)
1119
- listbox.bind("<Return>", choose)
1120
-
1121
- button_frame = self.ttk.Frame(dialog)
1122
- button_frame.grid(row=2, column=0, columnspan=2, pady=(8, 12))
1123
- self.ttk.Button(button_frame, text="Use server", command=choose).pack(
1124
- side=self.tk.LEFT, padx=(0, 8)
1125
- )
1126
- self.ttk.Button(button_frame, text="Cancel", command=cancel).pack(
1127
- side=self.tk.LEFT
1128
- )
1129
- dialog.protocol("WM_DELETE_WINDOW", cancel)
642
+ discovery_helpers.show_discovery_results(self, urls)
1130
643
 
1131
644
  def _toggle_simple_mode(self) -> None:
1132
- self._update_setting("simple_mode", self.simple_mode_var.get())
645
+ self.preferences.update("simple_mode", self.simple_mode_var.get())
1133
646
  self._apply_simple_mode()
1134
647
 
1135
648
  def _apply_simple_mode(self, *, initial: bool = False) -> None:
1136
- simple = self.simple_mode_var.get()
1137
- if simple:
1138
- self.basic_options_frame.grid_remove()
1139
- self.log_frame.grid_remove()
1140
- self.stop_button.grid_remove()
1141
- self.advanced_button.grid_remove()
1142
- self.advanced_frame.grid_remove()
1143
- self.run_after_drop_var.set(True)
1144
- self._apply_window_size(simple=True)
1145
- else:
1146
- self.basic_options_frame.grid()
1147
- self.log_frame.grid()
1148
- self.advanced_button.grid()
1149
- if self.advanced_visible.get():
1150
- self.advanced_frame.grid()
1151
- self._apply_window_size(simple=False)
1152
-
1153
- if initial and simple:
1154
- # Ensure the hidden widgets do not retain focus outlines on start.
1155
- self.drop_zone.focus_set()
649
+ layout_helpers.apply_simple_mode(self, initial=initial)
1156
650
 
1157
651
  def _apply_window_size(self, *, simple: bool) -> None:
1158
- width, height = self._simple_size if simple else self._full_size
1159
- self.root.update_idletasks()
1160
- self.root.minsize(width, height)
1161
- if simple:
1162
- self.root.geometry(f"{width}x{height}")
1163
- else:
1164
- current_width = self.root.winfo_width()
1165
- current_height = self.root.winfo_height()
1166
- if current_width < width or current_height < height:
1167
- self.root.geometry(f"{width}x{height}")
652
+ layout_helpers.apply_window_size(self, simple=simple)
1168
653
 
1169
654
  def _toggle_advanced(self, *, initial: bool = False) -> None:
1170
655
  if not initial:
@@ -1178,14 +663,14 @@ class TalksReducerGUI:
1178
663
  self.advanced_button.configure(text="Advanced")
1179
664
 
1180
665
  def _on_theme_change(self, *_: object) -> None:
1181
- self._update_setting("theme", self.theme_var.get())
1182
- self._apply_theme()
666
+ self.preferences.update("theme", self.theme_var.get())
667
+ self._refresh_theme()
1183
668
 
1184
669
  def _on_small_video_change(self, *_: object) -> None:
1185
- self._update_setting("small_video", bool(self.small_var.get()))
670
+ self.preferences.update("small_video", bool(self.small_var.get()))
1186
671
 
1187
672
  def _on_open_after_convert_change(self, *_: object) -> None:
1188
- self._update_setting(
673
+ self.preferences.update(
1189
674
  "open_after_convert", bool(self.open_after_convert_var.get())
1190
675
  )
1191
676
 
@@ -1194,216 +679,63 @@ class TalksReducerGUI:
1194
679
  if value not in {"local", "remote"}:
1195
680
  self.processing_mode_var.set("local")
1196
681
  return
1197
- self._update_setting("processing_mode", value)
682
+ self.preferences.update("processing_mode", value)
1198
683
  self._update_processing_mode_state()
1199
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
+
1200
704
  def _on_server_url_change(self, *_: object) -> None:
1201
705
  value = self.server_url_var.get().strip()
1202
- self._update_setting("server_url", value)
706
+ self.preferences.update("server_url", value)
1203
707
  self._update_processing_mode_state()
1204
708
 
1205
- def _apply_theme(self) -> None:
709
+ def _resolve_theme_mode(self) -> str:
1206
710
  preference = self.theme_var.get().lower()
1207
711
  if preference not in {"light", "dark"}:
1208
- mode = self._detect_system_theme()
1209
- else:
1210
- mode = preference
1211
-
1212
- palette = LIGHT_THEME if mode == "light" else DARK_THEME
1213
-
1214
- self.root.configure(bg=palette["background"])
1215
- self.style.theme_use("clam")
1216
- self.style.configure(
1217
- ".", background=palette["background"], foreground=palette["foreground"]
1218
- )
1219
- self.style.configure("TFrame", background=palette["background"])
1220
- self.style.configure(
1221
- "TLabelframe",
1222
- background=palette["background"],
1223
- foreground=palette["foreground"],
1224
- borderwidth=0,
1225
- relief="flat",
1226
- )
1227
- self.style.configure(
1228
- "TLabelframe.Label",
1229
- background=palette["background"],
1230
- foreground=palette["foreground"],
1231
- )
1232
- self.style.configure(
1233
- "TLabel", background=palette["background"], foreground=palette["foreground"]
1234
- )
1235
- self.style.configure(
1236
- "TCheckbutton",
1237
- background=palette["background"],
1238
- foreground=palette["foreground"],
1239
- )
1240
- self.style.map(
1241
- "TCheckbutton",
1242
- background=[("active", palette.get("hover", palette["background"]))],
1243
- )
1244
- self.style.configure(
1245
- "TRadiobutton",
1246
- background=palette["background"],
1247
- foreground=palette["foreground"],
1248
- )
1249
- self.style.map(
1250
- "TRadiobutton",
1251
- background=[("active", palette.get("hover", palette["background"]))],
1252
- )
1253
- self.style.configure(
1254
- "Link.TButton",
1255
- background=palette["background"],
1256
- foreground=palette["accent"],
1257
- borderwidth=0,
1258
- relief="flat",
1259
- highlightthickness=0,
1260
- padding=2,
1261
- font=("TkDefaultFont", 8, "underline"),
1262
- )
1263
- self.style.map(
1264
- "Link.TButton",
1265
- background=[
1266
- ("active", palette.get("hover", palette["background"])),
1267
- ("disabled", palette["background"]),
1268
- ],
1269
- foreground=[
1270
- ("active", palette.get("accent", palette["foreground"])),
1271
- ("disabled", palette["foreground"]),
1272
- ],
1273
- )
1274
- self.style.configure(
1275
- "TButton",
1276
- background=palette["surface"],
1277
- foreground=palette["foreground"],
1278
- padding=4,
1279
- font=("TkDefaultFont", 8),
1280
- )
1281
- self.style.map(
1282
- "TButton",
1283
- background=[
1284
- ("active", palette.get("hover", palette["accent"])),
1285
- ("disabled", palette["surface"]),
1286
- ],
1287
- foreground=[
1288
- ("active", palette.get("hover_text", "#000000")),
1289
- ("disabled", palette["foreground"]),
1290
- ],
1291
- )
1292
- self.style.configure(
1293
- "TEntry",
1294
- fieldbackground=palette["surface"],
1295
- foreground=palette["foreground"],
1296
- )
1297
- self.style.configure(
1298
- "TCombobox",
1299
- fieldbackground=palette["surface"],
1300
- foreground=palette["foreground"],
1301
- )
1302
-
1303
- # Configure progress bar styles for different states
1304
- self.style.configure(
1305
- "Idle.Horizontal.TProgressbar",
1306
- background=STATUS_COLORS["idle"],
1307
- troughcolor=palette["surface"],
1308
- borderwidth=0,
1309
- thickness=20,
1310
- )
1311
- self.style.configure(
1312
- "Processing.Horizontal.TProgressbar",
1313
- background=STATUS_COLORS["processing"],
1314
- troughcolor=palette["surface"],
1315
- borderwidth=0,
1316
- thickness=20,
1317
- )
1318
- self.style.configure(
1319
- "Success.Horizontal.TProgressbar",
1320
- background=STATUS_COLORS["success"],
1321
- troughcolor=palette["surface"],
1322
- borderwidth=0,
1323
- thickness=20,
1324
- )
1325
- self.style.configure(
1326
- "Error.Horizontal.TProgressbar",
1327
- background=STATUS_COLORS["error"],
1328
- troughcolor=palette["surface"],
1329
- borderwidth=0,
1330
- thickness=20,
1331
- )
1332
- self.style.configure(
1333
- "Aborted.Horizontal.TProgressbar",
1334
- background=STATUS_COLORS["aborted"],
1335
- troughcolor=palette["surface"],
1336
- borderwidth=0,
1337
- thickness=20,
1338
- )
1339
-
1340
- self.drop_zone.configure(
1341
- bg=palette["surface"],
1342
- fg=palette["foreground"],
1343
- highlightthickness=0,
1344
- )
1345
-
1346
- slider_relief = self.tk.FLAT if mode == "dark" else self.tk.RAISED
1347
- active_background = (
1348
- palette.get("accent", palette["surface"])
1349
- if mode == "dark"
1350
- else palette.get("hover", palette["surface"])
1351
- )
1352
- for slider in getattr(self, "_sliders", []):
1353
- slider.configure(
1354
- # background=palette["background"],
1355
- # foreground=palette["foreground"],
1356
- troughcolor=palette["surface"],
1357
- # activebackground=active_background,
1358
- # highlightbackground=palette["background"],
1359
- highlightcolor=palette["background"],
1360
- sliderrelief=slider_relief,
1361
- bd=0,
712
+ return detect_system_theme(
713
+ os.environ,
714
+ sys.platform,
715
+ read_windows_theme_registry,
716
+ run_defaults_command,
1362
717
  )
1363
- self.log_text.configure(
1364
- bg=palette["surface"],
1365
- fg=palette["foreground"],
1366
- insertbackground=palette["foreground"],
1367
- highlightbackground=palette["border"],
1368
- highlightcolor=palette["border"],
1369
- )
1370
- self.status_label.configure(bg=palette["background"])
1371
-
1372
- self._apply_status_style(self._status_state)
1373
-
1374
- def _detect_system_theme(self) -> str:
1375
- if sys.platform.startswith("win"):
1376
- try:
1377
- import winreg # type: ignore
1378
-
1379
- with winreg.OpenKey(
1380
- winreg.HKEY_CURRENT_USER,
1381
- r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
1382
- ) as key:
1383
- value, _ = winreg.QueryValueEx(key, "AppsUseLightTheme")
1384
- return "light" if int(value) else "dark"
1385
- except OSError:
1386
- return "light"
1387
- if sys.platform == "darwin":
1388
- try:
1389
- result = subprocess.run(
1390
- ["defaults", "read", "-g", "AppleInterfaceStyle"],
1391
- capture_output=True,
1392
- text=True,
1393
- check=False,
1394
- )
1395
- if result.returncode == 0 and result.stdout.strip().lower() == "dark":
1396
- return "dark"
1397
- except Exception:
1398
- pass
1399
- return "light"
718
+ return preference
1400
719
 
1401
- theme = os.environ.get("GTK_THEME", "").lower()
1402
- if "dark" in theme:
1403
- return "dark"
1404
- return "light"
1405
-
1406
- 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:
1407
739
  if not self._dnd_available:
1408
740
  return
1409
741
  widget.drop_target_register(DND_FILES) # type: ignore[arg-type]
@@ -1495,124 +827,16 @@ class TalksReducerGUI:
1495
827
  if result:
1496
828
  variable.set(result)
1497
829
 
1498
- def _start_run(self) -> None:
1499
- if self._processing_thread and self._processing_thread.is_alive():
1500
- self.messagebox.showinfo("Processing", "A job is already running.")
1501
- return
1502
-
1503
- if not self.input_files:
1504
- self.messagebox.showwarning(
1505
- "Missing input", "Please add at least one file or folder."
1506
- )
1507
- return
1508
-
1509
- try:
1510
- args = self._collect_arguments()
1511
- except ValueError as exc:
1512
- self.messagebox.showerror("Invalid value", str(exc))
1513
- return
1514
-
1515
- self._append_log("Starting processing…")
1516
- self._stop_requested = False
1517
- open_after_convert = bool(self.open_after_convert_var.get())
1518
- server_url = self.server_url_var.get().strip()
1519
- remote_mode = self.processing_mode_var.get() == "remote"
1520
- if remote_mode and not server_url:
1521
- self.messagebox.showerror(
1522
- "Missing server URL", "Remote mode requires a server URL."
1523
- )
1524
- return
1525
- remote_mode = remote_mode and bool(server_url)
1526
-
1527
- def worker() -> None:
1528
- def set_process(proc: subprocess.Popen) -> None:
1529
- self._ffmpeg_process = proc
1530
-
1531
- try:
1532
- files = gather_input_files(self.input_files)
1533
- if not files:
1534
- self._notify(
1535
- lambda: self.messagebox.showwarning(
1536
- "No files", "No supported media files were found."
1537
- )
1538
- )
1539
- self._set_status("Idle")
1540
- return
1541
-
1542
- if remote_mode:
1543
- success = self._process_files_via_server(
1544
- files,
1545
- args,
1546
- server_url,
1547
- open_after_convert=open_after_convert,
1548
- )
1549
- if success:
1550
- self._notify(self._hide_stop_button)
1551
- return
1552
-
1553
- reporter = _TkProgressReporter(
1554
- self._append_log, process_callback=set_process
1555
- )
1556
- for index, file in enumerate(files, start=1):
1557
- self._append_log(
1558
- f"Processing {index}/{len(files)}: {os.path.basename(file)}"
1559
- )
1560
- options = self._build_options(Path(file), args)
1561
- result = speed_up_video(options, reporter=reporter)
1562
- self._last_output = result.output_file
1563
- self._last_time_ratio = result.time_ratio
1564
- self._last_size_ratio = result.size_ratio
1565
-
1566
- # Create completion message with ratios if available
1567
- completion_msg = f"Completed: {result.output_file}"
1568
- if result.time_ratio is not None and result.size_ratio is not None:
1569
- completion_msg += f" (Time: {result.time_ratio:.2%}, Size: {result.size_ratio:.2%})"
1570
-
1571
- self._append_log(completion_msg)
1572
- if open_after_convert:
1573
- self._notify(
1574
- lambda path=result.output_file: self._open_in_file_manager(
1575
- path
1576
- )
1577
- )
1578
-
1579
- self._append_log("All jobs finished successfully.")
1580
- self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
1581
- self._notify(self._clear_input_files)
1582
- except FFmpegNotFoundError as exc:
1583
- self._notify(
1584
- lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
1585
- )
1586
- self._set_status("Error")
1587
- except Exception as exc: # pragma: no cover - GUI level safeguard
1588
- # If stop was requested, don't show error (FFmpeg termination is expected)
1589
- if self._stop_requested:
1590
- self._append_log("Processing aborted by user.")
1591
- self._set_status("Aborted")
1592
- else:
1593
- error_msg = f"Processing failed: {exc}"
1594
- self._append_log(error_msg)
1595
- print(error_msg, file=sys.stderr) # Also output to console
1596
- self._notify(lambda: self.messagebox.showerror("Error", error_msg))
1597
- self._set_status("Error")
1598
- finally:
1599
- self._notify(self._hide_stop_button)
1600
-
1601
- self._processing_thread = threading.Thread(target=worker, daemon=True)
1602
- self._processing_thread.start()
1603
-
1604
- # Show Stop button when processing starts
1605
- if remote_mode:
1606
- self.stop_button.grid_remove()
1607
- else:
1608
- self.stop_button.grid()
1609
-
1610
830
  def _stop_processing(self) -> None:
1611
831
  """Stop the currently running processing by terminating FFmpeg."""
1612
832
  import signal
1613
833
 
1614
834
  self._stop_requested = True
1615
- 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:
1616
840
  self._append_log("Stopping FFmpeg process...")
1617
841
  try:
1618
842
  # Send SIGTERM to FFmpeg process
@@ -1679,101 +903,15 @@ class TalksReducerGUI:
1679
903
  ) -> bool:
1680
904
  """Send *files* to the configured server for processing."""
1681
905
 
1682
- try:
1683
- service_module = importlib.import_module("talks_reducer.service_client")
1684
- except ModuleNotFoundError as exc:
1685
- self._append_log(f"Server client unavailable: {exc}")
1686
- self._notify(
1687
- lambda: self.messagebox.showerror(
1688
- "Server unavailable",
1689
- "Remote processing requires the gradio_client package.",
1690
- )
1691
- )
1692
- self._notify(lambda: self._set_status("Error"))
1693
- return False
1694
-
1695
- host_label = self._format_server_host(server_url)
1696
- self._notify(
1697
- 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,
1698
914
  )
1699
- if not self._ping_server(server_url):
1700
- self._append_log(f"Server unreachable: {server_url}")
1701
- self._notify(
1702
- lambda: self._set_status("Error", f"Server {host_label} unreachable")
1703
- )
1704
- return False
1705
-
1706
- self._notify(lambda: self._set_status("waiting", f"Server {host_label} ready"))
1707
-
1708
- output_override = args.get("output_file") if len(files) == 1 else None
1709
- ignored = [key for key in args if key not in {"output_file", "small"}]
1710
- if ignored:
1711
- ignored_options = ", ".join(sorted(ignored))
1712
- self._append_log(
1713
- f"Server mode ignores the following options: {ignored_options}"
1714
- )
1715
-
1716
- small_mode = bool(args.get("small", False))
1717
-
1718
- for index, file in enumerate(files, start=1):
1719
- basename = os.path.basename(file)
1720
- self._append_log(
1721
- f"Uploading {index}/{len(files)}: {basename} to {server_url}"
1722
- )
1723
- input_path = Path(file)
1724
-
1725
- if output_override is not None:
1726
- output_path = Path(output_override)
1727
- if output_path.is_dir():
1728
- output_path = (
1729
- output_path
1730
- / _default_remote_destination(input_path, small=small_mode).name
1731
- )
1732
- else:
1733
- output_path = _default_remote_destination(input_path, small=small_mode)
1734
-
1735
- try:
1736
- destination, summary, log_text = service_module.send_video(
1737
- input_path=input_path,
1738
- output_path=output_path,
1739
- server_url=server_url,
1740
- small=small_mode,
1741
- stream_updates=True,
1742
- log_callback=self._append_log,
1743
- # progress_callback=self._handle_service_progress,
1744
- )
1745
- except Exception as exc: # pragma: no cover - network safeguard
1746
- error_detail = f"{exc.__class__.__name__}: {exc}"
1747
- error_msg = f"Processing failed: {error_detail}"
1748
- self._append_log(error_msg)
1749
- self._notify(lambda: self._set_status("Error"))
1750
- self._notify(
1751
- lambda: self.messagebox.showerror(
1752
- "Server error",
1753
- f"Failed to process {basename}: {error_detail}",
1754
- )
1755
- )
1756
- return False
1757
-
1758
- self._last_output = Path(destination)
1759
- time_ratio, size_ratio = _parse_ratios_from_summary(summary)
1760
- self._last_time_ratio = time_ratio
1761
- self._last_size_ratio = size_ratio
1762
- for line in summary.splitlines():
1763
- self._append_log(line)
1764
- if log_text.strip():
1765
- self._append_log("Server log:")
1766
- for line in log_text.splitlines():
1767
- self._append_log(line)
1768
- if open_after_convert:
1769
- self._notify(
1770
- lambda path=self._last_output: self._open_in_file_manager(path)
1771
- )
1772
-
1773
- self._append_log("All jobs finished successfully.")
1774
- self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
1775
- self._notify(self._clear_input_files)
1776
- return True
1777
915
 
1778
916
  def _parse_float(self, value: str, label: str) -> float:
1779
917
  try:
@@ -1781,7 +919,7 @@ class TalksReducerGUI:
1781
919
  except ValueError as exc: # pragma: no cover - input validation
1782
920
  raise ValueError(f"{label} must be a number.") from exc
1783
921
 
1784
- def _build_options(
922
+ def _create_processing_options(
1785
923
  self, input_file: Path, args: dict[str, object]
1786
924
  ) -> ProcessingOptions:
1787
925
  options = dict(args)
@@ -1826,7 +964,7 @@ class TalksReducerGUI:
1826
964
  def _update_status_from_message(self, message: str) -> None:
1827
965
  normalized = message.strip().lower()
1828
966
  metadata_match = re.search(
1829
- r"source metadata [—-] duration:\s*([\d.]+)s",
967
+ r"source metadata: duration:\s*([\d.]+)s",
1830
968
  message,
1831
969
  re.IGNORECASE,
1832
970
  )
@@ -1836,18 +974,48 @@ class TalksReducerGUI:
1836
974
  except ValueError:
1837
975
  self._source_duration_seconds = None
1838
976
  if "all jobs finished successfully" in normalized:
1839
- # Create status message with ratios if available
1840
- 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
+
1841
1003
  if self._last_time_ratio is not None and self._last_size_ratio is not None:
1842
- 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)
1843
1009
 
1844
1010
  self._reset_audio_progress_state(clear_source=True)
1845
1011
  self._set_status("success", status_msg)
1846
1012
  self._set_progress(100) # 100% on success
1013
+ self._run_start_time = None
1847
1014
  self._video_duration_seconds = None # Reset for next video
1848
1015
  self._encode_target_duration_seconds = None
1849
1016
  self._encode_total_frames = None
1850
1017
  self._encode_current_frame = None
1018
+ self._last_progress_seconds = None
1851
1019
  elif normalized.startswith("extracting audio"):
1852
1020
  self._reset_audio_progress_state(clear_source=False)
1853
1021
  self._set_status("processing", "Extracting audio...")
@@ -1856,6 +1024,7 @@ class TalksReducerGUI:
1856
1024
  self._encode_target_duration_seconds = None
1857
1025
  self._encode_total_frames = None
1858
1026
  self._encode_current_frame = None
1027
+ self._last_progress_seconds = None
1859
1028
  self._start_audio_progress()
1860
1029
  elif normalized.startswith("uploading"):
1861
1030
  self._set_status("processing", "Uploading...")
@@ -1867,6 +1036,7 @@ class TalksReducerGUI:
1867
1036
  self._encode_target_duration_seconds = None
1868
1037
  self._encode_total_frames = None
1869
1038
  self._encode_current_frame = None
1039
+ self._last_progress_seconds = None
1870
1040
  elif normalized.startswith("processing"):
1871
1041
  is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
1872
1042
  should_reset = self._status_state.lower() != "processing" or is_new_job
@@ -1876,6 +1046,7 @@ class TalksReducerGUI:
1876
1046
  self._encode_target_duration_seconds = None
1877
1047
  self._encode_total_frames = None
1878
1048
  self._encode_current_frame = None
1049
+ self._last_progress_seconds = None
1879
1050
  if is_new_job:
1880
1051
  self._reset_audio_progress_state(clear_source=True)
1881
1052
  self._set_status("processing", "Processing")
@@ -1948,6 +1119,8 @@ class TalksReducerGUI:
1948
1119
  time_str = self._format_progress_time(current_seconds)
1949
1120
  speed_str = speed_match.group(1)
1950
1121
 
1122
+ self._last_progress_seconds = current_seconds
1123
+
1951
1124
  total_seconds = (
1952
1125
  self._encode_target_duration_seconds or self._video_duration_seconds
1953
1126
  )
@@ -1996,7 +1169,7 @@ class TalksReducerGUI:
1996
1169
  interval_ms, self._advance_audio_progress
1997
1170
  )
1998
1171
 
1999
- self._notify(_start)
1172
+ self._schedule_on_ui_thread(_start)
2000
1173
 
2001
1174
  def _advance_audio_progress(self) -> None:
2002
1175
  self._audio_progress_job = None
@@ -2033,7 +1206,7 @@ class TalksReducerGUI:
2033
1206
  self._audio_progress_job = None
2034
1207
  self._audio_progress_interval_ms = None
2035
1208
 
2036
- self._notify(_cancel)
1209
+ self._schedule_on_ui_thread(_cancel)
2037
1210
 
2038
1211
  def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
2039
1212
  if clear_source:
@@ -2055,7 +1228,7 @@ class TalksReducerGUI:
2055
1228
  if current_value < self.AUDIO_PROGRESS_STEPS:
2056
1229
  self._set_progress(self.AUDIO_PROGRESS_STEPS)
2057
1230
 
2058
- self._notify(_complete)
1231
+ self._schedule_on_ui_thread(_complete)
2059
1232
 
2060
1233
  def _apply_status_style(self, status: str) -> None:
2061
1234
  color = STATUS_COLORS.get(status.lower())
@@ -2067,7 +1240,10 @@ class TalksReducerGUI:
2067
1240
  status_lower = status.lower()
2068
1241
  if (
2069
1242
  "extracting audio" in status_lower
2070
- 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
+ )
2071
1247
  or ("time:" in status_lower and "size:" in status_lower)
2072
1248
  ):
2073
1249
  if "time:" in status_lower and "size:" in status_lower:
@@ -2118,7 +1294,7 @@ class TalksReducerGUI:
2118
1294
  self.root.after(0, apply)
2119
1295
 
2120
1296
  def _format_progress_time(self, total_seconds: float) -> str:
2121
- """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."""
2122
1298
 
2123
1299
  try:
2124
1300
  rounded_seconds = max(0, int(round(total_seconds)))
@@ -2129,7 +1305,7 @@ class TalksReducerGUI:
2129
1305
  minutes, seconds = divmod(remainder, 60)
2130
1306
 
2131
1307
  if hours > 0:
2132
- return f"{hours}:{minutes:02d}"
1308
+ return f"{hours}:{minutes:02d}:{seconds:02d}"
2133
1309
 
2134
1310
  total_minutes = rounded_seconds // 60
2135
1311
  return f"{total_minutes}:{seconds:02d}"
@@ -2179,7 +1355,7 @@ class TalksReducerGUI:
2179
1355
  # Update color based on percentage gradient
2180
1356
  color = self._calculate_gradient_color(percentage, 0.5)
2181
1357
  palette = (
2182
- LIGHT_THEME if self._detect_system_theme() == "light" else DARK_THEME
1358
+ LIGHT_THEME if self._resolve_theme_mode() == "light" else DARK_THEME
2183
1359
  )
2184
1360
  if self.theme_var.get().lower() in {"light", "dark"}:
2185
1361
  palette = (
@@ -2230,7 +1406,7 @@ class TalksReducerGUI:
2230
1406
 
2231
1407
  self.root.after(0, updater)
2232
1408
 
2233
- def _notify(self, callback: Callable[[], None]) -> None:
1409
+ def _schedule_on_ui_thread(self, callback: Callable[[], None]) -> None:
2234
1410
  self.root.after(0, callback)
2235
1411
 
2236
1412
  def run(self) -> None:
@@ -2264,7 +1440,14 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
2264
1440
  parsed_args, remaining = parser.parse_known_args(argv)
2265
1441
  if parsed_args.server:
2266
1442
  package_name = __package__ or "talks_reducer"
2267
- 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")
2268
1451
  tray_main = getattr(tray_module, "main")
2269
1452
  tray_main(remaining)
2270
1453
  return False