Files
bifrost/src/widgets/soundpack_manager_dialog.py
Storm Dragon f2c4ad1bd6 Add comprehensive soundpack manager with security-first design
- **Soundpack Manager**: Full-featured package discovery, download, and installation system
  - Repository management with HTTPS enforcement and validation
  - Directory listing support (auto-discovers .zip files) + soundpacknames.txt fallback
  - Secure download with size limits, timeout protection, and path sanitization
  - Zip bomb protection: file count, individual size, and total extraction limits
  - Audio file validation using magic numbers (not just extensions)
  - Accessible UI with keyboard navigation and screen reader optimization

- **Auto-refresh system**: Smart timeline updates respecting user activity
  - 300-second default interval + 10-second idle buffer
  - Keyboard activity tracking prevents interrupting active users
  - True new content detection using post IDs instead of counts
  - Preserves cursor position during background refreshes

- **Enhanced notifications**: Fixed spam issues and improved targeting
  - Timeline switching now silent (no notifications for actively viewed content)
  - Initial app load notifications disabled for 2 seconds
  - Generic "New content in timeline" messages instead of misleading post counts
  - Separate handling for auto-refresh vs manual refresh scenarios

- **Load more posts improvements**: Better positioning and user experience
  - New posts load below cursor position instead of above
  - Cursor automatically focuses on first new post for natural reading flow
  - Fixed widget hierarchy issues preventing activation

- **Accessibility enhancements**: Workarounds for screen reader quirks
  - Added list headers to fix Orca single-item list reading issues
  - Improved accessible names and descriptions throughout
  - Non-selectable header items with dynamic counts
  - Better error messages and status updates

- **Settings integration**: Corrected soundpack configuration management
  - Fixed inconsistent config keys (now uses [audio] sound_pack consistently)
  - Added soundpack manager to File menu (Ctrl+Shift+P)
  - Repository persistence and validation

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 15:18:50 -04:00

674 lines
26 KiB
Python

