talks-reducer 0.2.24__tar.gz → 0.3.0__tar.gz

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.
Files changed (22) hide show
  1. {talks_reducer-0.2.24/talks_reducer.egg-info → talks_reducer-0.3.0}/PKG-INFO +7 -1
  2. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/README.md +6 -0
  3. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/pyproject.toml +1 -1
  4. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/ffmpeg.py +13 -1
  5. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/gui.py +451 -72
  6. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/pipeline.py +5 -0
  7. {talks_reducer-0.2.24 → talks_reducer-0.3.0/talks_reducer.egg-info}/PKG-INFO +7 -1
  8. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/LICENSE +0 -0
  9. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/setup.cfg +0 -0
  10. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/__init__.py +0 -0
  11. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/__main__.py +0 -0
  12. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/audio.py +0 -0
  13. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/chunks.py +0 -0
  14. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/cli.py +0 -0
  15. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/models.py +0 -0
  16. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer/progress.py +0 -0
  17. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer.egg-info/SOURCES.txt +0 -0
  18. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer.egg-info/dependency_links.txt +0 -0
  19. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer.egg-info/entry_points.txt +0 -0
  20. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer.egg-info/requires.txt +0 -0
  21. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/talks_reducer.egg-info/top_level.txt +0 -0
  22. {talks_reducer-0.2.24 → talks_reducer-0.3.0}/tests/test_pipeline_service.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: talks-reducer
3
- Version: 0.2.24
3
+ Version: 0.3.0
4
4
  Summary: CLI for speeding up long-form talks by removing silence
5
5
  Author: Talks Reducer Maintainers
6
6
  License-Expression: MIT
@@ -85,6 +85,8 @@ continues to work unchanged for local development.
85
85
  - **Input drop zone** — drag files or folders from your desktop or add them via
86
86
  the Explorer/Finder dialog; duplicates are ignored.
87
87
  - **Small video** — toggles the `--small` preset used by the CLI.
88
+ - **Open after convert** — controls whether the exported file is revealed in
89
+ your system file manager as soon as each job finishes.
88
90
  - **Advanced** — reveals optional controls for the output path, temp folder,
89
91
  timing/audio knobs mirrored from the command line, and an appearance picker
90
92
  that can force dark or light mode or follow your operating system.
@@ -94,6 +96,10 @@ a background thread. Once every queued job succeeds an **Open last output**
94
96
  button appears so you can jump straight to the exported file in your system
95
97
  file manager.
96
98
 
99
+ The GUI stores your last-used Simple mode, Small video, Open after convert, and
100
+ theme preferences in a cross-platform configuration file so they persist across
101
+ launches.
102
+
97
103
  ## Repository Structure
98
104
  - `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
99
105
  - `cli.py` parses arguments and dispatches to the pipeline.
@@ -60,6 +60,8 @@ continues to work unchanged for local development.
60
60
  - **Input drop zone** — drag files or folders from your desktop or add them via
61
61
  the Explorer/Finder dialog; duplicates are ignored.
62
62
  - **Small video** — toggles the `--small` preset used by the CLI.
63
+ - **Open after convert** — controls whether the exported file is revealed in
64
+ your system file manager as soon as each job finishes.
63
65
  - **Advanced** — reveals optional controls for the output path, temp folder,
64
66
  timing/audio knobs mirrored from the command line, and an appearance picker
65
67
  that can force dark or light mode or follow your operating system.
@@ -69,6 +71,10 @@ a background thread. Once every queued job succeeds an **Open last output**
69
71
  button appears so you can jump straight to the exported file in your system
70
72
  file manager.
71
73
 
74
+ The GUI stores your last-used Simple mode, Small video, Open after convert, and
75
+ theme preferences in a cross-platform configuration file so they persist across
76
+ launches.
77
+
72
78
  ## Repository Structure
73
79
  - `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
74
80
  - `cli.py` parses arguments and dispatches to the pipeline.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "talks-reducer"
7
- version = "0.2.24"
7
+ version = "0.3.0"
8
8
  description = "CLI for speeding up long-form talks by removing silence"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -190,8 +190,13 @@ def run_timed_ffmpeg_command(
190
190
  desc: str = "",
191
191
  total: Optional[int] = None,
192
192
  unit: str = "frames",
193
+ process_callback: Optional[callable] = None,
193
194
  ) -> None:
194
- """Execute an FFmpeg command while streaming progress information."""
195
+ """Execute an FFmpeg command while streaming progress information.
196
+
197
+ Args:
198
+ process_callback: Optional callback that receives the subprocess.Popen object
199
+ """
195
200
 
196
201
  import shlex
197
202
 
