QoL
This commit is contained in:
383
gui.py
383
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,66 +911,67 @@ 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}"')
|
||||
self.upload_filename = filename
|
||||
self.upload_start_time = time.time()
|
||||
|
||||
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.tray.setIcon(create_digit_icon(0))
|
||||
self.tray.setToolTip(f"Uploading {filename}...\n0% (0 B)")
|
||||
|
||||
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.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()
|
||||
|
||||
file_part.setBodyDevice(qfile)
|
||||
qfile.setParent(multi_part)
|
||||
multi_part.append(file_part)
|
||||
|
||||
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}%")
|
||||
|
||||
def on_upload_finished(self, reply, file_path):
|
||||
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_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 not success:
|
||||
if response_text == "ABORTED":
|
||||
self.show_message("Upload Aborted", "The active upload was canceled.", QSystemTrayIcon.Information)
|
||||
else:
|
||||
self.show_message("Upload Failed", response_text, QSystemTrayIcon.Critical)
|
||||
return
|
||||
|
||||
if reply.error() == QNetworkReply.NoError:
|
||||
try:
|
||||
res = json.loads(err_body)
|
||||
res = json.loads(response_text)
|
||||
if res.get("success") and res.get("url"):
|
||||
item_url = res.get("url")
|
||||
QApplication.clipboard().setText(item_url)
|
||||
@@ -709,18 +980,16 @@ class SystemTrayApp(QObject):
|
||||
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)
|
||||
else:
|
||||
server_msg = err_body if err_body else reply.errorString()
|
||||
self.show_message("Upload Failed", f"Server Response: {server_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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user