feat: rewrite application as modular fcco-Converter V2 with new GUI, metadata handling, and updater logic
This commit is contained in:
@@ -0,0 +1,949 @@
|
||||
"""
|
||||
fcco-Converter V2 – GUI-Modul
|
||||
|
||||
Moderne GUI mit CustomTkinter, optimiert für macOS und Windows.
|
||||
|
||||
Bekannte macOS-Probleme und deren Lösungen:
|
||||
- Buttons brauchen mehrere Klicks: Gelöst durch globales Fokus-Binding
|
||||
und Vermeidung von grid_propagate(False) / fixen Höhen auf Containern.
|
||||
- grab_set() auf Dialogen: Komplett entfernt auf macOS.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from tkinter import filedialog, messagebox
|
||||
|
||||
import customtkinter as ctk
|
||||
|
||||
from .config import (
|
||||
DEFAULT_EXPORT_PATHS,
|
||||
BITRATE_OPTIONS,
|
||||
SAMPLERATE_OPTIONS,
|
||||
CHANNEL_OPTIONS,
|
||||
ENCODING_MODE_OPTIONS,
|
||||
VBR_QUALITY_OPTIONS,
|
||||
OUTPUT_FORMAT_OPTIONS,
|
||||
load_settings,
|
||||
save_settings,
|
||||
save_export_history,
|
||||
)
|
||||
from .converter import run_batch_conversion, _ensure_ffmpeg
|
||||
from .metadata import (
|
||||
generate_album_name,
|
||||
generate_folder_name,
|
||||
generate_date_part,
|
||||
extract_event_from_folder,
|
||||
)
|
||||
from .updater import (
|
||||
get_current_version,
|
||||
check_for_updates,
|
||||
download_and_install_update,
|
||||
)
|
||||
from .icons import IconSet
|
||||
|
||||
IS_MACOS = platform.system() == "Darwin"
|
||||
|
||||
# =========================================================================
|
||||
# Farbschema
|
||||
# =========================================================================
|
||||
|
||||
T = {
|
||||
"bg": "#000000",
|
||||
"surface": "#111111",
|
||||
"elevated": "#1a1a1a",
|
||||
"card": "#222222",
|
||||
"input": "#000000",
|
||||
"accent": "#ffffff",
|
||||
"accent_h": "#e6e6e6",
|
||||
"success": "#ffffff",
|
||||
"success_h": "#e6e6e6",
|
||||
"warn": "#ffffff",
|
||||
"error": "#ffffff",
|
||||
"text": "#ffffff",
|
||||
"text2": "#b3b3b3",
|
||||
"muted": "#808080",
|
||||
"border": "#333333",
|
||||
"btn2": "#1a1a1a",
|
||||
"btn2_h": "#333333",
|
||||
}
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# App
|
||||
# =========================================================================
|
||||
|
||||
class App(ctk.CTk):
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
ctk.set_appearance_mode("dark")
|
||||
ctk.set_default_color_theme("blue")
|
||||
|
||||
self.title(f"fcco Converter v{get_current_version()}")
|
||||
self.geometry("900x700")
|
||||
self.minsize(700, 500)
|
||||
self.configure(fg_color=T["bg"])
|
||||
|
||||
# Icon (nur Windows)
|
||||
icon = Path(__file__).parent.parent / "icon.ico"
|
||||
if icon.exists() and not IS_MACOS:
|
||||
try:
|
||||
self.iconbitmap(str(icon))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.icons = IconSet(size=(18, 18))
|
||||
|
||||
# State
|
||||
self.settings = load_settings()
|
||||
self.file_entries: list[dict] = []
|
||||
self.base_folder: Path | None = None
|
||||
self.is_converting = False
|
||||
self._ffmpeg_ready = False
|
||||
|
||||
# FFmpeg im Hintergrund laden
|
||||
threading.Thread(target=self._preload_ffmpeg, daemon=True).start()
|
||||
|
||||
# Layout
|
||||
self._build()
|
||||
|
||||
# ---- macOS Fokus-Fix ----
|
||||
# Jeder Klick irgendwo im Fenster erzwingt den Fokus auf das
|
||||
# angeklickte Widget. Das behebt das Problem, dass Buttons
|
||||
# auf macOS mehrfach geklickt werden müssen.
|
||||
self.bind_all("<Button-1>", self._on_click, add="+")
|
||||
self.after(100, self._activate)
|
||||
|
||||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||||
|
||||
# ----- Fokus-Management (behebt macOS-Bug) -----
|
||||
|
||||
def _on_click(self, event):
|
||||
"""Erzwingt Fokus auf das geklickte Widget."""
|
||||
try:
|
||||
w = event.widget
|
||||
if w and w.winfo_exists():
|
||||
w.focus_set()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _activate(self):
|
||||
"""Bringt das Fenster nach vorne und aktiviert es."""
|
||||
self.lift()
|
||||
self.attributes("-topmost", True)
|
||||
self.after(50, lambda: self.attributes("-topmost", False))
|
||||
self.focus_force()
|
||||
|
||||
def _btn(self, parent, text, command, **kwargs):
|
||||
btn = ctk.CTkButton(parent, text=text, **kwargs)
|
||||
if IS_MACOS and command:
|
||||
# macOS Bugfix: Direkter Bind statt Canvas-Command
|
||||
btn.bind("<ButtonRelease-1>", lambda e: self.after(10, command))
|
||||
elif command:
|
||||
btn.configure(command=command)
|
||||
return btn
|
||||
|
||||
def _preload_ffmpeg(self):
|
||||
try:
|
||||
_ensure_ffmpeg()
|
||||
self._ffmpeg_ready = True
|
||||
self.after(0, lambda: self._set_status("Bereit", T["success"]))
|
||||
except Exception as e:
|
||||
self.after(0, lambda: self._set_status(f"FFmpeg-Fehler: {e}", T["error"]))
|
||||
|
||||
def _set_status(self, text, color=None):
|
||||
self.status_lbl.configure(text=text, text_color=color or T["muted"])
|
||||
|
||||
# =================================================================
|
||||
# Layout
|
||||
# =================================================================
|
||||
|
||||
def _build(self):
|
||||
self.grid_columnconfigure(0, weight=1)
|
||||
self.grid_rowconfigure(2, weight=1)
|
||||
|
||||
self._make_header()
|
||||
self._make_toolbar()
|
||||
self._make_filelist()
|
||||
self._make_progress()
|
||||
self._make_footer()
|
||||
|
||||
def _make_header(self):
|
||||
h = ctk.CTkFrame(self, fg_color=T["surface"], corner_radius=0)
|
||||
h.grid(row=0, column=0, sticky="ew")
|
||||
h.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(h, text="fcco Converter",
|
||||
font=ctk.CTkFont(size=20, weight="bold"),
|
||||
text_color=T["text"]).grid(row=0, column=0, padx=(20, 0), pady=12, sticky="w")
|
||||
|
||||
ctk.CTkLabel(h, text=f"v{get_current_version()}",
|
||||
font=ctk.CTkFont(size=12), text_color=T["muted"]
|
||||
).grid(row=0, column=1, padx=(8, 0), pady=12, sticky="w")
|
||||
|
||||
self.update_btn = self._btn(
|
||||
h, text=" Update prüfen", command=self._check_updates,
|
||||
image=self.icons.refresh, width=150, height=32,
|
||||
fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"],
|
||||
text_color=T["text2"], font=ctk.CTkFont(size=12))
|
||||
self.update_btn.grid(row=0, column=2, padx=(0, 8), pady=12)
|
||||
|
||||
self._btn(
|
||||
h, text="", command=self._open_settings,
|
||||
image=self.icons.settings, width=34, height=32,
|
||||
fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"]
|
||||
).grid(row=0, column=3, padx=(0, 20), pady=12)
|
||||
|
||||
def _make_toolbar(self):
|
||||
tb = ctk.CTkFrame(self, fg_color="transparent")
|
||||
tb.grid(row=1, column=0, sticky="ew", padx=20, pady=(10, 4))
|
||||
tb.grid_columnconfigure(2, weight=1)
|
||||
|
||||
self._btn(
|
||||
tb, text=" Dateien", command=self._pick_files,
|
||||
image=self.icons.file_add, width=130, height=34,
|
||||
fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"],
|
||||
text_color=T["text"], font=ctk.CTkFont(size=13)
|
||||
).grid(row=0, column=0, padx=(0, 6))
|
||||
|
||||
self._btn(
|
||||
tb, text=" Ordner", command=self._pick_folder,
|
||||
image=self.icons.folder, width=130, height=34,
|
||||
fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"],
|
||||
text_color=T["text"], font=ctk.CTkFont(size=13)
|
||||
).grid(row=0, column=1, padx=(0, 6))
|
||||
|
||||
self.count_lbl = ctk.CTkLabel(tb, text="Keine Dateien",
|
||||
text_color=T["muted"], font=ctk.CTkFont(size=12))
|
||||
self.count_lbl.grid(row=0, column=2, sticky="w", padx=(10, 0))
|
||||
|
||||
self.conv_btn = self._btn(
|
||||
tb, text=" Konvertieren", command=self._convert,
|
||||
image=self.icons.play, width=160, height=36,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
text_color="#000000", font=ctk.CTkFont(size=14, weight="bold"))
|
||||
self.conv_btn.grid(row=0, column=3, sticky="e")
|
||||
|
||||
def _make_filelist(self):
|
||||
box = ctk.CTkFrame(self, fg_color=T["surface"],
|
||||
border_width=1, border_color=T["border"], corner_radius=8)
|
||||
box.grid(row=2, column=0, sticky="nsew", padx=20, pady=6)
|
||||
box.grid_columnconfigure(0, weight=1)
|
||||
box.grid_rowconfigure(1, weight=1)
|
||||
|
||||
hdr = ctk.CTkFrame(box, fg_color="transparent")
|
||||
hdr.grid(row=0, column=0, sticky="ew", padx=12, pady=(8, 0))
|
||||
hdr.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(hdr, text="Dateien",
|
||||
font=ctk.CTkFont(size=13, weight="bold"),
|
||||
text_color=T["text"]).grid(row=0, column=0, sticky="w")
|
||||
|
||||
self.info_lbl = ctk.CTkLabel(hdr, text="", font=ctk.CTkFont(size=11),
|
||||
text_color=T["muted"])
|
||||
self.info_lbl.grid(row=0, column=1, sticky="e")
|
||||
|
||||
self.scroll = ctk.CTkScrollableFrame(box, fg_color="transparent",
|
||||
scrollbar_button_color=T["elevated"],
|
||||
scrollbar_button_hover_color=T["border"])
|
||||
self.scroll.grid(row=1, column=0, sticky="nsew", padx=6, pady=(4, 8))
|
||||
self.scroll.grid_columnconfigure(1, weight=1)
|
||||
|
||||
self.rows: list[dict] = []
|
||||
|
||||
def _make_progress(self):
|
||||
pf = ctk.CTkFrame(self, fg_color="transparent")
|
||||
pf.grid(row=3, column=0, sticky="ew", padx=20, pady=(2, 0))
|
||||
pf.grid_columnconfigure(0, weight=1)
|
||||
|
||||
self.total_bar = ctk.CTkProgressBar(pf, height=6,
|
||||
progress_color=T["accent"], fg_color=T["elevated"], corner_radius=3)
|
||||
self.total_bar.grid(row=0, column=0, sticky="ew")
|
||||
self.total_bar.set(0)
|
||||
|
||||
self.prog_lbl = ctk.CTkLabel(pf, text="", font=ctk.CTkFont(size=11),
|
||||
text_color=T["muted"])
|
||||
self.prog_lbl.grid(row=1, column=0, sticky="w", pady=(2, 0))
|
||||
|
||||
def _make_footer(self):
|
||||
f = ctk.CTkFrame(self, fg_color=T["surface"], corner_radius=0)
|
||||
f.grid(row=4, column=0, sticky="ew")
|
||||
|
||||
self.status_lbl = ctk.CTkLabel(f, text="FFmpeg wird geladen...",
|
||||
font=ctk.CTkFont(size=11), text_color=T["muted"])
|
||||
self.status_lbl.pack(side="left", padx=20, pady=6)
|
||||
|
||||
audio = self.settings.get("audio", {})
|
||||
self.audio_lbl = ctk.CTkLabel(f, text=self._audio_summary(audio),
|
||||
font=ctk.CTkFont(size=11), text_color=T["muted"])
|
||||
self.audio_lbl.pack(side="right", padx=20, pady=6)
|
||||
|
||||
def _audio_summary(self, audio: dict) -> str:
|
||||
fmt = audio.get("output_format", "mp3").upper()
|
||||
mode = audio.get("encoding_mode", "CBR")
|
||||
if mode == "CBR":
|
||||
s = f"{fmt} CBR {audio.get('bitrate', '192')}k"
|
||||
else:
|
||||
s = f"{fmt} VBR Q{audio.get('vbr_quality', '2')}"
|
||||
sr = audio.get("sample_rate", "Original")
|
||||
if sr != "Original":
|
||||
s += f" {sr}Hz"
|
||||
ch = audio.get("channels", "Original")
|
||||
if ch != "Original":
|
||||
s += f" {ch}"
|
||||
return s
|
||||
|
||||
# =================================================================
|
||||
# Dateien
|
||||
# =================================================================
|
||||
|
||||
def _pick_files(self):
|
||||
p = filedialog.askopenfilenames(filetypes=[("WAV", "*.wav"), ("Alle", "*.*")])
|
||||
if p:
|
||||
self.base_folder = None
|
||||
self.file_entries = [{"full_path": str(x), "rel_dir": ""} for x in p]
|
||||
self._refresh_list()
|
||||
self.after(30, self._activate)
|
||||
|
||||
def _pick_folder(self):
|
||||
d = filedialog.askdirectory()
|
||||
if d:
|
||||
dp = Path(d)
|
||||
self.base_folder = dp
|
||||
self.file_entries = []
|
||||
for r, _, fs in os.walk(dp):
|
||||
for fn in fs:
|
||||
if fn.lower().endswith(".wav"):
|
||||
fp = Path(r) / fn
|
||||
rel = str(Path(r).relative_to(dp))
|
||||
self.file_entries.append({"full_path": str(fp), "rel_dir": "" if rel == "." else rel})
|
||||
self._refresh_list()
|
||||
self.after(30, self._activate)
|
||||
|
||||
def _refresh_list(self):
|
||||
for w in self.rows:
|
||||
w["fr"].destroy()
|
||||
self.rows.clear()
|
||||
|
||||
self.file_entries.sort(key=lambda x: Path(x["full_path"]).name.lower())
|
||||
|
||||
for i, e in enumerate(self.file_entries):
|
||||
name = Path(e["full_path"]).name
|
||||
disp = f"{e['rel_dir']}/{name}" if e["rel_dir"] else name
|
||||
|
||||
fr = ctk.CTkFrame(self.scroll, fg_color="transparent")
|
||||
fr.grid(row=i, column=0, sticky="ew", pady=1, columnspan=4)
|
||||
fr.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(fr, text=f"{i+1}", font=ctk.CTkFont(size=11),
|
||||
text_color=T["muted"], width=30).grid(row=0, column=0, padx=(4, 6))
|
||||
|
||||
ctk.CTkLabel(fr, text=disp, font=ctk.CTkFont(size=12), anchor="w",
|
||||
text_color=T["text"]).grid(row=0, column=1, sticky="w")
|
||||
|
||||
sl = ctk.CTkLabel(fr, text="Wartet", font=ctk.CTkFont(size=11),
|
||||
text_color=T["muted"], width=70)
|
||||
sl.grid(row=0, column=2, padx=(8, 4))
|
||||
|
||||
pb = ctk.CTkProgressBar(fr, width=90, height=4,
|
||||
progress_color=T["accent"], fg_color=T["elevated"], corner_radius=2)
|
||||
pb.grid(row=0, column=3, padx=(0, 8))
|
||||
pb.set(0)
|
||||
|
||||
self.rows.append({"fr": fr, "sl": sl, "pb": pb})
|
||||
|
||||
n = len(self.file_entries)
|
||||
self.count_lbl.configure(text=f"{n} Datei{'en' if n != 1 else ''}")
|
||||
self.info_lbl.configure(text=f"{n} Einträge")
|
||||
|
||||
# =================================================================
|
||||
# Konvertierung
|
||||
# =================================================================
|
||||
|
||||
def _convert(self):
|
||||
if not self.file_entries:
|
||||
messagebox.showwarning("Keine Dateien", "Bitte zuerst Dateien auswählen.")
|
||||
self.after(30, self._activate)
|
||||
return
|
||||
if self.is_converting:
|
||||
return
|
||||
if not self._ffmpeg_ready:
|
||||
messagebox.showwarning("FFmpeg", "FFmpeg wird noch geladen.")
|
||||
self.after(30, self._activate)
|
||||
return
|
||||
|
||||
meta = self._meta_dialog()
|
||||
if not meta:
|
||||
return
|
||||
|
||||
ctype = meta.get("type", "Sonstiges")
|
||||
event = meta.get("event", "").strip()
|
||||
dpart = meta.get("date_part", generate_date_part())
|
||||
|
||||
if ctype in self.settings.get("export_paths", {}) and ctype != "Sonstiges":
|
||||
bd = Path(self.settings["export_paths"][ctype])
|
||||
bd.mkdir(parents=True, exist_ok=True)
|
||||
ed = bd / generate_folder_name(dpart, event)
|
||||
ed.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
chosen = filedialog.askdirectory(title="Export-Ordner wählen")
|
||||
if not chosen:
|
||||
return
|
||||
ed = Path(chosen)
|
||||
|
||||
self.settings["last_export_dir"] = str(ed)
|
||||
save_settings(self.settings)
|
||||
|
||||
subdirs = self.base_folder is not None
|
||||
self.is_converting = True
|
||||
self.conv_btn.configure(state="disabled", text=" Konvertiert...")
|
||||
self.total_bar.set(0)
|
||||
self.prog_lbl.configure(text="Konvertierung gestartet...")
|
||||
self._set_status("Konvertierung...", T["warn"])
|
||||
|
||||
workers = self.settings.get("threads", 0) or os.cpu_count() or 1
|
||||
audio = self.settings.get("audio", {})
|
||||
total = len(self.file_entries)
|
||||
done = {"n": 0}
|
||||
|
||||
def on_prog(idx, pct, fn):
|
||||
self.after(0, lambda: self._file_prog(idx, pct))
|
||||
if pct >= 100:
|
||||
done["n"] += 1
|
||||
r = done["n"] / total
|
||||
self.after(0, lambda r=r, c=done["n"]: (
|
||||
self.total_bar.set(r),
|
||||
self.prog_lbl.configure(text=f"{c} / {total}")
|
||||
))
|
||||
|
||||
def on_done(ok, errs):
|
||||
self.after(0, lambda: self._conv_done(ok, errs, ed))
|
||||
|
||||
threading.Thread(target=run_batch_conversion, daemon=True, kwargs={
|
||||
"entries": self.file_entries, "export_dir": ed, "meta": meta,
|
||||
"use_subdirs": subdirs, "audio_settings": audio,
|
||||
"max_workers": workers, "progress_callback": on_prog,
|
||||
"completion_callback": on_done,
|
||||
}).start()
|
||||
|
||||
def _file_prog(self, idx, pct):
|
||||
if 0 <= idx < len(self.rows):
|
||||
r = self.rows[idx]
|
||||
r["pb"].set(pct / 100)
|
||||
if pct >= 100:
|
||||
r["sl"].configure(text="Fertig", text_color=T["success"])
|
||||
else:
|
||||
r["sl"].configure(text=f"{pct}%", text_color=T["accent"])
|
||||
|
||||
def _conv_done(self, ok, errs, ed):
|
||||
self.is_converting = False
|
||||
self.conv_btn.configure(state="normal", text=" Konvertieren")
|
||||
self.total_bar.set(1.0)
|
||||
self._set_status("Bereit", T["success"])
|
||||
|
||||
for fn, _ in errs:
|
||||
for i, e in enumerate(self.file_entries):
|
||||
if Path(e["full_path"]).name == fn and i < len(self.rows):
|
||||
self.rows[i]["sl"].configure(text="Fehler", text_color=T["error"])
|
||||
break
|
||||
|
||||
save_export_history({
|
||||
"datum": datetime.now().strftime("%d.%m.%Y %H:%M"),
|
||||
"ordner": str(ed), "dateien": ok,
|
||||
})
|
||||
|
||||
msg = f"{len(ok)} Dateien konvertiert."
|
||||
if errs:
|
||||
msg += f"\n{len(errs)} Fehler:"
|
||||
for fn, err in errs[:5]:
|
||||
msg += f"\n {fn}: {err}"
|
||||
|
||||
res = messagebox.askquestion("Fertig", f"{msg}\n\nProgramm schließen?")
|
||||
if res == "yes":
|
||||
self.destroy()
|
||||
else:
|
||||
self.after(30, self._activate)
|
||||
|
||||
# =================================================================
|
||||
# Metadaten + Export Dialog (KEIN Scrolling)
|
||||
# =================================================================
|
||||
|
||||
def _meta_dialog(self) -> dict | None:
|
||||
meta = self.settings.get("metadata", {}).copy()
|
||||
audio = self.settings.get("audio", {})
|
||||
|
||||
if self.file_entries:
|
||||
fp = Path(self.file_entries[0]["full_path"])
|
||||
dpart, veranst = extract_event_from_folder(fp)
|
||||
if not dpart:
|
||||
dpart = generate_date_part()
|
||||
else:
|
||||
dpart = generate_date_part()
|
||||
veranst = ""
|
||||
|
||||
meta["album"] = generate_album_name(veranst)
|
||||
meta["year"] = str(datetime.now().year)
|
||||
|
||||
d = ctk.CTkToplevel(self)
|
||||
d.title("Metadaten und Export")
|
||||
d.geometry("620x350")
|
||||
d.transient(self)
|
||||
d.resizable(False, False)
|
||||
d.configure(fg_color=T["bg"])
|
||||
|
||||
result = {}
|
||||
|
||||
# ---- Kompaktes Layout ohne Scroll ----
|
||||
main = ctk.CTkFrame(d, fg_color="transparent")
|
||||
main.pack(fill="both", expand=True, padx=20, pady=(16, 8))
|
||||
|
||||
# --- Metadaten (links oben) ---
|
||||
left = ctk.CTkFrame(main, fg_color="transparent")
|
||||
left.pack(fill="x")
|
||||
|
||||
row0 = ctk.CTkFrame(left, fg_color="transparent")
|
||||
row0.pack(fill="x", pady=(0, 6))
|
||||
row0.grid_columnconfigure(0, weight=1)
|
||||
row0.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(row0, text="Künstler", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).grid(row=0, column=0, sticky="w")
|
||||
artist_e = ctk.CTkEntry(row0, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
artist_e.grid(row=1, column=0, sticky="ew", padx=(0, 8))
|
||||
artist_e.insert(0, meta.get("artist", ""))
|
||||
|
||||
ctk.CTkLabel(row0, text="Album", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w")
|
||||
album_e = ctk.CTkEntry(row0, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
album_e.grid(row=1, column=1, sticky="ew")
|
||||
album_e.insert(0, meta.get("album", ""))
|
||||
|
||||
row1 = ctk.CTkFrame(left, fg_color="transparent")
|
||||
row1.pack(fill="x", pady=(0, 6))
|
||||
row1.grid_columnconfigure(1, weight=1)
|
||||
|
||||
ctk.CTkLabel(row1, text="Jahr", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).grid(row=0, column=0, sticky="w")
|
||||
year_e = ctk.CTkEntry(row1, width=80, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
year_e.grid(row=1, column=0, sticky="w", padx=(0, 8))
|
||||
year_e.insert(0, meta.get("year", ""))
|
||||
|
||||
ctk.CTkLabel(row1, text="Cover-Bild", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w")
|
||||
cover_fr = ctk.CTkFrame(row1, fg_color="transparent")
|
||||
cover_fr.grid(row=1, column=1, sticky="ew")
|
||||
cover_fr.grid_columnconfigure(0, weight=1)
|
||||
cover_e = ctk.CTkEntry(cover_fr, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
cover_e.grid(row=0, column=0, sticky="ew")
|
||||
cover_e.insert(0, meta.get("cover", ""))
|
||||
|
||||
def _bc():
|
||||
p = filedialog.askopenfilename(filetypes=[("Bilder", "*.jpg *.jpeg *.png")])
|
||||
if p:
|
||||
cover_e.delete(0, "end")
|
||||
cover_e.insert(0, p)
|
||||
d.after(30, lambda: (d.lift(), d.focus_force()))
|
||||
|
||||
self._btn(cover_fr, text="", command=_bc, image=self.icons.browse,
|
||||
width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"]
|
||||
).grid(row=0, column=1, padx=(4, 0))
|
||||
|
||||
# --- Trennlinie ---
|
||||
ctk.CTkFrame(main, height=1, fg_color=T["border"]).pack(fill="x", pady=10)
|
||||
|
||||
# --- Mitte: Veranstaltung + Datum nebeneinander ---
|
||||
mid = ctk.CTkFrame(main, fg_color="transparent")
|
||||
mid.pack(fill="x")
|
||||
mid.grid_columnconfigure(0, weight=0)
|
||||
mid.grid_columnconfigure(1, weight=0)
|
||||
mid.grid_columnconfigure(2, weight=1)
|
||||
|
||||
# Veranstaltungstyp
|
||||
ctk.CTkLabel(mid, text="Typ", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12, weight="bold")).grid(row=0, column=0, sticky="w", pady=(0, 4))
|
||||
ctype_var = ctk.StringVar(value="Gottesdienst")
|
||||
for i, opt in enumerate(["Gottesdienst", "Hochzeit", "Weissagung", "Jugend", "Sonstiges"]):
|
||||
ctk.CTkRadioButton(mid, text=opt, variable=ctype_var, value=opt,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
border_color=T["border"], text_color=T["text"],
|
||||
font=ctk.CTkFont(size=12)
|
||||
).grid(row=i+1, column=0, sticky="w", pady=1, padx=(0, 24))
|
||||
|
||||
# Datum + Veranstaltungsname
|
||||
ctk.CTkLabel(mid, text="Datum", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).grid(row=0, column=1, sticky="w")
|
||||
date_e = ctk.CTkEntry(mid, width=120, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
date_e.grid(row=1, column=1, sticky="w", padx=(0, 12))
|
||||
date_e.insert(0, dpart)
|
||||
|
||||
ctk.CTkLabel(mid, text="Veranstaltung (opt.)", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).grid(row=0, column=2, sticky="w")
|
||||
event_e = ctk.CTkEntry(mid, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
event_e.grid(row=1, column=2, sticky="ew")
|
||||
event_e.insert(0, veranst)
|
||||
|
||||
def _upd_album(*_):
|
||||
album_e.delete(0, "end")
|
||||
album_e.insert(0, generate_album_name(event_e.get().strip()))
|
||||
event_e.bind("<KeyRelease>", _upd_album)
|
||||
|
||||
# --- Buttons ---
|
||||
bf = ctk.CTkFrame(d, fg_color="transparent")
|
||||
bf.pack(fill="x", padx=20, pady=(8, 16))
|
||||
|
||||
self._btn(bf, text="Abbrechen", command=d.destroy, width=110, height=34,
|
||||
fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"],
|
||||
text_color=T["text2"]
|
||||
).pack(side="left")
|
||||
|
||||
def _ok():
|
||||
result["artist"] = artist_e.get()
|
||||
result["album"] = album_e.get()
|
||||
result["year"] = year_e.get()
|
||||
result["cover"] = cover_e.get()
|
||||
result["type"] = ctype_var.get()
|
||||
result["event"] = event_e.get().strip()
|
||||
result["date_part"] = date_e.get().strip()
|
||||
d.destroy()
|
||||
|
||||
self._btn(bf, text=" Konvertieren starten", command=_ok,
|
||||
image=self.icons.play, width=180, height=34,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
text_color="#000000", font=ctk.CTkFont(weight="bold")
|
||||
).pack(side="right")
|
||||
|
||||
# Fokus (KEIN grab_set auf macOS!)
|
||||
d.after(50, lambda: (d.lift(), d.focus_force()))
|
||||
if not IS_MACOS:
|
||||
d.after(200, lambda: d.grab_set())
|
||||
|
||||
self.wait_window(d)
|
||||
self.after(30, self._activate)
|
||||
return result if result else None
|
||||
|
||||
# =================================================================
|
||||
# Einstellungen
|
||||
# =================================================================
|
||||
|
||||
def _open_settings(self):
|
||||
w = ctk.CTkToplevel(self)
|
||||
w.title("Einstellungen")
|
||||
w.geometry("560x580")
|
||||
w.transient(self)
|
||||
w.resizable(False, False)
|
||||
w.configure(fg_color=T["bg"])
|
||||
|
||||
tabs = ctk.CTkTabview(w, fg_color=T["surface"],
|
||||
segmented_button_fg_color=T["elevated"],
|
||||
segmented_button_selected_color=T["accent"],
|
||||
segmented_button_selected_hover_color=T["accent_h"],
|
||||
segmented_button_unselected_color=T["elevated"],
|
||||
segmented_button_unselected_hover_color=T["btn2_h"])
|
||||
tabs.pack(fill="both", expand=True, padx=12, pady=(12, 4))
|
||||
|
||||
t1 = tabs.add("Allgemein")
|
||||
t2 = tabs.add("Audio")
|
||||
t3 = tabs.add("Metadaten")
|
||||
t4 = tabs.add("Export-Pfade")
|
||||
|
||||
wg = {} # Widget-Sammlung
|
||||
|
||||
# == Allgemein ==
|
||||
s1 = ctk.CTkScrollableFrame(t1, fg_color="transparent")
|
||||
s1.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
|
||||
self._slabel(s1, "Leistung")
|
||||
ctk.CTkLabel(s1, text="Threads (0 = automatisch)", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w")
|
||||
te = ctk.CTkEntry(s1, width=100, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
te.pack(anchor="w", pady=(2, 10))
|
||||
te.insert(0, str(self.settings.get("threads", 0)))
|
||||
wg["threads"] = te
|
||||
|
||||
self._slabel(s1, "Darstellung")
|
||||
ctk.CTkLabel(s1, text="Theme", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w")
|
||||
av = ctk.StringVar(value=self.settings.get("appearance_mode", "Dark"))
|
||||
ctk.CTkOptionMenu(s1, values=["Dark", "Light", "System"], variable=av,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"],
|
||||
command=lambda v: ctk.set_appearance_mode(v)
|
||||
).pack(anchor="w", pady=(2, 10))
|
||||
wg["appearance"] = av
|
||||
|
||||
self._slabel(s1, "Updates")
|
||||
uv = ctk.BooleanVar(value=self.settings.get("check_updates_on_start", True))
|
||||
ctk.CTkCheckBox(s1, text="Beim Start auf Updates prüfen", variable=uv,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
border_color=T["border"], text_color=T["text"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=4)
|
||||
wg["auto_update"] = uv
|
||||
|
||||
# == Audio ==
|
||||
s2 = ctk.CTkScrollableFrame(t2, fg_color="transparent")
|
||||
s2.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
|
||||
self._slabel(s2, "Ausgabeformat")
|
||||
fv = ctk.StringVar(value=self.settings.get("audio", {}).get("output_format", "mp3"))
|
||||
ctk.CTkOptionMenu(s2, values=OUTPUT_FORMAT_OPTIONS, variable=fv,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"]
|
||||
).pack(anchor="w", pady=(2, 10))
|
||||
wg["fmt"] = fv
|
||||
|
||||
self._slabel(s2, "Encoding")
|
||||
mv = ctk.StringVar(value=self.settings.get("audio", {}).get("encoding_mode", "CBR"))
|
||||
ctk.CTkOptionMenu(s2, values=ENCODING_MODE_OPTIONS, variable=mv,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"]
|
||||
).pack(anchor="w", pady=(2, 6))
|
||||
wg["mode"] = mv
|
||||
|
||||
ctk.CTkLabel(s2, text="Bitrate (kbit/s, nur CBR)", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0))
|
||||
bv = ctk.StringVar(value=self.settings.get("audio", {}).get("bitrate", "192"))
|
||||
ctk.CTkOptionMenu(s2, values=BITRATE_OPTIONS, variable=bv,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"]
|
||||
).pack(anchor="w", pady=(2, 6))
|
||||
wg["br"] = bv
|
||||
|
||||
ctk.CTkLabel(s2, text="VBR-Qualität (0=best, 9=klein, nur VBR)", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0))
|
||||
vv = ctk.StringVar(value=self.settings.get("audio", {}).get("vbr_quality", "2"))
|
||||
ctk.CTkOptionMenu(s2, values=VBR_QUALITY_OPTIONS, variable=vv,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"]
|
||||
).pack(anchor="w", pady=(2, 10))
|
||||
wg["vbr"] = vv
|
||||
|
||||
self._slabel(s2, "Sampling")
|
||||
ctk.CTkLabel(s2, text="Sample-Rate (Hz)", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w")
|
||||
sv = ctk.StringVar(value=self.settings.get("audio", {}).get("sample_rate", "Original"))
|
||||
ctk.CTkOptionMenu(s2, values=SAMPLERATE_OPTIONS, variable=sv,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"]
|
||||
).pack(anchor="w", pady=(2, 6))
|
||||
wg["sr"] = sv
|
||||
|
||||
ctk.CTkLabel(s2, text="Kanäle", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(4, 0))
|
||||
cv = ctk.StringVar(value=self.settings.get("audio", {}).get("channels", "Original"))
|
||||
ctk.CTkOptionMenu(s2, values=CHANNEL_OPTIONS, variable=cv,
|
||||
fg_color=T["input"], button_color=T["accent"],
|
||||
button_hover_color=T["accent_h"], text_color=T["text"]
|
||||
).pack(anchor="w", pady=(2, 10))
|
||||
wg["ch"] = cv
|
||||
|
||||
self._slabel(s2, "Optionen")
|
||||
ov = ctk.BooleanVar(value=self.settings.get("audio", {}).get("overwrite_existing", True))
|
||||
ctk.CTkCheckBox(s2, text="Bestehende Dateien überschreiben", variable=ov,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
border_color=T["border"], text_color=T["text"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3)
|
||||
wg["ow"] = ov
|
||||
|
||||
# == Metadaten ==
|
||||
s3 = ctk.CTkScrollableFrame(t3, fg_color="transparent")
|
||||
s3.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
|
||||
self._slabel(s3, "Standard-Metadaten")
|
||||
ctk.CTkLabel(s3, text="Künstler", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w")
|
||||
mae = ctk.CTkEntry(s3, width=380, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
mae.pack(anchor="w", pady=(2, 8))
|
||||
mae.insert(0, self.settings.get("metadata", {}).get("artist", ""))
|
||||
wg["m_artist"] = mae
|
||||
|
||||
ctk.CTkLabel(s3, text="Standard-Cover", text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w")
|
||||
mcf = ctk.CTkFrame(s3, fg_color="transparent")
|
||||
mcf.pack(anchor="w", fill="x", pady=(2, 10))
|
||||
mce = ctk.CTkEntry(mcf, width=340, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
mce.pack(side="left")
|
||||
mce.insert(0, self.settings.get("metadata", {}).get("cover", ""))
|
||||
wg["m_cover"] = mce
|
||||
|
||||
def _bc2():
|
||||
p = filedialog.askopenfilename(filetypes=[("Bilder", "*.jpg *.jpeg *.png")])
|
||||
if p:
|
||||
mce.delete(0, "end")
|
||||
mce.insert(0, p)
|
||||
self._btn(mcf, text="", command=_bc2, image=self.icons.browse,
|
||||
width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"]
|
||||
).pack(side="left", padx=(4, 0))
|
||||
|
||||
self._slabel(s3, "Automatisierung")
|
||||
atv = ctk.BooleanVar(value=self.settings.get("metadata", {}).get("auto_track_number", True))
|
||||
ctk.CTkCheckBox(s3, text="Tracknummer automatisch vergeben", variable=atv,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
border_color=T["border"], text_color=T["text"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3)
|
||||
wg["auto_track"] = atv
|
||||
|
||||
aav = ctk.BooleanVar(value=self.settings.get("metadata", {}).get("auto_album_from_folder", True))
|
||||
ctk.CTkCheckBox(s3, text="Albumname aus Ordnername ableiten", variable=aav,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
border_color=T["border"], text_color=T["text"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=3)
|
||||
wg["auto_album"] = aav
|
||||
|
||||
# == Export-Pfade ==
|
||||
s4 = ctk.CTkScrollableFrame(t4, fg_color="transparent")
|
||||
s4.pack(fill="both", expand=True, padx=4, pady=4)
|
||||
|
||||
self._slabel(s4, "Standard-Exportverzeichnisse")
|
||||
pe = {}
|
||||
for key in DEFAULT_EXPORT_PATHS:
|
||||
ctk.CTkLabel(s4, text=key, text_color=T["text2"],
|
||||
font=ctk.CTkFont(size=12)).pack(anchor="w", pady=(6, 0))
|
||||
rf = ctk.CTkFrame(s4, fg_color="transparent")
|
||||
rf.pack(anchor="w", fill="x", pady=(2, 2))
|
||||
ent = ctk.CTkEntry(rf, width=360, fg_color=T["input"], border_color=T["border"],
|
||||
text_color=T["text"])
|
||||
ent.pack(side="left")
|
||||
ent.insert(0, self.settings.get("export_paths", {}).get(key, DEFAULT_EXPORT_PATHS[key]))
|
||||
|
||||
def _br(e=ent):
|
||||
p = filedialog.askdirectory()
|
||||
if p:
|
||||
e.delete(0, "end")
|
||||
e.insert(0, p)
|
||||
self._btn(rf, text="", command=_br, image=self.icons.browse,
|
||||
width=32, height=28, fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"]
|
||||
).pack(side="left", padx=(4, 0))
|
||||
pe[key] = ent
|
||||
wg["paths"] = pe
|
||||
|
||||
# --- Buttons ---
|
||||
bf = ctk.CTkFrame(w, fg_color="transparent")
|
||||
bf.pack(fill="x", padx=12, pady=(4, 12))
|
||||
|
||||
self._btn(bf, text="Abbrechen", command=w.destroy, width=110, height=34,
|
||||
fg_color=T["btn2"], hover_color=T["btn2_h"],
|
||||
border_width=1, border_color=T["border"],
|
||||
text_color=T["text2"]
|
||||
).pack(side="left")
|
||||
|
||||
self._btn(bf, text=" Speichern", command=lambda: self._save_s(wg, w),
|
||||
image=self.icons.save, width=140, height=34,
|
||||
fg_color=T["accent"], hover_color=T["accent_h"],
|
||||
text_color="#000000", font=ctk.CTkFont(weight="bold")
|
||||
).pack(side="right")
|
||||
|
||||
w.after(50, lambda: (w.lift(), w.focus_force()))
|
||||
if not IS_MACOS:
|
||||
w.after(200, lambda: w.grab_set())
|
||||
|
||||
def _save_s(self, wg, win):
|
||||
try:
|
||||
th = int(wg["threads"].get())
|
||||
if th < 0:
|
||||
messagebox.showerror("Fehler", "Threads >= 0")
|
||||
return
|
||||
except ValueError:
|
||||
messagebox.showerror("Fehler", "Ungültige Zahl")
|
||||
return
|
||||
|
||||
self.settings["threads"] = th
|
||||
self.settings["appearance_mode"] = wg["appearance"].get()
|
||||
self.settings["check_updates_on_start"] = wg["auto_update"].get()
|
||||
self.settings["audio"] = {
|
||||
"output_format": wg["fmt"].get(),
|
||||
"encoding_mode": wg["mode"].get(),
|
||||
"bitrate": wg["br"].get(),
|
||||
"vbr_quality": wg["vbr"].get(),
|
||||
"sample_rate": wg["sr"].get(),
|
||||
"channels": wg["ch"].get(),
|
||||
"overwrite_existing": wg["ow"].get(),
|
||||
}
|
||||
self.settings["metadata"] = {
|
||||
"artist": wg["m_artist"].get(),
|
||||
"cover": wg["m_cover"].get(),
|
||||
"auto_track_number": wg["auto_track"].get(),
|
||||
"auto_album_from_folder": wg["auto_album"].get(),
|
||||
}
|
||||
self.settings["export_paths"] = {k: v.get() for k, v in wg["paths"].items()}
|
||||
save_settings(self.settings)
|
||||
self.audio_lbl.configure(text=self._audio_summary(self.settings["audio"]))
|
||||
win.destroy()
|
||||
self.after(30, self._activate)
|
||||
|
||||
# =================================================================
|
||||
# Update
|
||||
# =================================================================
|
||||
|
||||
def _check_updates(self):
|
||||
self.update_btn.configure(state="disabled", text=" Prüfe...")
|
||||
self._set_status("Suche Updates...", T["warn"])
|
||||
|
||||
def _do():
|
||||
r = check_for_updates()
|
||||
self.after(0, lambda: self._upd_done(r))
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
def _upd_done(self, rel):
|
||||
self.update_btn.configure(state="normal", text=" Update prüfen")
|
||||
if not rel:
|
||||
self._set_status("Neueste Version", T["success"])
|
||||
return
|
||||
tag = rel.get("tag_name", "?")
|
||||
if messagebox.askyesno("Update", f"Version {tag} verfügbar.\nJetzt installieren?"):
|
||||
self._do_update(rel)
|
||||
self.after(30, self._activate)
|
||||
|
||||
def _do_update(self, rel):
|
||||
self.update_btn.configure(state="disabled", text=" Lädt...")
|
||||
def _p(pct, msg):
|
||||
self.after(0, lambda: self._set_status(msg, T["warn"]))
|
||||
self.after(0, lambda: self.total_bar.set(pct / 100))
|
||||
def _go():
|
||||
ok = download_and_install_update(rel, progress_callback=_p)
|
||||
self.after(0, lambda: self._upd_fin(ok))
|
||||
threading.Thread(target=_go, daemon=True).start()
|
||||
|
||||
def _upd_fin(self, ok):
|
||||
self.update_btn.configure(state="normal", text=" Update prüfen")
|
||||
if ok:
|
||||
messagebox.showinfo("Update", "Bitte neu starten.")
|
||||
else:
|
||||
self._set_status("Update fehlgeschlagen", T["error"])
|
||||
self.after(30, self._activate)
|
||||
|
||||
# =================================================================
|
||||
# Helfer
|
||||
# =================================================================
|
||||
|
||||
@staticmethod
|
||||
def _slabel(parent, text):
|
||||
ctk.CTkLabel(parent, text=text,
|
||||
font=ctk.CTkFont(size=14, weight="bold"),
|
||||
text_color=T["text"]).pack(anchor="w", pady=(10, 2))
|
||||
|
||||
def _on_close(self):
|
||||
if self.is_converting:
|
||||
if messagebox.askyesno("Beenden?", "Konvertierung läuft. Wirklich beenden?"):
|
||||
self.destroy()
|
||||
else:
|
||||
self.destroy()
|
||||
Reference in New Issue
Block a user