Move state update to UIStateManager

This commit is contained in:
Jeena 2025-06-04 14:34:12 +09:00
parent e90744852a
commit 6807717714
2 changed files with 210 additions and 178 deletions

View file

@ -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)

View file

@ -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