935 lines
38 KiB
Python
935 lines
38 KiB
Python
"""
|
||
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,
|
||
T,
|
||
APP_INFO,
|
||
)
|
||
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
|
||
# =========================================================================
|
||
|
||
# (Farben werden jetzt aus config.T importiert)
|
||
|
||
|
||
# =========================================================================
|
||
# App
|
||
# =========================================================================
|
||
|
||
class App(ctk.CTk):
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
ctk.set_appearance_mode("dark")
|
||
ctk.set_default_color_theme("blue")
|
||
|
||
self.title(f"{APP_INFO['app_name']} V{APP_INFO['app_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["btn2"], hover_color=T["btn2_h"],
|
||
text_color="#ffffff", 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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
|
||
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["btn2"], hover_color=T["btn2_h"],
|
||
text_color="#ffffff", 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["border"],
|
||
segmented_button_selected_hover_color=T["btn2_h"],
|
||
segmented_button_unselected_color=T["elevated"],
|
||
segmented_button_unselected_hover_color=T["btn2_h"],
|
||
text_color=T["text"])
|
||
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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
|
||
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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"],
|
||
button_hover_color=T["btn2_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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
|
||
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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
|
||
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["btn2"], hover_color=T["btn2_h"], checkmark_color="#ffffff",
|
||
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["btn2"], hover_color=T["btn2_h"],
|
||
text_color="#ffffff", 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()
|