From e90744852a31bd80aa1fe16c56906a18d96e7106 Mon Sep 17 00:00:00 2001 From: Jeena Date: Wed, 4 Jun 2025 11:43:52 +0900 Subject: [PATCH] Add start/pause/cancel buttons --- src/recoder/recoder_window.py | 149 ++++++++++++++++++++++++++------ src/recoder/transcoder.py | 68 ++++++++++----- src/resources/recoder_window.ui | 13 ++- 3 files changed, 180 insertions(+), 50 deletions(-) diff --git a/src/recoder/recoder_window.py b/src/recoder/recoder_window.py index 4dffa69..b47fa62 100644 --- a/src/recoder/recoder_window.py +++ b/src/recoder/recoder_window.py @@ -1,4 +1,6 @@ import gi +import signal + gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') gi.require_version('Notify', '0.7') @@ -10,6 +12,62 @@ from recoder.transcoder import Transcoder from recoder.utils import extract_video_files, notify_done, play_complete_sound from recoder.file_entry_row import FileEntryRow + +class DropHandler: + def __init__(self, overlay, drop_hint, progress_bar, on_files_dropped): + self.overlay = overlay + self.drop_hint = drop_hint + self.progress_bar = progress_bar + self.on_files_dropped = on_files_dropped + + self.drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY) + self.drop_target.connect("enter", self.on_drop_enter) + self.drop_target.connect("leave", self.on_drop_leave) + self.drop_target.connect("drop", self.on_drop) + + def controller(self): + return self.drop_target + + def on_drop_enter(self, *_): + self.overlay.add_css_class("drop-highlight") + return True + + def on_drop_leave(self, *_): + self.overlay.remove_css_class("drop-highlight") + return True + + def on_drop(self, _, value, __, ___): + self.overlay.remove_overlay(self.drop_hint) + self.drop_hint.set_visible(False) + self.progress_bar.set_visible(True) + self.progress_bar.set_fraction(0.0) + GLib.idle_add(partial(self.on_files_dropped, value)) + return value + + +class UIStateManager: + def __init__(self, window): + self.window = window + + def reset_ui(self): + self.window.btn_transcode.set_label("Start Transcoding") + self.window.btn_transcode.set_sensitive(False) + self.window.btn_transcode.remove_css_class("suggested-action") + self.window.btn_cancel.set_visible(False) + self.window.progress_bar.set_fraction(0.0) + self.window.progress_bar.set_text("") + self.window.progress_bar.set_visible(False) + self.window.drop_hint.set_visible(True) + + if self.window.drop_hint.get_parent() != self.window.overlay: + self.window.overlay.add_overlay(self.window.drop_hint) + + self.window.clear_listbox() + self.window.file_rows.clear() + self.window.file_items_to_process = [] + self.window.is_paused = False + + @Gtk.Template(resource_path="/net/jeena/recoder/recoder_window.ui") class RecoderWindow(Adw.ApplicationWindow): __gtype_name__ = "RecoderWindow" @@ -19,6 +77,8 @@ class RecoderWindow(Adw.ApplicationWindow): drop_hint = Gtk.Template.Child() listbox = Gtk.Template.Child() scrolled_window = Gtk.Template.Child() + btn_transcode = Gtk.Template.Child() + btn_cancel = Gtk.Template.Child() def __init__(self, application): super().__init__(application=application) @@ -26,12 +86,18 @@ class RecoderWindow(Adw.ApplicationWindow): self.file_items_to_process = [] self.transcoder = None self.file_rows = {} + self.is_paused = False - self.drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY) - self.drop_target.connect("drop", self.on_drop) - self.drop_target.connect("enter", self.on_drop_enter) - self.drop_target.connect("leave", self.on_drop_leave) - self.add_controller(self.drop_target) + self.ui_manager = UIStateManager(self) + self.drop_handler = DropHandler(self.overlay, self.drop_hint, self.progress_bar, self.process_drop_value) + self.add_controller(self.drop_handler.controller()) + + self.btn_transcode.connect("clicked", self.on_transcode_clicked) + self.btn_cancel.connect("clicked", self.on_cancel_clicked) + + self.btn_transcode.set_sensitive(False) + self.btn_cancel.set_visible(False) + self.btn_transcode.set_label("Start Transcoding") css_provider = Gtk.CssProvider() css_provider.load_from_resource("/net/jeena/recoder/style.css") @@ -43,22 +109,6 @@ class RecoderWindow(Adw.ApplicationWindow): Notify.init("Recoder") - def on_drop_enter(self, drop_target, x, y): - self.overlay.add_css_class("drop-highlight") - return True - - def on_drop_leave(self, drop_target): - self.overlay.remove_css_class("drop-highlight") - return True - - def on_drop(self, drop_target, value, x, y): - GLib.idle_add(partial(self.process_drop_value, value)) - self.overlay.remove_overlay(self.drop_hint) - self.progress_bar.set_visible(True) - self.progress_bar.set_fraction(0.0) - self.drop_hint.set_visible(False) - return value - def process_drop_value(self, value): file_items = extract_video_files(value) if not file_items: @@ -70,10 +120,12 @@ class RecoderWindow(Adw.ApplicationWindow): for file_item in file_items: row = FileEntryRow(file_item) self.listbox.append(row) - self.file_rows[file_item] = row + self.file_rows[file_item.file] = row self.file_items_to_process = file_items - self.start_transcoding() + self.btn_transcode.set_sensitive(True) + self.btn_transcode.add_css_class("suggested-action") + self.btn_transcode.set_label("Start Transcoding") return False def clear_listbox(self): @@ -83,11 +135,25 @@ class RecoderWindow(Adw.ApplicationWindow): self.listbox.remove(child) child = next_child - def start_transcoding(self): + def on_transcode_clicked(self, button): if self.transcoder and self.transcoder.is_processing: + if self.is_paused: + self.resume_transcoding() + else: + self.pause_transcoding() + else: + self.start_transcoding() + + def start_transcoding(self): + if not self.file_items_to_process: return - self.remove_controller(self.drop_target) + self.remove_controller(self.drop_handler.controller()) + self.btn_transcode.set_label("Pause") + self.btn_transcode.set_sensitive(True) + self.btn_transcode.remove_css_class("suggested-action") + self.btn_cancel.set_visible(False) + self.is_paused = False self.progress_bar.set_fraction(0.0) self.progress_bar.set_text("Starting transcoding...") @@ -100,6 +166,29 @@ class RecoderWindow(Adw.ApplicationWindow): ) self.transcoder.start() + def pause_transcoding(self): + if self.transcoder: + self.transcoder.pause() + self.is_paused = True + self.btn_transcode.set_label("Resume") + self.progress_bar.set_text("Paused") + self.btn_cancel.set_visible(True) + + def resume_transcoding(self): + if self.transcoder: + self.transcoder.resume() + self.is_paused = False + self.btn_transcode.set_label("Pause") + self.progress_bar.set_text("Resuming...") + self.btn_cancel.set_visible(False) + + def on_cancel_clicked(self, button): + if self.transcoder and self.transcoder.is_processing: + self.transcoder.stop() + self.transcoder = None + self.add_controller(self.drop_handler.controller()) + self.ui_manager.reset_ui() + def update_progress(self, text, fraction): self.progress_bar.set_show_text(True) self.progress_bar.set_text(text) @@ -115,4 +204,12 @@ class RecoderWindow(Adw.ApplicationWindow): self.progress_bar.set_text("Transcoding Complete!") play_complete_sound() notify_done("Recoder", "Transcoding finished!") - self.add_controller(self.drop_target) + self.add_controller(self.drop_handler.controller()) + self.btn_transcode.set_label("Start Transcoding") + self.btn_transcode.set_sensitive(False) + self.btn_cancel.set_visible(False) + self.is_paused = False + + self.drop_hint.set_visible(True) + if self.drop_hint.get_parent() != self.overlay: + self.overlay.add_overlay(self.drop_hint) diff --git a/src/recoder/transcoder.py b/src/recoder/transcoder.py index c8d017a..95f127a 100644 --- a/src/recoder/transcoder.py +++ b/src/recoder/transcoder.py @@ -2,6 +2,7 @@ import os import threading import subprocess import re +import signal from gi.repository import GLib from recoder.models import FileStatus, FileItem @@ -15,23 +16,46 @@ class Transcoder: self.done_callback = done_callback self.file_done_callback = file_done_callback self.is_processing = False + self._stop_requested = False + self._paused = threading.Event() + self._paused.set() # Initially not paused + self._process = None def start(self): if self.is_processing: return self.is_processing = True - thread = threading.Thread(target=self._process_files) - thread.daemon = True + self._stop_requested = False + self._paused.set() + thread = threading.Thread(target=self._process_files, daemon=True) thread.start() + def pause(self): + self._paused.clear() + if self._process and self._process.poll() is None: + self._process.send_signal(signal.SIGSTOP) + + def resume(self): + self._paused.set() + if self._process and self._process.poll() is None: + self._process.send_signal(signal.SIGCONT) + + def stop(self): + self._stop_requested = True + self._paused.set() # Unpause so thread can exit if paused + if self._process and self._process.poll() is None: + self._process.terminate() def _process_files(self): total = len(self.file_items) for idx, file_item in enumerate(self.file_items, start=1): + if self._stop_requested: + GLib.idle_add(file_item.set_property, "status", FileStatus.WAITING) + continue + filepath = file_item.file.get_path() basename = os.path.basename(filepath) - # Set status PROCESSING and reset progress to 0 GLib.idle_add(file_item.set_property, "status", FileStatus.PROCESSING) GLib.idle_add(file_item.set_property, "progress", 0) @@ -43,10 +67,9 @@ class Transcoder: basename, idx, total, - file_item, # Pass file_item to update progress inside _transcode_file + file_item, ) - # Update status DONE or ERROR after processing new_status = FileStatus.DONE if success else FileStatus.ERROR GLib.idle_add(file_item.set_property, "status", new_status) GLib.idle_add(file_item.set_property, "progress", 100 if success else 0) @@ -59,9 +82,13 @@ class Transcoder: if self.file_done_callback: GLib.idle_add(self.file_done_callback, filepath) + if self._stop_requested: + break + self.is_processing = False self._update_progress("All done!", 1.0) - self._notify_done() + if not self._stop_requested: + self._notify_done() def _transcode_file(self, input_path, output_dir, basename, idx, total, file_item=None): os.makedirs(output_dir, exist_ok=True) @@ -71,32 +98,25 @@ class Transcoder: width, height, rotate = self._get_video_info(input_path) vf = self._build_filters(width, height, rotate) - cmd = self._build_ffmpeg_command(input_path, output_path, vf) return self._run_ffmpeg(cmd, duration, idx, total, basename, file_item, output_path) - def _get_output_path(self, output_dir, basename): name, _ = os.path.splitext(basename) return os.path.join(output_dir, f"{name}.mov") - def _build_filters(self, width, height, rotate): filters = [] - - # If rotated or vertical, transpose and swap dimensions if rotate in [90, 270] or (width and height and height > width): filters.append("transpose=1") - width, height = height, width # Swap dimensions after transpose + width, height = height, width - # Resize only if not 1920x1080 if (width, height) != (1920, 1080): filters.append("scale=1920:1080") return ",".join(filters) if filters else None - def _build_ffmpeg_command(self, input_path, output_path, vf): cmd = [ "ffmpeg", @@ -117,12 +137,18 @@ class Transcoder: cmd.append(output_path) return cmd - def _run_ffmpeg(self, cmd, duration, idx, total, basename, file_item, output_path): - process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) + self._process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) while True: - line = process.stderr.readline() + # Pause support + self._paused.wait() + + if self._stop_requested: + self._process.terminate() + return False, output_path + + line = self._process.stderr.readline() if not line: break @@ -138,14 +164,10 @@ class Transcoder: percent = int(progress_fraction * 100) GLib.idle_add(file_item.set_property, "progress", percent) - process.wait() - success = process.returncode == 0 + self._process.wait() + success = self._process.returncode == 0 return success, output_path - - - - def _get_duration(self, input_path): cmd = [ "ffprobe", diff --git a/src/resources/recoder_window.ui b/src/resources/recoder_window.ui index d84dee0..4e3f5f6 100644 --- a/src/resources/recoder_window.ui +++ b/src/resources/recoder_window.ui @@ -12,7 +12,18 @@ - + + + Transcode + False + + + + + Cancel + False + +