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 gi
import signal import signal
from enum import Enum, auto
from functools import partial
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')
from gi.repository import Gtk, Gdk, Gio, Adw, GLib, Notify 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.utils import extract_video_files, notify_done, play_complete_sound
from recoder.file_entry_row import FileEntryRow from recoder.file_entry_row import FileEntryRow
@ -45,27 +46,99 @@ class DropHandler:
return value 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: class UIStateManager:
def __init__(self, window): def __init__(self, window):
self.window = 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): def set_state(self, state: AppState):
self.window.btn_transcode.set_label("Start Transcoding") handler = self.handlers.get(state)
self.window.btn_transcode.set_sensitive(False) if handler:
self.window.btn_transcode.remove_css_class("suggested-action") handler()
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: def _handle_idle(self):
self.window.overlay.add_overlay(self.window.drop_hint) 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") @Gtk.Template(resource_path="/net/jeena/recoder/recoder_window.ui")
@ -73,12 +146,12 @@ class RecoderWindow(Adw.ApplicationWindow):
__gtype_name__ = "RecoderWindow" __gtype_name__ = "RecoderWindow"
overlay = Gtk.Template.Child() overlay = Gtk.Template.Child()
progress_bar = Gtk.Template.Child()
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_transcode = Gtk.Template.Child()
btn_cancel = Gtk.Template.Child() btn_cancel = Gtk.Template.Child()
progress_bar = Gtk.Template.Child()
def __init__(self, application): def __init__(self, application):
super().__init__(application=application) super().__init__(application=application)
@ -88,16 +161,15 @@ class RecoderWindow(Adw.ApplicationWindow):
self.file_rows = {} self.file_rows = {}
self.is_paused = False 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.drop_handler = DropHandler(self.overlay, self.drop_hint, self.progress_bar, self.process_drop_value)
self.add_controller(self.drop_handler.controller()) self.add_controller(self.drop_handler.controller())
self.ui_manager = UIStateManager(self)
self.btn_transcode.connect("clicked", self.on_transcode_clicked) self.btn_transcode.connect("clicked", self.on_transcode_clicked)
self.btn_cancel.connect("clicked", self.on_cancel_clicked) self.btn_cancel.connect("clicked", self.on_cancel_clicked)
self.btn_transcode.set_sensitive(False) self.ui_manager.set_state(AppState.IDLE)
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")
@ -123,9 +195,7 @@ class RecoderWindow(Adw.ApplicationWindow):
self.file_rows[file_item.file] = row self.file_rows[file_item.file] = row
self.file_items_to_process = file_items self.file_items_to_process = file_items
self.btn_transcode.set_sensitive(True) self.ui_manager.set_state(AppState.FILES_LOADED)
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):
@ -149,67 +219,49 @@ class RecoderWindow(Adw.ApplicationWindow):
return return
self.remove_controller(self.drop_handler.controller()) 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.transcoder = Transcoder(self.file_items_to_process)
self.progress_bar.set_text("Starting transcoding...") self.transcoder.connect("notify::batch-progress", self.on_transcoder_progress)
self.transcoder.connect("notify::batch-status", self.on_transcoder_status)
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.start() self.transcoder.start()
self.ui_manager.set_state(AppState.TRANSCODING)
def pause_transcoding(self): def pause_transcoding(self):
if self.transcoder: if self.transcoder:
self.transcoder.pause() self.transcoder.pause()
self.is_paused = True self.is_paused = True
self.btn_transcode.set_label("Resume") self.ui_manager.set_state(AppState.PAUSED)
self.progress_bar.set_text("Paused")
self.btn_cancel.set_visible(True)
def resume_transcoding(self): def resume_transcoding(self):
if self.transcoder: if self.transcoder:
self.transcoder.resume() self.transcoder.resume()
self.is_paused = False self.is_paused = False
self.btn_transcode.set_label("Pause") self.ui_manager.set_state(AppState.TRANSCODING)
self.progress_bar.set_text("Resuming...")
self.btn_cancel.set_visible(False)
def on_cancel_clicked(self, button): def on_cancel_clicked(self, button):
if self.transcoder and self.transcoder.is_processing: if self.transcoder and self.transcoder.is_processing:
self.transcoder.stop() self.transcoder.stop()
self.transcoder = None self.transcoder = None
self.add_controller(self.drop_handler.controller()) 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): def on_transcoder_progress(self, transcoder, param):
self.progress_bar.set_show_text(True) self.progress_bar.set_fraction(transcoder.batch_progress / 100.0)
self.progress_bar.set_text(text) self.progress_bar.set_visible(True)
self.progress_bar.set_fraction(fraction)
def mark_file_done(self, filepath): def on_transcoder_status(self, transcoder, param):
if filepath in self.file_rows: if transcoder.batch_status == BatchStatus.DONE:
row = self.file_rows[filepath] play_complete_sound()
GLib.idle_add(row.mark_done) notify_done("Recoder", "Transcoding finished!")
self.add_controller(self.drop_handler.controller())
self.ui_manager.set_state(AppState.DONE)
def _done_callback(self): elif transcoder.batch_status == BatchStatus.STOPPED:
self.progress_bar.set_show_text(True) self.add_controller(self.drop_handler.controller())
self.progress_bar.set_text("Transcoding Complete!") self.ui_manager.set_state(AppState.STOPPED)
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
self.drop_hint.set_visible(True) elif transcoder.batch_status == BatchStatus.ERROR:
if self.drop_hint.get_parent() != self.overlay: notify_done("Recoder", "An error occurred during transcoding.")
self.overlay.add_overlay(self.drop_hint) 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 subprocess
import re import re
import signal import signal
from gi.repository import GLib
from gi.repository import GLib, GObject
from recoder.models import FileStatus, FileItem 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+)") 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.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.is_processing = False
self._stop_requested = False self._stop_requested = False
self._paused = threading.Event() self._paused = threading.Event()
self._paused.set() # Initially not paused self._paused.set()
self._process = None self._process = None
def start(self): def start(self):
@ -27,123 +40,81 @@ class Transcoder:
self.is_processing = True self.is_processing = True
self._stop_requested = False self._stop_requested = False
self._paused.set() self._paused.set()
thread = threading.Thread(target=self._process_files, daemon=True) self.batch_status = BatchStatus.RUNNING
thread.start() threading.Thread(target=self._process_files, daemon=True).start()
def pause(self): def pause(self):
self._paused.clear() self._paused.clear()
self.batch_status = BatchStatus.PAUSED
if self._process and self._process.poll() is None: if self._process and self._process.poll() is None:
self._process.send_signal(signal.SIGSTOP) self._process.send_signal(signal.SIGSTOP)
def resume(self): def resume(self):
self._paused.set() self._paused.set()
self.batch_status = BatchStatus.RUNNING
if self._process and self._process.poll() is None: if self._process and self._process.poll() is None:
self._process.send_signal(signal.SIGCONT) self._process.send_signal(signal.SIGCONT)
def stop(self): def stop(self):
self._stop_requested = True 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: if self._process and self._process.poll() is None:
self._process.terminate() self._process.terminate()
self.batch_status = BatchStatus.STOPPED
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: if self._stop_requested:
GLib.idle_add(file_item.set_property, "status", FileStatus.WAITING) GLib.idle_add(file_item.set_property, "status", FileStatus.WAITING)
continue continue
filepath = file_item.file.get_path() path = file_item.file.get_path()
basename = os.path.basename(filepath) base = os.path.basename(path)
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)
self._update_progress(f"Starting {basename}", (idx - 1) / total)
success, output_path = self._transcode_file( success, _ = self._transcode_file(path, os.path.join(os.path.dirname(path), "transcoded"),
filepath, base, idx, total, file_item)
os.path.join(os.path.dirname(filepath), "transcoded"),
basename,
idx,
total,
file_item,
)
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)
if not success: if not success:
self._update_progress(f"Error transcoding {basename}", idx / total) self.batch_status = BatchStatus.ERROR
else: break
self._update_progress(f"Finished {basename}", idx / total)
if self.file_done_callback:
GLib.idle_add(self.file_done_callback, filepath)
if self._stop_requested: if self._stop_requested:
break break
self.is_processing = False 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) os.makedirs(output_dir, exist_ok=True)
output_path = self._get_output_path(output_dir, basename) output_path = self._get_output_path(output_dir, basename)
duration = self._get_duration(input_path) or 1.0 duration = self._get_duration(input_path) or 1.0
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, file_item, output_path)
def _get_output_path(self, output_dir, basename): def _run_ffmpeg(self, cmd, duration, idx, total, file_item, output_path):
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):
self._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:
# Pause support
self._paused.wait() self._paused.wait()
if self._stop_requested: if self._stop_requested:
self._process.terminate() self._process.terminate()
return False, output_path return False, output_path
@ -154,56 +125,65 @@ class Transcoder:
match = self.TIME_RE.search(line) match = self.TIME_RE.search(line)
if match: if match:
hours, minutes, seconds, milliseconds = map(int, match.groups()) h, m, s, ms = map(int, match.groups())
elapsed = hours * 3600 + minutes * 60 + seconds + milliseconds / 1000.0 elapsed = h * 3600 + m * 60 + s + ms / 1000.0
progress_fraction = min(elapsed / duration, 1.0) file_progress = min(elapsed / duration, 1.0)
overall_fraction = (idx - 1 + progress_fraction) / total
self._update_progress(f"Transcoding {basename}", overall_fraction)
if file_item: file_percent = int(file_progress * 100)
percent = int(progress_fraction * 100) batch_fraction = (idx - 1 + file_progress) / total
GLib.idle_add(file_item.set_property, "progress", percent) 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() self._process.wait()
success = self._process.returncode == 0 return self._process.returncode == 0, output_path
return success, output_path
def _get_duration(self, input_path): def _get_output_path(self, out_dir, basename):
cmd = [ name, _ = os.path.splitext(basename)
"ffprobe", return os.path.join(out_dir, f"{name}.mov")
"-v", "error",
"-show_entries", "format=duration", def _get_duration(self, path):
"-of", "default=noprint_wrappers=1:nokey=1",
input_path,
]
try: try:
output = subprocess.check_output(cmd, text=True) out = subprocess.check_output([
return float(output.strip()) "ffprobe", "-v", "error", "-show_entries", "format=duration",
"-of", "default=noprint_wrappers=1:nokey=1", path
], text=True)
return float(out.strip())
except Exception: except Exception:
return None return None
def _get_video_info(self, input_path): def _get_video_info(self, 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,
]
try: try:
output = subprocess.check_output(cmd, text=True).splitlines() out = subprocess.check_output([
width = int(output[0]) "ffprobe", "-v", "error", "-select_streams", "v:0",
height = int(output[1]) "-show_entries", "stream=width,height:stream_tags=rotate",
rotate = int(output[2]) if len(output) > 2 else 0 "-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 return width, height, rotate
except Exception: except Exception:
return None, None, 0 return None, None, 0
def _update_progress(self, text, fraction): def _build_filters(self, width, height, rotate):
if self.progress_callback: filters = []
GLib.idle_add(self.progress_callback, text, fraction) 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): def _build_ffmpeg_command(self, in_path, out_path, vf):
if self.done_callback: cmd = [
GLib.idle_add(self.done_callback) "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