@@ -221,6 +226,10 @@ def run_timed_ffmpeg_command(
221
226
  print(f"Error starting FFmpeg: {exc}", file=sys.stderr)
222
227
  raise
223
228
 
229
+ # Notify callback with process object
230
+ if process_callback:
231
+ process_callback(process)
232
+
224
233
  progress_reporter = reporter or TqdmProgressReporter()
225
234
  task_manager = progress_reporter.task(desc=desc, total=total, unit=unit)
226
235
  with task_manager as progress:
@@ -234,6 +243,9 @@ def run_timed_ffmpeg_command(
234
243
 
235
244
  sys.stderr.write(line)
236
245
  sys.stderr.flush()
246
+
247
+ # Send FFmpeg output to reporter for GUI display
248
+ progress_reporter.log(line.strip())
237
249
 
238
250
  match = re.search(r"frame=\s*(\d+)", line)
239
251
  if match:
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  import os
7
+ import re
7
8
  import subprocess
8
9
  import sys
9
10
  import threading
@@ -117,9 +118,10 @@ except ModuleNotFoundError: # pragma: no cover - runtime dependency
117
118
 
118
119
  STATUS_COLORS = {
119
120
  "idle": "#9ca3af",
120
- "processing": "#facc15",
121
- "success": "#22c55e",
122
- "error": "#f87171",
121
+ "processing": "#af8e0e",
122
+ "success": "#178941",
123
+ "error": "#ad4f4f",
124
+ "aborted": "#6d727a",
123
125
  }
124
126
 
125
127
  LIGHT_THEME = {
@@ -128,7 +130,8 @@ LIGHT_THEME = {
128
130
  "accent": "#2563eb",
129
131
  "surface": "#ffffff",
130
132
  "border": "#cbd5e1",
131
- "hover": "#1d4ed8",
133
+ "hover": "#efefef",
134
+ "hover_text": "#000000",
132
135
  "selection_background": "#2563eb",
133
136
  "selection_foreground": "#ffffff",
134
137
  }
@@ -140,6 +143,7 @@ DARK_THEME = {
140
143
  "surface": "#2b2b3c",
141
144
  "border": "#4b5563",
142
145
  "hover": "#333333",
146
+ "hover_text": "#ffffff",
143
147
  "selection_background": "#333333",
144
148
  "selection_foreground": "#f3f4f6",
145
149
  }
@@ -186,8 +190,13 @@ class _GuiProgressHandle(ProgressHandle):
186
190
  class _TkProgressReporter(SignalProgressReporter):
187
191
  """Progress reporter that forwards updates to the GUI thread."""
188
192
 
189
- def __init__(self, log_callback: Callable[[str], None]) -> None:
193
+ def __init__(
194
+ self,
195
+ log_callback: Callable[[str], None],
196
+ process_callback: Optional[Callable] = None,
197
+ ) -> None:
190
198
  self._log_callback = log_callback
199
+ self.process_callback = process_callback
191
200
 
192
201
  def log(self, message: str) -> None:
193
202
  self._log_callback(message)
@@ -202,7 +211,55 @@ class _TkProgressReporter(SignalProgressReporter):
202
211
  class TalksReducerGUI:
203
212
  """Tkinter application mirroring the CLI options with form controls."""
204
213
 
214
+ PADDING = 10
215
+
216
+ def _determine_config_path(self) -> Path:
217
+ if sys.platform == "win32":
218
+ appdata = os.environ.get("APPDATA")
219
+ base = Path(appdata) if appdata else Path.home() / "AppData" / "Roaming"
220
+ elif sys.platform == "darwin":
221
+ base = Path.home() / "Library" / "Application Support"
222
+ else:
223
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
224
+ base = Path(xdg_config) if xdg_config else Path.home() / ".config"
225
+ return base / "talks-reducer" / "settings.json"
226
+
227
+ def _load_settings(self) -> dict[str, object]:
228
+ try:
229
+ with self._config_path.open("r", encoding="utf-8") as handle:
230
+ data = json.load(handle)
231
+ if isinstance(data, dict):
232
+ return data
233
+ except FileNotFoundError:
234
+ return {}
235
+ except (OSError, json.JSONDecodeError):
236
+ return {}
237
+ return {}
238
+
239
+ def _save_settings(self) -> None:
240
+ try:
241
+ self._config_path.parent.mkdir(parents=True, exist_ok=True)
242
+ with self._config_path.open("w", encoding="utf-8") as handle:
243
+ json.dump(self._settings, handle, indent=2, sort_keys=True)
244
+ except OSError:
245
+ pass
246
+
247
+ def _get_setting(self, key: str, default: object) -> object:
248
+ value = self._settings.get(key, default)
249
+ if key not in self._settings:
250
+ self._settings[key] = value
251
+ return value
252
+
253
+ def _update_setting(self, key: str, value: object) -> None:
254
+ if self._settings.get(key) == value:
255
+ return
256
+ self._settings[key] = value
257
+ self._save_settings()
258
+
205
259
  def __init__(self) -> None:
260
+ self._config_path = self._determine_config_path()
261
+ self._settings = self._load_settings()
262
+
206
263
  # Import tkinter here to avoid loading it at module import time
207
264
  import tkinter as tk
208
265
  from tkinter import filedialog, messagebox, ttk
@@ -217,18 +274,18 @@ class TalksReducerGUI:
217
274
  self.root = TkinterDnD.Tk() # type: ignore[call-arg]
218
275
  else:
219
276
  self.root = tk.Tk()
220
-
277
+
221
278
  # Set window title with version
222
279
  try:
223
280
  app_version = version("talks-reducer")
224
281
  self.root.title(f"Talks Reducer v{app_version}")
225
282
  except Exception:
226
283
  self.root.title("Talks Reducer")
227
-
284
+
228
285
  self._apply_window_icon()
229
286
 
230
287
  self._full_size = (760, 680)
231
- self._simple_size = (255, 300)
288
+ self._simple_size = (255, 330)
232
289
  self.root.geometry(f"{self._full_size[0]}x{self._full_size[1]}")
233
290
  self.style = self.ttk.Style(self.root)
234
291
 
@@ -238,21 +295,36 @@ class TalksReducerGUI:
238
295
  self.status_var = tk.StringVar(value=self._status_state)
239
296
  self._status_animation_job: Optional[str] = None
240
297
  self._status_animation_phase = 0
298
+ self._video_duration_seconds: Optional[float] = None
299
+ self.progress_var = tk.IntVar(value=0)
300
+ self._ffmpeg_process: Optional[subprocess.Popen] = None
301
+ self._stop_requested = False
241
302
 
242
303
  self.input_files: List[str] = []
243
304
 
244
305
  self._dnd_available = TkinterDnD is not None and DND_FILES is not None
245
306
 
246
- self.simple_mode_var = tk.BooleanVar(value=True)
307
+ self.simple_mode_var = tk.BooleanVar(
308
+ value=self._get_setting("simple_mode", True)
309
+ )
247
310
  self.run_after_drop_var = tk.BooleanVar(value=True)
248
- self.small_var = tk.BooleanVar(value=True)
249
- self.theme_var = tk.StringVar(value="os")
311
+ self.small_var = tk.BooleanVar(value=self._get_setting("small_video", True))
312
+ self.open_after_convert_var = tk.BooleanVar(
313
+ value=self._get_setting("open_after_convert", True)
314
+ )
315
+ self.theme_var = tk.StringVar(value=self._get_setting("theme", "os"))
250
316
  self.theme_var.trace_add("write", self._on_theme_change)
317
+ self.small_var.trace_add("write", self._on_small_video_change)
318
+ self.open_after_convert_var.trace_add(
319
+ "write", self._on_open_after_convert_change
320
+ )
251
321
 
252
322
  self._build_layout()
253
323
  self._apply_simple_mode(initial=True)
254
324
  self._apply_status_style(self._status_state)
255
325
  self._apply_theme()
326
+ self._save_settings()
327
+ self._hide_stop_button()
256
328
 
257
329
  if not self._dnd_available:
258
330
  self._append_log(
@@ -289,16 +361,17 @@ class TalksReducerGUI:
289
361
  continue
290
362
 
291
363
  def _build_layout(self) -> None:
292
- main = self.ttk.Frame(self.root, padding=16)
364
+ main = self.ttk.Frame(self.root, padding=self.PADDING)
293
365
  main.grid(row=0, column=0, sticky="nsew")
294
366
  self.root.columnconfigure(0, weight=1)
295
367
  self.root.rowconfigure(0, weight=1)
296
368
 
297
369
  # Input selection frame
298
- input_frame = self.ttk.LabelFrame(main, text="Input files", padding=12)
370
+ input_frame = self.ttk.Frame(main, padding=self.PADDING)
299
371
  input_frame.grid(row=0, column=0, sticky="nsew")
300
372
  main.rowconfigure(0, weight=1)
301
- for column in range(4):
373
+ main.columnconfigure(0, weight=1)
374
+ for column in range(5):
302
375
  input_frame.columnconfigure(column, weight=1)
303
376
 
304
377
  self.input_list = self.tk.Listbox(input_frame, height=5)
@@ -312,13 +385,13 @@ class TalksReducerGUI:
312
385
  self.drop_zone = self.tk.Label(
313
386
  input_frame,
314
387
  text="Drop files or folders here",
315
- relief=self.tk.RIDGE,
316
- borderwidth=2,
317
- padx=16,
318
- pady=16,
319
- highlightthickness=1,
388
+ relief=self.tk.FLAT,
389
+ borderwidth=0,
390
+ padx=self.PADDING,
391
+ pady=self.PADDING,
392
+ highlightthickness=0,
320
393
  )
321
- self.drop_zone.grid(row=1, column=0, columnspan=4, sticky="nsew")
394
+ self.drop_zone.grid(row=1, column=0, columnspan=5, sticky="nsew")
322
395
  input_frame.rowconfigure(1, weight=1)
323
396
  self._configure_drop_targets(self.drop_zone)
324
397
  self._configure_drop_targets(self.input_list)
@@ -343,8 +416,8 @@ class TalksReducerGUI:
343
416
  self.run_after_drop_check.grid(row=2, column=3, pady=8, sticky="e")
344
417
 
345
418
  # Options frame
346
- options = self.ttk.LabelFrame(main, text="Options", padding=12)
347
- options.grid(row=1, column=0, pady=(16, 0), sticky="nsew")
419
+ options = self.ttk.Frame(main, padding=self.PADDING)
420
+ options.grid(row=2, column=0, pady=(16, 0), sticky="ew")
348
421
  options.columnconfigure(0, weight=1)
349
422
 
350
423
  self.simple_mode_check = self.ttk.Checkbutton(
@@ -359,6 +432,12 @@ class TalksReducerGUI:
359
432
  row=1, column=0, sticky="w", pady=(8, 0)
360
433
  )
361
434
 
435
+ self.ttk.Checkbutton(
436
+ options,
437
+ text="Open after convert",
438
+ variable=self.open_after_convert_var,
439
+ ).grid(row=2, column=0, sticky="w", pady=(8, 0))
440
+
362
441
  self.advanced_visible = self.tk.BooleanVar(value=False)
363
442
  self.advanced_button = self.ttk.Button(
364
443
  options,
@@ -367,8 +446,8 @@ class TalksReducerGUI:
367
446
  )
368
447
  self.advanced_button.grid(row=0, column=1, sticky="e")
369
448
 
370
- self.advanced_frame = self.ttk.Frame(options, padding=(0, 12, 0, 0))
371
- self.advanced_frame.grid(row=2, column=0, columnspan=2, sticky="nsew")
449
+ self.advanced_frame = self.ttk.Frame(options, padding=self.PADDING)
450
+ self.advanced_frame.grid(row=3, column=0, columnspan=2, sticky="nsew")
372
451
  self.advanced_frame.columnconfigure(1, weight=1)
373
452
 
374
453
  self.output_var = self.tk.StringVar()
@@ -424,33 +503,54 @@ class TalksReducerGUI:
424
503
  self._toggle_advanced(initial=True)
425
504
 
426
505
  # Action buttons and log output
427
- self.actions_frame = self.ttk.Frame(main)
428
- self.actions_frame.grid(row=2, column=0, pady=(16, 0), sticky="ew")
429
- self.actions_frame.columnconfigure(1, weight=1)
506
+ status_frame = self.ttk.Frame(main, padding=self.PADDING)
507
+ status_frame.grid(row=1, column=0, sticky="ew")
508
+ status_frame.columnconfigure(0, weight=0)
509
+ status_frame.columnconfigure(1, weight=1)
510
+ status_frame.columnconfigure(2, weight=0)
511
+
512
+
513
+ self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
514
+ self.status_label = self.tk.Label(status_frame, textvariable=self.status_var, anchor="e")
515
+ self.status_label.grid(row=0, column=1, sticky="e")
516
+
517
+ # Progress bar
518
+ self.progress_bar = self.ttk.Progressbar(
519
+ status_frame,
520
+ variable=self.progress_var,
521
+ maximum=100,
522
+ mode="determinate",
523
+ style="Idle.Horizontal.TProgressbar",
524
+ )
525
+ self.progress_bar.grid(row=1, column=0, columnspan=3, sticky="ew", pady=(0, 0))
430
526
 
431
- self.run_button = self.ttk.Button(
432
- self.actions_frame, text="Run", command=self._start_run
527
+ self.stop_button = self.ttk.Button(
528
+ status_frame, text="Stop", command=self._stop_processing
433
529
  )
434
- self.run_button.grid(row=0, column=0, sticky="w")
530
+ self.stop_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
531
+ self.stop_button.grid_remove() # Hidden by default
435
532
 
436
533
  self.open_button = self.ttk.Button(
437
- self.actions_frame,
438
- text="Open last output",
534
+ status_frame,
535
+ text="Open last",
439
536
  command=self._open_last_output,
440
537
  state=self.tk.DISABLED,
441
538
  )
442
- self.open_button.grid(row=0, column=1, sticky="e")
539
+ self.open_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
443
540
  self.open_button.grid_remove()
444
541
 
445
- status_frame = self.ttk.Frame(main, padding=(0, 8, 0, 0))
446
- status_frame.grid(row=3, column=0, sticky="ew")
447
- status_frame.columnconfigure(1, weight=1)
448
- self.ttk.Label(status_frame, text="Status:").grid(row=0, column=0, sticky="w")
449
- self.status_label = self.tk.Label(status_frame, textvariable=self.status_var)
450
- self.status_label.grid(row=0, column=1, sticky="w")
542
+ # Button shown when no other action buttons are visible
543
+ self.drop_hint_button = self.ttk.Button(
544
+ status_frame,
545
+ text="Drop video to convert",
546
+ state=self.tk.DISABLED,
547
+ )
548
+ self.drop_hint_button.grid(row=2, column=0, columnspan=3, sticky="ew", pady=self.PADDING)
549
+ self.drop_hint_button.grid_remove() # Hidden by default
550
+ self._configure_drop_targets(self.drop_hint_button)
451
551
 
452
- self.log_frame = self.ttk.LabelFrame(main, text="Log", padding=12)
453
- self.log_frame.grid(row=4, column=0, pady=(16, 0), sticky="nsew")
552
+ self.log_frame = self.ttk.Frame(main, padding=self.PADDING)
553
+ self.log_frame.grid(row=3, column=0, pady=(16, 0), sticky="nsew")
454
554
  main.rowconfigure(4, weight=1)
455
555
  self.log_frame.columnconfigure(0, weight=1)
456
556
  self.log_frame.rowconfigure(0, weight=1)
@@ -486,6 +586,7 @@ class TalksReducerGUI:
486
586
  button.grid(row=row, column=2, padx=(8, 0))
487
587
 
488
588
  def _toggle_simple_mode(self) -> None:
589
+ self._update_setting("simple_mode", self.simple_mode_var.get())
489
590
  self._apply_simple_mode()
490
591
 
491
592
  def _apply_simple_mode(self, *, initial: bool = False) -> None:
@@ -503,21 +604,23 @@ class TalksReducerGUI:
503
604
  for widget in widgets:
504
605
  widget.grid_remove()
505
606
  self.log_frame.grid_remove()
506
- self.run_button.grid_remove()
607
+ self.stop_button.grid_remove()
507
608
  self.advanced_button.grid_remove()
508
609
  self.advanced_frame.grid_remove()
509
- self.actions_frame.grid_remove()
610
+ if hasattr(self, 'status_frame'):
611
+ self.status_frame.grid_remove()
510
612
  self.run_after_drop_var.set(True)
511
613
  self._apply_window_size(simple=True)
512
- if self.status_var.get().lower() == "success":
513
- self.actions_frame.grid()
614
+ if self.status_var.get().lower() == "success" and hasattr(self, 'status_frame'):
615
+ self.status_frame.grid()
514
616
  self.open_button.grid()
617
+ self.drop_hint_button.grid_remove()
515
618
  else:
516
619
  for widget in widgets:
517
620
  widget.grid()
518
621
  self.log_frame.grid()
519
- self.actions_frame.grid()
520
- self.run_button.grid()
622
+ if hasattr(self, 'status_frame'):
623
+ self.status_frame.grid()
521
624
  self.advanced_button.grid()
522
625
  if self.advanced_visible.get():
523
626
  self.advanced_frame.grid()
@@ -551,8 +654,17 @@ class TalksReducerGUI:
551
654
  self.advanced_button.configure(text="Advanced")
552
655
 
553
656
  def _on_theme_change(self, *_: object) -> None:
657
+ self._update_setting("theme", self.theme_var.get())
554
658
  self._apply_theme()
555
659
 
660
+ def _on_small_video_change(self, *_: object) -> None:
661
+ self._update_setting("small_video", bool(self.small_var.get()))
662
+
663
+ def _on_open_after_convert_change(self, *_: object) -> None:
664
+ self._update_setting(
665
+ "open_after_convert", bool(self.open_after_convert_var.get())
666
+ )
667
+
556
668
  def _apply_theme(self) -> None:
557
669
  preference = self.theme_var.get().lower()
558
670
  if preference not in {"light", "dark"}:
@@ -572,6 +684,8 @@ class TalksReducerGUI:
572
684
  "TLabelframe",
573
685
  background=palette["background"],
574
686
  foreground=palette["foreground"],
687
+ borderwidth=0,
688
+ relief="flat",
575
689
  )
576
690
  self.style.configure(
577
691
  "TLabelframe.Label",
@@ -586,11 +700,19 @@ class TalksReducerGUI:
586
700
  background=palette["background"],
587
701
  foreground=palette["foreground"],
588
702
  )
703
+ self.style.map(
704
+ "TCheckbutton",
705
+ background=[("active", palette.get("hover", palette["background"]))],
706
+ )
589
707
  self.style.configure(
590
708
  "TRadiobutton",
591
709
  background=palette["background"],
592
710
  foreground=palette["foreground"],
593
711
  )
712
+ self.style.map(
713
+ "TRadiobutton",
714
+ background=[("active", palette.get("hover", palette["background"]))],
715
+ )
594
716
  self.style.configure(
595
717
  "TButton",
596
718
  background=palette["surface"],
@@ -604,7 +726,7 @@ class TalksReducerGUI:
604
726
  ("disabled", palette["surface"]),
605
727
  ],
606
728
  foreground=[
607
- ("active", palette["surface"]),
729
+ ("active", palette.get("hover_text", "#000000")),
608
730
  ("disabled", palette["foreground"]),
609
731
  ],
610
732
  )
@@ -619,11 +741,47 @@ class TalksReducerGUI:
619
741
  foreground=palette["foreground"],
620
742
  )
621
743
 
744
+ # Configure progress bar styles for different states
745
+ self.style.configure(
746
+ "Idle.Horizontal.TProgressbar",
747
+ background=STATUS_COLORS["idle"],
748
+ troughcolor=palette["surface"],
749
+ borderwidth=0,
750
+ thickness=20,
751
+ )
752
+ self.style.configure(
753
+ "Processing.Horizontal.TProgressbar",
754
+ background=STATUS_COLORS["processing"],
755
+ troughcolor=palette["surface"],
756
+ borderwidth=0,
757
+ thickness=20,
758
+ )
759
+ self.style.configure(
760
+ "Success.Horizontal.TProgressbar",
761
+ background=STATUS_COLORS["success"],
762
+ troughcolor=palette["surface"],
763
+ borderwidth=0,
764
+ thickness=20,
765
+ )
766
+ self.style.configure(
767
+ "Error.Horizontal.TProgressbar",
768
+ background=STATUS_COLORS["error"],
769
+ troughcolor=palette["surface"],
770
+ borderwidth=0,
771
+ thickness=20,
772
+ )
773
+ self.style.configure(
774
+ "Aborted.Horizontal.TProgressbar",
775
+ background=STATUS_COLORS["aborted"],
776
+ troughcolor=palette["surface"],
777
+ borderwidth=0,
778
+ thickness=20,
779
+ )
780
+
622
781
  self.drop_zone.configure(
623
782
  bg=palette["surface"],
624
783
  fg=palette["foreground"],
625
- highlightbackground=palette["border"],
626
- highlightcolor=palette["border"],
784
+ highlightthickness=0,
627
785
  )
628
786
  self.input_list.configure(
629
787
  bg=palette["surface"],
@@ -714,12 +872,20 @@ class TalksReducerGUI:
714
872
  self.input_list.delete(index)
715
873
  del self.input_files[index]
716
874
 
875
+ def _clear_input_files(self) -> None:
876
+ """Clear all input files from the list."""
877
+ self.input_files.clear()
878
+ self.input_list.delete(0, self.tk.END)
879
+
717
880
  def _on_drop(self, event: object) -> None:
718
881
  data = getattr(event, "data", "")
719
882
  if not data:
720
883
  return
721
884
  paths = self.root.tk.splitlist(data)
722
885
  cleaned = [path.strip("{}") for path in paths]
886
+ # Clear existing files before adding dropped files
887
+ self.input_files.clear()
888
+ self.input_list.delete(0, self.tk.END)
723
889
  self._extend_inputs(cleaned, auto_run=True)
724
890
 
725
891
  def _browse_path(
@@ -753,10 +919,16 @@ class TalksReducerGUI:
753
919
  return
754
920
 
755
921
  self._append_log("Starting processing…")
756
- self.run_button.configure(state=self.tk.DISABLED)
922
+ self._stop_requested = False
923
+ open_after_convert = bool(self.open_after_convert_var.get())
757
924
 
758
925
  def worker() -> None:
759
- reporter = _TkProgressReporter(self._append_log)
926
+ def set_process(proc: subprocess.Popen) -> None:
927
+ self._ffmpeg_process = proc
928
+
929
+ reporter = _TkProgressReporter(
930
+ self._append_log, process_callback=set_process
931
+ )
760
932
  try:
761
933
  files = gather_input_files(self.input_files)
762
934
  if not files:
@@ -776,30 +948,75 @@ class TalksReducerGUI:
776
948
  result = speed_up_video(options, reporter=reporter)
777
949
  self._last_output = result.output_file
778
950
  self._append_log(f"Completed: {result.output_file}")
779
- self._notify(
780
- lambda path=result.output_file: self._open_in_file_manager(path)
781
- )
951
+ if open_after_convert:
952
+ self._notify(
953
+ lambda path=result.output_file: self._open_in_file_manager(
954
+ path
955
+ )
956
+ )
782
957
 
783
958
  self._append_log("All jobs finished successfully.")
784
959
  self._notify(lambda: self.open_button.configure(state=self.tk.NORMAL))
960
+ self._notify(self._clear_input_files)
785
961
  except FFmpegNotFoundError as exc:
786
962
  self._notify(
787
963
  lambda: self.messagebox.showerror("FFmpeg not found", str(exc))
788
964
  )
789
965
  self._set_status("Error")
790
966
  except Exception as exc: # pragma: no cover - GUI level safeguard
791
- self._notify(
792
- lambda: self.messagebox.showerror(
793
- "Error", f"Processing failed: {exc}"
967
+ # If stop was requested, don't show error (FFmpeg termination is expected)
968
+ if self._stop_requested:
969
+ self._append_log("Processing aborted by user.")
970
+ self._set_status("Aborted")
971
+ else:
972
+ self._notify(
973
+ lambda: self.messagebox.showerror(
974
+ "Error", f"Processing failed: {exc}"
975
+ )
794
976
  )
795
- )
796
- self._set_status("Error")
977
+ self._set_status("Error")
797
978
  finally:
798
- self._notify(lambda: self.run_button.configure(state=self.tk.NORMAL))
979
+ self._notify(self._hide_stop_button)
799
980
 
800
981
  self._processing_thread = threading.Thread(target=worker, daemon=True)
801
982
  self._processing_thread.start()
802
983
 
984
+ # Show Stop button when processing starts
985
+ self.stop_button.grid()
986
+
987
+ def _stop_processing(self) -> None:
988
+ """Stop the currently running processing by terminating FFmpeg."""
989
+ import signal
990
+
991
+ self._stop_requested = True
992
+ if self._ffmpeg_process and self._ffmpeg_process.poll() is None:
993
+ self._append_log("Stopping FFmpeg process...")
994
+ try:
995
+ # Send SIGTERM to FFmpeg process
996
+ if sys.platform == "win32":
997
+ # Windows doesn't have SIGTERM, use terminate()
998
+ self._ffmpeg_process.terminate()
999
+ else:
1000
+ # Unix-like systems can use SIGTERM
1001
+ self._ffmpeg_process.send_signal(signal.SIGTERM)
1002
+
1003
+ self._append_log("FFmpeg process stopped.")
1004
+ except Exception as e:
1005
+ self._append_log(f"Error stopping process: {e}")
1006
+ else:
1007
+ self._append_log("No active FFmpeg process to stop.")
1008
+
1009
+ self._hide_stop_button()
1010
+
1011
+ def _hide_stop_button(self) -> None:
1012
+ """Hide Stop button."""
1013
+ self.stop_button.grid_remove()
1014
+ # Show drop hint when stop button is hidden and no other buttons are visible
1015
+ if (not self.open_button.winfo_viewable() and
1016
+ hasattr(self, 'drop_hint_button') and
1017
+ not self.drop_hint_button.winfo_viewable()):
1018
+ self.drop_hint_button.grid()
1019
+
803
1020
  def _collect_arguments(self) -> dict[str, object]:
804
1021
  args: dict[str, object] = {}
805
1022
 
@@ -884,17 +1101,59 @@ class TalksReducerGUI:
884
1101
  normalized = message.strip().lower()
885
1102
  if "all jobs finished successfully" in normalized:
886
1103
  self._set_status("Success")
1104
+ self._set_progress(100) # 100% on success
1105
+ self._video_duration_seconds = None # Reset for next video
1106
+ elif normalized.startswith("extracting audio"):
1107
+ self._set_status("Extracting audio...")
1108
+ self._set_progress(0) # 0% on start
1109
+ self._video_duration_seconds = None # Reset for new processing
887
1110
  elif normalized.startswith("starting processing") or normalized.startswith(
888
1111
  "processing"
889
1112
  ):
890
1113
  self._set_status("Processing")
1114
+ self._set_progress(0) # 0% on start
1115
+ self._video_duration_seconds = None # Reset for new processing
1116
+
1117
+ # Parse video duration from FFmpeg output
1118
+ duration_match = re.search(r"Duration:\s*(\d{2}):(\d{2}):(\d{2}\.\d+)", message)
1119
+ if duration_match:
1120
+ hours = int(duration_match.group(1))
1121
+ minutes = int(duration_match.group(2))
1122
+ seconds = float(duration_match.group(3))
1123
+ self._video_duration_seconds = hours * 3600 + minutes * 60 + seconds
1124
+
1125
+ # Parse FFmpeg progress information (time and speed)
1126
+ time_match = re.search(r"time=(\d{2}):(\d{2}):(\d{2})\.\d+", message)
1127
+ speed_match = re.search(r"speed=\s*([\d.]+)x", message)
1128
+
1129
+ if time_match and speed_match:
1130
+ hours = int(time_match.group(1))
1131
+ minutes = int(time_match.group(2))
1132
+ seconds = int(time_match.group(3))
1133
+ time_str = f"{hours:02d}:{minutes:02d}:{seconds:02d}"
1134
+ speed_str = speed_match.group(1)
1135
+
1136
+ # Calculate percentage if we have duration
1137
+ if self._video_duration_seconds and self._video_duration_seconds > 0:
1138
+ current_seconds = hours * 3600 + minutes * 60 + seconds
1139
+ percentage = min(
1140
+ 100, int((current_seconds / self._video_duration_seconds) * 100)
1141
+ )
1142
+ self._set_status(f"{time_str}, {speed_str}x ({percentage}%)")
1143
+ self._set_progress(percentage) # Update progress bar
1144
+ else:
1145
+ self._set_status(f"{time_str}, {speed_str}x")
891
1146
 
892
1147
  def _apply_status_style(self, status: str) -> None:
893
1148
  color = STATUS_COLORS.get(status.lower())
894
1149
  if color:
895
1150
  self.status_label.configure(fg=color)
896
1151
  else:
897
- self.status_label.configure(fg="")
1152
+ # For extracting audio or FFmpeg progress messages, use processing color
1153
+ if "extracting audio" in status.lower() or re.search(
1154
+ r"\d{2}:\d{2}:\d{2}.*\d+\.?\d*x", status
1155
+ ):
1156
+ self.status_label.configure(fg=STATUS_COLORS["processing"])
898
1157
 
899
1158
  def _set_status(self, status: str) -> None:
900
1159
  def apply() -> None:
@@ -902,22 +1161,35 @@ class TalksReducerGUI:
902
1161
  self._status_state = status
903
1162
  self.status_var.set(status)
904
1163
  self._apply_status_style(status)
1164
+ self._set_progress_bar_style(status)
905
1165
  lowered = status.lower()
906
- if lowered == "processing":
907
- self.run_button.configure(state=self.tk.DISABLED)
1166
+ is_processing = lowered == "processing" or "extracting audio" in lowered
1167
+
1168
+ if is_processing:
908
1169
  self._start_status_animation()
909
- else:
910
- if not self.simple_mode_var.get():
911
- self.run_button.configure(state=self.tk.NORMAL)
1170
+ # Show stop button during processing
1171
+ if hasattr(self, 'status_frame'):
1172
+ self.status_frame.grid()
1173
+ self.stop_button.grid()
1174
+ self.drop_hint_button.grid_remove()
912
1175
 
913
1176
  if lowered == "success":
914
- if self.simple_mode_var.get():
915
- self.actions_frame.grid()
1177
+ if self.simple_mode_var.get() and hasattr(self, 'status_frame'):
1178
+ self.status_frame.grid()
1179
+ self.stop_button.grid_remove()
1180
+ self.drop_hint_button.grid_remove()
916
1181
  self.open_button.grid()
1182
+ self.open_button.lift() # Ensure open_button is above drop_hint_button
1183
+ print("success status")
917
1184
  else:
918
1185
  self.open_button.grid_remove()
919
- if self.simple_mode_var.get():
920
- self.actions_frame.grid_remove()
1186
+ print("not success status")
1187
+ if self.simple_mode_var.get() and not is_processing and hasattr(self, 'status_frame'):
1188
+ self.status_frame.grid_remove()
1189
+ self.stop_button.grid_remove()
1190
+ # Show drop hint when no other buttons are visible
1191
+ if hasattr(self, 'drop_hint_button'):
1192
+ self.drop_hint_button.grid()
921
1193
 
922
1194
  self.root.after(0, apply)
923
1195
 
@@ -945,6 +1217,99 @@ class TalksReducerGUI:
945
1217
  if self._status_state.lower() != "processing":
946
1218
  self.status_var.set(self._status_state)
947
1219
 
1220
+ def _calculate_gradient_color(self, percentage: int, darken: float = 1.0) -> str:
1221
+ """Calculate color gradient from red (0%) to green (100%).
1222
+
1223
+ Args:
1224
+ percentage: The position in the gradient (0-100)
1225
+ darken: Value between 0.0 (black) and 1.0 (original brightness)
1226
+
1227
+ Returns:
1228
+ Hex color code string
1229
+ """
1230
+ # Clamp percentage between 0 and 100
1231
+ percentage = max(0, min(100, percentage))
1232
+ # Clamp darken between 0.0 and 1.0
1233
+ darken = max(0.0, min(1.0, darken))
1234
+
1235
+ if percentage <= 50:
1236
+ # Red to Yellow (0% to 50%)
1237
+ # Red: (248, 113, 113) -> Yellow: (250, 204, 21)
1238
+ ratio = percentage / 50.0
1239
+ r = int((248 + (250 - 248) * ratio) * darken)
1240
+ g = int((113 + (204 - 113) * ratio) * darken)
1241
+ b = int((113 + (21 - 113) * ratio) * darken)
1242
+ else:
1243
+ # Yellow to Green (50% to 100%)
1244
+ # Yellow: (250, 204, 21) -> Green: (34, 197, 94)
1245
+ ratio = (percentage - 50) / 50.0
1246
+ r = int((250 + (34 - 250) * ratio) * darken)
1247
+ g = int((204 + (197 - 204) * ratio) * darken)
1248
+ b = int((21 + (94 - 21) * ratio) * darken)
1249
+
1250
+ # Ensure values are within 0-255 range after darkening
1251
+ r = max(0, min(255, r))
1252
+ g = max(0, min(255, g))
1253
+ b = max(0, min(255, b))
1254
+
1255
+ return f"#{r:02x}{g:02x}{b:02x}"
1256
+
1257
+ def _set_progress(self, percentage: int) -> None:
1258
+ """Update the progress bar value and color (thread-safe)."""
1259
+
1260
+ def updater() -> None:
1261
+ self.progress_var.set(percentage)
1262
+ # Update color based on percentage gradient
1263
+ color = self._calculate_gradient_color(percentage, 0.5)
1264
+ palette = (
1265
+ LIGHT_THEME if self._detect_system_theme() == "light" else DARK_THEME
1266
+ )
1267
+ if self.theme_var.get().lower() in {"light", "dark"}:
1268
+ palette = (
1269
+ LIGHT_THEME
1270
+ if self.theme_var.get().lower() == "light"
1271
+ else DARK_THEME
1272
+ )
1273
+
1274
+ self.style.configure(
1275
+ "Dynamic.Horizontal.TProgressbar",
1276
+ background=color,
1277
+ troughcolor=palette["surface"],
1278
+ borderwidth=0,
1279
+ thickness=20,
1280
+ )
1281
+ self.progress_bar.configure(style="Dynamic.Horizontal.TProgressbar")
1282
+
1283
+ # Show stop button when progress < 100
1284
+ if percentage < 100:
1285
+ if hasattr(self, 'status_frame'):
1286
+ self.status_frame.grid()
1287
+ self.stop_button.grid()
1288
+ self.drop_hint_button.grid_remove()
1289
+ self.root.after(0, updater)
1290
+
1291
+ def _set_progress_bar_style(self, status: str) -> None:
1292
+ """Update the progress bar color based on status."""
1293
+
1294
+ def updater() -> None:
1295
+ # Map status to progress bar style
1296
+ status_lower = status.lower()
1297
+ if status_lower == "success":
1298
+ style = "Success.Horizontal.TProgressbar"
1299
+ elif status_lower == "error":
1300
+ style = "Error.Horizontal.TProgressbar"
1301
+ elif status_lower == "aborted":
1302
+ style = "Aborted.Horizontal.TProgressbar"
1303
+ elif status_lower == "idle":
1304
+ style = "Idle.Horizontal.TProgressbar"
1305
+ else:
1306
+ # For processing states, use dynamic gradient (will be set by _set_progress)
1307
+ return
1308
+
1309
+ self.progress_bar.configure(style=style)
1310
+
1311
+ self.root.after(0, updater)
1312
+
948
1313
  def _notify(self, callback: Callable[[], None]) -> None:
949
1314
  self.root.after(0, callback)
950
1315
 
@@ -996,7 +1361,21 @@ def main(argv: Optional[Sequence[str]] = None) -> bool:
996
1361
  print()
997
1362
  print("Run 'python3 -m talks_reducer --help' for all options.")
998
1363
  print()
999
- print("This is likely a macOS/Tkinter compatibility issue.")
1364
+ print("Troubleshooting tips:")
1365
+ if sys.platform == "darwin":
1366
+ print(
1367
+ " - On macOS, install Python from python.org or ensure "
1368
+ "Homebrew's python-tk package is present."
1369
+ )
1370
+ elif sys.platform.startswith("linux"):
1371
+ print(
1372
+ " - On Linux, install the Tk bindings for Python (for example, "
1373
+ "python3-tk)."
1374
+ )
1375
+ else:
1376
+ print(" - Ensure your Python installation includes Tk support.")
1377
+ print(" - You can always fall back to the CLI workflow below.")
1378
+ print()
1000
1379
  print("The CLI interface works perfectly and is recommended.")
1001
1380
  except UnicodeEncodeError:
1002
1381
  # Fallback for extreme encoding issues
@@ -157,12 +157,15 @@ def speed_up_video(
157
157
  ffmpeg_path=ffmpeg_path,
158
158
  )
159
159
 
160
+ reporter.log("Extracting audio...")
161
+ process_callback = getattr(reporter, 'process_callback', None)
160
162
  run_timed_ffmpeg_command(
161
163
  extract_command,
162
164
  reporter=reporter,
163
165
  total=int(original_duration * frame_rate),
164
166
  unit="frames",
165
167
  desc="Extracting audio:",
168
+ process_callback=process_callback,
166
169
  )
167
170
 
168
171
  wav_sample_rate, audio_data = wavfile.read(os.fspath(audio_wav))
@@ -249,6 +252,7 @@ def speed_up_video(
249
252
  total=updated_chunks[-1][3],
250
253
  unit="frames",
251
254
  desc="Generating final:",
255
+ process_callback=process_callback,
252
256
  )
253
257
  except subprocess.CalledProcessError as exc:
254
258
  if fallback_command_str and use_cuda_encoder:
@@ -259,6 +263,7 @@ def speed_up_video(
259
263
  total=updated_chunks[-1][3],
260
264
  unit="frames",
261
265
  desc="Generating final (fallback):",
266
+ process_callback=process_callback,
262
267
  )
263
268
  else:
264
269
  raise
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: talks-reducer
3
- Version: 0.2.24
3
+ Version: 0.3.0
4
4
  Summary: CLI for speeding up long-form talks by removing silence
5
5
  Author: Talks Reducer Maintainers
6
6
  License-Expression: MIT
@@ -85,6 +85,8 @@ continues to work unchanged for local development.
85
85
  - **Input drop zone** — drag files or folders from your desktop or add them via
86
86
  the Explorer/Finder dialog; duplicates are ignored.
87
87
  - **Small video** — toggles the `--small` preset used by the CLI.
88
+ - **Open after convert** — controls whether the exported file is revealed in
89
+ your system file manager as soon as each job finishes.
88
90
  - **Advanced** — reveals optional controls for the output path, temp folder,
89
91
  timing/audio knobs mirrored from the command line, and an appearance picker
90
92
  that can force dark or light mode or follow your operating system.
@@ -94,6 +96,10 @@ a background thread. Once every queued job succeeds an **Open last output**
94
96
  button appears so you can jump straight to the exported file in your system
95
97
  file manager.
96
98
 
99
+ The GUI stores your last-used Simple mode, Small video, Open after convert, and
100
+ theme preferences in a cross-platform configuration file so they persist across
101
+ launches.
102
+
97
103
  ## Repository Structure
98
104
  - `talks_reducer/` — Python package that exposes the CLI and reusable pipeline:
99
105
  - `cli.py` parses arguments and dispatches to the pipeline.
File without changes
File without changes