diff --git a/gui.py b/gui.py index 4445117..d6eca54 100644 --- a/gui.py +++ b/gui.py @@ -10,6 +10,7 @@ import time import mimetypes import urllib.parse import re +import shutil def normalize_file_path(path_str: str) -> str: if not path_str: @@ -28,7 +29,7 @@ from PySide6.QtNetwork import QNetworkAccessManager, QNetworkRequest, QHttpMulti from PySide6.QtWidgets import ( QApplication, QSystemTrayIcon, QMenu, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QComboBox, QCheckBox, QPushButton, QGroupBox, - QFormLayout, QMessageBox, QFileDialog + QFormLayout, QMessageBox, QFileDialog, QSlider ) # Configuration paths @@ -215,7 +216,7 @@ class SettingsDialog(QDialog): super().__init__(parent) self.tray_app = tray_app self.setWindowTitle("f0ckm Uploader Settings") - self.resize(520, 560) + self.resize(540, 680) app_icon = get_app_icon() if not app_icon.isNull(): @@ -331,6 +332,45 @@ class SettingsDialog(QDialog): main_layout.addWidget(grp_behavior) + # Section 4: Sound Notification + grp_sound = QGroupBox("Audio Notification") + sound_layout = QVBoxLayout(grp_sound) + sound_layout.setContentsMargins(15, 20, 15, 15) + sound_layout.setSpacing(10) + + self.chk_sound = QCheckBox("Play sound on successful upload") + sound_layout.addWidget(self.chk_sound) + + file_picker_layout = QHBoxLayout() + self.txt_sound_path = QLineEdit() + self.txt_sound_path.setPlaceholderText("Select native audio file (.wav, .ogg, .flac)...") + file_picker_layout.addWidget(self.txt_sound_path) + + self.btn_browse_sound = QPushButton("Browse...") + self.btn_browse_sound.clicked.connect(self.on_browse_sound) + file_picker_layout.addWidget(self.btn_browse_sound) + + self.btn_test_sound = QPushButton("Test Sound") + self.btn_test_sound.clicked.connect(self.on_test_sound) + file_picker_layout.addWidget(self.btn_test_sound) + + sound_layout.addLayout(file_picker_layout) + + vol_layout = QHBoxLayout() + vol_label = QLabel("Volume:") + self.slider_volume = QSlider(Qt.Orientation.Horizontal) + self.slider_volume.setRange(0, 100) + self.slider_volume.setValue(15) + self.lbl_volume = QLabel("15%") + self.lbl_volume.setFixedWidth(40) + self.slider_volume.valueChanged.connect(lambda v: self.lbl_volume.setText(f"{v}%")) + vol_layout.addWidget(vol_label) + vol_layout.addWidget(self.slider_volume) + vol_layout.addWidget(self.lbl_volume) + sound_layout.addLayout(vol_layout) + + main_layout.addWidget(grp_sound) + # Save / Cancel Buttons btn_layout = QHBoxLayout() btn_layout.addStretch() @@ -378,6 +418,18 @@ class SettingsDialog(QDialog): icon_theme = config.get("icon_theme", "dark").lower() self.cb_icon_theme.setCurrentIndex(1 if icon_theme == "light" else 0) + self.chk_sound.setChecked(config.get("play_success_sound", False)) + self.txt_sound_path.setText(config.get("success_audio_path", "")) + + vol = config.get("success_audio_volume", 15) + try: + vol = int(vol) + except Exception: + vol = 15 + vol = min(100, max(0, vol)) + self.slider_volume.setValue(vol) + self.lbl_volume.setText(f"{vol}%") + def on_test_connection(self): self.btn_test.setEnabled(False) self.btn_test.setText("Testing...") @@ -406,6 +458,32 @@ class SettingsDialog(QDialog): else: QMessageBox.warning(self, "Connection Test Failed", message) + def on_browse_sound(self): + file_path, _ = QFileDialog.getOpenFileName( + self, + "Select Native Audio File for Success Notification", + "", + "Supported Audio Files (*.wav *.ogg *.oga *.flac *.aiff *.aif *.au);;WAV Audio (*.wav);;OGG Audio (*.ogg *.oga);;FLAC Audio (*.flac)" + ) + if file_path: + self.txt_sound_path.setText(file_path) + + def on_test_sound(self): + sound_path = self.txt_sound_path.text().strip() + if not sound_path or not os.path.exists(sound_path): + QMessageBox.warning(self, "Invalid File", "Please select a valid audio file first.") + return + ext = os.path.splitext(sound_path)[1].lower() + supported = [".wav", ".ogg", ".oga", ".flac", ".aiff", ".aif", ".au"] + if ext not in supported: + QMessageBox.warning( + self, + "Unsupported Format", + f"The format '{ext}' is not supported natively by PipeWire/PulseAudio.\nPlease select a .wav, .ogg, or .flac file." + ) + return + play_success_sound(sound_path, volume=self.slider_volume.value()) + def save_settings(self): config = { "api_url": self.txt_url.text().strip(), @@ -417,7 +495,10 @@ class SettingsDialog(QDialog): "enable_notifications": self.chk_notifications.isChecked(), "show_progress_dialog": self.chk_progress.isChecked(), "autostart": self.chk_autostart.isChecked(), - "icon_theme": "light" if self.cb_icon_theme.currentIndex() == 1 else "dark" + "icon_theme": "light" if self.cb_icon_theme.currentIndex() == 1 else "dark", + "play_success_sound": self.chk_sound.isChecked(), + "success_audio_path": self.txt_sound_path.text().strip(), + "success_audio_volume": self.slider_volume.value() } if save_config(config): @@ -519,6 +600,190 @@ def generate_thumbnail(file_path: str) -> str: return "" +def format_size(bytes_val: int) -> str: + if bytes_val < 1024: + return f"{bytes_val} B" + elif bytes_val < 1024 * 1024: + return f"{bytes_val / 1024:.1f} KB" + elif bytes_val < 1024 * 1024 * 1024: + return f"{bytes_val / (1024 * 1024):.1f} MB" + else: + return f"{bytes_val / (1024 * 1024 * 1024):.2f} GB" + +def format_speed(bytes_per_sec: float) -> str: + if bytes_per_sec < 1024: + return f"{int(bytes_per_sec)} B/s" + elif bytes_per_sec < 1024 * 1024: + return f"{bytes_per_sec / 1024:.1f} KB/s" + else: + return f"{bytes_per_sec / (1024 * 1024):.1f} MB/s" + +def play_success_sound(audio_path=None, volume=None): + config = get_env_config() + if not audio_path: + audio_path = config.get("success_audio_path", "") + if not audio_path or not os.path.exists(audio_path): + return + + if volume is None: + volume = config.get("success_audio_volume", 15) + + try: + volume = min(100, max(0, int(volume))) + except Exception: + volume = 15 + + ext = os.path.splitext(audio_path)[1].lower() + supported = [".wav", ".ogg", ".oga", ".flac", ".aiff", ".aif", ".au"] + if ext not in supported: + return + + vol_float = volume / 100.0 + pa_vol_int = int(65536 * vol_float) + + # Use native Linux audio daemons (pw-play, paplay, canberra-gtk-play, aplay) + for player in ["pw-play", "paplay", "canberra-gtk-play", "aplay"]: + if shutil.which(player): + try: + if player == "pw-play": + cmd = [player, "--volume", f"{vol_float:.2f}", audio_path] + elif player == "paplay": + cmd = [player, f"--volume={pa_vol_int}", audio_path] + elif player == "canberra-gtk-play": + cmd = [player, "-f", audio_path] + else: + cmd = [player, audio_path] + + subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + return + except Exception: + pass + + # Fallback to Qt QSoundEffect for WAV files + if ext == ".wav": + try: + from PySide6.QtMultimedia import QSoundEffect + effect = QSoundEffect() + effect.setSource(QUrl.fromLocalFile(audio_path)) + effect.setVolume(vol_float) + effect.play() + app = QApplication.instance() + if app: + if not hasattr(app, "_sound_effects"): + app._sound_effects = [] + app._sound_effects.append(effect) + effect.playingChanged.connect( + lambda: app._sound_effects.remove(effect) if not effect.isPlaying() and effect in getattr(app, "_sound_effects", []) else None + ) + return + except Exception: + pass + +class CurlUploadThread(QThread): + progress = Signal(int, int) # bytes_sent, bytes_total + finished = Signal(bool, str) # success, response_or_error_msg + + def __init__(self, api_url, api_key, file_path, rating, tags, visibility, is_oc, parent=None): + super().__init__(parent) + self.api_url = api_url + self.api_key = api_key + self.file_path = file_path + self.rating = rating + self.tags = tags + self.visibility = visibility + self.is_oc = is_oc + self.process = None + self.file_size = os.path.getsize(file_path) if os.path.exists(file_path) else 0 + self.aborted = False + + def abort(self): + self.aborted = True + if self.process: + try: + self.process.kill() + except Exception: + pass + + def run(self): + mime_type, _ = mimetypes.guess_type(self.file_path) + if not mime_type: + ext = os.path.splitext(self.file_path)[1].lower() + if ext in ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']: + mime_type = f"image/{ext.lstrip('.')}" + if mime_type == "image/jpg": + mime_type = "image/jpeg" + elif ext in ['.mp4', '.webm', '.mkv', '.mov', '.avi']: + mime_type = f"video/{ext.lstrip('.')}" + else: + mime_type = "image/png" + + file_param = f"file=@{self.file_path}" + if mime_type: + file_param += f";type={mime_type}" + + cmd = [ + "curl", "-#", + "-F", file_param + ] + if self.api_key: + cmd.extend(["-H", f"X-Api-Key: {self.api_key}"]) + if self.rating: + cmd.extend(["-F", f"rating={self.rating}"]) + if self.tags: + cmd.extend(["-F", f"tags={self.tags}"]) + if self.visibility != "": + cmd.extend(["-F", f"visibility={self.visibility}"]) + if self.is_oc != "0": + cmd.extend(["-F", f"is_oc={self.is_oc}"]) + + cmd.append(self.api_url) + + try: + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1 + ) + + buffer = "" + while True: + char = self.process.stderr.read(1) + if not char and self.process.poll() is not None: + break + if char in ("\r", "\n"): + matches = re.findall(r'(\d+(?:\.\d+)?)\s*%', buffer) + if matches: + try: + pct = float(matches[-1]) + bytes_sent = int(self.file_size * (pct / 100.0)) + self.progress.emit(bytes_sent, self.file_size) + except Exception: + pass + buffer = "" + else: + buffer += char + + stdout_data, stderr_data = self.process.communicate() + exit_code = self.process.returncode + + if self.aborted: + self.finished.emit(False, "ABORTED") + elif exit_code == 0: + self.finished.emit(True, stdout_data) + else: + self.finished.emit(False, stderr_data or f"curl exited with code {exit_code}") + except Exception as e: + if self.aborted: + self.finished.emit(False, "ABORTED") + else: + self.finished.emit(False, str(e)) + # ========================================== # System Tray Application Manager # ========================================== @@ -554,6 +819,11 @@ class SystemTrayApp(QObject): clipboard_action.triggered.connect(self.on_upload_clipboard) self.menu.addAction(clipboard_action) + self.abort_action = QAction("Abort Upload", self.menu) + self.abort_action.setEnabled(False) + self.abort_action.triggered.connect(self.on_abort_upload) + self.menu.addAction(self.abort_action) + settings_action = QAction("Settings...", self.menu) settings_action.triggered.connect(self.on_open_settings) self.menu.addAction(settings_action) @@ -641,86 +911,85 @@ class SystemTrayApp(QObject): visibility = str(config.get("default_visibility", "0")) is_oc = "1" if config.get("default_is_oc", False) else "0" - self.tray.setIcon(create_digit_icon(0)) - self.tray.setToolTip("Uploading: 0%") - - request = QNetworkRequest(QUrl(api_url)) - if api_key: - request.setRawHeader(b"X-Api-Key", api_key.encode("utf-8")) - - multi_part = QHttpMultiPart(QHttpMultiPart.FormDataType) - - file_part = QHttpPart() filename = os.path.basename(file_path) - file_part.setHeader(QNetworkRequest.ContentDispositionHeader, f'form-data; name="file"; filename="{filename}"') - - mime_type, _ = mimetypes.guess_type(file_path) - if not mime_type: - mime_type = "image/png" if file_path.lower().endswith(".png") else "application/octet-stream" - file_part.setHeader(QNetworkRequest.ContentTypeHeader, mime_type) + self.upload_filename = filename + self.upload_start_time = time.time() - qfile = QFile(file_path) - if not qfile.open(QIODevice.ReadOnly): - self.tray.setIcon(self.default_icon) - self.show_message("Upload Error", f"Cannot open file: {file_path}", QSystemTrayIcon.Critical) - return + self.tray.setIcon(create_digit_icon(0)) + self.tray.setToolTip(f"Uploading {filename}...\n0% (0 B)") - file_part.setBodyDevice(qfile) - qfile.setParent(multi_part) - multi_part.append(file_part) + self.upload_thread = CurlUploadThread(api_url, api_key, file_path, rating, tags, visibility, is_oc) + self.upload_thread.progress.connect(self.on_upload_progress) + self.upload_thread.finished.connect(lambda ok, resp: self.on_upload_finished(ok, resp, file_path)) + self.upload_thread.start() - for name, val in [("rating", rating), ("tags", tags), ("visibility", visibility), ("is_oc", is_oc)]: - if val != "": - p = QHttpPart() - p.setHeader(QNetworkRequest.ContentDispositionHeader, f'form-data; name="{name}"') - p.setBody(str(val).encode("utf-8")) - multi_part.append(p) - - reply = self.network_manager.post(request, multi_part) - multi_part.setParent(reply) - reply.qfile = qfile - reply.multi_part = multi_part - self.current_reply = reply - - reply.uploadProgress.connect(self.on_upload_progress) - reply.finished.connect(lambda: self.on_upload_finished(reply, file_path)) + self.abort_action.setEnabled(True) def on_upload_progress(self, bytes_sent, bytes_total): if bytes_total > 0: pct = min(100, max(0, int((bytes_sent / bytes_total) * 100))) self.tray.setIcon(create_digit_icon(pct)) - self.tray.setToolTip(f"Uploading: {pct}%") + + elapsed = time.time() - getattr(self, "upload_start_time", time.time()) + speed = (bytes_sent / elapsed) if elapsed > 0.1 else 0 + + sent_str = format_size(bytes_sent) + total_str = format_size(bytes_total) + filename = getattr(self, "upload_filename", "file") + + if pct >= 99: + tooltip = f"Uploading: {filename}\n99% ({total_str})\nProcessing upload on server..." + else: + speed_str = format_speed(speed) + remaining_bytes = bytes_total - bytes_sent + eta_sec = int(remaining_bytes / speed) if speed > 0 else 0 + if eta_sec < 60: + eta_str = f"{eta_sec}s" + elif eta_sec < 3600: + eta_str = f"{eta_sec // 60}m {eta_sec % 60}s" + else: + eta_str = f"{eta_sec // 3600}h {(eta_sec % 3600) // 60}m" + + tooltip = f"Uploading: {filename}\n{pct}% ({sent_str} / {total_str})\nSpeed: {speed_str} | ETA: {eta_str}" + + self.tray.setToolTip(tooltip) - def on_upload_finished(self, reply, file_path): + def on_abort_upload(self): + if hasattr(self, "upload_thread") and self.upload_thread: + self.upload_thread.abort() + + def on_upload_finished(self, success, response_text, file_path): + self.abort_action.setEnabled(False) self.tray.setIcon(self.default_icon) self.tray.setToolTip("f0ckm Uploader (Click to capture & upload)") - try: - err_body = reply.readAll().data().decode("utf-8", errors="ignore") - - if reply.error() == QNetworkReply.NoError: - try: - res = json.loads(err_body) - if res.get("success") and res.get("url"): - item_url = res.get("url") - QApplication.clipboard().setText(item_url) - self.show_success_notification(file_path, item_url) - else: - msg = res.get("msg", "Upload failed") - self.show_message("Upload Failed", msg, QSystemTrayIcon.Critical) - except Exception as e: - self.show_message("Upload Error", f"Error parsing response: {e}", QSystemTrayIcon.Critical) + if not success: + if response_text == "ABORTED": + self.show_message("Upload Aborted", "The active upload was canceled.", QSystemTrayIcon.Information) else: - server_msg = err_body if err_body else reply.errorString() - self.show_message("Upload Failed", f"Server Response: {server_msg}", QSystemTrayIcon.Critical) + self.show_message("Upload Failed", response_text, QSystemTrayIcon.Critical) + return + + try: + res = json.loads(response_text) + if res.get("success") and res.get("url"): + item_url = res.get("url") + QApplication.clipboard().setText(item_url) + self.show_success_notification(file_path, item_url) + else: + msg = res.get("msg", "Upload failed") + self.show_message("Upload Failed", msg, QSystemTrayIcon.Critical) except Exception as e: - self.show_message("Upload Error", f"Unexpected error: {e}", QSystemTrayIcon.Critical) - finally: - reply.deleteLater() - self.current_reply = None + self.show_message("Upload Error", f"Error parsing response: {e}\nResponse: {response_text}", QSystemTrayIcon.Critical) def show_success_notification(self, file_path, item_url): config = get_env_config() + if config.get("play_success_sound", False) or config.get("success_audio_path"): + audio_path = config.get("success_audio_path", "") + volume = config.get("success_audio_volume", 15) + if audio_path: + play_success_sound(audio_path, volume=volume) + if not config.get("enable_notifications", True): return