diff --git a/.gitignore b/.gitignore index c18dd8d..ee236df 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ __pycache__/ +*.py[cod] +*$py.class + +pkg/ +*.tar.zst +*.tar.xz \ No newline at end of file diff --git a/PKGBUILD b/PKGBUILD deleted file mode 100644 index b38fb98..0000000 --- a/PKGBUILD +++ /dev/null @@ -1,15 +0,0 @@ -pkgname=recoder -pkgver=1.0 -pkgrel=1 -pkgdesc="GTK4/libadwaita Video Recoder for DaVinci Resolve" -arch=('any') -url="https://example.com" -license=('MIT') -depends=('python' 'python-gobject' 'gtk4' 'libadwaita') -source=('recoder.py' 'recoder.desktop') -sha256sums=('SKIP' 'SKIP') - -package() { - install -Dm755 "recoder.py" "$pkgdir/usr/bin/recoder" - install -Dm644 "recoder.desktop" "$pkgdir/usr/share/applications/recoder.desktop" -} diff --git a/Pipfile b/Pipfile deleted file mode 100644 index 2c08205..0000000 --- a/Pipfile +++ /dev/null @@ -1,10 +0,0 @@ -[[source]] -name = "pypi" -url = "https://pypi.org/simple" -verify_ssl = true - -[packages] -PyGObject = "*" - -[requires] -python_version = "3.10" diff --git a/README.md b/README.md new file mode 100644 index 0000000..e69de29 diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD new file mode 100644 index 0000000..1c74335 --- /dev/null +++ b/packaging/PKGBUILD @@ -0,0 +1,25 @@ +# Maintainer: Jeena +pkgname=recoder +pkgver=1.0.0 +pkgrel=1 +pkgdesc="A GTK4 video transcoding GUI application" +arch=('x86_64' 'aarch64') +url="https://github.com/yourusername/recoder" +license=('GPL3') +depends=('gtk4' 'libadwaita' 'gobject-introspection' 'python' 'python-gobject' 'pulseaudio' 'ffmpeg') +optdepends=('libcanberra: play system notification sounds') +makedepends=('python-setuptools') +source=() +noextract=() +sha256sums=() + +package() { + install -dm755 "$pkgdir/usr/bin" + install -m755 ../src/app.py "$pkgdir/usr/bin/recoder" + + install -dm755 "$pkgdir/usr/lib/recoder" + cp -r ../src/* "$pkgdir/usr/lib/recoder/" + + install -Dm644 ../data/recoder.desktop "$pkgdir/usr/share/applications/recoder.desktop" + install -Dm644 ../data/icons/recoder.png "$pkgdir/usr/share/icons/hicolor/256x256/apps/recoder.png" +} diff --git a/recoder.desktop b/recoder.desktop deleted file mode 100644 index 4186fc6..0000000 --- a/recoder.desktop +++ /dev/null @@ -1,8 +0,0 @@ -[Desktop Entry] -Type=Application -Name=Recoder -Exec=recoder -Icon=video-x-generic -Terminal=false -Categories=AudioVideo;Video;GTK; -StartupNotify=true diff --git a/resources/recoder.desktop b/resources/recoder.desktop new file mode 100644 index 0000000..2933b39 --- /dev/null +++ b/resources/recoder.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Name=Recoder +Comment=GTK4 Video Transcoding GUI Application +Exec=recoder +Icon=recoder +Terminal=false +Type=Application +Categories=AudioVideo;Video;Utility; +StartupNotify=true diff --git a/src/app.py b/src/app.py new file mode 100644 index 0000000..11cc975 --- /dev/null +++ b/src/app.py @@ -0,0 +1,28 @@ +import sys +import gi + +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') + +from gi.repository import Adw, Gio +from ui import RecoderWindow + +Adw.init() + +class RecoderApp(Adw.Application): + def __init__(self): + super().__init__(application_id="net.jeena.recoder", + flags=Gio.ApplicationFlags.FLAGS_NONE) + self.window = None + + def do_activate(self): + if not self.window: + self.window = RecoderWindow(application=self) + self.window.present() + +def main(): + app = RecoderApp() + return app.run(sys.argv) + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..c2bc835 --- /dev/null +++ b/src/config.py @@ -0,0 +1,16 @@ +import json +from pathlib import Path +from gi.repository import GLib + +CONFIG_PATH = Path(GLib.get_user_config_dir()) / "recoder" / "config.json" +CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + +def save_config(config): + with open(CONFIG_PATH, "w") as f: + json.dump(config, f) + +def load_config(): + if CONFIG_PATH.exists(): + with open(CONFIG_PATH) as f: + return json.load(f) + return {} diff --git a/models.py b/src/models.py similarity index 100% rename from models.py rename to src/models.py diff --git a/src/transcoder_worker.py b/src/transcoder_worker.py new file mode 100644 index 0000000..c65848f --- /dev/null +++ b/src/transcoder_worker.py @@ -0,0 +1,57 @@ +import os +import threading +from gi.repository import GLib +import subprocess + +def transcode_file(input_path, output_dir): + os.makedirs(output_dir, exist_ok=True) + basename = os.path.basename(input_path) + output_path = os.path.join(output_dir, basename) + + cmd = [ + "ffmpeg", "-y", "-i", input_path, + "-c:v", "libx264", "-preset", "fast", + "-c:a", "aac", output_path + ] + + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return result.returncode == 0, output_path + +class TranscoderWorker: + def __init__(self, files, progress_callback=None, done_callback=None): + self.files = files + self.progress_callback = progress_callback + self.done_callback = done_callback + self.is_processing = False + + def start(self): + if self.is_processing: + return + self.is_processing = True + thread = threading.Thread(target=self._process_files) + thread.daemon = True + thread.start() + + def _process_files(self): + total = len(self.files) + for idx, filepath in enumerate(self.files, start=1): + basename = os.path.basename(filepath) + self._update_progress(f"Processing {basename} ({idx}/{total})...", idx / total) + + success, output_path = transcode_file(filepath, os.path.join(os.path.dirname(filepath), "transcoded")) + if not success: + self._update_progress(f"Error transcoding {basename}", idx / total) + else: + self._update_progress(f"Finished {basename}", idx / total) + + self.is_processing = False + self._update_progress("All done!", 1.0) + self._notify_done() + + def _update_progress(self, text, fraction): + if self.progress_callback: + GLib.idle_add(self.progress_callback, text, fraction) + + def _notify_done(self): + if self.done_callback: + GLib.idle_add(self.done_callback) diff --git a/recoder.py b/src/ui.py old mode 100755 new mode 100644 similarity index 51% rename from recoder.py rename to src/ui.py index d578091..24e5599 --- a/recoder.py +++ b/src/ui.py @@ -1,35 +1,14 @@ -#!/usr/bin/env python3 - +import os import gi +import shutil +import subprocess gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, Gio, Gdk, GLib, GObject +from gi.repository import Gtk, Adw, Gio, Gdk, GLib +from functools import partial -import os -import sys -import subprocess -import threading -import json -from pathlib import Path -import asyncio - -from models import FileItem, FileStatus -from transcoder import transcode_file - -Adw.init() - -CONFIG_PATH = Path(GLib.get_user_config_dir()) / "recoder" / "config.json" -CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) - -def save_config(config): - with open(CONFIG_PATH, "w") as f: - json.dump(config, f) - -def load_config(): - if CONFIG_PATH.exists(): - with open(CONFIG_PATH) as f: - return json.load(f) - return {} +from config import load_config, save_config +from transcoder_worker import TranscoderWorker class RecoderWindow(Adw.ApplicationWindow): def __init__(self, **kwargs): @@ -38,35 +17,53 @@ class RecoderWindow(Adw.ApplicationWindow): self.set_default_size(700, 400) self.config = load_config() - self.current_dir = self.config.get("last_directory", str(Path.home())) + self.current_dir = self.config.get("last_directory", str(os.path.expanduser("~"))) - # Main Box + self.files_to_process = [] + self.transcoder = None + + # UI setup self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6) - - # Create the header bar self.header_bar = Adw.HeaderBar() - - # Create the toolbar view and set the header bar self.toolbar_view = Adw.ToolbarView() self.toolbar_view.add_top_bar(self.header_bar) self.toolbar_view.set_content(self.vbox) - - # Set the toolbar view as the window content self.set_content(self.toolbar_view) - # TextView to list files self.textview = Gtk.TextView() self.textview.set_editable(False) self.textbuffer = self.textview.get_buffer() - # Create a DropTarget for URI list (files) 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) self.textview.add_controller(self.drop_target) - # CSS for highlight + # CSS + self._setup_css() + + self.drop_hint = Gtk.Label(label="📂 Drop files here to get started") + self.drop_hint.add_css_class("dim-label") + self._setup_dim_label_css() + + overlay = Gtk.Overlay() + overlay.set_child(self.textview) + overlay.add_overlay(self.drop_hint) + self.drop_hint.set_halign(Gtk.Align.CENTER) + self.drop_hint.set_valign(Gtk.Align.CENTER) + + self.scrolled = Gtk.ScrolledWindow() + self.scrolled.set_child(overlay) + self.vbox.append(self.scrolled) + self.scrolled.set_vexpand(True) + self.scrolled.set_hexpand(True) + + self.progress = Gtk.ProgressBar() + self.vbox.append(self.progress) + self.progress.set_hexpand(True) + + def _setup_css(self): css = b""" textview.drop-highlight { background-color: @theme_selected_bg_color; @@ -81,11 +78,7 @@ class RecoderWindow(Adw.ApplicationWindow): Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) - # Create label overlay - self.drop_hint = Gtk.Label(label="📂 Drop files here to get started") - self.drop_hint.add_css_class("dim-label") - - # Optional: use CSS to style it + def _setup_dim_label_css(self): css = b""" .dim-label { font-size: 16px; @@ -100,47 +93,19 @@ class RecoderWindow(Adw.ApplicationWindow): Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, ) - # Overlay - overlay = Gtk.Overlay() - overlay.set_child(self.textview) - overlay.add_overlay(self.drop_hint) - self.drop_hint.set_halign(Gtk.Align.CENTER) - self.drop_hint.set_valign(Gtk.Align.CENTER) - - # Scroll container for textview - self.scrolled = Gtk.ScrolledWindow() - self.scrolled.set_child(overlay) - self.vbox.append(self.scrolled) - self.scrolled.set_vexpand(True) - self.scrolled.set_hexpand(True) - - # Progress bar - self.progress = Gtk.ProgressBar() - self.vbox.append(self.progress) - self.progress.set_hexpand(True) - - self.files_to_process = [] - self.is_processing = False - - - def load_files_from_directory(self, directory): - self.files_to_process = [] - self.textbuffer.set_text("") - for entry in os.scandir(directory): - if entry.is_file() and entry.name.lower().endswith((".mp4", ".mov", ".mkv", ".avi")): - self.files_to_process.append(entry.path) - self.textbuffer.insert_at_cursor(f"{entry.name}\n") - if self.files_to_process: - self.start_transcoding() - def on_drop_enter(self, drop_target, x, y): self.textview.add_css_class("drop-highlight") + return True def on_drop_leave(self, drop_target): self.textview.remove_css_class("drop-highlight") + return True def on_drop(self, drop_target, value, x, y): - # value is a Gio.File or a list of Gio.Files + GLib.idle_add(partial(self.process_drop_value, value)) + return value + + def process_drop_value(self, value): if isinstance(value, Gio.File): uris = [value] elif isinstance(value, list): @@ -152,7 +117,6 @@ class RecoderWindow(Adw.ApplicationWindow): for file in uris: path = file.get_path() if os.path.isdir(path): - # Add all supported video files from the dropped folder for entry in os.scandir(path): if entry.is_file() and entry.name.lower().endswith((".mp4", ".mov", ".mkv", ".avi")): paths.append(entry.path) @@ -163,75 +127,38 @@ class RecoderWindow(Adw.ApplicationWindow): return False self.textview.remove_css_class("drop-highlight") - self.drop_hint.set_visible(False) # Hide hint - + self.drop_hint.set_visible(False) self.files_to_process = paths self.textbuffer.set_text("\n".join(os.path.basename(p) for p in paths) + "\n") self.start_transcoding() - return True + return False def start_transcoding(self): - if self.is_processing: + if self.transcoder and self.transcoder.is_processing: return - self.is_processing = True self.progress.set_fraction(0.0) self.progress.set_text("Starting transcoding...") - thread = threading.Thread(target=self.transcode_files) - thread.daemon = True - thread.start() + self.transcoder = TranscoderWorker( + self.files_to_process, + progress_callback=self.update_progress, + done_callback=self.notify_done, + ) + self.transcoder.start() - def transcode_files(self): - total = len(self.files_to_process) - - for idx, filepath in enumerate(self.files_to_process, start=1): - basename = os.path.basename(filepath) - self.update_progress_text(f"Processing {basename} ({idx}/{total})...") - success, output_path = transcode_file(filepath, os.path.join(os.path.dirname(filepath), "transcoded")) - - if not success: - self.update_progress_text(f"Error transcoding {basename}") - else: - self.update_progress_text(f"Finished {basename}") - - self.update_progress_fraction(idx / total) - - self.is_processing = False - self.update_progress_text("All done!") - self.progress.set_fraction(1.0) - self.notify_done() - - - def update_progress_text(self, text): - GLib.idle_add(self.progress.set_show_text, True) - GLib.idle_add(self.progress.set_text, text) - - def update_progress_fraction(self, fraction): - GLib.idle_add(self.progress.set_fraction, fraction) + def update_progress(self, text, fraction): + self.progress.set_show_text(True) + self.progress.set_text(text) + self.progress.set_fraction(fraction) def notify_done(self): - # Visual cue - change progress bar color briefly - GLib.idle_add(self.progress.set_show_text, True) - GLib.idle_add(self.progress.set_text, "Transcoding Complete!") - # Audio cue - subprocess.Popen(["paplay", "/usr/share/sounds/freedesktop/stereo/complete.oga"]) - -class RecoderApp(Adw.Application): - def __init__(self): - super().__init__(application_id="net.jeena.recoder", - flags=Gio.ApplicationFlags.FLAGS_NONE) - self.window = None - - def do_activate(self): - if not self.window: - self.window = RecoderWindow(application=self) - self.window.present() - -def main(): - app = RecoderApp() - return app.run(sys.argv) - -if __name__ == "__main__": - sys.exit(main()) + self.progress.set_show_text(True) + self.progress.set_text("Transcoding Complete!") + self.play_complete_sound() + def play_complete_sound(self): + if shutil.which("canberra-gtk-play"): + subprocess.Popen(["canberra-gtk-play", "--id", "complete"]) + else: + print("canberra-gtk-play not found.") diff --git a/transcoder.py b/transcoder.py deleted file mode 100644 index 21bea75..0000000 --- a/transcoder.py +++ /dev/null @@ -1,16 +0,0 @@ -import os -import subprocess - -def transcode_file(input_path, output_dir): - os.makedirs(output_dir, exist_ok=True) - basename = os.path.basename(input_path) - output_path = os.path.join(output_dir, basename) - - cmd = [ - "ffmpeg", "-y", "-i", input_path, - "-c:v", "libx264", "-preset", "fast", - "-c:a", "aac", output_path - ] - - result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) - return result.returncode == 0, output_path