Add start/pause/cancel buttons

This commit is contained in:
Jeena 2025-06-04 11:43:52 +09:00
parent 03e670ebd2
commit e90744852a
3 changed files with 180 additions and 50 deletions

View file

@ -1,4 +1,6 @@
import gi import gi
import signal
gi.require_version('Gtk', '4.0') gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1') gi.require_version('Adw', '1')
gi.require_version('Notify', '0.7') 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.utils import extract_video_files, notify_done, play_complete_sound
from recoder.file_entry_row import FileEntryRow 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") @Gtk.Template(resource_path="/net/jeena/recoder/recoder_window.ui")
class RecoderWindow(Adw.ApplicationWindow): class RecoderWindow(Adw.ApplicationWindow):
__gtype_name__ = "RecoderWindow" __gtype_name__ = "RecoderWindow"
@ -19,6 +77,8 @@ class RecoderWindow(Adw.ApplicationWindow):
drop_hint = Gtk.Template.Child() drop_hint = Gtk.Template.Child()
listbox = Gtk.Template.Child() listbox = Gtk.Template.Child()
scrolled_window = Gtk.Template.Child() scrolled_window = Gtk.Template.Child()
btn_transcode = Gtk.Template.Child()
btn_cancel = Gtk.Template.Child()
def __init__(self, application): def __init__(self, application):
super().__init__(application=application) super().__init__(application=application)
@ -26,12 +86,18 @@ class RecoderWindow(Adw.ApplicationWindow):
self.file_items_to_process = [] self.file_items_to_process = []
self.transcoder = None self.transcoder = None
self.file_rows = {} self.file_rows = {}
self.is_paused = False
self.drop_target = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY) self.ui_manager = UIStateManager(self)
self.drop_target.connect("drop", self.on_drop) self.drop_handler = DropHandler(self.overlay, self.drop_hint, self.progress_bar, self.process_drop_value)
self.drop_target.connect("enter", self.on_drop_enter) self.add_controller(self.drop_handler.controller())
self.drop_target.connect("leave", self.on_drop_leave)
self.add_controller(self.drop_target) 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 = Gtk.CssProvider()
css_provider.load_from_resource("/net/jeena/recoder/style.css") css_provider.load_from_resource("/net/jeena/recoder/style.css")
@ -43,22 +109,6 @@ class RecoderWindow(Adw.ApplicationWindow):
Notify.init("Recoder") 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): def process_drop_value(self, value):
file_items = extract_video_files(value) file_items = extract_video_files(value)
if not file_items: if not file_items:
@ -70,10 +120,12 @@ class RecoderWindow(Adw.ApplicationWindow):
for file_item in file_items: for file_item in file_items:
row = FileEntryRow(file_item) row = FileEntryRow(file_item)
self.listbox.append(row) 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.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 return False
def clear_listbox(self): def clear_listbox(self):
@ -83,11 +135,25 @@ class RecoderWindow(Adw.ApplicationWindow):
self.listbox.remove(child) self.listbox.remove(child)
child = next_child child = next_child
def start_transcoding(self): def on_transcode_clicked(self, button):
if self.transcoder and self.transcoder.is_processing: 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 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_fraction(0.0)
self.progress_bar.set_text("Starting transcoding...") self.progress_bar.set_text("Starting transcoding...")
@ -100,6 +166,29 @@ class RecoderWindow(Adw.ApplicationWindow):
) )
self.transcoder.start() 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): def update_progress(self, text, fraction):
self.progress_bar.set_show_text(True) self.progress_bar.set_show_text(True)
self.progress_bar.set_text(text) self.progress_bar.set_text(text)
@ -115,4 +204,12 @@ class RecoderWindow(Adw.ApplicationWindow):
self.progress_bar.set_text("Transcoding Complete!") self.progress_bar.set_text("Transcoding Complete!")
play_complete_sound() play_complete_sound()
notify_done("Recoder", "Transcoding finished!") 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)

View file

