e2c4dfea4e
Expanded the clickable area for the menu button in HistoryDelegate to 60x50px and adjusted text width accordingly. This improves usability and addresses issues with the left side of the button not responding to clicks.
577 lines
20 KiB
Python
577 lines
20 KiB
Python
"""
|
|
History Dialog for YTSage application.
|
|
Displays download history with thumbnails using a virtualized list for performance.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import json
|
|
from datetime import datetime
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING, Optional, List, Any, Dict
|
|
|
|
import requests
|
|
from PIL import Image
|
|
from PySide6.QtCore import (
|
|
Qt, QSize, Signal, QThread, QTimer, QAbstractListModel,
|
|
QModelIndex, QRect, QPoint, QEvent
|
|
)
|
|
from PySide6.QtGui import (
|
|
QPixmap, QIcon, QPainter, QColor, QFont, QBrush, QPen,
|
|
QMouseEvent, QDesktopServices, QAction, QCursor, QPainterPath
|
|
)
|
|
from PySide6.QtWidgets import (
|
|
QDialog,
|
|
QVBoxLayout,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QPushButton,
|
|
QListView,
|
|
QWidget,
|
|
QMenu,
|
|
QMessageBox,
|
|
QStyledItemDelegate,
|
|
QStyle,
|
|
QApplication
|
|
)
|
|
|
|
from src.utils.ytsage_history_manager import HistoryManager
|
|
from src.utils.ytsage_constants import APP_THUMBNAILS_DIR, SUBPROCESS_CREATIONFLAGS
|
|
from src.utils.ytsage_localization import _
|
|
from src.utils.ytsage_logger import logger
|
|
|
|
if TYPE_CHECKING:
|
|
from src.gui.ytsage_gui_main import YTSageApp
|
|
|
|
|
|
class HistoryLoaderThread(QThread):
|
|
"""Thread to load history and pre-fetch thumbnails."""
|
|
|
|
entries_loaded = Signal(list)
|
|
thumbnail_loaded = Signal(str, bytes) # entry_id, image_bytes
|
|
|
|
def run(self):
|
|
try:
|
|
# Load entries from DB
|
|
entries = HistoryManager.get_all_entries()
|
|
self.entries_loaded.emit(entries)
|
|
|
|
# Background thumbnail loader
|
|
for entry in entries:
|
|
if self.isInterruptionRequested():
|
|
break
|
|
|
|
thumbnail_url = entry.get("thumbnail_url")
|
|
entry_id = entry.get("id", "")
|
|
|
|
if not thumbnail_url or not entry_id:
|
|
continue
|
|
|
|
thumbnail_filename = f"{entry_id}.jpg"
|
|
thumbnail_path = APP_THUMBNAILS_DIR / thumbnail_filename
|
|
|
|
if not thumbnail_path.exists():
|
|
try:
|
|
response = requests.get(thumbnail_url, timeout=5)
|
|
if response.status_code == 200:
|
|
APP_THUMBNAILS_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Optimize image before saving
|
|
img_io = BytesIO(response.content)
|
|
image = Image.open(img_io)
|
|
|
|
# Save to disk
|
|
image.save(thumbnail_path, "JPEG", quality=90, optimize=True)
|
|
|
|
# Emit bytes for memory cache
|
|
self.thumbnail_loaded.emit(entry_id, response.content)
|
|
except Exception as e:
|
|
logger.debug(f"Error caching thumbnail: {e}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error loading history: {e}")
|
|
self.entries_loaded.emit([])
|
|
|
|
|
|
class HistoryModel(QAbstractListModel):
|
|
"""List Model for History Entries."""
|
|
|
|
EntryRole = Qt.ItemDataRole.UserRole + 1
|
|
IdRole = Qt.ItemDataRole.UserRole + 2
|
|
ThumbnailRole = Qt.ItemDataRole.UserRole + 3
|
|
|
|
def __init__(self, entries=None, parent=None):
|
|
super().__init__(parent)
|
|
self._entries = entries or []
|
|
self.thumbnail_cache = {} # Map entry_id -> QPixmap
|
|
|
|
def rowCount(self, parent=QModelIndex()):
|
|
return len(self._entries)
|
|
|
|
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
|
|
if not index.isValid() or not (0 <= index.row() < len(self._entries)):
|
|
return None
|
|
|
|
entry = self._entries[index.row()]
|
|
entry_id = entry.get("id")
|
|
|
|
if role == self.EntryRole:
|
|
return entry
|
|
|
|
elif role == self.IdRole:
|
|
return entry_id
|
|
|
|
elif role == self.ThumbnailRole:
|
|
return self.thumbnail_cache.get(entry_id)
|
|
|
|
elif role == Qt.ItemDataRole.DisplayRole:
|
|
return entry.get("title", "")
|
|
|
|
return None
|
|
|
|
def update_entries(self, entries):
|
|
self.beginResetModel()
|
|
self._entries = entries
|
|
self.endResetModel()
|
|
|
|
def remove_item(self, row):
|
|
if 0 <= row < len(self._entries):
|
|
self.beginRemoveRows(QModelIndex(), row, row)
|
|
del self._entries[row]
|
|
self.endRemoveRows()
|
|
|
|
def update_thumbnail(self, entry_id, pixmap):
|
|
"""Update cache and notify view."""
|
|
self.thumbnail_cache[entry_id] = pixmap
|
|
# Find index for this ID
|
|
for i, entry in enumerate(self._entries):
|
|
if entry.get("id") == entry_id:
|
|
idx = self.index(i)
|
|
self.dataChanged.emit(idx, idx, [self.ThumbnailRole])
|
|
break
|
|
|
|
|
|
class HistoryDelegate(QStyledItemDelegate):
|
|
"""Delegate to render history cards similar to the widgets."""
|
|
|
|
menu_clicked = Signal(QModelIndex, QPoint) # Signal for menu click
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self.padding = 10
|
|
self.thumb_width = 240
|
|
self.thumb_height = 135
|
|
# Increased card height to accommodate spacing
|
|
self.card_height = 175
|
|
# Define margins for spacing between cards
|
|
self.h_margin = 10
|
|
self.v_margin = 8
|
|
|
|
def sizeHint(self, option, index):
|
|
return QSize(option.rect.width(), self.card_height)
|
|
|
|
def paint(self, painter, option, index):
|
|
entry = index.data(HistoryModel.EntryRole)
|
|
if not entry:
|
|
return
|
|
|
|
painter.save()
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
|
|
rect = option.rect
|
|
# Apply margins for spacing
|
|
card_rect = rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
|
|
|
|
is_hover = option.state & QStyle.StateFlag.State_MouseOver
|
|
bg_color = QColor("#252830") if is_hover else QColor("#1d1e22")
|
|
border_color = QColor("#3a3d46") if is_hover else QColor("#2a2d36")
|
|
|
|
# Draw Card
|
|
path = QPainterPath()
|
|
path.addRoundedRect(card_rect, 8, 8)
|
|
|
|
painter.fillPath(path, QBrush(bg_color))
|
|
painter.setPen(QPen(border_color, 1))
|
|
painter.drawPath(path)
|
|
|
|
# Draw Thumbnail
|
|
thumb_rect = QRect(
|
|
card_rect.left() + 10,
|
|
card_rect.top() + 10,
|
|
self.thumb_width,
|
|
self.thumb_height
|
|
)
|
|
|
|
pixmap = index.data(HistoryModel.ThumbnailRole)
|
|
if pixmap and not pixmap.isNull():
|
|
scaled = pixmap.scaled(
|
|
thumb_rect.size(),
|
|
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
|
|
Qt.TransformationMode.SmoothTransformation
|
|
)
|
|
# Clip to rect
|
|
painter.setClipRect(thumb_rect)
|
|
painter.drawPixmap(thumb_rect.topLeft(), scaled)
|
|
painter.setClipping(False)
|
|
else:
|
|
painter.fillRect(thumb_rect, QColor("#15181b"))
|
|
painter.setPen(QPen(QColor("#666666")))
|
|
icon_char = "🎵" if entry.get("is_audio_only") else "📹"
|
|
painter.setFont(QFont("Segoe UI Emoji", 24))
|
|
painter.drawText(thumb_rect, Qt.AlignmentFlag.AlignCenter, icon_char)
|
|
|
|
# Draw Border around thumb
|
|
painter.setPen(QPen(QColor("#3d3d3d"), 2))
|
|
painter.drawRect(thumb_rect)
|
|
|
|
# Text Area
|
|
text_x = thumb_rect.right() + 12
|
|
text_width = card_rect.right() - text_x - 85 # Leave even more space for the larger 60px button
|
|
|
|
# Title
|
|
title_rect = QRect(text_x, thumb_rect.top(), text_width, 50)
|
|
painter.setPen(QColor("#ffffff"))
|
|
font_title = QFont()
|
|
font_title.setBold(True)
|
|
font_title.setPixelSize(14)
|
|
painter.setFont(font_title)
|
|
|
|
# Use simple alignment flags
|
|
painter.drawText(title_rect, Qt.AlignmentFlag.AlignLeft | Qt.TextFlag.TextWordWrap, entry.get("title", ""))
|
|
|
|
current_y = title_rect.bottom() + 5
|
|
|
|
# Channel
|
|
channel = entry.get("channel")
|
|
if channel:
|
|
painter.setPen(QColor("#cccccc"))
|
|
font_meta = QFont()
|
|
font_meta.setPixelSize(12)
|
|
painter.setFont(font_meta)
|
|
painter.drawText(text_x, current_y, f"{_('video_info.channel')}: {channel}")
|
|
current_y += 18
|
|
|
|
# Date
|
|
date_str = entry.get("download_date", "")[:16].replace('T', ' ')
|
|
if date_str:
|
|
painter.setPen(QColor("#aaaaaa"))
|
|
painter.setFont(QFont("Arial", 11))
|
|
painter.drawText(text_x, current_y, f"{_('history.downloaded_on', date=date_str)}")
|
|
current_y += 25
|
|
|
|
# Badge
|
|
is_audio = entry.get("is_audio_only", False)
|
|
badge_text = _("history.audio_download") if is_audio else _("history.video_download")
|
|
badge_color = QColor("#0066cc") if is_audio else QColor("#c90000")
|
|
|
|
badge_rect = QRect(text_x, current_y, 80, 20)
|
|
painter.setBrush(QBrush(badge_color))
|
|
painter.setPen(Qt.PenStyle.NoPen)
|
|
painter.drawRoundedRect(badge_rect, 3, 3)
|
|
|
|
painter.setPen(QColor("white"))
|
|
font_badge = QFont()
|
|
font_badge.setBold(True)
|
|
font_badge.setPixelSize(10)
|
|
painter.setFont(font_badge)
|
|
painter.drawText(badge_rect, Qt.AlignmentFlag.AlignCenter, badge_text)
|
|
|
|
# File Size
|
|
file_size = entry.get("file_size", 0)
|
|
if file_size > 0:
|
|
size_str = self.format_file_size(file_size)
|
|
painter.setPen(QColor("#aaaaaa"))
|
|
painter.setFont(QFont("Arial", 11))
|
|
painter.drawText(badge_rect.right() + 10, current_y + 14, size_str)
|
|
|
|
# Menu Button
|
|
menu_rect = self.get_menu_rect(card_rect)
|
|
|
|
# Check hover on menu button specifically
|
|
mouse_pos = QCursor.pos()
|
|
if option.widget:
|
|
mouse_pos = option.widget.mapFromGlobal(mouse_pos)
|
|
|
|
if menu_rect.contains(mouse_pos):
|
|
painter.setPen(QColor("#c90000"))
|
|
else:
|
|
painter.setPen(QColor("#ffffff"))
|
|
|
|
painter.setFont(QFont("Arial", 18, QFont.Weight.Bold))
|
|
painter.drawText(menu_rect, Qt.AlignmentFlag.AlignCenter, "⋮")
|
|
|
|
painter.restore()
|
|
|
|
def get_menu_rect(self, card_rect):
|
|
# Widen the clickable area significantly (60x50) and shift slightly left
|
|
# to fix "left side not working" issues.
|
|
w = 60
|
|
h = 50
|
|
margin_right = 5
|
|
margin_top = 5
|
|
return QRect(
|
|
card_rect.right() - w - margin_right,
|
|
card_rect.top() + margin_top,
|
|
w,
|
|
h
|
|
)
|
|
|
|
def editorEvent(self, event, model, option, index):
|
|
"""Handle mouse clicks."""
|
|
if event.type() == QEvent.Type.MouseButtonRelease:
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
# Use same margins as paint to ensure hit consistency
|
|
card_rect = option.rect.adjusted(self.h_margin, self.v_margin, -self.h_margin, -self.v_margin)
|
|
menu_rect = self.get_menu_rect(card_rect)
|
|
|
|
if menu_rect.contains(event.pos()):
|
|
self.menu_clicked.emit(index, event.globalPos())
|
|
return True
|
|
|
|
return super().editorEvent(event, model, option, index)
|
|
|
|
def format_file_size(self, size_bytes: int) -> str:
|
|
for unit in ['B', 'KB', 'MB', 'GB']:
|
|
if size_bytes < 1024.0:
|
|
return f"{size_bytes:.1f} {unit}"
|
|
size_bytes /= 1024.0
|
|
return f"{size_bytes:.1f} TB"
|
|
|
|
|
|
class HistoryDialog(QDialog):
|
|
"""Dialog to display and manage download history."""
|
|
|
|
redownload_requested = Signal(dict)
|
|
|
|
def __init__(self, parent: Optional["YTSageApp"] = None):
|
|
super().__init__(parent)
|
|
self.parent_app = parent
|
|
|
|
self.setup_ui()
|
|
self.show_loading_state()
|
|
|
|
QTimer.singleShot(100, self.start_loading_history)
|
|
|
|
def setup_ui(self):
|
|
self.setWindowTitle(_("history.title"))
|
|
self.resize(850, 600)
|
|
self.setWindowFlags(Qt.WindowType.Dialog | Qt.WindowType.WindowCloseButtonHint)
|
|
self.setStyleSheet("""
|
|
QDialog { background-color: #15181b; }
|
|
QLabel { color: #ffffff; }
|
|
""")
|
|
|
|
layout = QVBoxLayout(self)
|
|
|
|
# --- Header ---
|
|
header = QHBoxLayout()
|
|
title = QLabel(_("history.title"))
|
|
title.setStyleSheet("font-size: 18px; font-weight: bold;")
|
|
header.addWidget(title)
|
|
header.addStretch()
|
|
|
|
self.clear_btn = QPushButton(_("history.clear_all"))
|
|
self.clear_btn.setStyleSheet("""
|
|
QPushButton {
|
|
background-color: #c90000; color: white; padding: 6px 12px;
|
|
border: none; border-radius: 4px; font-weight: bold;
|
|
}
|
|
QPushButton:hover { background-color: #a50000; }
|
|
QPushButton:disabled { background-color: #555555; color: #aaaaaa; }
|
|
""")
|
|
self.clear_btn.clicked.connect(self.clear_all_history)
|
|
header.addWidget(self.clear_btn)
|
|
layout.addLayout(header)
|
|
|
|
# --- Search ---
|
|
self.search_input = QLineEdit()
|
|
self.search_input.setPlaceholderText(_("history.search_placeholder"))
|
|
self.search_input.setStyleSheet("""
|
|
QLineEdit {
|
|
padding: 8px; border: 2px solid #2a2d36; border-radius: 4px;
|
|
background-color: #1b2021; color: white;
|
|
}
|
|
""")
|
|
self.search_input.textChanged.connect(self.filter_history)
|
|
layout.addWidget(self.search_input)
|
|
|
|
# --- List View ---
|
|
self.list_view = QListView()
|
|
self.list_view.setStyleSheet("""
|
|
QListView {
|
|
background-color: transparent;
|
|
border: none;
|
|
outline: none;
|
|
}
|
|
QListView::item {
|
|
border: none;
|
|
background: transparent;
|
|
}
|
|
""")
|
|
self.list_view.setVerticalScrollMode(QListView.ScrollMode.ScrollPerPixel)
|
|
self.list_view.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
|
|
self.list_view.setUniformItemSizes(True)
|
|
self.list_view.setSelectionMode(QListView.SelectionMode.NoSelection)
|
|
self.list_view.setMouseTracking(True)
|
|
self.list_view.setResizeMode(QListView.ResizeMode.Adjust)
|
|
|
|
self.model = HistoryModel([], self)
|
|
self.list_view.setModel(self.model)
|
|
|
|
self.delegate = HistoryDelegate(self.list_view)
|
|
self.delegate.menu_clicked.connect(self.show_context_menu)
|
|
self.list_view.setItemDelegate(self.delegate)
|
|
|
|
self.list_view.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
|
|
self.list_view.customContextMenuRequested.connect(self.on_context_menu_requested)
|
|
|
|
layout.addWidget(self.list_view)
|
|
|
|
# --- Status ---
|
|
self.status_label = QLabel()
|
|
self.status_label.setStyleSheet("color: #aaaaaa; font-size: 12px;")
|
|
layout.addWidget(self.status_label)
|
|
|
|
def show_loading_state(self):
|
|
self.status_label.setText("Loading history...")
|
|
self.clear_btn.setEnabled(False)
|
|
|
|
def start_loading_history(self):
|
|
self.loader_thread = HistoryLoaderThread()
|
|
self.loader_thread.entries_loaded.connect(self.on_entries_loaded)
|
|
self.loader_thread.thumbnail_loaded.connect(self.on_thumbnail_loaded)
|
|
self.loader_thread.start()
|
|
|
|
def on_entries_loaded(self, entries):
|
|
self.model.update_entries(entries)
|
|
|
|
count = len(entries)
|
|
if count == 0:
|
|
self.status_label.setText(_("history.no_history"))
|
|
self.clear_btn.setEnabled(False)
|
|
else:
|
|
self.status_label.setText(_("history.entries_count", count=count))
|
|
self.clear_btn.setEnabled(True)
|
|
|
|
self.load_cached_thumbnails(entries)
|
|
|
|
def load_cached_thumbnails(self, entries):
|
|
for entry in entries:
|
|
eid = entry.get("id")
|
|
if not eid: continue
|
|
|
|
p = APP_THUMBNAILS_DIR / f"{eid}.jpg"
|
|
if p.exists():
|
|
pix = QPixmap(str(p))
|
|
if not pix.isNull():
|
|
self.model.thumbnail_cache[eid] = pix
|
|
|
|
def on_thumbnail_loaded(self, entry_id, data_bytes):
|
|
pixmap = QPixmap()
|
|
pixmap.loadFromData(data_bytes)
|
|
if not pixmap.isNull():
|
|
self.model.update_thumbnail(entry_id, pixmap)
|
|
|
|
def on_context_menu_requested(self, pos):
|
|
index = self.list_view.indexAt(pos)
|
|
if index.isValid():
|
|
global_pos = self.list_view.mapToGlobal(pos)
|
|
self.show_context_menu(index, global_pos)
|
|
|
|
def show_context_menu(self, index, global_pos):
|
|
entry = index.data(HistoryModel.EntryRole)
|
|
if not entry: return
|
|
|
|
menu = QMenu(self)
|
|
menu.setStyleSheet("""
|
|
QMenu {
|
|
background-color: #2a2d36; border: 1px solid #3a3d46; color: white;
|
|
}
|
|
QMenu::item {
|
|
padding: 8px 20px;
|
|
}
|
|
QMenu::item:selected {
|
|
background-color: #c90000;
|
|
}
|
|
""")
|
|
|
|
act_open = menu.addAction("📁 " + _("history.open_location"))
|
|
act_redownload = menu.addAction("⬇️ " + _("history.redownload"))
|
|
menu.addSeparator()
|
|
act_remove = menu.addAction("🗑️ " + _("history.remove"))
|
|
|
|
action = menu.exec(global_pos)
|
|
|
|
if action == act_open:
|
|
self.open_file_location(entry)
|
|
elif action == act_redownload:
|
|
self.redownload_entry(entry)
|
|
elif action == act_remove:
|
|
self.remove_entry(index)
|
|
|
|
def open_file_location(self, entry):
|
|
path_str = entry.get("file_path", "")
|
|
if not path_str: return
|
|
|
|
path = Path(path_str)
|
|
if not path.exists():
|
|
QMessageBox.warning(self, "Error", f"File not found: {path}")
|
|
return
|
|
|
|
try:
|
|
if os.name == "nt":
|
|
subprocess.run(['explorer', '/select,', str(path)], creationflags=SUBPROCESS_CREATIONFLAGS)
|
|
elif subprocess.sys.platform == "darwin":
|
|
subprocess.run(['open', '-R', str(path)])
|
|
else:
|
|
folder_path = path.parent
|
|
subprocess.run(['xdg-open', str(folder_path)])
|
|
except Exception as e:
|
|
logger.error(f"Failed to open file: {e}")
|
|
|
|
def redownload_entry(self, entry):
|
|
reply = QMessageBox.question(
|
|
self,
|
|
_("history.redownload_confirm_title"),
|
|
_("history.redownload_confirm_message", title=entry.get("title")),
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
)
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
self.redownload_requested.emit(entry)
|
|
self.accept()
|
|
|
|
def remove_entry(self, index):
|
|
entry = index.data(HistoryModel.EntryRole)
|
|
reply = QMessageBox.question(
|
|
self,
|
|
_("history.remove_confirm_title"),
|
|
_("history.remove_confirm_message", title=entry.get("title")),
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
)
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
if HistoryManager.remove_entry(entry.get("id")):
|
|
self.model.remove_item(index.row())
|
|
self.status_label.setText(
|
|
_("history.entries_count", count=self.model.rowCount())
|
|
)
|
|
|
|
def clear_all_history(self):
|
|
reply = QMessageBox.question(
|
|
self,
|
|
_("history.clear_confirm_title"),
|
|
_("history.clear_confirm_message"),
|
|
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No
|
|
)
|
|
if reply == QMessageBox.StandardButton.Yes:
|
|
HistoryManager.clear_history()
|
|
self.model.update_entries([])
|
|
self.clear_btn.setEnabled(False)
|
|
self.status_label.setText(_("history.no_history"))
|
|
|
|
def filter_history(self, query):
|
|
results = HistoryManager.search_entries(query)
|
|
self.model.update_entries(results)
|
|
self.load_cached_thumbnails(results)
|