talks-reducer 0.5.5__py3-none-any.whl → 0.6.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.
talks_reducer/gui.py CHANGED
@@ -10,9 +10,21 @@ 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
16
  from importlib.metadata import version
14
17
  from pathlib import Path
15
- from typing import TYPE_CHECKING, Any, Callable, Iterable, List, Optional, Sequence
18
+ from typing import (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ Callable,
22
+ Iterable,
23
+ List,
24
+ Optional,
25
+ Sequence,
26
+ Tuple,
27
+ )
16
28
 
17
29
  if TYPE_CHECKING:
18
30
  import tkinter as tk
@@ -122,6 +134,7 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
122
134
 
123
135
  STATUS_COLORS = {
124
136
  "idle": "#9ca3af",
137
+ "waiting": "#9ca3af",
125
138
  "processing": "#af8e0e",
126
139
  "success": "#178941",
127
140
  "error": "#ad4f4f",
@@ -157,6 +170,25 @@ _TRAY_LOCK = threading.Lock()
157
170
  _TRAY_PROCESS: Optional[subprocess.Popen[Any]] = None
158
171
 
159
172
 
173
+ def _default_remote_destination(input_file: Path, *, small: bool) -> Path:
174
+ """Return the default remote output path for *input_file*.
175
+
176
+ Mirrors the naming scheme from the local pipeline so that remote jobs save
177
+ next to the source media with the expected suffix.
178
+ """
179
+
180
+ name = input_file.name
181
+ dot_index = name.rfind(".")
182
+ suffix = "_speedup_small" if small else "_speedup"
183
+
184
+ if dot_index != -1:
185
+ new_name = name[:dot_index] + suffix + name[dot_index:]
186
+ else:
187
+ new_name = name + suffix
188
+
189
+ return input_file.with_name(new_name)
190
+
191
+
160
192
  def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> None:
161
193
  """Start the server tray in a background process if one is not active."""
162
194
 
@@ -179,6 +211,31 @@ def _ensure_server_tray_running(extra_args: Optional[Sequence[str]] = None) -> N
179
211
  )
180
212
 
181
213
 
214
+ def _parse_ratios_from_summary(summary: str) -> Tuple[Optional[float], Optional[float]]:
215
+ """Extract time and size ratios from a Markdown *summary* string."""
216
+
217
+ time_ratio: Optional[float] = None
218
+ size_ratio: Optional[float] = None
219
+
220
+ for line in summary.splitlines():
221
+ if "**Duration:**" in line:
222
+ match = re.search(r"—\s*([0-9]+(?:\.[0-9]+)?)% of the original", line)
223
+ if match:
224
+ try:
225
+ time_ratio = float(match.group(1)) / 100
226
+ except ValueError:
227
+ time_ratio = None
228
+ elif "**Size:**" in line:
229
+ match = re.search(r"\*\*Size:\*\*\s*([0-9]+(?:\.[0-9]+)?)%", line)
230
+ if match:
231
+ try:
232
+ size_ratio = float(match.group(1)) / 100
233
+ except ValueError:
234
+ size_ratio = None
235
+
236
+ return time_ratio, size_ratio
237
+
238
+
182
239
  class _GuiProgressHandle(ProgressHandle):
183
240
  """Simple progress handle that records totals but only logs milestones."""
184
241
 
@@ -243,6 +300,10 @@ class TalksReducerGUI:
243
300
  """Tkinter application mirroring the CLI options with form controls."""
244
301
 
245
302
  PADDING = 10
303
+ AUDIO_PROCESSING_RATIO = 0.02
304
+ AUDIO_PROGRESS_STEPS = 20
305
+ MIN_AUDIO_INTERVAL_MS = 10
306
+ DEFAULT_AUDIO_INTERVAL_MS = 200
246
307
 
247
308
  def _determine_config_path(self) -> Path:
248
309
  if sys.platform == "win32":
@@ -281,6 +342,21 @@ class TalksReducerGUI:
281
342
  self._settings[key] = value
282
343
  return value
283
344
 
345
+ def _get_float_setting(self, key: str, default: float) -> float:
346
+ """Return *key* as a float, coercing stored strings when necessary."""
347
+
348
+ raw_value = self._get_setting(key, default)
349
+ try:
350
+ number = float(raw_value)
351
+ except (TypeError, ValueError):
352
+ number = float(default)
353
+
354
+ if self._settings.get(key) != number:
355
+ self._settings[key] = number
356
+ self._save_settings()
357
+
358
+ return number
359
+
284
360
  def _update_setting(self, key: str, value: object) -> None:
285
361
  if self._settings.get(key) == value:
286
362
  return
@@ -337,6 +413,10 @@ class TalksReducerGUI:
337
413
  self._encode_target_duration_seconds: Optional[float] = None
338
414
  self._encode_total_frames: Optional[int] = None
339
415
  self._encode_current_frame: Optional[int] = None
416
+ self._source_duration_seconds: Optional[float] = None
417
+ self._audio_progress_job: Optional[str] = None
418
+ self._audio_progress_interval_ms: Optional[int] = None
419
+ self._audio_progress_steps_completed = 0
340
420
  self.progress_var = tk.IntVar(value=0)
341
421
  self._ffmpeg_process: Optional[subprocess.Popen] = None
342
422
  self._stop_requested = False
@@ -353,6 +433,11 @@ class TalksReducerGUI:
353
433
  self.open_after_convert_var = tk.BooleanVar(
354
434
  value=self._get_setting("open_after_convert", True)
355
435
  )
436
+ stored_mode = str(self._get_setting("processing_mode", "local"))
437
+ if stored_mode not in {"local", "remote"}:
438
+ stored_mode = "local"
439
+ self.processing_mode_var = tk.StringVar(value=stored_mode)
440
+ self.processing_mode_var.trace_add("write", self._on_processing_mode_change)
356
441
  self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
357
442
  self.theme_var.trace_add("write", self._on_theme_change)
358
443
  self.small_var.trace_add("write", self._on_small_video_change)
@@ -365,6 +450,10 @@ class TalksReducerGUI:
365
450
  self.server_url_var.trace_add("write", self._on_server_url_change)
366
451
  self._discovery_thread: Optional[threading.Thread] = None
367
452
 
453
+ self._basic_defaults: dict[str, float] = {}
454
+ self._basic_variables: dict[str, tk.DoubleVar] = {}
455
+ self._slider_updaters: dict[str, Callable[[str], None]] = {}
456
+
368
457
  self._build_layout()
