Files

935 lines
38 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()