"""
Soundpack Manager Dialog - UI for managing soundpack repositories and installations
"""
from PySide6.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QTabWidget, QWidget,
QListWidget, QListWidgetItem, QPushButton, QLabel,
QLineEdit, QTextEdit, QGroupBox, QMessageBox,
QProgressDialog, QDialogButtonBox, QFormLayout,
QCheckBox, QSplitter
)
from PySide6.QtCore import Qt, QThread, Signal, QTimer
from PySide6.QtGui import QFont
from typing import List, Optional
from config.settings import SettingsManager
from audio.soundpack_manager import SoundpackManager, SoundpackRepository, SoundpackInfo
class SoundpackOperationThread(QThread):
"""Thread for soundpack operations to prevent UI blocking"""
operation_complete = Signal(bool, str) # success, message
progress_update = Signal(str) # status message
def __init__(self, operation, *args):
super().__init__()
self.operation = operation
self.args = args
def run(self):
try:
if self.operation == 'discover':
self.discover_soundpacks()
elif self.operation == 'install':
self.install_soundpack()
elif self.operation == 'validate_repo':
self.validate_repository()
except Exception as e:
self.operation_complete.emit(False, f"Operation failed: {str(e)}")
def discover_soundpacks(self):
manager = self.args[0]
self.progress_update.emit("Discovering soundpacks...")
soundpacks = manager.discover_soundpacks()
self.operation_complete.emit(True, f"Found {len(soundpacks)} soundpacks")
def install_soundpack(self):
manager, soundpack = self.args
self.progress_update.emit(f"Installing {soundpack.name}...")
success, message = manager.install_soundpack(soundpack)
self.operation_complete.emit(success, message)
def validate_repository(self):
manager, url = self.args
self.progress_update.emit("Validating repository...")
success, message = manager.validate_repository(url)
self.operation_complete.emit(success, message)
class SoundpackManagerDialog(QDialog):
"""Dialog for managing soundpacks and repositories"""
def __init__(self, settings: SettingsManager, parent=None):
super().__init__(parent)
self.settings = settings
self.manager = SoundpackManager(settings)
self.soundpacks = []
self.current_thread = None
self.setWindowTitle("Soundpack Manager")
self.setMinimumSize(800, 600)
self.setModal(True)
self.setup_ui()
self.load_data()
def setup_ui(self):
"""Initialize the user interface"""
layout = QVBoxLayout(self)
# Create tab widget
self.tab_widget = QTabWidget()
layout.addWidget(self.tab_widget)
# Soundpacks tab
self.setup_soundpacks_tab()
# Repositories tab
self.setup_repositories_tab()
# Installed tab
self.setup_installed_tab()
# Button box
button_box = QDialogButtonBox(QDialogButtonBox.Close)
button_box.rejected.connect(self.close)
layout.addWidget(button_box)
def setup_soundpacks_tab(self):
"""Setup the available soundpacks tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Header
header_label = QLabel("Available Soundpacks")
header_label.setFont(QFont("", 12, QFont.Bold))
header_label.setAccessibleName("Available Soundpacks")
layout.addWidget(header_label)
# Refresh button
refresh_layout = QHBoxLayout()
self.refresh_button = QPushButton("&Refresh Soundpack List")
self.refresh_button.setAccessibleName("Refresh soundpack list from repositories")
self.refresh_button.clicked.connect(self.refresh_soundpacks)
refresh_layout.addWidget(self.refresh_button)
refresh_layout.addStretch()
layout.addLayout(refresh_layout)
# Splitter for list and details
splitter = QSplitter(Qt.Horizontal)
layout.addWidget(splitter)
# Soundpack list
self.soundpack_list = QListWidget()
self.soundpack_list.setAccessibleName("Available soundpacks")
self.soundpack_list.itemSelectionChanged.connect(self.on_soundpack_selected)
splitter.addWidget(self.soundpack_list)
# Details panel
details_widget = QWidget()
details_layout = QVBoxLayout(details_widget)
self.details_label = QLabel("Select a soundpack to view details")
self.details_label.setAccessibleName("Soundpack details")
self.details_label.setWordWrap(True)
details_layout.addWidget(self.details_label)
self.description_text = QTextEdit()
self.description_text.setAccessibleName("Soundpack description")
self.description_text.setReadOnly(True)
self.description_text.setMaximumHeight(150)
details_layout.addWidget(self.description_text)
# Action buttons
action_layout = QHBoxLayout()
self.install_button = QPushButton("&Install")
self.install_button.setAccessibleName("Install selected soundpack")
self.install_button.clicked.connect(self.install_selected)
self.install_button.setEnabled(False)
action_layout.addWidget(self.install_button)
self.switch_button = QPushButton("&Switch To")
self.switch_button.setAccessibleName("Switch to selected soundpack")
self.switch_button.clicked.connect(self.switch_to_selected)
self.switch_button.setEnabled(False)
action_layout.addWidget(self.switch_button)
action_layout.addStretch()
details_layout.addLayout(action_layout)
details_layout.addStretch()
splitter.addWidget(details_widget)
# Set splitter proportions
splitter.setSizes([400, 400])
self.tab_widget.addTab(tab, "&Available")
def setup_repositories_tab(self):
"""Setup the repositories management tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Header
header_label = QLabel("Soundpack Repositories")
header_label.setFont(QFont("", 12, QFont.Bold))
header_label.setAccessibleName("Soundpack repositories")
layout.addWidget(header_label)
# Repository list
self.repo_list = QListWidget()
self.repo_list.setAccessibleName("Repository list")
self.repo_list.itemSelectionChanged.connect(self.on_repository_selected)
layout.addWidget(self.repo_list)
# Repository management buttons
repo_button_layout = QHBoxLayout()
self.add_repo_button = QPushButton("&Add Repository")
self.add_repo_button.setAccessibleName("Add new repository")
self.add_repo_button.clicked.connect(self.add_repository)
repo_button_layout.addWidget(self.add_repo_button)
self.remove_repo_button = QPushButton("&Remove Repository")
self.remove_repo_button.setAccessibleName("Remove selected repository")
self.remove_repo_button.clicked.connect(self.remove_repository)
self.remove_repo_button.setEnabled(False)
repo_button_layout.addWidget(self.remove_repo_button)
repo_button_layout.addStretch()
layout.addLayout(repo_button_layout)
self.tab_widget.addTab(tab, "&Repositories")
def setup_installed_tab(self):
"""Setup the installed soundpacks tab"""
tab = QWidget()
layout = QVBoxLayout(tab)
# Header
header_label = QLabel("Installed Soundpacks")
header_label.setFont(QFont("", 12, QFont.Bold))
header_label.setAccessibleName("Installed soundpacks")
layout.addWidget(header_label)
# Current soundpack info
current_group = QGroupBox("Current Soundpack")
current_layout = QVBoxLayout(current_group)
self.current_pack_label = QLabel("Loading...")
self.current_pack_label.setAccessibleName("Current soundpack")
current_layout.addWidget(self.current_pack_label)
layout.addWidget(current_group)
# Installed soundpacks list
self.installed_list = QListWidget()
self.installed_list.setAccessibleName("Installed soundpacks")
self.installed_list.itemSelectionChanged.connect(self.on_installed_selected)
layout.addWidget(self.installed_list)
# Management buttons
installed_button_layout = QHBoxLayout()
self.switch_installed_button = QPushButton("&Switch To")
self.switch_installed_button.setAccessibleName("Switch to selected installed soundpack")
self.switch_installed_button.clicked.connect(self.switch_to_installed)
self.switch_installed_button.setEnabled(False)
installed_button_layout.addWidget(self.switch_installed_button)
self.uninstall_button = QPushButton("&Uninstall")
self.uninstall_button.setAccessibleName("Uninstall selected soundpack")
self.uninstall_button.clicked.connect(self.uninstall_selected)
self.uninstall_button.setEnabled(False)
installed_button_layout.addWidget(self.uninstall_button)
installed_button_layout.addStretch()
layout.addLayout(installed_button_layout)
self.tab_widget.addTab(tab, "&Installed")
def load_data(self):
"""Load initial data"""
self.load_repositories()
self.load_installed_soundpacks()
self.update_current_soundpack()
self.refresh_soundpacks()
def load_repositories(self):
"""Load repository list"""
self.repo_list.clear()
repositories = self.manager.get_repositories()
# Add header item to help Orca read single-item lists
header_item = QListWidgetItem(f"Repositories ({len(repositories)} configured):")
header_item.setFlags(header_item.flags() & ~Qt.ItemIsSelectable) # Make it non-selectable
header_item.setData(Qt.AccessibleDescriptionRole, "Repository list header")
self.repo_list.addItem(header_item)
for repo in repositories:
# Format for screen readers - use single line with clear separators
item_text = f"{repo.description} - {repo.url}"
if not repo.enabled:
item_text += " (Disabled)"
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, repo)
# Set accessible description with more detail
item.setData(Qt.AccessibleDescriptionRole, f"Repository: {repo.description}, URL: {repo.url}")
self.repo_list.addItem(item)
def load_installed_soundpacks(self):
"""Load installed soundpacks list"""
self.installed_list.clear()
installed = self.manager._get_installed_soundpacks()
current_pack = self.settings.get('audio', 'sound_pack', 'default')
for pack_name in installed:
item_text = pack_name
if pack_name == current_pack:
item_text += " (Current)"
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, pack_name)
self.installed_list.addItem(item)
def update_current_soundpack(self):
"""Update current soundpack display"""
# Check both possible keys - audio.sound_pack is the real one being used
current_pack = self.settings.get('audio', 'sound_pack', None)
if not current_pack:
current_pack = self.settings.get('audio', 'sound_pack', 'default')
print(f"Debug: Found soundpack setting = '{current_pack}'")
self.current_pack_label.setText(f"Current soundpack: {current_pack}")
def refresh_soundpacks(self):
"""Refresh available soundpacks from repositories"""
if self.current_thread and self.current_thread.isRunning():
return
self.refresh_button.setEnabled(False)
self.refresh_button.setText("Refreshing...")
# Start discovery in background thread
self.current_thread = SoundpackOperationThread('discover', self.manager)
self.current_thread.operation_complete.connect(self.on_discover_complete)
self.current_thread.start()
def on_discover_complete(self, success: bool, message: str):
"""Handle soundpack discovery completion"""
self.refresh_button.setEnabled(True)
self.refresh_button.setText("&Refresh Soundpack List")
if success:
self.soundpacks = self.manager.discover_soundpacks()
self.update_soundpack_list()
else:
QMessageBox.warning(self, "Discovery Failed", f"Failed to discover soundpacks: {message}")
def update_soundpack_list(self):
"""Update the soundpack list display"""
self.soundpack_list.clear()
# Add header item to help Orca read single-item lists
header_item = QListWidgetItem(f"Available Soundpacks ({len(self.soundpacks)} found):")
header_item.setFlags(header_item.flags() & ~Qt.ItemIsSelectable) # Make it non-selectable
header_item.setData(Qt.AccessibleDescriptionRole, "Available soundpacks list header")
self.soundpack_list.addItem(header_item)
for soundpack in self.soundpacks:
item_text = soundpack.name
if soundpack.installed:
item_text += " (Installed)"
item_text += f" - {soundpack.description}"
item = QListWidgetItem(item_text)
item.setData(Qt.UserRole, soundpack)
self.soundpack_list.addItem(item)
def on_soundpack_selected(self):
"""Handle soundpack selection"""
current = self.soundpack_list.currentItem()
if not current:
self.details_label.setText("Select a soundpack to view details")
self.description_text.clear()
self.install_button.setEnabled(False)
self.switch_button.setEnabled(False)
return
soundpack = current.data(Qt.UserRole)
# Skip header items (they don't have soundpack data)
if not soundpack:
self.details_label.setText("Select a soundpack to view details")
self.description_text.clear()
self.install_button.setEnabled(False)
self.switch_button.setEnabled(False)
return
# Update details
status = "Installed" if soundpack.installed else "Not installed"
self.details_label.setText(f"Name: {soundpack.name}\nStatus: {status}\nRepository: {soundpack.repository_url}")
self.description_text.setText(soundpack.description)
# Update buttons
self.install_button.setEnabled(not soundpack.installed)
self.switch_button.setEnabled(soundpack.installed)
def install_selected(self):
"""Install the selected soundpack"""
current = self.soundpack_list.currentItem()
if not current:
return
soundpack = current.data(Qt.UserRole)
# Confirm installation
reply = QMessageBox.question(
self,
"Install Soundpack",
f"Install soundpack '{soundpack.name}'?\n\nThis will download and extract the soundpack to your soundpacks directory.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply != QMessageBox.Yes:
return
# Start installation
self.install_button.setEnabled(False)
self.install_button.setText("Installing...")
self.current_thread = SoundpackOperationThread('install', self.manager, soundpack)
self.current_thread.operation_complete.connect(self.on_install_complete)
self.current_thread.progress_update.connect(self.on_progress_update)
self.current_thread.start()
# Show progress dialog
self.progress_dialog = QProgressDialog("Installing soundpack...", "Cancel", 0, 0, self)
self.progress_dialog.setWindowModality(Qt.WindowModal)
self.progress_dialog.show()
def on_install_complete(self, success: bool, message: str):
"""Handle installation completion"""
self.install_button.setEnabled(True)
self.install_button.setText("&Install")
if hasattr(self, 'progress_dialog'):
self.progress_dialog.close()
if success:
QMessageBox.information(self, "Installation Successful", message)
# Ask if user wants to switch to the new soundpack
reply = QMessageBox.question(
self,
"Switch Soundpack",
"Soundpack installed successfully!\n\nWould you like to switch to this soundpack now?",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.Yes
)
if reply == QMessageBox.Yes:
current = self.soundpack_list.currentItem()
if current:
soundpack = current.data(Qt.UserRole)
self.manager.switch_soundpack(soundpack.name)
self.update_current_soundpack()
# Refresh lists
self.refresh_soundpacks()
self.load_installed_soundpacks()
else:
QMessageBox.critical(self, "Installation Failed", f"Failed to install soundpack: {message}")
def on_progress_update(self, message: str):
"""Handle progress updates"""
if hasattr(self, 'progress_dialog'):
self.progress_dialog.setLabelText(message)
def switch_to_selected(self):
"""Switch to the selected soundpack"""
current = self.soundpack_list.currentItem()
if not current:
return
soundpack = current.data(Qt.UserRole)
success, message = self.manager.switch_soundpack(soundpack.name)
if success:
QMessageBox.information(self, "Soundpack Switched", message)
self.update_current_soundpack()
self.load_installed_soundpacks()
else:
QMessageBox.critical(self, "Switch Failed", f"Failed to switch soundpack: {message}")
def add_repository(self):
"""Add a new repository"""
dialog = AddRepositoryDialog(self.manager, self)
if dialog.exec() == QDialog.Accepted:
self.load_repositories()
def remove_repository(self):
"""Remove selected repository"""
current = self.repo_list.currentItem()
if not current:
return
repo = current.data(Qt.UserRole)
if not repo: # Skip header items
return
reply = QMessageBox.question(
self,
"Remove Repository",
f"Remove repository '{repo.description}'?\n\nThis will not affect installed soundpacks.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.manager.remove_repository(repo.url)
self.load_repositories()
def on_repository_selected(self):
"""Handle repository selection"""
current = self.repo_list.currentItem()
# Enable remove button only if it's not the header item and has repo data
has_repo_data = current and current.data(Qt.UserRole) is not None
self.remove_repo_button.setEnabled(has_repo_data)
def on_installed_selected(self):
"""Handle installed soundpack selection"""
current = self.installed_list.currentItem()
if current:
pack_name = current.data(Qt.UserRole)
current_pack = self.settings.get('audio', 'sound_pack', 'default')
self.switch_installed_button.setEnabled(pack_name != current_pack)
self.uninstall_button.setEnabled(pack_name != 'default') # Can't uninstall default
else:
self.switch_installed_button.setEnabled(False)
self.uninstall_button.setEnabled(False)
def switch_to_installed(self):
"""Switch to selected installed soundpack"""
current = self.installed_list.currentItem()
if not current:
return
pack_name = current.data(Qt.UserRole)
success, message = self.manager.switch_soundpack(pack_name)
if success:
QMessageBox.information(self, "Soundpack Switched", message)
self.update_current_soundpack()
self.load_installed_soundpacks()
else:
QMessageBox.critical(self, "Switch Failed", f"Failed to switch soundpack: {message}")
def uninstall_selected(self):
"""Uninstall selected soundpack"""
current = self.installed_list.currentItem()
if not current:
return
pack_name = current.data(Qt.UserRole)
reply = QMessageBox.question(
self,
"Uninstall Soundpack",
f"Uninstall soundpack '{pack_name}'?\n\nThis will permanently remove the soundpack files.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
success, message = self.manager.uninstall_soundpack(pack_name)
if success:
QMessageBox.information(self, "Uninstall Successful", message)
self.load_installed_soundpacks()
self.update_current_soundpack()
self.refresh_soundpacks() # Update available list
else:
QMessageBox.critical(self, "Uninstall Failed", f"Failed to uninstall soundpack: {message}")
class AddRepositoryDialog(QDialog):
"""Dialog for adding a new repository"""
def __init__(self, manager: SoundpackManager, parent=None):
super().__init__(parent)
self.manager = manager
self.setWindowTitle("Add Repository")
self.setModal(True)
self.resize(500, 200)
self.setup_ui()
def setup_ui(self):
"""Setup the UI"""
layout = QVBoxLayout(self)
# Form layout
form_layout = QFormLayout()
self.url_edit = QLineEdit()
self.url_edit.setAccessibleName("Repository URL")
self.url_edit.setPlaceholderText("https://example.com/soundpacks")
self.url_edit.textChanged.connect(self.validate_form)
form_layout.addRow("&URL:", self.url_edit)
self.description_edit = QLineEdit()
self.description_edit.setAccessibleName("Repository description")
self.description_edit.setPlaceholderText("Description of this repository")
self.description_edit.textChanged.connect(self.validate_form)
form_layout.addRow("&Description:", self.description_edit)
layout.addLayout(form_layout)
# Validation button
self.validate_button = QPushButton("&Validate Repository")
self.validate_button.setAccessibleName("Validate repository URL")
self.validate_button.clicked.connect(self.validate_repository)
self.validate_button.setEnabled(False)
layout.addWidget(self.validate_button)
# Status label
self.status_label = QLabel("")
self.status_label.setAccessibleName("Validation status")
self.status_label.setWordWrap(True)
layout.addWidget(self.status_label)
# Button box
self.button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.button_box.accepted.connect(self.accept_repository)
self.button_box.rejected.connect(self.reject)
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
layout.addWidget(self.button_box)
def validate_form(self):
"""Validate form inputs"""
url = self.url_edit.text().strip()
description = self.description_edit.text().strip()
has_content = bool(url and description)
self.validate_button.setEnabled(has_content)
if has_content:
# Basic URL validation
valid_format, message = self.manager.validate_url(url)
if not valid_format:
self.status_label.setText(f"URL Error: {message}")
self.status_label.setStyleSheet("color: red;")
else:
self.status_label.setText("Click 'Validate Repository' to check if this URL contains soundpacks")
self.status_label.setStyleSheet("color: blue;")
else:
self.status_label.clear()
def validate_repository(self):
"""Validate the repository URL"""
url = self.url_edit.text().strip()
self.validate_button.setEnabled(False)
self.validate_button.setText("Validating...")
self.status_label.setText("Checking repository...")
self.status_label.setStyleSheet("color: blue;")
# Start validation in background
self.validation_thread = SoundpackOperationThread('validate_repo', self.manager, url)
self.validation_thread.operation_complete.connect(self.on_validation_complete)
self.validation_thread.start()
def on_validation_complete(self, success: bool, message: str):
"""Handle validation completion"""
self.validate_button.setEnabled(True)
self.validate_button.setText("&Validate Repository")
if success:
self.status_label.setText(f"{message}")
self.status_label.setStyleSheet("color: green;")
self.button_box.button(QDialogButtonBox.Ok).setEnabled(True)
else:
self.status_label.setText(f"{message}")
self.status_label.setStyleSheet("color: red;")
self.button_box.button(QDialogButtonBox.Ok).setEnabled(False)
def accept_repository(self):
"""Add the repository"""
url = self.url_edit.text().strip()
description = self.description_edit.text().strip()
success, message = self.manager.add_repository(url, description)
if success:
self.accept()
else:
QMessageBox.critical(self, "Add Repository Failed", f"Failed to add repository: {message}")