diff --git a/src/recoder/recoder_window.py b/src/recoder/recoder_window.py index b47fa62..ab76401 100644 --- a/src/recoder/recoder_window.py +++ b/src/recoder/recoder_window.py @@ -1,14 +1,15 @@ import gi import signal +from enum import Enum, auto +from functools import partial gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') gi.require_version('Notify', '0.7') from gi.repository import Gtk, Gdk, Gio, Adw, GLib, Notify -from functools import partial -from recoder.transcoder import Transcoder +from recoder.transcoder import Transcoder, BatchStatus from recoder.utils import extract_video_files, notify_done, play_complete_sound from recoder.file_entry_row import FileEntryRow @@ -45,27 +46,99 @@ class DropHandler: return value +class AppState(Enum): + IDLE = auto() # Nothing loaded, waiting for drop + FILES_LOADED = auto() # Files dropped, ready to start transcoding + TRANSCODING = auto() # Transcoding in progress + PAUSED = auto() # Transcoding paused + DONE = auto() # Transcoding finished + STOPPED = auto() # Transcoding canceled + ERROR = auto() # Error occurred + + class UIStateManager: def __init__(self, window): self.window = window + self.handlers = { + AppState.IDLE: self._handle_idle, + AppState.FILES_LOADED: self._handle_files_loaded, + AppState.TRANSCODING: self._handle_transcoding, + AppState.PAUSED: self._handle_paused, + AppState.DONE: self._handle_done, + AppState.STOPPED: lambda: self.set_state(AppState.IDLE), + AppState.ERROR: self._handle_error, + } - 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) + def set_state(self, state: AppState): + handler = self.handlers.get(state) + if handler: + handler() - if self.window.drop_hint.get_parent() != self.window.overlay: - self.window.overlay.add_overlay(self.window.drop_hint) + def _handle_idle(self): + w = self.window + w.clear_listbox() + w.file_rows.clear() + w.file_items_to_process = [] + w.is_paused = False + w.progress_bar.set_visible(False) + w.progress_bar.set_fraction(0.0) + w.progress_bar.set_text("") + w.drop_hint.set_visible(True) + if w.drop_hint.get_parent() != w.overlay: + w.overlay.add_overlay(w.drop_hint) + w.btn_transcode.set_visible(False) + w.btn_cancel.set_visible(False) + + def _handle_files_loaded(self): + w = self.window + w.drop_hint.set_visible(False) + w.progress_bar.set_visible(False) + w.btn_transcode.set_visible(True) + w.btn_transcode.set_sensitive(True) + w.btn_transcode.set_label("Start Transcoding") + w.btn_transcode.add_css_class("suggested-action") + w.btn_cancel.set_visible(False) + w.is_paused = False + + def _handle_transcoding(self): + w = self.window + w.drop_hint.set_visible(False) + w.progress_bar.set_visible(True) + w.btn_transcode.set_visible(True) + w.btn_transcode.set_sensitive(True) + w.btn_transcode.set_label("Pause") + w.btn_transcode.remove_css_class("suggested-action") + w.btn_cancel.set_visible(False) + w.is_paused = False + + def _handle_paused(self): + w = self.window + w.drop_hint.set_visible(False) + w.progress_bar.set_visible(True) + w.btn_transcode.set_visible(True) + w.btn_transcode.set_sensitive(True) + w.btn_transcode.set_label("Resume") + w.btn_cancel.set_visible(True) + w.is_paused = True + + def _handle_done(self): + w = self.window + w.drop_hint.set_visible(False) + w.progress_bar.set_visible(True) + w.progress_bar.set_fraction(1.0) + w.btn_transcode.set_visible(False) + w.btn_transcode.remove_css_class("suggested-action") + w.btn_cancel.set_visible(True) + w.is_paused = False + + def _handle_error(self): + w = self.window + w.drop_hint.set_visible(False) + w.progress_bar.set_visible(False) + w.btn_transcode.set_visible(False) + w.btn_cancel.set_visible(True) + w.is_paused = False - 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") @@ -73,12 +146,12 @@ class RecoderWindow(Adw.ApplicationWindow): __gtype_name__ = "RecoderWindow" overlay = Gtk.Template.Child() - progress_bar = Gtk.Template.Child() drop_hint = Gtk.Template.Child() listbox = Gtk.Template.Child() scrolled_window = Gtk.Template.Child() btn_transcode = Gtk.Template.Child() btn_cancel = Gtk.Template.Child() + progress_bar = Gtk.Template.Child() def __init__(self, application): super().__init__(application=application) @@ -88,16 +161,15 @@ class RecoderWindow(Adw.ApplicationWindow): self.file_rows = {} self.is_paused = False - 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.ui_manager = UIStateManager(self) + 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") + self.ui_manager.set_state(AppState.IDLE) css_provider = Gtk.CssProvider() css_provider.load_from_resource("/net/jeena/recoder/style.css") @@ -123,9 +195,7 @@ class RecoderWindow(Adw.ApplicationWindow): self.file_rows[file_item.file] = row self.file_items_to_process = file_items - self.btn_transcode.set_sensitive(True) - self.btn_transcode.add_css_class("suggested-action") - self.btn_transcode.set_label("Start Transcoding") + self.ui_manager.set_state(AppState.FILES_LOADED) return False def clear_listbox(self): @@ -149,67 +219,49 @@ class RecoderWindow(Adw.ApplicationWindow): return 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...") - - self.transcoder = Transcoder( - self.file_items_to_process, - progress_callback=self.update_progress, - done_callback=self._done_callback, - file_done_callback=self.mark_file_done, - ) + self.transcoder = Transcoder(self.file_items_to_process) + self.transcoder.connect("notify::batch-progress", self.on_transcoder_progress) + self.transcoder.connect("notify::batch-status", self.on_transcoder_status) self.transcoder.start() + self.ui_manager.set_state(AppState.TRANSCODING) + 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) + self.ui_manager.set_state(AppState.PAUSED) 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) + self.ui_manager.set_state(AppState.TRANSCODING) 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() + self.ui_manager.set_state(AppState.STOPPED) - def update_progress(self, text, fraction): - self.progress_bar.set_show_text(True) - self.progress_bar.set_text(text) - self.progress_bar.set_fraction(fraction) + def on_transcoder_progress(self, transcoder, param): + self.progress_bar.set_fraction(transcoder.batch_progress / 100.0) + self.progress_bar.set_visible(True) - def mark_file_done(self, filepath): - if filepath in self.file_rows: - row = self.file_rows[filepath] - GLib.idle_add(row.mark_done) + def on_transcoder_status(self, transcoder, param): + if transcoder.batch_status == BatchStatus.DONE: + play_complete_sound() + notify_done("Recoder", "Transcoding finished!") + self.add_controller(self.drop_handler.controller()) + self.ui_manager.set_state(AppState.DONE) - def _done_callback(self): - self.progress_bar.set_show_text(True) - self.progress_bar.set_text("Transcoding Complete!") - play_complete_sound() - notify_done("Recoder", "Transcoding finished!") - 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 + elif transcoder.batch_status == BatchStatus.STOPPED: + self.add_controller(self.drop_handler.controller()) + self.ui_manager.set_state(AppState.STOPPED) - self.drop_hint.set_visible(True) - if self.drop_hint.get_parent() != self.overlay: - self.overlay.add_overlay(self.drop_hint) + elif transcoder.batch_status == BatchStatus.ERROR: + notify_done("Recoder", "An error occurred during transcoding.") + self.add_controller(self.drop_handler.controller()) + self.ui_manager.set_state(AppState.ERROR) diff --git a/src/recoder/transcoder.py b/src/recoder/transcoder.py index 95f127a..05a513d 100644 --- a/src/recoder/transcoder.py +++ b/src/recoder/transcoder.py @@ -3,22 +3,35 @@ import threading import subprocess import re import signal -from gi.repository import GLib +from gi.repository import GLib, GObject from recoder.models import FileStatus, FileItem -class Transcoder: + +class BatchStatus(GObject.GEnum): + __gtype_name__ = 'BatchStatus' + + IDLE = 0 + RUNNING = 1 + PAUSED = 2 + DONE = 3 + STOPPED = 4 + ERROR = 5 + + +class Transcoder(GObject.GObject): TIME_RE = re.compile(r"time=(\d+):(\d+):(\d+)\.(\d+)") - def __init__(self, file_items, progress_callback=None, done_callback=None, file_done_callback=None): + batch_progress = GObject.Property(type=int, minimum=0, maximum=100, default=0) + batch_status = GObject.Property(type=BatchStatus, default=BatchStatus.IDLE) + + def __init__(self, file_items): + super().__init__() self.file_items = file_items - self.progress_callback = progress_callback - 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._paused.set() self._process = None def start(self): @@ -27,123 +40,81 @@ class Transcoder: self.is_processing = True self._stop_requested = False self._paused.set() - thread = threading.Thread(target=self._process_files, daemon=True) - thread.start() + self.batch_status = BatchStatus.RUNNING + threading.Thread(target=self._process_files, daemon=True).start() def pause(self): self._paused.clear() + self.batch_status = BatchStatus.PAUSED if self._process and self._process.poll() is None: self._process.send_signal(signal.SIGSTOP) def resume(self): self._paused.set() + self.batch_status = BatchStatus.RUNNING 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 + self._paused.set() if self._process and self._process.poll() is None: self._process.terminate() + self.batch_status = BatchStatus.STOPPED 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) + path = file_item.file.get_path() + base = os.path.basename(path) GLib.idle_add(file_item.set_property, "status", FileStatus.PROCESSING) GLib.idle_add(file_item.set_property, "progress", 0) - - self._update_progress(f"Starting {basename}", (idx - 1) / total) - success, output_path = self._transcode_file( - filepath, - os.path.join(os.path.dirname(filepath), "transcoded"), - basename, - idx, - total, - file_item, - ) + success, _ = self._transcode_file(path, os.path.join(os.path.dirname(path), "transcoded"), + base, idx, total, file_item) 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) if not success: - self._update_progress(f"Error transcoding {basename}", idx / total) - else: - self._update_progress(f"Finished {basename}", idx / total) - - if self.file_done_callback: - GLib.idle_add(self.file_done_callback, filepath) + self.batch_status = BatchStatus.ERROR + break if self._stop_requested: break self.is_processing = False - self._update_progress("All done!", 1.0) - if not self._stop_requested: - self._notify_done() - def _transcode_file(self, input_path, output_dir, basename, idx, total, file_item=None): + if not self._stop_requested and self.batch_status != BatchStatus.ERROR: + self.batch_status = BatchStatus.DONE + elif self._stop_requested: + self.batch_status = BatchStatus.STOPPED + + GLib.idle_add(self.set_property, "batch_progress", 0) + + def _transcode_file(self, input_path, output_dir, basename, idx, total, file_item): os.makedirs(output_dir, exist_ok=True) output_path = self._get_output_path(output_dir, basename) duration = self._get_duration(input_path) or 1.0 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) + return self._run_ffmpeg(cmd, duration, idx, total, 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 rotate in [90, 270] or (width and height and height > width): - filters.append("transpose=1") - width, height = height, width - - 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", - "-y", - "-i", input_path, - "-vcodec", "dnxhd", - "-acodec", "pcm_s16le", - "-b:v", "36M", - "-pix_fmt", "yuv422p", - "-r", "30000/1001", - "-f", "mov", - "-map_metadata", "0", - ] - - if vf: - cmd += ["-vf", vf] - - cmd.append(output_path) - return cmd - - def _run_ffmpeg(self, cmd, duration, idx, total, basename, file_item, output_path): + def _run_ffmpeg(self, cmd, duration, idx, total, file_item, output_path): self._process = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) while True: - # Pause support self._paused.wait() - if self._stop_requested: self._process.terminate() return False, output_path @@ -154,56 +125,65 @@ class Transcoder: match = self.TIME_RE.search(line) if match: - hours, minutes, seconds, milliseconds = map(int, match.groups()) - elapsed = hours * 3600 + minutes * 60 + seconds + milliseconds / 1000.0 - progress_fraction = min(elapsed / duration, 1.0) - overall_fraction = (idx - 1 + progress_fraction) / total - self._update_progress(f"Transcoding {basename}", overall_fraction) + h, m, s, ms = map(int, match.groups()) + elapsed = h * 3600 + m * 60 + s + ms / 1000.0 + file_progress = min(elapsed / duration, 1.0) - if file_item: - percent = int(progress_fraction * 100) - GLib.idle_add(file_item.set_property, "progress", percent) + file_percent = int(file_progress * 100) + batch_fraction = (idx - 1 + file_progress) / total + batch_percent = int(batch_fraction * 100) + + GLib.idle_add(file_item.set_property, "progress", file_percent) + GLib.idle_add(self.set_property, "batch_progress", batch_percent) self._process.wait() - success = self._process.returncode == 0 - return success, output_path + return self._process.returncode == 0, output_path - def _get_duration(self, input_path): - cmd = [ - "ffprobe", - "-v", "error", - "-show_entries", "format=duration", - "-of", "default=noprint_wrappers=1:nokey=1", - input_path, - ] + def _get_output_path(self, out_dir, basename): + name, _ = os.path.splitext(basename) + return os.path.join(out_dir, f"{name}.mov") + + def _get_duration(self, path): try: - output = subprocess.check_output(cmd, text=True) - return float(output.strip()) + out = subprocess.check_output([ + "ffprobe", "-v", "error", "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", path + ], text=True) + return float(out.strip()) except Exception: return None - def _get_video_info(self, input_path): - cmd = [ - "ffprobe", - "-v", "error", - "-select_streams", "v:0", - "-show_entries", "stream=width,height:stream_tags=rotate", - "-of", "default=noprint_wrappers=1:nokey=1", - input_path, - ] + def _get_video_info(self, path): try: - output = subprocess.check_output(cmd, text=True).splitlines() - width = int(output[0]) - height = int(output[1]) - rotate = int(output[2]) if len(output) > 2 else 0 + out = subprocess.check_output([ + "ffprobe", "-v", "error", "-select_streams", "v:0", + "-show_entries", "stream=width,height:stream_tags=rotate", + "-of", "default=noprint_wrappers=1:nokey=1", path + ], text=True).splitlines() + width = int(out[0]) + height = int(out[1]) + rotate = int(out[2]) if len(out) > 2 else 0 return width, height, rotate except Exception: return None, None, 0 - def _update_progress(self, text, fraction): - if self.progress_callback: - GLib.idle_add(self.progress_callback, text, fraction) + def _build_filters(self, width, height, rotate): + filters = [] + if rotate in [90, 270] or (width and height and height > width): + filters.append("transpose=1") + width, height = height, width + if (width, height) != (1920, 1080): + filters.append("scale=1920:1080") + return ",".join(filters) if filters else None - def _notify_done(self): - if self.done_callback: - GLib.idle_add(self.done_callback) + def _build_ffmpeg_command(self, in_path, out_path, vf): + cmd = [ + "ffmpeg", "-y", "-i", in_path, + "-vcodec", "dnxhd", "-acodec", "pcm_s16le", + "-b:v", "36M", "-pix_fmt", "yuv422p", "-r", "30000/1001", + "-f", "mov", "-map_metadata", "0" + ] + if vf: + cmd += ["-vf", vf] + cmd.append(out_path) + return cmd