369
458
  self._apply_simple_mode(initial=True)
370
459
  self._apply_status_style(self._status_state)
@@ -372,6 +461,47 @@ class TalksReducerGUI:
372
461
  self._save_settings()
373
462
  self._hide_stop_button()
374
463
 
464
+ # Ping server on startup if in remote mode
465
+ if (
466
+ self.processing_mode_var.get() == "remote"
467
+ and self.server_url_var.get().strip()
468
+ and hasattr(self, "_ping_server")
469
+ ):
470
+ server_url = self.server_url_var.get().strip()
471
+ host_label = self._format_server_host(server_url)
472
+
473
+ def ping_worker() -> None:
474
+ try:
475
+ if self._ping_server(server_url):
476
+ self._set_status("Idle", f"Server {host_label} is reachable")
477
+ self._notify(
478
+ lambda: self._append_log(f"Server {host_label} ready")
479
+ )
480
+ else:
481
+ self._set_status(
482
+ "Error", f"Server {host_label} is not reachable"
483
+ )
484
+ self._notify(
485
+ lambda: self._append_log(
486
+ f"Server {host_label} is not reachable"
487
+ )
488
+ )
489
+ ping_worker()
490
+ except Exception as exc:
491
+ self._set_status(
492
+ "Idle", f"Error pinging server {host_label}: {exc}"
493
+ )
494
+ self._notify(
495
+ lambda: self._append_log(
496
+ f"Error pinging server {host_label}: {exc}"
497
+ )
498
+ )
499
+
500
+ import threading
501
+
502
+ ping_thread = threading.Thread(target=ping_worker, daemon=True)
503
+ ping_thread.start()
504
+
375
505
  if not self._dnd_available:
376
506
  self._append_log(
377
507
  "Drag and drop requires the tkinterdnd2 package. Install it to enable the drop zone."
@@ -420,16 +550,8 @@ class TalksReducerGUI:
420
550
  input_frame.grid(row=0, column=0, sticky="nsew")
421
551
  main.rowconfigure(0, weight=1)
422
552
  main.columnconfigure(0, weight=1)
423
- for column in range(5):
424
- input_frame.columnconfigure(column, weight=1)
425
-
426
- self.input_list = self.tk.Listbox(input_frame, height=5)
427
- self.input_list.grid(row=0, column=0, columnspan=4, sticky="nsew", pady=(0, 12))
428
- self.input_scrollbar = self.ttk.Scrollbar(
429
- input_frame, orient=self.tk.VERTICAL, command=self.input_list.yview
430
- )
431
- self.input_scrollbar.grid(row=0, column=4, sticky="ns", pady=(0, 12))
432
- self.input_list.configure(yscrollcommand=self.input_scrollbar.set)
553
+ input_frame.columnconfigure(0, weight=1)
554
+ input_frame.rowconfigure(0, weight=1)
433
555
 
434
556
  self.drop_zone = self.tk.Label(
435
557
  input_frame,
@@ -440,40 +562,19 @@ class TalksReducerGUI:
440
562
  pady=self.PADDING,
441
563
  highlightthickness=0,
442
564
  )
443
- self.drop_zone.grid(row=1, column=0, columnspan=5, sticky="nsew")
444
- input_frame.rowconfigure(1, weight=1)
565
+ self.drop_zone.grid(row=0, column=0, sticky="nsew")
445
566
  self._configure_drop_targets(self.drop_zone)
446
- self._configure_drop_targets(self.input_list)
447
567
  self.drop_zone.configure(cursor="hand2", takefocus=1)
448
568
  self.drop_zone.bind("<Button-1>", self._on_drop_zone_click)
449
569
  self.drop_zone.bind("<Return>", self._on_drop_zone_click)
450
570
  self.drop_zone.bind("<space>", self._on_drop_zone_click)
451
571
 
452
- self.add_files_button = self.ttk.Button(
453
- input_frame, text="Add files", command=self._add_files
454
- )
455
- self.add_files_button.grid(row=2, column=0, pady=8, sticky="w")
456
- self.add_folder_button = self.ttk.Button(
457
- input_frame, text="Add folder", command=self._add_directory
458
- )
459
- self.add_folder_button.grid(row=2, column=1, pady=8)
460
- self.remove_selected_button = self.ttk.Button(
461
- input_frame, text="Remove selected", command=self._remove_selected
462
- )
463
- self.remove_selected_button.grid(row=2, column=2, pady=8, sticky="w")
464
- self.run_after_drop_check = self.ttk.Checkbutton(
465
- input_frame,
466
- text="Run after drop",
467
- variable=self.run_after_drop_var,
468
- )
469
- self.run_after_drop_check.grid(row=2, column=3, pady=8, sticky="e")
470
-
471
572
  # Options frame
472
- options = self.ttk.Frame(main, padding=self.PADDING)
473
- options.grid(row=2, column=0, pady=(0, 0), sticky="ew")
474
- options.columnconfigure(0, weight=1)
573
+ self.options_frame = self.ttk.Frame(main, padding=self.PADDING)
574
+ self.options_frame.grid(row=2, column=0, pady=(0, 0), sticky="ew")
575
+ self.options_frame.columnconfigure(0, weight=1)
475
576
 
476
- checkbox_frame = self.ttk.Frame(options)
577
+ checkbox_frame = self.ttk.Frame(self.options_frame)
477
578
  checkbox_frame.grid(row=0, column=0, columnspan=2, sticky="w")
478
579
 
479
580
  self.ttk.Checkbutton(
@@ -499,70 +600,119 @@ class TalksReducerGUI:
499
600
  )
500
601
 
501
602
  self.advanced_visible = self.tk.BooleanVar(value=False)
502
- self.advanced_button = self.ttk.Button(
503
- options,
504
- text="Advanced",
505
- command=self._toggle_advanced,
506
- )
507
- self.advanced_button.grid(row=1, column=1, sticky="e")
508
603
 
509
- self.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
510
- self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
511
- self.advanced_frame.columnconfigure(1, weight=1)
604
+ basic_label_container = self.ttk.Frame(self.options_frame)
605
+ basic_label = self.ttk.Label(basic_label_container, text="Basic options")
606
+ basic_label.pack(side=self.tk.LEFT)
512
607
 
513
- self.output_var = self.tk.StringVar()
514
- self._add_entry(
515
- self.advanced_frame, "Output file", self.output_var, row=0, browse=True
608
+ self.reset_basic_button = self.ttk.Button(
609
+ basic_label_container,
610
+ text="Reset to defaults",
611
+ command=self._reset_basic_defaults,
612
+ state=self.tk.DISABLED,
516
613
  )
517
614
 
518
- self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
519
- self._add_entry(
520
- self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
615
+ self.basic_options_frame = self.ttk.Labelframe(
616
+ self.options_frame, padding=0, labelwidget=basic_label_container
521
617
  )
522
-
523
- self.silent_threshold_var = self.tk.StringVar()
524
- self._add_entry(
525
- self.advanced_frame,
526
- "Silent threshold",
527
- self.silent_threshold_var,
528
- row=2,
618
+ self.basic_options_frame.grid(
619
+ row=2, column=0, columnspan=2, sticky="ew", pady=(12, 0)
529
620
  )
621
+ self.basic_options_frame.columnconfigure(1, weight=1)
530
622
 
531
- self.sounded_speed_var = self.tk.StringVar()
532
- self._add_entry(
533
- self.advanced_frame, "Sounded speed", self.sounded_speed_var, row=3
534
- )
623
+ self._reset_button_visible = False
535
624
 
536
- self.silent_speed_var = self.tk.StringVar()
537
- self._add_entry(
538
- self.advanced_frame, "Silent speed", self.silent_speed_var, row=4
625
+ self.silent_speed_var = self.tk.DoubleVar(
626
+ value=min(max(self._get_float_setting("silent_speed", 4.0), 1.0), 10.0)
627
+ )
628
+ self._add_slider(
629
+ self.basic_options_frame,
630
+ "Silent speed",
631
+ self.silent_speed_var,
632
+ row=0,
633
+ setting_key="silent_speed",
634
+ minimum=1.0,
635
+ maximum=10.0,
636
+ resolution=0.5,
637
+ display_format="{:.1f}×",
638
+ default_value=4.0,
539
639
  )
540
640
 
541
- self.frame_margin_var = self.tk.StringVar()
542
- self._add_entry(
543
- self.advanced_frame, "Frame margin", self.frame_margin_var, row=5
641
+ self.sounded_speed_var = self.tk.DoubleVar(
642
+ value=min(max(self._get_float_setting("sounded_speed", 1.0), 0.75), 2.0)
643
+ )
644
+ self._add_slider(
645
+ self.basic_options_frame,
646
+ "Sounded speed",
647
+ self.sounded_speed_var,
648
+ row=1,
649
+ setting_key="sounded_speed",
650
+ minimum=0.75,
651
+ maximum=2.0,
652
+ resolution=0.25,
653
+ display_format="{:.2f}×",
654
+ default_value=1.0,
544
655
  )
545
656
 
546
- self.sample_rate_var = self.tk.StringVar(value="48000")
547
- self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=6)
657
+ self.silent_threshold_var = self.tk.DoubleVar(
658
+ value=min(max(self._get_float_setting("silent_threshold", 0.05), 0.0), 1.0)
659
+ )
660
+ self._add_slider(
661
+ self.basic_options_frame,
662
+ "Silent threshold",
663
+ self.silent_threshold_var,
664
+ row=2,
665
+ setting_key="silent_threshold",
666
+ minimum=0.0,
667
+ maximum=1.0,
668
+ resolution=0.01,
669
+ display_format="{:.2f}",
670
+ default_value=0.05,
671
+ )
548
672
 
549
- self.ttk.Label(self.advanced_frame, text="Server URL").grid(
550
- row=7, column=0, sticky="w", pady=4
673
+ self.ttk.Label(self.basic_options_frame, text="Server URL").grid(
674
+ row=3, column=0, sticky="w", pady=4
675
+ )
676
+ stored_server_url = str(
677
+ self._get_setting("server_url", "http://localhost:9005")
551
678
  )
679
+ if not stored_server_url:
680
+ stored_server_url = "http://localhost:9005"
681
+ self._update_setting("server_url", stored_server_url)
682
+ self.server_url_var.set(stored_server_url)
552
683
  self.server_url_entry = self.ttk.Entry(
553
- self.advanced_frame, textvariable=self.server_url_var
684
+ self.basic_options_frame, textvariable=self.server_url_var, width=30
554
685
  )
555
- self.server_url_entry.grid(row=7, column=1, sticky="ew", pady=4)
686
+ self.server_url_entry.grid(row=3, column=1, sticky="w", pady=4)
556
687
  self.server_discover_button = self.ttk.Button(
557
- self.advanced_frame, text="Discover", command=self._start_discovery
558
- )
559
- self.server_discover_button.grid(row=7, column=2, padx=(8, 0))
560
-
561
- self.ttk.Label(self.advanced_frame, text="Theme").grid(
562
- row=8, column=0, sticky="w", pady=(8, 0)
563
- )
564
- theme_choice = self.ttk.Frame(self.advanced_frame)
565
- theme_choice.grid(row=8, column=1, columnspan=2, sticky="w", pady=(8, 0))
688
+ self.basic_options_frame, text="Discover", command=self._start_discovery
689
+ )
690
+ self.server_discover_button.grid(row=3, column=2, padx=(8, 0))
691
+
692
+ self.ttk.Label(self.basic_options_frame, text="Processing mode").grid(
693
+ row=4, column=0, sticky="w", pady=4
694
+ )
695
+ mode_choice = self.ttk.Frame(self.basic_options_frame)
696
+ mode_choice.grid(row=4, column=1, columnspan=2, sticky="w", pady=4)
697
+ self.ttk.Radiobutton(
698
+ mode_choice,
699
+ text="Local",
700
+ value="local",
701
+ variable=self.processing_mode_var,
702
+ ).pack(side=self.tk.LEFT, padx=(0, 8))
703
+ self.remote_mode_button = self.ttk.Radiobutton(
704
+ mode_choice,
705
+ text="Remote",
706
+ value="remote",
707
+ variable=self.processing_mode_var,
708
+ )
709
+ self.remote_mode_button.pack(side=self.tk.LEFT, padx=(0, 8))
710
+
711
+ self.ttk.Label(self.basic_options_frame, text="Theme").grid(
712
+ row=5, column=0, sticky="w", pady=(8, 0)
713
+ )
714
+ theme_choice = self.ttk.Frame(self.basic_options_frame)
715
+ theme_choice.grid(row=5, column=1, columnspan=2, sticky="w", pady=(8, 0))
566
716
  for value, label in ("os", "OS"), ("light", "Light"), ("dark", "Dark"):
567
717
  self.ttk.Radiobutton(
568
718
  theme_choice,
@@ -572,7 +722,47 @@ class TalksReducerGUI:
572
722
  command=self._apply_theme,
573
723
  ).pack(side=self.tk.LEFT, padx=(0, 8))
574
724
 
725
+ self.advanced_button = self.ttk.Button(
726
+ self.options_frame,
727
+ text="Advanced",
728
+ command=self._toggle_advanced,
729
+ )
730
+ self.advanced_button.grid(
731
+ row=3, column=0, columnspan=2, sticky="w", pady=(12, 0)
732
+ )
733
+
734
+ self.advanced_frame = self.ttk.Frame(self.options_frame, padding=0)
735
+ self.advanced_frame.grid(row=4, column=0, columnspan=2, sticky="nsew")
736
+ self.advanced_frame.columnconfigure(1, weight=1)
737
+
738
+ self.output_var = self.tk.StringVar()
739
+ self._add_entry(
740
+ self.advanced_frame, "Output file", self.output_var, row=0, browse=True
741
+ )
742
+
743
+ self.temp_var = self.tk.StringVar(value=str(default_temp_folder()))
744
+ self._add_entry(
745
+ self.advanced_frame, "Temp folder", self.temp_var, row=1, browse=True
746
+ )
747
+
748
+ self.sample_rate_var = self.tk.StringVar(value="48000")
749
+ self._add_entry(self.advanced_frame, "Sample rate", self.sample_rate_var, row=2)
750
+
751
+ frame_margin_setting = self._get_setting("frame_margin", 2)
752
+ try:
753
+ frame_margin_default = int(frame_margin_setting)
754
+ except (TypeError, ValueError):
755
+ frame_margin_default = 2
756
+ self._update_setting("frame_margin", frame_margin_default)
757
+
758
+ self.frame_margin_var = self.tk.StringVar(value=str(frame_margin_default))
759
+ self._add_entry(
760
+ self.advanced_frame, "Frame margin", self.frame_margin_var, row=3
761
+ )
762
+
575
763
  self._toggle_advanced(initial=True)
764
+ self._update_processing_mode_state()
765
+ self._update_basic_reset_state()
576
766
 
577
767
  # Action buttons and log output
578
768
  status_frame = self.ttk.Frame(main, padding=self.PADDING)
@@ -580,6 +770,7 @@ class TalksReducerGUI:
580
770
  status_frame.columnconfigure(0, weight=0)
581
771
  status_frame.columnconfigure(1, weight=1)
582
772
  status_frame.columnconfigure(2, weight=0)
773
+ self.status_frame = status_frame
583
774
 
584
775
  self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
585
776
  self.status_label = self.tk.Label(
@@ -664,6 +855,166 @@ class TalksReducerGUI:
664
855
  )
665
856
  button.grid(row=row, column=2, padx=(8, 0))
666
857
 
858
+ def _add_slider(
859
+ self,
860
+ parent, # type: tk.Misc
861
+ label: str,
862
+ variable, # type: tk.DoubleVar
863
+ *,
864
+ row: int,
865
+ setting_key: str,
866
+ minimum: float,
867
+ maximum: float,
868
+ resolution: float,
869
+ display_format: str,
870
+ default_value: float,
871
+ ) -> None:
872
+ self.ttk.Label(parent, text=label).grid(row=row, column=0, sticky="w", pady=4)
873
+
874
+ value_label = self.ttk.Label(parent)
875
+ value_label.grid(row=row, column=2, sticky="e", pady=4)
876
+
877
+ def update(value: str) -> None:
878
+ numeric = float(value)
879
+ clamped = max(minimum, min(maximum, numeric))
880
+ steps = round((clamped - minimum) / resolution)
881
+ quantized = minimum + steps * resolution
882
+ if abs(variable.get() - quantized) > 1e-9:
883
+ variable.set(quantized)
884
+ value_label.configure(text=display_format.format(quantized))
885
+ self._update_setting(setting_key, float(f"{quantized:.6f}"))
886
+ self._update_basic_reset_state()
887
+
888
+ slider = self.tk.Scale(
889
+ parent,
890
+ variable=variable,
891
+ from_=minimum,
892
+ to=maximum,
893
+ orient=self.tk.HORIZONTAL,
894
+ resolution=resolution,
895
+ showvalue=False,
896
+ command=update,
897
+ length=240,
898
+ )
899
+ slider.grid(row=row, column=1, sticky="ew", pady=4, padx=(0, 8))
900
+
901
+ update(str(variable.get()))
902
+
903
+ self._slider_updaters[setting_key] = update
904
+ self._basic_defaults[setting_key] = default_value
905
+ self._basic_variables[setting_key] = variable
906
+ variable.trace_add("write", lambda *_: self._update_basic_reset_state())
907
+
908
+ def _update_basic_reset_state(self) -> None:
909
+ """Enable or disable the reset control based on slider values."""
910
+
911
+ if not hasattr(self, "reset_basic_button"):
912
+ return
913
+
914
+ should_enable = False
915
+ for key, default_value in self._basic_defaults.items():
916
+ variable = self._basic_variables.get(key)
917
+ if variable is None:
918
+ continue
919
+ try:
920
+ current_value = float(variable.get())
921
+ except (TypeError, ValueError):
922
+ should_enable = True
923
+ break
924
+ if abs(current_value - default_value) > 1e-9:
925
+ should_enable = True
926
+ break
927
+
928
+ if should_enable:
929
+ if not getattr(self, "_reset_button_visible", False):
930
+ self.reset_basic_button.pack(side=self.tk.LEFT, padx=(8, 0))
931
+ self._reset_button_visible = True
932
+ self.reset_basic_button.configure(state=self.tk.NORMAL)
933
+ else:
934
+ if getattr(self, "_reset_button_visible", False):
935
+ self.reset_basic_button.pack_forget()
936
+ self._reset_button_visible = False
937
+ self.reset_basic_button.configure(state=self.tk.DISABLED)
938
+
939
+ def _reset_basic_defaults(self) -> None:
940
+ """Restore the basic numeric controls to their default values."""
941
+
942
+ for key, default_value in self._basic_defaults.items():
943
+ variable = self._basic_variables.get(key)
944
+ if variable is None:
945
+ continue
946
+
947
+ try:
948
+ current_value = float(variable.get())
949
+ except (TypeError, ValueError):
950
+ current_value = default_value
951
+
952
+ if abs(current_value - default_value) <= 1e-9:
953
+ continue
954
+
955
+ variable.set(default_value)
956
+ updater = self._slider_updaters.get(key)
957
+ if updater is not None:
958
+ updater(str(default_value))
959
+ else:
960
+ self._update_setting(key, float(f"{default_value:.6f}"))
961
+
962
+ self._update_basic_reset_state()
963
+
964
+ def _update_processing_mode_state(self) -> None:
965
+ has_url = bool(self.server_url_var.get().strip())
966
+ if not has_url and self.processing_mode_var.get() == "remote":
967
+ self.processing_mode_var.set("local")
968
+ return
969
+
970
+ if hasattr(self, "remote_mode_button"):
971
+ state = self.tk.NORMAL if has_url else self.tk.DISABLED
972
+ self.remote_mode_button.configure(state=state)
973
+
974
+ def _normalize_server_url(self, server_url: str) -> str:
975
+ parsed = urllib.parse.urlsplit(server_url)
976
+ if not parsed.scheme:
977
+ parsed = urllib.parse.urlsplit(f"http://{server_url}")
978
+
979
+ netloc = parsed.netloc or parsed.path
980
+ if not netloc:
981
+ return server_url
982
+
983
+ path = parsed.path if parsed.netloc else ""
984
+ normalized_path = path or "/"
985
+ return urllib.parse.urlunsplit((parsed.scheme, netloc, normalized_path, "", ""))
986
+
987
+ def _format_server_host(self, server_url: str) -> str:
988
+ parsed = urllib.parse.urlsplit(server_url)
989
+ if not parsed.scheme:
990
+ parsed = urllib.parse.urlsplit(f"http://{server_url}")
991
+
992
+ host = parsed.netloc or parsed.path or server_url
993
+ if parsed.netloc and parsed.path and parsed.path not in {"", "/"}:
994
+ host = f"{parsed.netloc}{parsed.path}"
995
+
996
+ host = host.rstrip("/").split(":")[0]
997
+ return host or server_url
998
+
999
+ def _ping_server(self, server_url: str, *, timeout: float = 5.0) -> bool:
1000
+ normalized = self._normalize_server_url(server_url)
1001
+ request = urllib.request.Request(
1002
+ normalized,
1003
+ headers={"User-Agent": "talks-reducer-gui"},
1004
+ method="GET",
1005
+ )
1006
+
1007
+ try:
1008
+ with urllib.request.urlopen(request, timeout=timeout) as response:
1009
+ status = getattr(response, "status", None)
1010
+ if status is None:
1011
+ status = response.getcode()
1012
+ if status is None:
1013
+ return False
1014
+ return 200 <= int(status) < 500
1015
+ except (urllib.error.URLError, ValueError):
1016
+ return False
1017
+
667
1018
  def _start_discovery(self) -> None:
668
1019
  """Search the local network for running Talks Reducer servers."""
669
1020
 
@@ -677,7 +1028,11 @@ class TalksReducerGUI:
677
1028
 
678
1029
  def worker() -> None:
679
1030
  try:
680
- urls = discover_servers()
1031
+ urls = discover_servers(
1032
+ progress_callback=lambda current, total: self._notify(
1033
+ lambda c=current, t=total: self._on_discovery_progress(c, t)
1034
+ )
1035
+ )
681
1036
  except Exception as exc: # pragma: no cover - network failure safeguard
682
1037
  self._notify(lambda: self._on_discovery_failed(exc))
683
1038
  return
@@ -692,6 +1047,14 @@ class TalksReducerGUI:
692
1047
  self._append_log(message)
693
1048
  self.messagebox.showerror("Discovery failed", message)
694
1049
 
1050
+ def _on_discovery_progress(self, current: int, total: int) -> None:
1051
+ if total > 0:
1052
+ bounded = max(0, min(current, total))
1053
+ label = f"{bounded} / {total}"
1054
+ else:
1055
+ label = "Discovering…"
1056
+ self.server_discover_button.configure(text=label)
1057
+
695
1058
  def _on_discovery_complete(self, urls: List[str]) -> None:
696
1059
  self.server_discover_button.configure(state=self.tk.NORMAL, text="Discover")
697
1060
  if not urls:
@@ -774,38 +1137,17 @@ class TalksReducerGUI:
774
1137
 
775
1138
  def _apply_simple_mode(self, *, initial: bool = False) -> None:
776
1139
  simple = self.simple_mode_var.get()
777
- widgets = [
778
- self.input_list,
779
- self.input_scrollbar,
780
- self.add_files_button,
781
- self.add_folder_button,
782
- self.remove_selected_button,
783
- self.run_after_drop_check,
784
- ]
785
-
786
1140
  if simple:
787
- for widget in widgets:
788
- widget.grid_remove()
1141
+ self.basic_options_frame.grid_remove()
789
1142
  self.log_frame.grid_remove()
790
1143
  self.stop_button.grid_remove()
791
1144
  self.advanced_button.grid_remove()
792
1145
  self.advanced_frame.grid_remove()
793
- if hasattr(self, "status_frame"):
794
- self.status_frame.grid_remove()
795
1146
  self.run_after_drop_var.set(True)
796
1147
  self._apply_window_size(simple=True)
797
- if self.status_var.get().lower() == "success" and hasattr(
798
- self, "status_frame"
799
- ):
800
- self.status_frame.grid()
801
- self.open_button.grid()
802
- self.drop_hint_button.grid_remove()
803
1148
  else:
804
- for widget in widgets:
805
- widget.grid()
1149
+ self.basic_options_frame.grid()
806
1150
  self.log_frame.grid()
807
- if hasattr(self, "status_frame"):
808
- self.status_frame.grid()
809
1151
  self.advanced_button.grid()
810
1152
  if self.advanced_visible.get():
811
1153
  self.advanced_frame.grid()
@@ -850,9 +1192,18 @@ class TalksReducerGUI:
850
1192
  "open_after_convert", bool(self.open_after_convert_var.get())
851
1193
  )
852
1194
 
1195
+ def _on_processing_mode_change(self, *_: object) -> None:
1196
+ value = self.processing_mode_var.get()
1197
+ if value not in {"local", "remote"}:
1198
+ self.processing_mode_var.set("local")
1199
+ return
1200
+ self._update_setting("processing_mode", value)
1201
+ self._update_processing_mode_state()
1202
+
853
1203
  def _on_server_url_change(self, *_: object) -> None:
854
1204
  value = self.server_url_var.get().strip()
855
1205
  self._update_setting("server_url", value)
1206
+ self._update_processing_mode_state()
856
1207
 
857
1208
  def _apply_theme(self) -> None:
858
1209
  preference = self.theme_var.get().lower()
@@ -972,14 +1323,6 @@ class TalksReducerGUI:
972
1323
  fg=palette["foreground"],
973
1324
  highlightthickness=0,
974
1325
  )
975
- self.input_list.configure(
976
- bg=palette["surface"],
977
- fg=palette["foreground"],
978
- selectbackground=palette.get("selection_background", palette["accent"]),
979
- selectforeground=palette.get("selection_foreground", palette["surface"]),
980
- highlightbackground=palette["border"],
981
- highlightcolor=palette["border"],
982
- )
983
1326
  self.log_text.configure(
984
1327
  bg=palette["surface"],
985
1328
  fg=palette["foreground"],
@@ -1041,7 +1384,6 @@ class TalksReducerGUI:
1041
1384
  resolved = os.fspath(Path(path))
1042
1385
  if resolved not in self.input_files:
1043
1386
  self.input_files.append(resolved)
1044
- self.input_list.insert(self.tk.END, resolved)
1045
1387
  normalized.append(resolved)
1046
1388
 
1047
1389
  if auto_run and normalized:
@@ -1075,21 +1417,13 @@ class TalksReducerGUI:
1075
1417
  for path in paths:
1076
1418
  if path and path not in self.input_files:
1077
1419
  self.input_files.append(path)
1078
- self.input_list.insert(self.tk.END, path)
1079
1420
  added = True
1080
1421
  if auto_run and added and self.run_after_drop_var.get():
1081
1422
  self._start_run()
1082
1423
 
1083
- def _remove_selected(self) -> None:
1084
- selection = list(self.input_list.curselection())
1085
- for index in reversed(selection):
1086
- self.input_list.delete(index)
1087
- del self.input_files[index]
1088
-
1089
1424
  def _clear_input_files(self) -> None:
1090
- """Clear all input files from the list."""
1425
+ """Clear all queued input files."""
1091
1426
  self.input_files.clear()
1092
- self.input_list.delete(0, self.tk.END)
1093
1427
 
1094
1428
  def _on_drop(self, event: object) -> None:
1095
1429
  data = getattr(event, "data", "")
@@ -1099,7 +1433,6 @@ class TalksReducerGUI:
1099
1433
  cleaned = [path.strip("{}") for path in paths]
1100
1434
  # Clear existing files before adding dropped files
1101
1435
  self.input_files.clear()
1102
- self.input_list.delete(0, self.tk.END)
1103
1436
  self._extend_inputs(cleaned, auto_run=True)
1104
1437
 
1105
1438
  def _on_drop_zone_click(self, event: object) -> str | None:
@@ -1146,6 +1479,13 @@ class TalksReducerGUI:
1146
1479
  self._stop_requested = False
1147
1480
  open_after_convert = bool(self.open_after_convert_var.get())
1148
1481
  server_url = self.server_url_var.get().strip()
1482
+ remote_mode = self.processing_mode_var.get() == "remote"
1483
+ if remote_mode and not server_url:
1484
+ self.messagebox.showerror(
1485
+ "Missing server URL", "Remote mode requires a server URL."
1486
+ )
1487
+ return
1488
+ remote_mode = remote_mode and bool(server_url)
1149
1489
 
1150
1490
  def worker() -> None:
1151
1491
  def set_process(proc: subprocess.Popen) -> None:
@@ -1162,7 +1502,7 @@ class TalksReducerGUI:
1162
1502
  self._set_status("Idle")
1163
1503
  return
1164
1504
 
1165
- if server_url:
1505
+ if remote_mode:
1166
1506
  success = self._process_files_via_server(
1167
1507
  files,
1168
1508
  args,
@@ -1225,7 +1565,7 @@ class TalksReducerGUI:
1225
1565
  self._processing_thread.start()
1226
1566
 
1227
1567
  # Show Stop button when processing starts
1228
- if server_url:
1568
+ if remote_mode:
1229
1569
  self.stop_button.grid_remove()
1230
1570
  else:
1231
1571
  self.stop_button.grid()
@@ -1272,18 +1612,14 @@ class TalksReducerGUI:
1272
1612
  args["output_file"] = Path(self.output_var.get())
1273
1613
  if self.temp_var.get():
1274
1614
  args["temp_folder"] = Path(self.temp_var.get())
1275
- if self.silent_threshold_var.get():
1276
- args["silent_threshold"] = self._parse_float(
1277
- self.silent_threshold_var.get(), "Silent threshold"
1278
- )
1279
- if self.sounded_speed_var.get():
1280
- args["sounded_speed"] = self._parse_float(
1281
- self.sounded_speed_var.get(), "Sounded speed"
1282
- )
1283
- if self.silent_speed_var.get():
1284
- args["silent_speed"] = self._parse_float(
1285
- self.silent_speed_var.get(), "Silent speed"
1286
- )
1615
+ silent_threshold = float(self.silent_threshold_var.get())
1616
+ args["silent_threshold"] = round(silent_threshold, 2)
1617
+
1618
+ sounded_speed = float(self.sounded_speed_var.get())
1619
+ args["sounded_speed"] = round(sounded_speed, 2)
1620
+
1621
+ silent_speed = float(self.silent_speed_var.get())
1622
+ args["silent_speed"] = round(silent_speed, 2)
1287
1623
  if self.frame_margin_var.get():
1288
1624
  args["frame_spreadage"] = int(
1289
1625
  round(self._parse_float(self.frame_margin_var.get(), "Frame margin"))
@@ -1319,6 +1655,19 @@ class TalksReducerGUI:
1319
1655
  self._notify(lambda: self._set_status("Error"))
1320
1656
  return False
1321
1657
 
1658
+ host_label = self._format_server_host(server_url)
1659
+ self._notify(
1660
+ lambda: self._set_status("waiting", f"Waiting server {host_label}...")
1661
+ )
1662
+ if not self._ping_server(server_url):
1663
+ self._append_log(f"Server unreachable: {server_url}")
1664
+ self._notify(
1665
+ lambda: self._set_status("Error", f"Server {host_label} unreachable")
1666
+ )
1667
+ return False
1668
+
1669
+ self._notify(lambda: self._set_status("waiting", f"Server {host_label} ready"))
1670
+
1322
1671
  output_override = args.get("output_file") if len(files) == 1 else None
1323
1672
  ignored = [key for key in args if key not in {"output_file", "small"}]
1324
1673
  if ignored:
@@ -1327,33 +1676,52 @@ class TalksReducerGUI:
1327
1676
  f"Server mode ignores the following options: {ignored_options}"
1328
1677
  )
1329
1678
 
1679
+ small_mode = bool(args.get("small", False))
1680
+
1330
1681
  for index, file in enumerate(files, start=1):
1331
1682
  basename = os.path.basename(file)
1332
1683
  self._append_log(
1333
1684
  f"Uploading {index}/{len(files)}: {basename} to {server_url}"
1334
1685
  )
1686
+ input_path = Path(file)
1687
+
1688
+ if output_override is not None:
1689
+ output_path = Path(output_override)
1690
+ if output_path.is_dir():
1691
+ output_path = (
1692
+ output_path
1693
+ / _default_remote_destination(input_path, small=small_mode).name
1694
+ )
1695
+ else:
1696
+ output_path = _default_remote_destination(input_path, small=small_mode)
1697
+
1335
1698
  try:
1336
1699
  destination, summary, log_text = service_module.send_video(
1337
- input_path=Path(file),
1338
- output_path=output_override,
1700
+ input_path=input_path,
1701
+ output_path=output_path,
1339
1702
  server_url=server_url,
1340
- small=bool(args.get("small", False)),
1703
+ small=small_mode,
1704
+ stream_updates=True,
1705
+ log_callback=self._append_log,
1706
+ # progress_callback=self._handle_service_progress,
1341
1707
  )
1342
1708
  except Exception as exc: # pragma: no cover - network safeguard
1343
- error_msg = f"Processing failed: {exc}"
1709
+ error_detail = f"{exc.__class__.__name__}: {exc}"
1710
+ error_msg = f"Processing failed: {error_detail}"
1344
1711
  self._append_log(error_msg)
1345
1712
  self._notify(lambda: self._set_status("Error"))
1346
1713
  self._notify(
1347
1714
  lambda: self.messagebox.showerror(
1348
1715
  "Server error",
1349
- f"Failed to process {basename}: {exc}",
1716
+ f"Failed to process {basename}: {error_detail}",
1350
1717
  )
1351
1718
  )
1352
1719
  return False
1353
1720
 
1354
1721
  self._last_output = Path(destination)
1355
- self._last_time_ratio = None
1356
- self._last_size_ratio = None
1722
+ time_ratio, size_ratio = _parse_ratios_from_summary(summary)
1723
+ self._last_time_ratio = time_ratio
1724
+ self._last_size_ratio = size_ratio
1357
1725
  for line in summary.splitlines():
1358
1726
  self._append_log(line)
1359
1727
  if log_text.strip():
@@ -1420,12 +1788,23 @@ class TalksReducerGUI:
1420
1788
 
1421
1789
  def _update_status_from_message(self, message: str) -> None:
1422
1790
  normalized = message.strip().lower()
1791
+ metadata_match = re.search(
1792
+ r"source metadata [—-] duration:\s*([\d.]+)s",
1793
+ message,
1794
+ re.IGNORECASE,
1795
+ )
1796
+ if metadata_match:
1797
+ try:
1798
+ self._source_duration_seconds = float(metadata_match.group(1))
1799
+ except ValueError:
1800
+ self._source_duration_seconds = None
1423
1801
  if "all jobs finished successfully" in normalized:
1424
1802
  # Create status message with ratios if available
1425
1803
  status_msg = "Success"
1426
1804
  if self._last_time_ratio is not None and self._last_size_ratio is not None:
1427
1805
  status_msg = f"Time: {self._last_time_ratio:.0%}, Size: {self._last_size_ratio:.0%}"
1428
1806
 
1807
+ self._reset_audio_progress_state(clear_source=True)
1429
1808
  self._set_status("success", status_msg)
1430
1809
  self._set_progress(100) # 100% on success
1431
1810
  self._video_duration_seconds = None # Reset for next video
@@ -1433,21 +1812,36 @@ class TalksReducerGUI:
1433
1812
  self._encode_total_frames = None
1434
1813
  self._encode_current_frame = None
1435
1814
  elif normalized.startswith("extracting audio"):
1815
+ self._reset_audio_progress_state(clear_source=False)
1436
1816
  self._set_status("processing", "Extracting audio...")
1437
1817
  self._set_progress(0) # 0% on start
1438
1818
  self._video_duration_seconds = None # Reset for new processing
1439
1819
  self._encode_target_duration_seconds = None
1440
1820
  self._encode_total_frames = None
1441
1821
  self._encode_current_frame = None
1442
- elif normalized.startswith("starting processing") or normalized.startswith(
1443
- "processing"
1444
- ):
1822
+ self._start_audio_progress()
1823
+ elif normalized.startswith("uploading"):
1824
+ self._set_status("processing", "Uploading...")
1825
+ elif normalized.startswith("starting processing"):
1826
+ self._reset_audio_progress_state(clear_source=True)
1445
1827
  self._set_status("processing", "Processing")
1446
1828
  self._set_progress(0) # 0% on start
1447
1829
  self._video_duration_seconds = None # Reset for new processing
1448
1830
  self._encode_target_duration_seconds = None
1449
1831
  self._encode_total_frames = None
1450
1832
  self._encode_current_frame = None
1833
+ elif normalized.startswith("processing"):
1834
+ is_new_job = bool(re.match(r"processing \d+/\d+:", normalized))
1835
+ should_reset = self._status_state.lower() != "processing" or is_new_job
1836
+ if should_reset:
1837
+ self._set_progress(0) # 0% on start
1838
+ self._video_duration_seconds = None # Reset for new processing
1839
+ self._encode_target_duration_seconds = None
1840
+ self._encode_total_frames = None
1841
+ self._encode_current_frame = None
1842
+ if is_new_job:
1843
+ self._reset_audio_progress_state(clear_source=True)
1844
+ self._set_status("processing", "Processing")
1451
1845
 
1452
1846
  frame_total_match = re.search(
1453
1847
  r"Final encode target frames(?: \(fallback\))?:\s*(\d+)", message
@@ -1473,12 +1867,12 @@ class TalksReducerGUI:
1473
1867
 
1474
1868
  self._encode_current_frame = current_frame
1475
1869
  if self._encode_total_frames and self._encode_total_frames > 0:
1476
- percentage = min(
1477
- 100,
1478
- int((current_frame / self._encode_total_frames) * 100),
1479
- )
1870
+ self._complete_audio_phase()
1871
+ frame_ratio = min(current_frame / self._encode_total_frames, 1.0)
1872
+ percentage = min(100, 5 + int(frame_ratio * 95))
1480
1873
  self._set_progress(percentage)
1481
1874
  else:
1875
+ self._complete_audio_phase()
1482
1876
  self._set_status("processing", f"{current_frame} frames encoded")
1483
1877
 
1484
1878
  # Parse encode target duration reported by the pipeline
@@ -1537,11 +1931,95 @@ class TalksReducerGUI:
1537
1931
  and total_seconds
1538
1932
  and total_seconds > 0
1539
1933
  ):
1540
- percentage = min(100, int((current_seconds / total_seconds) * 100))
1934
+ self._complete_audio_phase()
1935
+ time_ratio = min(current_seconds / total_seconds, 1.0)
1936
+ percentage = min(100, 5 + int(time_ratio * 95))
1541
1937
  self._set_progress(percentage)
1542
1938
 
1543
1939
  self._set_status("processing", status_msg)
1544
1940
 
1941
+ def _compute_audio_progress_interval(self) -> int:
1942
+ duration = self._source_duration_seconds or self._video_duration_seconds
1943
+ if duration and duration > 0:
1944
+ audio_seconds = max(duration * self.AUDIO_PROCESSING_RATIO, 0.0)
1945
+ interval_seconds = audio_seconds / self.AUDIO_PROGRESS_STEPS
1946
+ interval_ms = int(round(interval_seconds * 1000))
1947
+ return max(self.MIN_AUDIO_INTERVAL_MS, interval_ms)
1948
+ return self.DEFAULT_AUDIO_INTERVAL_MS
1949
+
1950
+ def _start_audio_progress(self) -> None:
1951
+ interval_ms = self._compute_audio_progress_interval()
1952
+
1953
+ def _start() -> None:
1954
+ if self._audio_progress_job is not None:
1955
+ self.root.after_cancel(self._audio_progress_job)
1956
+ self._audio_progress_steps_completed = 0
1957
+ self._audio_progress_interval_ms = interval_ms
1958
+ self._audio_progress_job = self.root.after(
1959
+ interval_ms, self._advance_audio_progress
1960
+ )
1961
+
1962
+ self._notify(_start)
1963
+
1964
+ def _advance_audio_progress(self) -> None:
1965
+ self._audio_progress_job = None
1966
+ if self._audio_progress_steps_completed >= self.AUDIO_PROGRESS_STEPS:
1967
+ self._audio_progress_interval_ms = None
1968
+ return
1969
+
1970
+ self._audio_progress_steps_completed += 1
1971
+ audio_percentage = (
1972
+ self._audio_progress_steps_completed / self.AUDIO_PROGRESS_STEPS * 100
1973
+ )
1974
+ percentage = (audio_percentage / 100) * 5
1975
+ self._set_progress(percentage)
1976
+ self._set_status("processing", "Audio processing: %d%%" % (audio_percentage))
1977
+
1978
+ if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
1979
+ interval_ms = (
1980
+ self._audio_progress_interval_ms or self.DEFAULT_AUDIO_INTERVAL_MS
1981
+ )
1982
+ self._audio_progress_job = self.root.after(
1983
+ interval_ms, self._advance_audio_progress
1984
+ )
1985
+ else:
1986
+ self._audio_progress_interval_ms = None
1987
+
1988
+ def _cancel_audio_progress(self) -> None:
1989
+ if self._audio_progress_job is None:
1990
+ self._audio_progress_interval_ms = None
1991
+ return
1992
+
1993
+ def _cancel() -> None:
1994
+ if self._audio_progress_job is not None:
1995
+ self.root.after_cancel(self._audio_progress_job)
1996
+ self._audio_progress_job = None
1997
+ self._audio_progress_interval_ms = None
1998
+
1999
+ self._notify(_cancel)
2000
+
2001
+ def _reset_audio_progress_state(self, *, clear_source: bool) -> None:
2002
+ if clear_source:
2003
+ self._source_duration_seconds = None
2004
+ self._audio_progress_steps_completed = 0
2005
+ self._audio_progress_interval_ms = None
2006
+ if self._audio_progress_job is not None:
2007
+ self._cancel_audio_progress()
2008
+
2009
+ def _complete_audio_phase(self) -> None:
2010
+ def _complete() -> None:
2011
+ if self._audio_progress_job is not None:
2012
+ self.root.after_cancel(self._audio_progress_job)
2013
+ self._audio_progress_job = None
2014
+ self._audio_progress_interval_ms = None
2015
+ if self._audio_progress_steps_completed < self.AUDIO_PROGRESS_STEPS:
2016
+ self._audio_progress_steps_completed = self.AUDIO_PROGRESS_STEPS
2017
+ current_value = self.progress_var.get()
2018
+ if current_value < self.AUDIO_PROGRESS_STEPS:
2019
+ self._set_progress(self.AUDIO_PROGRESS_STEPS)
2020
+
2021
+ self._notify(_complete)
2022
+
1545
2023
  def _apply_status_style(self, status: str) -> None:
1546
2024
  color = STATUS_COLORS.get(status.lower())
1547
2025
  if color:
@@ -1580,6 +2058,8 @@ class TalksReducerGUI:
1580
2058
  self.status_frame.grid()
1581
2059
  self.stop_button.grid()
1582
2060
  self.drop_hint_button.grid_remove()
2061
+ else:
2062
+ self._reset_audio_progress_state(clear_source=True)
1583
2063
 
1584
2064
  if lowered == "success" or "time:" in lowered and "size:" in lowered:
1585
2065
  if self.simple_mode_var.get() and hasattr(self, "status_frame"):
@@ -1592,12 +2072,7 @@ class TalksReducerGUI:
1592
2072
  else:
1593
2073
  self.open_button.grid_remove()
1594
2074
  # print("not success status")
1595
- if (
1596
- self.simple_mode_var.get()
1597
- and not is_processing
1598
- and hasattr(self, "status_frame")
1599
- ):
1600
- self.status_frame.grid_remove()
2075
+ if self.simple_mode_var.get() and not is_processing:
1601
2076
  self.stop_button.grid_remove()
1602
2077
  # Show drop hint when no other buttons are visible
1603
2078
  if hasattr(self, "drop_hint_button"):