""" 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("", 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("", 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("", _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 check_updates(self, manual=False): import threading def _do_check(): try: has_upd, ver, url = check_for_updates() if has_upd: self.after(0, lambda: self._prompt_update(ver, url)) elif manual: self.after(0, lambda: messagebox.showinfo("Updates", "Du bist auf der neuesten Version!")) except Exception as e: if manual: self.after(0, lambda: messagebox.showerror("Fehler", f"Fehler bei Update-Prüfung:\n{e}")) if manual: self.after(30, self._activate) threading.Thread(target=_do_check, daemon=True).start() 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()