Restructure app and move into files

This commit is contained in:
Jeena 2025-06-01 12:18:58 +09:00
parent 32a4e78d1b
commit 86bf8750de
13 changed files with 205 additions and 186 deletions

6
.gitignore vendored
View file

@ -1 +1,7 @@
__pycache__/ __pycache__/
*.py[cod]
*$py.class
pkg/
*.tar.zst
*.tar.xz

View file

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

10
Pipfile
View file

@ -1,10 +0,0 @@
[[source]]
name = "pypi"
url = "https://pypi.org/simple"
verify_ssl = true
[packages]
PyGObject = "*"
[requires]
python_version = "3.10"

0
README.md Normal file
View file

25
packaging/PKGBUILD Normal file
View file

@ -0,0 +1,25 @@
# Maintainer: Jeena <your-email@example.com>
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"
}

View file

@ -1,8 +0,0 @@
[Desktop Entry]
Type=Application
Name=Recoder
Exec=recoder
Icon=video-x-generic
Terminal=false
Categories=AudioVideo;Video;GTK;
StartupNotify=true

View file

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

28
src/app.py Normal file
View file

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

16
src/config.py Normal file
View file

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

57
src/transcoder_worker.py Normal file
View file

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

201
recoder.py → src/ui.py Executable file → Normal file
View file

@ -1,35 +1,14 @@
#!/usr/bin/env python3 import os
import gi import gi
import shutil
import subprocess
gi.require_version('Gtk', '4.0') gi.require_version('Gtk', '4.0')
gi.require_version('Adw', '1') 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 from config import load_config, save_config
import sys from transcoder_worker import TranscoderWorker
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 {}
class RecoderWindow(Adw.ApplicationWindow): class RecoderWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs): def __init__(self, **kwargs):
@ -38,35 +17,53 @@ class RecoderWindow(Adw.ApplicationWindow):
self.set_default_size(700, 400) self.set_default_size(700, 400)
self.config = load_config() 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) self.vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
# Create the header bar
self.header_bar = Adw.HeaderBar() self.header_bar = Adw.HeaderBar()
# Create the toolbar view and set the header bar
self.toolbar_view = Adw.ToolbarView() self.toolbar_view = Adw.ToolbarView()
self.toolbar_view.add_top_bar(self.header_bar) self.toolbar_view.add_top_bar(self.header_bar)
self.toolbar_view.set_content(self.vbox) self.toolbar_view.set_content(self.vbox)
# Set the toolbar view as the window content
self.set_content(self.toolbar_view) self.set_content(self.toolbar_view)
# TextView to list files
self.textview = Gtk.TextView() self.textview = Gtk.TextView()
self.textview.set_editable(False) self.textview.set_editable(False)
self.textbuffer = self.textview.get_buffer() 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 = Gtk.DropTarget.new(Gio.File, Gdk.DragAction.COPY)
self.drop_target.connect("enter", self.on_drop_enter) self.drop_target.connect("enter", self.on_drop_enter)
self.drop_target.connect("leave", self.on_drop_leave) self.drop_target.connect("leave", self.on_drop_leave)
self.drop_target.connect("drop", self.on_drop) self.drop_target.connect("drop", self.on_drop)
self.textview.add_controller(self.drop_target) 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""" css = b"""
textview.drop-highlight { textview.drop-highlight {
background-color: @theme_selected_bg_color; background-color: @theme_selected_bg_color;
@ -81,11 +78,7 @@ class RecoderWindow(Adw.ApplicationWindow):
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
) )
# Create label overlay def _setup_dim_label_css(self):
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
css = b""" css = b"""
.dim-label { .dim-label {
font-size: 16px; font-size: 16px;
@ -100,47 +93,19 @@ class RecoderWindow(Adw.ApplicationWindow):
Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, 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): def on_drop_enter(self, drop_target, x, y):
self.textview.add_css_class("drop-highlight") self.textview.add_css_class("drop-highlight")
return True
def on_drop_leave(self, drop_target): def on_drop_leave(self, drop_target):
self.textview.remove_css_class("drop-highlight") self.textview.remove_css_class("drop-highlight")
return True
def on_drop(self, drop_target, value, x, y): 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): if isinstance(value, Gio.File):
uris = [value] uris = [value]
elif isinstance(value, list): elif isinstance(value, list):
@ -152,7 +117,6 @@ class RecoderWindow(Adw.ApplicationWindow):
for file in uris: for file in uris:
path = file.get_path() path = file.get_path()
if os.path.isdir(path): if os.path.isdir(path):
# Add all supported video files from the dropped folder
for entry in os.scandir(path): for entry in os.scandir(path):
if entry.is_file() and entry.name.lower().endswith((".mp4", ".mov", ".mkv", ".avi")): if entry.is_file() and entry.name.lower().endswith((".mp4", ".mov", ".mkv", ".avi")):
paths.append(entry.path) paths.append(entry.path)
@ -163,75 +127,38 @@ class RecoderWindow(Adw.ApplicationWindow):
return False return False
self.textview.remove_css_class("drop-highlight") 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.files_to_process = paths
self.textbuffer.set_text("\n".join(os.path.basename(p) for p in paths) + "\n") self.textbuffer.set_text("\n".join(os.path.basename(p) for p in paths) + "\n")
self.start_transcoding() self.start_transcoding()
return True return False
def start_transcoding(self): def start_transcoding(self):
if self.is_processing: if self.transcoder and self.transcoder.is_processing:
return return
self.is_processing = True
self.progress.set_fraction(0.0) self.progress.set_fraction(0.0)
self.progress.set_text("Starting transcoding...") self.progress.set_text("Starting transcoding...")
thread = threading.Thread(target=self.transcode_files) self.transcoder = TranscoderWorker(
thread.daemon = True self.files_to_process,
thread.start() progress_callback=self.update_progress,
done_callback=self.notify_done,
)
self.transcoder.start()
def transcode_files(self): def update_progress(self, text, fraction):
total = len(self.files_to_process) self.progress.set_show_text(True)
self.progress.set_text(text)
for idx, filepath in enumerate(self.files_to_process, start=1): self.progress.set_fraction(fraction)
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 notify_done(self): def notify_done(self):
# Visual cue - change progress bar color briefly self.progress.set_show_text(True)
GLib.idle_add(self.progress.set_show_text, True) self.progress.set_text("Transcoding Complete!")
GLib.idle_add(self.progress.set_text, "Transcoding Complete!") self.play_complete_sound()
# 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())
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.")

View file

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