@ -2,6 +2,7 @@ import os
import threading import threading
import subprocess import subprocess
import re import re
import signal
from gi.repository import GLib from gi.repository import GLib
from recoder.models import FileStatus, FileItem from recoder.models import FileStatus, FileItem
@ -15,23 +16,46 @@ class Transcoder:
self.done_callback = done_callback self.done_callback = done_callback
self.file_done_callback = file_done_callback self.file_done_callback = file_done_callback
self.is_processing = False self.is_processing = False
self._stop_requested = False
self._paused = threading.Event()
self._paused.set() # Initially not paused
self._process = None
def start(self): def start(self):
if self.is_processing: if self.is_processing:
return return
self.is_processing = True self.is_processing = True
thread = threading.Thread(target=self._process_files) self._stop_requested = False
thread.daemon = True self._paused.set()
thread = threading.Thread(target=self._process_files, daemon=True)
thread.start() 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): def _process_files(self):
total = len(self.file_items) total = len(self.file_items)
for idx, file_item in enumerate(self.file_items, start=1): 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() filepath = file_item.file.get_path()
basename = os.path.basename(filepath) 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, "status", FileStatus.PROCESSING)
GLib.idle_add(file_item.set_property, "progress", 0) GLib.idle_add(file_item.set_property, "progress", 0)
@ -43,10 +67,9 @@ class Transcoder:
basename, basename,
idx, idx,
total, 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 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, "status", new_status)
GLib.idle_add(file_item.set_property, "progress", 100 if success else 0) GLib.idle_add(file_item.set_property, "progress", 100 if success else 0)
@ -59,9 +82,13 @@ class Transcoder:
if self.file_done_callback: if self.file_done_callback:
GLib.idle_add(self.file_done_callback, filepath) GLib.idle_add(self.file_done_callback, filepath)
if self._stop_requested:
break
self.is_processing = False self.is_processing = False
self._update_progress("All done!", 1.0) 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): def _transcode_file(self, input_path, output_dir, basename, idx, total, file_item=None):
os.makedirs(output_dir, exist_ok=True) os.makedirs(output_dir, exist_ok=True)
@ -71,32 +98,25 @@ class Transcoder:
width, height, rotate = self._get_video_info(input_path) width, height, rotate = self._get_video_info(input_path)
vf = self._build_filters(width, height, rotate) vf = self._build_filters(width, height, rotate)
cmd = self._build_ffmpeg_command(input_path, output_path, vf) 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, basename, file_item, output_path)
def _get_output_path(self, output_dir, basename): def _get_output_path(self, output_dir, basename):
name, _ = os.path.splitext(basename) name, _ = os.path.splitext(basename)
return os.path.join(output_dir, f"{name}.mov") return os.path.join(output_dir, f"{name}.mov")
def _build_filters(self, width, height, rotate): def _build_filters(self, width, height, rotate):
filters = [] filters = []
# If rotated or vertical, transpose and swap dimensions
if rotate in [90, 270] or (width and height and height > width): if rotate in [90, 270] or (width and height and height > width):
filters.append("transpose=1") 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): if (width, height) != (1920, 1080):
filters.append("scale=1920:1080") filters.append("scale=1920:1080")
return ",".join(filters) if filters else None return ",".join(filters) if filters else None
def _build_ffmpeg_command(self, input_path, output_path, vf): def _build_ffmpeg_command(self, input_path, output_path, vf):
cmd = [ cmd = [
"ffmpeg", "ffmpeg",
@ -117,12 +137,18 @@ class Transcoder:
cmd.append(output_path) cmd.append(output_path)
return cmd return cmd
def _run_ffmpeg(self, cmd, duration, idx, total, basename, file_item, output_path): 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: 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: if not line:
break break
@ -138,14 +164,10 @@ class Transcoder:
percent = int(progress_fraction * 100) percent = int(progress_fraction * 100)
GLib.idle_add(file_item.set_property, "progress", percent) GLib.idle_add(file_item.set_property, "progress", percent)
process.wait() self._process.wait()
success = process.returncode == 0 success = self._process.returncode == 0
return success, output_path return success, output_path
def _get_duration(self, input_path): def _get_duration(self, input_path):
cmd = [ cmd = [
"ffprobe", "ffprobe",

View file

@ -12,7 +12,18 @@
<object class="AdwToolbarView" id="toolbar_view"> <object class="AdwToolbarView" id="toolbar_view">
<child type="top"> <child type="top">
<object class="AdwHeaderBar" id="header_bar"> <object class="AdwHeaderBar" id="header_bar">
<!-- Optional header bar content --> <child>
<object class="GtkButton" id="btn_transcode">
<property name="label">Transcode</property>
<property name="sensitive">False</property>
</object>
</child>
<child>
<object class="GtkButton" id="btn_cancel">
<property name="label">Cancel</property>
<property name="visible">False</property>
</object>
</child>
</object> </object>
</child> </child>