Fbrowser/Fbrowser.py
2025-04-06 20:00:26 -05:00

737 lines
25 KiB
Python

# flake8: noqa: E501
"""File browser implementation with tools and audio playback.
this includes a midi player"""
import sys
import os
import warnings
from PyQt6.QtWidgets import (
QApplication,
QMainWindow,
QWidget,
QVBoxLayout,
QPushButton,
QTreeView,
QLabel,
QHBoxLayout,
QLineEdit,
QProgressBar,
QSplitter,
QMessageBox,
QSlider,
QMenu,
QFileDialog,
QDialog,
QToolTip,
QFrame,
)
from PyQt6.QtCore import Qt, QDir, QUrl, QTimer, QEvent, QPoint
from PyQt6.QtGui import QFileSystemModel, QCursor
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
from MidPlay import MidPlay
from timer_m import Timer_Ui
from ScanOrg101 import (
Organizer,
FileFilterProxyModel,
DirectoryFilterProxyModel,
ArchiveExtractor,
mutagen,
)
warnings.filterwarnings("ignore", category=DeprecationWarning)
class MetadataTooltip(QFrame):
"""Custom styled tooltip for displaying audio metadata"""
def __init__(self, parent=None):
super().__init__(
parent,
Qt.WindowType.ToolTip | Qt.WindowType.FramelessWindowHint,
)
self.setStyleSheet(
"""
MetadataTooltip {
background-color: #2a2a2a;
border: 1px solid #555555;
border-radius: 5px;
color: white;
padding: 5px;
}
QLabel {
color: white;
}
QLabel#title {
font-weight: bold;
font-size: 14px;
color: #e0e0e0;
}
"""
)
layout = QVBoxLayout(self)
self.title_label = QLabel("")
self.title_label.setObjectName("title")
self.artist_label = QLabel("")
self.album_label = QLabel("")
self.genre_label = QLabel("")
self.year_label = QLabel("")
self.size_label = QLabel("")
layout.addWidget(self.title_label)
layout.addWidget(self.artist_label)
layout.addWidget(self.album_label)
layout.addWidget(self.genre_label)
layout.addWidget(self.year_label)
layout.addWidget(self.size_label)
self.setLayout(layout)
self.hide()
def set_metadata(self, metadata):
"""Update the tooltip with metadata"""
self.title_label.setText(metadata["title"])
self.artist_label.setText(f"Artist: {metadata['artist']}")
self.album_label.setText(f"Album: {metadata['album']}")
self.genre_label.setText(f"Genre: {metadata['genre']}")
self.year_label.setText(f"Year: {metadata['year']}")
self.size_label.setText(f"Size: {metadata['size']}")
# Adjust size to fit content
self.adjustSize()
class Fbrowser(QMainWindow):
"""Fbrowser main class"""
def __init__(self):
super().__init__()
self.midplay = None
self.current_path = QDir.homePath()
self.organizer = Organizer()
self.player = QMediaPlayer()
self.audio_output = QAudioOutput()
self.player.setAudioOutput(self.audio_output)
self.audio_output.setVolume(0.5) # 50% volume
self.init_ui()
self.timer = Timer_Ui()
self.history = []
self.history_position = -1
self.hover_timer = QTimer()
self.hover_timer.setSingleShot(True)
self.hover_timer.timeout.connect(self.on_hover_timeout)
self.hover_position = None
self.hover_path = None
self.metadata_cache = {}
def init_ui(self):
"""initilize UI"""
self.setWindowTitle("File Browser")
central_widget = QWidget()
layout = QVBoxLayout(central_widget)
# Address bar
address_layout = QHBoxLayout()
self.address_bar = QLineEdit(self.current_path)
self.address_bar.returnPressed.connect(self.navigate_to_address)
address_layout.addWidget(self.address_bar)
# Navigation buttons
back_button = QPushButton("Back")
back_button.clicked.connect(self.go_back)
address_layout.addWidget(back_button)
forward_button = QPushButton("Forward")
forward_button.clicked.connect(self.go_forward_directory)
address_layout.addWidget(forward_button)
up_button = QPushButton("Up")
up_button.clicked.connect(self.go_up_directory)
address_layout.addWidget(up_button)
layout.addLayout(address_layout)
# Add a progress bar
self.progress_bar = QProgressBar()
layout.addWidget(self.progress_bar)
# File view with splitter
splitter = QSplitter()
# Directory tree
self.tree_model = QFileSystemModel()
self.tree_model.setRootPath(self.current_path)
self.directory_model = DirectoryFilterProxyModel()
self.directory_model.setSourceModel(self.tree_model)
self.file_tree = QTreeView()
self.file_tree.setModel(self.directory_model)
self.file_tree.setRootIndex(
self.directory_model.mapFromSource(
self.tree_model.index(self.current_path)
)
)
self.file_tree.setHeaderHidden(True)
self.file_tree.clicked.connect(self.change_directory)
splitter.addWidget(self.file_tree)
# File list
self.list_model = QFileSystemModel()
self.list_model.setRootPath(self.current_path)
self.file_filter_model = FileFilterProxyModel()
self.file_filter_model.setSourceModel(self.list_model)
self.folder_contents_view = QTreeView()
self.folder_contents_view.setModel(self.file_filter_model)
self.folder_contents_view.setRootIndex(
self.file_filter_model.mapFromSource(
self.list_model.index(self.current_path)
)
)
self.folder_contents_view.setEditTriggers(
QTreeView.EditTrigger.NoEditTriggers
)
self.folder_contents_view.doubleClicked.connect(
self.on_item_double_clicked
)
splitter.addWidget(self.folder_contents_view)
# Set up context menu for both views
self.folder_contents_view.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.folder_contents_view.customContextMenuRequested.connect(
self.on_folder_contents_context_menu
)
self.file_tree.setContextMenuPolicy(
Qt.ContextMenuPolicy.CustomContextMenu
)
self.file_tree.customContextMenuRequested.connect(
self.on_file_tree_context_menu
)
layout.addWidget(splitter)
# Current directory label
self.current_dir_label = QLabel(self.current_path)
layout.addWidget(self.current_dir_label)
# Media controls
media_controls = QHBoxLayout()
play_button = QPushButton("Play")
play_button.clicked.connect(self.player.play)
media_controls.addWidget(play_button)
pause_button = QPushButton("Pause")
pause_button.clicked.connect(self.player.pause)
media_controls.addWidget(pause_button)
stop_button = QPushButton("Stop")
stop_button.clicked.connect(self.player.stop)
media_controls.addWidget(stop_button)
volume_slider = QSlider(Qt.Orientation.Horizontal)
volume_slider.setRange(0, 100)
volume_slider.setValue(50)
volume_slider.valueChanged.connect(self.set_volume)
media_controls.addWidget(volume_slider)
layout.addLayout(media_controls)
# MidPlay button
open_midplay_button = QPushButton("Open MidPlay")
open_midplay_button.clicked.connect(self.open_midplay)
layout.addWidget(open_midplay_button)
# Timer Button
self.timer_button = QPushButton("Start Timer")
self.timer_button.clicked.connect(self.open_timer)
layout.addWidget(self.timer_button)
# Exit button
exit_button = QPushButton("Exit")
exit_button.clicked.connect(self.show_exit_popup)
layout.addWidget(exit_button)
self.setCentralWidget(central_widget)
self.resize(800, 600)
self.folder_contents_view.setMouseTracking(True)
self.folder_contents_view.viewport().setMouseTracking(True) # type: ignore
self.folder_contents_view.viewport().installEventFilter(self) # type: ignore
def set_volume(self, volume):
"""Set Volume"""
self.audio_output.setVolume(volume / 100.0)
def show_exit_popup(self):
"""Exit Prompt"""
reply = QMessageBox.question(
self,
"Exit",
"Are you sure you want to exit?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if reply == QMessageBox.StandardButton.Yes:
sys.exit()
def navigate_to_address(self):
"""Navigation Via Address Bar"""
new_path = self.address_bar.text()
if os.path.isdir(new_path):
self.add_to_history(self.current_path)
self.current_path = new_path
self.update_file_views(new_path)
else:
self.address_bar.setText(self.current_path)
def add_to_history(self, path):
"""History"""
# If we're not at the end of the history, truncate it
if self.history_position < len(self.history) - 1:
self.history = self.history[: self.history_position + 1]
self.history.append(path)
self.history_position = len(self.history) - 1
def change_directory(self, index):
"""Change dir to user dir"""
index = self.directory_model.mapToSource(index)
try:
file_path = self.tree_model.filePath(index)
if os.path.isdir(file_path):
self.add_to_history(self.current_path)
self.current_path = file_path
self.update_file_views(file_path)
except (OSError, IOError) as e:
print(f"Error Changing Dirs.: {e}")
def update_file_views(self, path):
"""Updates the File trees"""
self.address_bar.setText(path)
self.current_dir_label.setText(path)
# Update tree view
self.file_tree.setRootIndex(
self.directory_model.mapFromSource(self.tree_model.index(path))
)
# Update file list view
self.list_model.setRootPath(path)
self.folder_contents_view.setRootIndex(
self.file_filter_model.mapFromSource(self.list_model.index(path))
)
def on_item_double_clicked(self, index):
"""on double click do stuff"""
source_index = self.file_filter_model.mapToSource(index)
path = self.list_model.filePath(source_index)
if os.path.isdir(path):
self.add_to_history(self.current_path)
self.current_path = path
self.update_file_views(path)
else:
self.play_file(path)
def play_file(self, file_path):
"""Play a Audio file if midi load midi player"""
if file_path.lower().endswith((".mid", ".midi")):
if not self.midplay:
self.midplay = MidPlay()
self.midplay.add_to_playlist(file_path)
self.midplay.load_midi(file_path)
self.midplay.play_midi(file_path)
else:
# For audio files
self.player.setSource(QUrl.fromLocalFile(file_path))
self.player.play()
def go_back(self):
"""Go back a dir"""
if self.history_position > 0:
self.history_position -= 1
path = self.history[self.history_position]
self.current_path = path
self.update_file_views(path)
def go_forward_directory(self):
"""Go forward a dir"""
if self.history_position < len(self.history) - 1:
self.history_position += 1
path = self.history[self.history_position]
self.current_path = path
self.update_file_views(path)
def go_up_directory(self):
"""Similar to back"""
parent_dir = os.path.dirname(self.current_path)
if parent_dir != self.current_path:
self.add_to_history(self.current_path)
self.current_path = parent_dir
self.update_file_views(parent_dir)
def open_midplay(self):
"""Opens the midi player"""
if not self.midplay:
self.midplay = MidPlay()
self.midplay.showUI()
def open_timer(self):
"""Opens the Timer"""
self.timer.showUI()
def create_context_menu(self, position, view):
"""Create and display a context menu for files and folders"""
# Get the index at position
if view == self.folder_contents_view:
index = self.folder_contents_view.indexAt(position)
if not index.isValid():
return
# Map to source model to get the actual file path
source_index = self.file_filter_model.mapToSource(index)
path = self.list_model.filePath(source_index)
else: # Directory tree
index = self.file_tree.indexAt(position)
if not index.isValid():
return
source_index = self.directory_model.mapToSource(index)
path = self.tree_model.filePath(source_index)
# Create menu
menu = QMenu()
if os.path.isdir(path):
# Directory options
scan_action = menu.addAction("Scan Directory")
scan_action.triggered.connect(lambda: self.scan_directory(path)) # type: ignore
extract_metadata_action = menu.addAction("Extract Audio Metadata")
extract_metadata_action.triggered.connect( # type: ignore
lambda: self.extract_audio_metadata(path)
)
menu.addSeparator()
elif os.path.isfile(path):
# File options
if path.lower().endswith((".zip", ".rar", ".7z")):
# Archive options
extract_here_action = menu.addAction("Extract Here")
extract_here_action.triggered.connect( # type: ignore
lambda: self.extract_archive(path, self.current_path)
)
extract_to_action = menu.addAction("Extract To...")
extract_to_action.triggered.connect( # type: ignore
lambda: self.extract_archive_to(path)
)
if path.lower().endswith(
(".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi")
):
# Audio file options
play_action = menu.addAction("Play")
play_action.triggered.connect(lambda: self.play_file(path)) # type: ignore
extract_metadata_action = menu.addAction("Extract Metadata")
extract_metadata_action.triggered.connect( # type: ignore
lambda: self.extract_file_metadata(path)
)
# Add general options
menu.addSeparator()
refresh_action = menu.addAction("Refresh")
refresh_action.triggered.connect( # type: ignore
lambda: self.update_file_views(self.current_path)
)
# Show menu
menu.exec(view.viewport().mapToGlobal(position))
def on_folder_contents_context_menu(self, position):
"""Handle context menu in folder contents view"""
self.create_context_menu(position, self.folder_contents_view)
def on_file_tree_context_menu(self, position):
"""Handle context menu in file tree view"""
self.create_context_menu(position, self.file_tree)
def scan_directory(self, path):
"""Scan a directory using ScanOrg101"""
self.progress_bar.setValue(0)
# Connect organizer signals
self.organizer.on_progress_update = self.update_progress # type: ignore
self.organizer.on_scan_complete = lambda: self.show_scan_results(path) # type: ignore
# Start scan
self.organizer.start_scan(path)
def extract_archive(self, archive_path, extract_dir):
"""Extract an archive to specified directory"""
self.progress_bar.setValue(0)
# Create an extractor instance
self.extract_dir = extract_dir
self.archive_extractor = self.organizer.archive_extractor = (
ArchiveExtractor(archive_path, extract_dir)
)
# Connect signals
self.archive_extractor.extraction_progress.connect(
self.update_progress
)
self.archive_extractor.extraction_complete.connect(
self.extraction_complete
)
self.archive_extractor.extraction_error.connect(
self.show_error_message
)
# Start extraction
self.archive_extractor.start()
def extract_archive_to(self, archive_path):
"""Extract an archive to a user-selected directory"""
extract_dir = QFileDialog.getExistingDirectory(
self, "Select Extraction Directory", self.current_path
)
if extract_dir:
self.extract_archive(archive_path, extract_dir)
def extraction_complete(self, extracted_files):
"""Handle archive extraction completion"""
self.progress_bar.setValue(100)
self.update_file_views(self.extract_dir)
self.show_message(
f"Extraction complete: {len(extracted_files)} files extracted"
)
def extract_audio_metadata(self, directory_path):
"""Extract metadata from audio files in a directory"""
self.progress_bar.setValue(0)
# Scan directory first to get file list
self.organizer.on_progress_update = self.update_progress # type: ignore
self.organizer.on_scan_complete = ( # type: ignore
lambda: self.start_metadata_extraction(directory_path)
)
self.organizer.start_scan(directory_path)
def start_metadata_extraction(self, directory_path):
"""Start metadata extraction after scanning is complete"""
self.organizer.on_progress_update = self.update_progress # type: ignore
self.organizer.on_metadata_complete = ( # type: ignore
lambda: self.show_metadata_results()
)
self.organizer.extract_metadata()
def extract_file_metadata(self, file_path):
"""Extract metadata from a single audio file"""
self.progress_bar.setValue(0)
# Create a temporary file list with just this file
self.organizer.file_list = [file_path]
# Set up extraction
self.organizer.on_progress_update = self.update_progress # type: ignore
self.organizer.on_metadata_complete = ( # type: ignore
lambda: self.show_metadata_results(single_file=True)
)
# Start extraction
self.organizer.extract_metadata()
def update_progress(self, value):
"""Update the progress bar"""
self.progress_bar.setValue(value)
def show_scan_results(self, path):
"""Display scan results"""
self.progress_bar.setValue(100)
file_count = len(self.organizer.file_list)
dir_count = len(self.organizer.dir_list)
self.show_message(
f"Scan complete: {file_count} files, {dir_count} directories found"
)
def show_metadata_results(self, single_file=False):
"""Display metadata extraction results"""
self.progress_bar.setValue(100)
if single_file:
self.show_message("Metadata extraction complete")
# dialog to show metadata in a context menu
self.show_metadata_dialog()
else:
artists = len(self.organizer.artists)
albums = len(self.organizer.albums)
genres = len(self.organizer.genres)
self.show_message(
f"Metadata extraction complete: {artists} artists, {albums} albums, {genres} genres found"
)
def show_metadata_dialog(self):
"""Show a dialog with metadata information"""
# Create a dialog with metadata information
dialog = QDialog(self)
dialog.setWindowTitle("Metadata Information")
# layout = QVBoxLayout()
def show_message(self, message):
"""Show a message in a message box"""
QMessageBox.information(self, "Information", message)
def show_error_message(self, error):
"""Show an error message"""
QMessageBox.critical(self, "Error", error)
def eventFilter(self, obj, event):
"""Handle hover events for metadata tooltips"""
if obj == self.folder_contents_view.viewport():
if event.type() == QEvent.Type.HoverMove:
pos = event.position().toPoint()
# Get the item under the cursor
index = self.folder_contents_view.indexAt(pos)
if index.isValid():
source_index = self.file_filter_model.mapToSource(index)
path = self.list_model.filePath(source_index)
# Only proceed for audio files
if path.lower().endswith(
(
".mp3",
".wav",
".flac",
".m4a",
".wma",
".mid",
".midi",
)
):
# If we've moved to a new position or new file, restart the timer
if self.hover_path != path or (
self.hover_position
and (
abs(self.hover_position.x() - pos.x()) > 5
or abs(self.hover_position.y() - pos.y()) > 5
)
):
self.hover_timer.stop()
self.hover_position = pos
self.hover_path = path
self.hover_timer.start(1200)
return True
else:
# Not an audio file, stop any running timer
self.hover_timer.stop()
self.hover_path = None
else:
# No valid item under cursor
self.hover_timer.stop()
self.hover_path = None
elif event.type() == QEvent.Type.Leave:
# Mouse left the widget, stop the timer
self.hover_timer.stop()
self.hover_path = None
QToolTip.hideText()
return super().eventFilter(obj, event)
def on_hover_timeout(self):
"""Called when the hover timer expires, extract and show metadata"""
if not self.hover_path:
return
# Check if we already have metadata for this file in cache
if self.hover_path in self.metadata_cache:
self.show_metadata_tooltip(self.metadata_cache[self.hover_path])
else:
# Extract metadata in background
self.extract_hover_metadata(self.hover_path)
def extract_hover_metadata(self, file_path):
"""Extract metadata for the hovered file"""
try:
# Use mutagen directly for speed
audio = mutagen.File(file_path) # type: ignore
if not audio:
return
# Extract basic metadata
metadata = {
"file_path": file_path,
"filename": os.path.basename(file_path),
"size": f"{os.path.getsize(file_path) / (1024*1024):.2f} MB",
"artist": self._get_tag(audio, "artist", "Unknown Artist"),
"album": self._get_tag(audio, "album", "Unknown Album"),
"title": self._get_tag(
audio, "title", os.path.basename(file_path)
),
"genre": self._get_tag(audio, "genre", "Unknown Genre"),
"year": self._get_tag(audio, "date", "Unknown Year"),
}
# Cache the metadata
self.metadata_cache[file_path] = metadata
# Show the tooltip if we're still hovering over the same file
if self.hover_path == file_path:
self.show_metadata_tooltip(metadata)
except Exception as e:
print(f"Error extracting metadata for hover: {e}")
def _get_tag(self, audio, tag_name, default_value):
"""Helper method to safely extract tags from audio files"""
try:
if tag_name in audio:
value = audio[tag_name]
if isinstance(value, list) and len(value) > 0:
return str(value[0])
return str(value)
except Exception:
pass
return default_value
def show_metadata_tooltip(self, metadata):
"""Display metadata in a stylish custom tooltip"""
# Create or get the tooltip
if not hasattr(self, "metadata_tooltip"):
self.metadata_tooltip = MetadataTooltip()
# Update tooltip content
self.metadata_tooltip.set_metadata(metadata)
# Position near but not under cursor
cursor_pos = QCursor.pos()
tooltip_pos = QPoint(cursor_pos.x() + 15, cursor_pos.y() + 15)
# Show the tooltip
self.metadata_tooltip.move(tooltip_pos)
self.metadata_tooltip.show()
# Auto-hide after 8 seconds
QTimer.singleShot(8000, self.metadata_tooltip.hide)
if __name__ == "__main__":
app = QApplication(sys.argv)
ex = Fbrowser()
ex.show()
sys.exit(app.exec())