Add start/pause/cancel buttons
This commit is contained in:
parent
03e670ebd2
commit
e90744852a
3 changed files with 180 additions and 50 deletions
|
@ -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)
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue