import os import threading import shutil import subprocess import wx import wx.media AUDIO_FORMATS = ["mp3", "wav", "aac", "flac", "ogg", "m4a"] VIDEO_FORMATS = ["mp4", "mkv", "mov", "webm"] class ConverterFrame(wx.Frame): def __init__(self): super().__init__(parent=None, title="Audio/Video Converter", size=(640, 380)) panel = wx.Panel(self) label_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT) self.input_picker = wx.FilePickerCtrl( panel, message="Select input file", style=wx.FLP_OPEN | wx.FLP_FILE_MUST_EXIST | wx.FLP_USE_TEXTCTRL, ) self.mode_choice = wx.Choice(panel, choices=["Audio", "Video"]) self.mode_choice.SetSelection(0) self.format_choice = wx.Choice(panel, choices=AUDIO_FORMATS) self.format_choice.SetSelection(0) self.output_picker = wx.FilePickerCtrl( panel, message="Select output file", style=wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT | wx.FLP_USE_TEXTCTRL, ) self.audio_bitrate = wx.TextCtrl(panel, value="", size=(120, -1)) self.video_bitrate = wx.TextCtrl(panel, value="", size=(120, -1)) self.activation_bytes = wx.TextCtrl(panel, value="", size=(120, -1)) self.copy_streams = wx.CheckBox(panel, label="Co&py streams (fast, no re-encode)") self.copy_streams.SetForegroundColour(label_color) self.start_btn = wx.Button(panel, label="&Convert") self.log_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY) self.status = wx.StaticText(panel, label="Ready") self.status.SetForegroundColour(label_color) self.media = wx.media.MediaCtrl(panel, style=wx.SIMPLE_BORDER) self.play_btn = wx.Button(panel, label="&Play/Pause") self.stop_btn = wx.Button(panel, label="S&top") self.position_slider = wx.Slider(panel, minValue=0, maxValue=1000, style=wx.SL_HORIZONTAL) self.time_label = wx.StaticText(panel, label="00:00 / 00:00") self.time_label.SetForegroundColour(label_color) self.media_length_ms = 0 self.dragging_slider = False self.pending_play = False self.media_loaded = False self.loaded_media_path = "" self.player_timer = wx.Timer(self) self.audio_bitrate.SetHint("e.g. 192k") self.video_bitrate.SetHint("e.g. 2000k") self.activation_bytes.SetHint("e.g. 1") self.audio_bitrate.SetName("Audio bitrate") self.video_bitrate.SetName("Video bitrate") self.activation_bytes.SetName("Activation bytes") self.configure_file_picker_accessibility(self.input_picker, "Input file") self.mode_choice.SetName("Conversion mode") self.format_choice.SetName("Output format") self.configure_file_picker_accessibility(self.output_picker, "Output file") self.copy_streams.SetName("Copy streams") self.start_btn.SetName("Convert") self.play_btn.SetName("Play or pause") self.stop_btn.SetName("Stop playback") self.input_picker.SetHelpText("Input file") self.mode_choice.SetHelpText("Conversion mode") self.format_choice.SetHelpText("Output format") self.output_picker.SetHelpText("Output file") self.audio_bitrate.SetHelpText("Audio bitrate") self.video_bitrate.SetHelpText("Video bitrate") self.activation_bytes.SetHelpText("Activation bytes") self.copy_streams.SetHelpText("Copy streams") self.start_btn.SetHelpText("Convert") self.play_btn.SetHelpText("Play or pause") self.stop_btn.SetHelpText("Stop playback") self.media.SetMinSize((200, 36)) if hasattr(self.media, "SetVolume"): self.media.SetVolume(1.0) form = wx.BoxSizer(wx.VERTICAL) self.add_labeled_control(panel, form, "&Input file", self.input_picker, "Input file") self.add_labeled_control(panel, form, "&Mode", self.mode_choice, "Conversion mode") self.add_labeled_control(panel, form, "F&ormat", self.format_choice, "Output format") self.add_labeled_control(panel, form, "O&utput file", self.output_picker, "Output file") self.add_labeled_control(panel, form, "Audio &bitrate", self.audio_bitrate, "Audio bitrate") self.add_labeled_control(panel, form, "Video b&itrate", self.video_bitrate, "Video bitrate") self.add_labeled_control(panel, form, "A&ctivation bytes", self.activation_bytes, "Activation bytes") form.Add(self.copy_streams, 0, wx.EXPAND | wx.BOTTOM, 6) main = wx.BoxSizer(wx.VERTICAL) main.Add(form, 0, wx.EXPAND | wx.ALL, 10) main.Add(self.start_btn, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) main.Add(self.build_player(panel), 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) main.Add(self.log_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 10) main.Add(self.status, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10) panel.SetSizer(main) self.mode_choice.Bind(wx.EVT_CHOICE, self.on_mode_change) self.format_choice.Bind(wx.EVT_CHOICE, self.on_format_change) self.input_picker.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_input_change) self.start_btn.Bind(wx.EVT_BUTTON, self.on_start) self.play_btn.Bind(wx.EVT_BUTTON, self.on_play_pause) self.stop_btn.Bind(wx.EVT_BUTTON, self.on_stop) self.position_slider.Bind(wx.EVT_SLIDER, self.on_seek) self.position_slider.Bind(wx.EVT_SCROLL_THUMBRELEASE, self.on_seek_release) self.Bind(wx.media.EVT_MEDIA_LOADED, self.on_media_loaded, self.media) self.Bind(wx.media.EVT_MEDIA_FINISHED, self.on_media_finished, self.media) self.Bind(wx.EVT_TIMER, self.on_player_timer, self.player_timer) self.setup_hotkeys() self.update_format_choices() self.Center() self.player_timer.Start(250) def on_mode_change(self, _event): self.update_format_choices() self.suggest_output_path() def on_format_change(self, _event): self.suggest_output_path() def on_input_change(self, _event): self.suggest_output_path() self.load_media_from_input() def update_format_choices(self): mode = self.mode_choice.GetStringSelection() if mode == "Audio": choices = AUDIO_FORMATS else: choices = VIDEO_FORMATS self.format_choice.Set(choices) self.format_choice.SetSelection(0) def suggest_output_path(self): in_path = self.input_picker.GetPath() if not in_path: return out_ext = self.format_choice.GetStringSelection() base, _ = os.path.splitext(in_path) suggested = base + "." + out_ext self.output_picker.SetPath(suggested) def build_player(self, panel): player = wx.BoxSizer(wx.VERTICAL) controls = wx.BoxSizer(wx.HORIZONTAL) controls.Add(self.play_btn, 0, wx.RIGHT, 8) controls.Add(self.stop_btn, 0, wx.RIGHT, 8) controls.Add(self.time_label, 0, wx.ALIGN_CENTER_VERTICAL) player.Add(self.media, 0, wx.EXPAND | wx.BOTTOM, 6) player.Add(self.position_slider, 0, wx.EXPAND | wx.BOTTOM, 6) player.Add(controls, 0, wx.EXPAND | wx.BOTTOM, 4) return player def add_labeled_control(self, panel, parent_sizer, label, control, accessible_name=None): label_ctrl = wx.StaticText(panel, label=label) label_ctrl.SetForegroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)) clean_label = label.replace("&", "") if accessible_name is None: accessible_name = clean_label # Keep a stable, human-readable name/description for assistive technologies. control.SetName(accessible_name) control.SetHelpText(accessible_name) label_ctrl.SetName(clean_label) # Keep label and field adjacent in tab order for native AT label lookup. if hasattr(label_ctrl, "MoveBeforeInTabOrder"): label_ctrl.MoveBeforeInTabOrder(control) field_sizer = wx.BoxSizer(wx.VERTICAL) field_sizer.Add(label_ctrl, 0, wx.BOTTOM, 2) field_sizer.Add(control, 0, wx.EXPAND) parent_sizer.Add(field_sizer, 0, wx.EXPAND | wx.BOTTOM, 6) def configure_file_picker_accessibility(self, picker, label): picker.SetName(label) picker.SetHelpText(label) text_ctrl = picker.GetTextCtrl() if text_ctrl: text_ctrl.SetName(f"{label} path") text_ctrl.SetHelpText(label) button = picker.GetPickerCtrl() if button: button.SetName(f"Browse {label}") button.SetHelpText(f"Browse {label}") def setup_hotkeys(self): self.ID_PLAYPAUSE = wx.NewIdRef() self.ID_STOP = wx.NewIdRef() self.ID_CONVERT = wx.NewIdRef() self.ID_SEEK_BACK = wx.NewIdRef() self.ID_SEEK_FWD = wx.NewIdRef() entries = [ wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_SPACE, self.ID_PLAYPAUSE), wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("S"), self.ID_STOP), wx.AcceleratorEntry(wx.ACCEL_CTRL, wx.WXK_RETURN, self.ID_CONVERT), wx.AcceleratorEntry(wx.ACCEL_ALT, wx.WXK_LEFT, self.ID_SEEK_BACK), wx.AcceleratorEntry(wx.ACCEL_ALT, wx.WXK_RIGHT, self.ID_SEEK_FWD), ] self.SetAcceleratorTable(wx.AcceleratorTable(entries)) self.Bind(wx.EVT_MENU, self.on_play_pause, id=self.ID_PLAYPAUSE) self.Bind(wx.EVT_MENU, self.on_stop, id=self.ID_STOP) self.Bind(wx.EVT_MENU, self.on_start, id=self.ID_CONVERT) self.Bind(wx.EVT_MENU, self.on_seek_back, id=self.ID_SEEK_BACK) self.Bind(wx.EVT_MENU, self.on_seek_forward, id=self.ID_SEEK_FWD) def load_media_from_input(self): path = self.input_picker.GetPath() if not path: return self.load_media(path) def load_media(self, path): if not os.path.isfile(path): return self.media_length_ms = 0 self.media_loaded = False self.loaded_media_path = path self.position_slider.SetRange(0, 1000) self.position_slider.SetValue(0) self.update_time_label(0) if not self.media.Load(path): self.pending_play = False self.append_log("Unable to load media: " + path) else: self.media_loaded = True def on_media_loaded(self, _event): self.media_loaded = True length = self.media.Length() if length and length > 0: self.media_length_ms = length self.position_slider.SetRange(0, length) self.update_time_label(0) if self.pending_play: self.pending_play = False self.media.Play() def on_media_finished(self, _event): self.update_time_label(self.media_length_ms) if self.media_length_ms > 0: self.position_slider.SetValue(self.media_length_ms) def on_player_timer(self, _event): if self.dragging_slider or self.media_length_ms <= 0: return if self.media.GetState() == wx.media.MEDIASTATE_PLAYING: position = self.media.Tell() if position >= 0: self.position_slider.SetValue(position) self.update_time_label(position) def on_play_pause(self, _event): path = self.input_picker.GetPath() if not path: return state = self.media.GetState() if state == wx.media.MEDIASTATE_PLAYING: self.pending_play = False self.media.Pause() return if (not self.media_loaded) or (self.loaded_media_path != path): self.load_media(path) if self.media.Play(): self.pending_play = False else: self.pending_play = True def on_stop(self, _event): self.pending_play = False self.media.Stop() self.position_slider.SetValue(0) self.update_time_label(0) def on_seek(self, _event): self.dragging_slider = True if self.media_length_ms <= 0: return self.update_time_label(self.position_slider.GetValue()) def on_seek_release(self, _event): self.dragging_slider = False if self.media_length_ms <= 0: return self.media.Seek(self.position_slider.GetValue()) def on_seek_back(self, _event): self.seek_relative(-5000) def on_seek_forward(self, _event): self.seek_relative(5000) def seek_relative(self, delta_ms): if self.media_length_ms <= 0: return position = self.media.Tell() if position < 0: position = 0 new_pos = max(0, min(self.media_length_ms, position + delta_ms)) self.media.Seek(new_pos) self.position_slider.SetValue(new_pos) self.update_time_label(new_pos) def update_time_label(self, position_ms): total = self.media_length_ms self.time_label.SetLabel( f"{self.format_time(position_ms)} / {self.format_time(total)}" ) def format_time(self, millis): seconds = max(0, int(millis / 1000)) minutes = seconds // 60 seconds = seconds % 60 return f"{minutes:02d}:{seconds:02d}" def on_start(self, _event): if not shutil.which("ffmpeg"): wx.MessageBox("ffmpeg not found on PATH.", "Error", wx.ICON_ERROR) return in_path = self.input_picker.GetPath() out_path = self.output_picker.GetPath() if not in_path or not out_path: wx.MessageBox("Please choose input and output files.", "Error", wx.ICON_ERROR) return self.log_ctrl.Clear() self.status.SetLabel("Running...") self.start_btn.Disable() thread = threading.Thread(target=self.run_ffmpeg, args=(in_path, out_path), daemon=True) thread.start() def run_ffmpeg(self, in_path, out_path): mode = self.mode_choice.GetStringSelection() a_bitrate = self.audio_bitrate.GetValue().strip() v_bitrate = self.video_bitrate.GetValue().strip() activation_bytes = self.activation_bytes.GetValue().strip() copy = self.copy_streams.GetValue() cmd = ["ffmpeg", "-y"] if activation_bytes: cmd += ["-activation_bytes", activation_bytes] cmd += ["-i", in_path] if mode == "Audio": cmd.append("-vn") if copy: cmd += ["-c:a", "copy"] elif a_bitrate: cmd += ["-b:a", a_bitrate] else: if copy: cmd += ["-c:v", "copy", "-c:a", "copy"] else: if v_bitrate: cmd += ["-b:v", v_bitrate] if a_bitrate: cmd += ["-b:a", a_bitrate] cmd.append(out_path) self.append_log("Command: " + " ".join(cmd)) try: proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1, universal_newlines=True, ) for line in proc.stdout: self.append_log(line.rstrip()) proc.wait() if proc.returncode == 0: self.finish("Done") else: self.finish("Failed") except Exception as exc: self.append_log(f"Error: {exc}") self.finish("Failed") def append_log(self, text): wx.CallAfter(self.log_ctrl.AppendText, text + "\n") def finish(self, status_text): wx.CallAfter(self.status.SetLabel, status_text) wx.CallAfter(self.start_btn.Enable) class App(wx.App): def OnInit(self): frame = ConverterFrame() frame.Show() return True if __name__ == "__main__": app = App() app.MainLoop()