Initial commit
This commit is contained in:
commit
3b909bb756
113
.gitignore
vendored
Normal file
113
.gitignore
vendored
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
# Python ignores
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.hypothesis/
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
.pytype/
|
||||||
|
.mypy_cache/
|
||||||
|
|
||||||
|
# C# ignores
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Dd]ebug/
|
||||||
|
[Rr]elease/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]uild/
|
||||||
|
msbuild.log
|
||||||
|
msbuild.err
|
||||||
|
msbuild.wrn
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
*.userprefs
|
||||||
|
.vs/
|
||||||
|
Generated\ Files/
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
.localhistory/
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
*.nupkg
|
||||||
|
**/packages/*
|
||||||
|
!**/packages/build/
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# IDE specific files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Specific file exclusion
|
||||||
|
maintest.py
|
||||||
|
|
||||||
|
# SoundFonts can be large - optional
|
||||||
|
*.sf2
|
||||||
|
|
||||||
|
# Generated MIDI files - optional
|
||||||
|
*.mid
|
||||||
|
*.midi
|
||||||
736
Fbrowser.py
Normal file
736
Fbrowser.py
Normal file
@ -0,0 +1,736 @@
|
|||||||
|
# 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())
|
||||||
524
MidPlay.py
Normal file
524
MidPlay.py
Normal file
@ -0,0 +1,524 @@
|
|||||||
|
# Path: MidPlay.py
|
||||||
|
# Name: MidPlay
|
||||||
|
# Author: Stanton
|
||||||
|
# Description: This file contains the class MidPlay
|
||||||
|
# which is used to play MIDI files.
|
||||||
|
# It includes FluidSynth initialization, MIDI playback controls.
|
||||||
|
# playlist management, volume control, and MIDI file validation.
|
||||||
|
# The script handles MIDI file loading, threading for playback,
|
||||||
|
# and soundfont configuration.
|
||||||
|
|
||||||
|
# flake8: noqa: E501
|
||||||
|
# Imports
|
||||||
|
import fluidsynth # typed: ignore
|
||||||
|
import time
|
||||||
|
import mido # typed: ignore
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
from queue import Queue
|
||||||
|
from threading import Lock
|
||||||
|
import threading
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QFileDialog,
|
||||||
|
QSlider,
|
||||||
|
QListWidget,
|
||||||
|
QHBoxLayout,
|
||||||
|
QProgressBar,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import Qt, QTimer
|
||||||
|
|
||||||
|
|
||||||
|
def find_default_soundfont():
|
||||||
|
"""Find the default soundfont based on the operating system"""
|
||||||
|
system = platform.system()
|
||||||
|
|
||||||
|
# List of possible soundfont paths by OS
|
||||||
|
if system == "Windows":
|
||||||
|
possible_paths = [
|
||||||
|
# Common FluidSynth installation locations
|
||||||
|
os.path.expandvars(
|
||||||
|
r"%PROGRAMFILES%\FluidSynth\share\soundfonts\FluidR3_GM.sf2"
|
||||||
|
),
|
||||||
|
# Common user locations
|
||||||
|
os.path.join(
|
||||||
|
os.path.expanduser("~"),
|
||||||
|
"Documents",
|
||||||
|
"SoundFonts",
|
||||||
|
"FluidR3_GM.sf2",
|
||||||
|
),
|
||||||
|
# Application directory
|
||||||
|
os.path.join(
|
||||||
|
os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
"SoundFonts",
|
||||||
|
"FluidR3_GM.sf2",
|
||||||
|
),
|
||||||
|
# Development fallback
|
||||||
|
"C:\\Users\\East\\Documents\\Dev\\Tests\\MidPlay\\SoundFonts\\tales_of_phantasia.sf2",
|
||||||
|
]
|
||||||
|
|
||||||
|
elif system == "Darwin": # macOS
|
||||||
|
possible_paths = [
|
||||||
|
"/Library/Audio/Sounds/SF2/FluidR3_GM.sf2",
|
||||||
|
"/usr/local/share/soundfonts/FluidR3_GM.sf2",
|
||||||
|
"/usr/share/soundfonts/FluidR3_GM.sf2",
|
||||||
|
os.path.expanduser("~/Library/Audio/Sounds/SF2/FluidR3_GM.sf2"),
|
||||||
|
]
|
||||||
|
else: # Linux and others
|
||||||
|
possible_paths = [
|
||||||
|
"/usr/share/soundfonts/FluidR3_GM.sf2",
|
||||||
|
"/usr/share/sounds/sf2/FluidR3_GM.sf2", # Common on many distros
|
||||||
|
"/usr/local/share/soundfonts/FluidR3_GM.sf2",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Check if files exist and return the first one found
|
||||||
|
for path in possible_paths:
|
||||||
|
if os.path.isfile(path):
|
||||||
|
return path
|
||||||
|
|
||||||
|
# If no soundfont found, look for a soundfont in the current directory
|
||||||
|
# or in a "SoundFonts" subdirectory
|
||||||
|
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
soundfont_dir = os.path.join(current_dir, "SoundFonts")
|
||||||
|
|
||||||
|
if os.path.isdir(soundfont_dir):
|
||||||
|
for file in os.listdir(soundfont_dir):
|
||||||
|
if file.lower().endswith(".sf2"):
|
||||||
|
return os.path.join(soundfont_dir, file)
|
||||||
|
|
||||||
|
# Last check - any .sf2 file in the current directory
|
||||||
|
for file in os.listdir(current_dir):
|
||||||
|
if file.lower().endswith(".sf2"):
|
||||||
|
return os.path.join(current_dir, file)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --- Fluidsynth Initialization --- #
|
||||||
|
try:
|
||||||
|
default_soundfont = find_default_soundfont()
|
||||||
|
if default_soundfont:
|
||||||
|
print(f"Using soundfont: {default_soundfont}")
|
||||||
|
else:
|
||||||
|
print("No soundfont found. Please select one manually.")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error finding soundfont: {e}")
|
||||||
|
default_soundfont = None
|
||||||
|
|
||||||
|
current_sf = "No soundfont loaded"
|
||||||
|
fs = fluidsynth.Synth()
|
||||||
|
sfid = None
|
||||||
|
|
||||||
|
if default_soundfont:
|
||||||
|
try:
|
||||||
|
sfid = fs.sfload(default_soundfont)
|
||||||
|
fs.program_select(0, sfid, 0, 0)
|
||||||
|
current_sf = default_soundfont
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading soundfont: {e}")
|
||||||
|
|
||||||
|
settings = fluidsynth.new_fluid_settings() # typed: ignore
|
||||||
|
|
||||||
|
# Use appropriate audio driver based on OS
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
fs.start(driver="dsound")
|
||||||
|
elif platform.system() == "Darwin": # macOS
|
||||||
|
fs.start(driver="coreaudio")
|
||||||
|
else: # Linux
|
||||||
|
fs.start(driver="alsa")
|
||||||
|
|
||||||
|
|
||||||
|
class MidPlay:
|
||||||
|
"""The Heart of Midi Playback"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.queue = Queue()
|
||||||
|
self.thread = threading.Thread(target=self.midi_threading)
|
||||||
|
self.thread.daemon = True
|
||||||
|
self.thread.start()
|
||||||
|
self.playback_lock = Lock()
|
||||||
|
self.playlist = []
|
||||||
|
self.current_midi = None
|
||||||
|
self.playing = False
|
||||||
|
self.current_index = 0
|
||||||
|
self.midi_start_time = 0
|
||||||
|
self.current_volume = 100
|
||||||
|
self.midi_length = 0
|
||||||
|
self.ui = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def is_valid_midi(filepath):
|
||||||
|
try:
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
header = f.read(4)
|
||||||
|
return header == b"MThd"
|
||||||
|
except IOError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def showUI(self):
|
||||||
|
if not self.ui:
|
||||||
|
self.ui = MidPlayGUI(self)
|
||||||
|
self.ui.show()
|
||||||
|
|
||||||
|
def set_volume(self, value):
|
||||||
|
self.current_volume = value
|
||||||
|
for channel in range(16):
|
||||||
|
fs.cc(channel, 7, int(value * 127))
|
||||||
|
|
||||||
|
def load_midi(self, filepath: str) -> None:
|
||||||
|
if not self.is_valid_midi(filepath):
|
||||||
|
print(f"Debug: Invalid MIDI file: {filepath}")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.current_midi = mido.MidiFile(filepath)
|
||||||
|
self.midi_length = self.current_midi.length
|
||||||
|
if filepath in self.playlist:
|
||||||
|
self.current_index = self.playlist.index(filepath)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error loading MIDI file {filepath}: {e}")
|
||||||
|
|
||||||
|
def add_to_playlist(self, filepath: str) -> None:
|
||||||
|
if filepath not in self.playlist:
|
||||||
|
self.playlist.append(filepath)
|
||||||
|
|
||||||
|
def stop_midi(self):
|
||||||
|
fs.all_notes_off(-1) # -1 means all channels in MIDI CC
|
||||||
|
fs.all_sounds_off(-1)
|
||||||
|
fs.system_reset()
|
||||||
|
self.playing = False
|
||||||
|
self.current_midi = None
|
||||||
|
|
||||||
|
def _play_midi(self, filepath):
|
||||||
|
try:
|
||||||
|
self.stop_midi()
|
||||||
|
mid = mido.MidiFile(filepath)
|
||||||
|
self.playing = True
|
||||||
|
self.midi_start_time = time.time()
|
||||||
|
tempo = mido.bpm2tempo(120)
|
||||||
|
|
||||||
|
for msg in mido.merge_tracks(mid.tracks):
|
||||||
|
if not self.playing:
|
||||||
|
break
|
||||||
|
if msg.is_meta:
|
||||||
|
if msg.type == "set_tempo":
|
||||||
|
# print(f"Set Tempo: {mido.tempo2bpm(msg.tempo)} BPM")
|
||||||
|
tempo = msg.tempo
|
||||||
|
|
||||||
|
else:
|
||||||
|
time.sleep(
|
||||||
|
mido.tick2second(msg.time, mid.ticks_per_beat, tempo)
|
||||||
|
)
|
||||||
|
if msg.type == "note_on":
|
||||||
|
fs.noteon(0, msg.note, msg.velocity)
|
||||||
|
|
||||||
|
elif msg.type == "note_off":
|
||||||
|
fs.noteoff(0, msg.note)
|
||||||
|
|
||||||
|
if self.playing:
|
||||||
|
self.next_song()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
self.playback_lock.release()
|
||||||
|
self.playing = False
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
if self.playing:
|
||||||
|
fs.all_notes_off(-1)
|
||||||
|
self.playing = False
|
||||||
|
|
||||||
|
def previous_song(self) -> None:
|
||||||
|
if not self.playlist:
|
||||||
|
return
|
||||||
|
self.stop_midi()
|
||||||
|
self.current_index = (self.current_index - 1) % len(self.playlist)
|
||||||
|
prev_file = self.playlist[self.current_index]
|
||||||
|
self.load_midi(prev_file)
|
||||||
|
self.play_midi(prev_file)
|
||||||
|
|
||||||
|
def next_song(self) -> None:
|
||||||
|
if not self.playlist:
|
||||||
|
return
|
||||||
|
self.stop_midi()
|
||||||
|
self.current_index = (self.current_index + 1) % len(self.playlist)
|
||||||
|
filepath = self.playlist[self.current_index]
|
||||||
|
self.load_midi(filepath)
|
||||||
|
self.set_volume(self.current_volume)
|
||||||
|
self.play_midi(filepath)
|
||||||
|
|
||||||
|
def play_midi(self, filepath):
|
||||||
|
if self.playback_lock.acquire(blocking=False):
|
||||||
|
try:
|
||||||
|
self.stop_midi()
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=self._play_midi, args=(filepath,)
|
||||||
|
)
|
||||||
|
thread.daemon = True
|
||||||
|
thread.start()
|
||||||
|
except IOError:
|
||||||
|
self.playback_lock.release()
|
||||||
|
|
||||||
|
def midi_threading(self):
|
||||||
|
while True:
|
||||||
|
file_path = self.queue.get()
|
||||||
|
try:
|
||||||
|
self._play_midi(file_path)
|
||||||
|
self.playing = True
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error playing MIDI file {file_path}: {e}")
|
||||||
|
self.queue.task_done()
|
||||||
|
self.next_song()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stop_midi()
|
||||||
|
self.playing = False
|
||||||
|
self.current_midi = None
|
||||||
|
fs.all_notes_off(-1)
|
||||||
|
fs.all_sounds_off(-1)
|
||||||
|
fs.program_unset(0)
|
||||||
|
|
||||||
|
|
||||||
|
# MidPlayGUI class
|
||||||
|
class MidPlayGUI(QWidget):
|
||||||
|
def __init__(self, player=None):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("MidPlay")
|
||||||
|
self.status_label = QLabel(f"SoundFont: {current_sf}")
|
||||||
|
|
||||||
|
if player:
|
||||||
|
self.player = player
|
||||||
|
else:
|
||||||
|
self.player = MidPlay()
|
||||||
|
|
||||||
|
self.midi_length = 0
|
||||||
|
self.playing = False
|
||||||
|
self.current_index = 0
|
||||||
|
self.init_ui()
|
||||||
|
self.timer = QTimer()
|
||||||
|
self.timer.timeout.connect(self.update_progress)
|
||||||
|
self.timer.start(100)
|
||||||
|
|
||||||
|
# If no soundfont is loaded, prompt the user immediately
|
||||||
|
if current_sf == "No soundfont loaded":
|
||||||
|
QTimer.singleShot(500, self.change_soundfont)
|
||||||
|
|
||||||
|
self.timer = QTimer()
|
||||||
|
self.timer.timeout.connect(self.update_progress)
|
||||||
|
self.timer.start(100)
|
||||||
|
|
||||||
|
def set_volume(self, value):
|
||||||
|
volume = value / 100.0
|
||||||
|
for channel in range(16):
|
||||||
|
fs.cc(channel, 7, int(volume * 127))
|
||||||
|
self.player.current_volume = value
|
||||||
|
|
||||||
|
def update_progress(self):
|
||||||
|
if self.playing and self.player.midi_length > 0:
|
||||||
|
elapsed_time = time.time() - self.player.midi_start_time
|
||||||
|
if elapsed_time >= self.player.midi_length:
|
||||||
|
self.handle_song_end()
|
||||||
|
else:
|
||||||
|
progress = min(
|
||||||
|
(elapsed_time / self.player.midi_length) * 100, 100
|
||||||
|
)
|
||||||
|
self.progress_bar.setValue(int(progress))
|
||||||
|
|
||||||
|
if self.player.playing:
|
||||||
|
progress = min(
|
||||||
|
(elapsed_time / self.player.midi_length) * 100, 100
|
||||||
|
)
|
||||||
|
self.progress_bar.setValue(int(progress))
|
||||||
|
if progress >= 100:
|
||||||
|
self.player.next_song()
|
||||||
|
|
||||||
|
def change_soundfont(self):
|
||||||
|
new_sf_path, _ = QFileDialog.getOpenFileName(
|
||||||
|
None, "Select SoundFont", filter="SoundFont (*.sf2)"
|
||||||
|
)
|
||||||
|
if new_sf_path: # If a file was chosen
|
||||||
|
global sfid, current_sf
|
||||||
|
try:
|
||||||
|
# Only try to unload if sfid is not None
|
||||||
|
if sfid is not None:
|
||||||
|
fs.sfunload(sfid)
|
||||||
|
|
||||||
|
# Load the new soundfont
|
||||||
|
sfid = fs.sfload(new_sf_path)
|
||||||
|
fs.program_select(0, sfid, 0, 0)
|
||||||
|
current_sf = new_sf_path
|
||||||
|
self.status_label.setText(f"SoundFont: {current_sf}")
|
||||||
|
except Exception as e:
|
||||||
|
self.status_label.setText(f"Error loading SoundFont: {e}")
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
# Widgets
|
||||||
|
self.progress_bar = QProgressBar()
|
||||||
|
self.volume_slider = QSlider(Qt.Orientation.Horizontal)
|
||||||
|
self.volume_slider.setMinimum(0)
|
||||||
|
self.volume_slider.setMaximum(100)
|
||||||
|
self.volume_slider.setValue(100)
|
||||||
|
self.volume_slider.valueChanged.connect(self.set_volume)
|
||||||
|
self.current_midi_label = QLabel("Current MIDI: None")
|
||||||
|
self.playlist_widget = QListWidget()
|
||||||
|
self.playlist_widget.itemDoubleClicked.connect(self.play_selected_song)
|
||||||
|
|
||||||
|
# Buttons
|
||||||
|
play_button = QPushButton("Play")
|
||||||
|
play_button.clicked.connect(self.handle_play)
|
||||||
|
pause_button = QPushButton("Pause")
|
||||||
|
pause_button.clicked.connect(self.handle_pause)
|
||||||
|
stop_button = QPushButton("Stop")
|
||||||
|
stop_button.clicked.connect(self.handle_stop)
|
||||||
|
next_button = QPushButton("Next")
|
||||||
|
next_button.clicked.connect(self.handle_next)
|
||||||
|
back_button = QPushButton("Back")
|
||||||
|
back_button.clicked.connect(self.handle_previous)
|
||||||
|
add_button = QPushButton("Add to Playlist")
|
||||||
|
add_button.clicked.connect(self.load_midi_file)
|
||||||
|
add_folder_button = QPushButton("Add Folder to Playlist")
|
||||||
|
add_folder_button.clicked.connect(self.load_folder)
|
||||||
|
clear_button = QPushButton("Clear Playlist")
|
||||||
|
clear_button.clicked.connect(self.clear_playlist)
|
||||||
|
change_sf_button = QPushButton("Change SoundFont")
|
||||||
|
change_sf_button.clicked.connect(self.change_soundfont)
|
||||||
|
|
||||||
|
# Window Layout
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
layout.addWidget(self.current_midi_label)
|
||||||
|
layout.addWidget(self.playlist_widget)
|
||||||
|
|
||||||
|
# Progress and volume layout
|
||||||
|
progress_volume_layout = QHBoxLayout()
|
||||||
|
progress_volume_layout.addWidget(self.progress_bar)
|
||||||
|
progress_volume_layout.addWidget(self.volume_slider)
|
||||||
|
layout.addLayout(progress_volume_layout)
|
||||||
|
|
||||||
|
# Playback controls
|
||||||
|
layout.addWidget(play_button)
|
||||||
|
layout.addWidget(pause_button)
|
||||||
|
layout.addWidget(stop_button)
|
||||||
|
layout.addWidget(next_button)
|
||||||
|
layout.addWidget(back_button)
|
||||||
|
|
||||||
|
# Playlist management
|
||||||
|
layout.addWidget(add_button)
|
||||||
|
layout.addWidget(add_folder_button)
|
||||||
|
layout.addWidget(clear_button)
|
||||||
|
layout.addWidget(change_sf_button)
|
||||||
|
layout.addWidget(self.status_label)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
# Event handlers
|
||||||
|
def handle_play(self):
|
||||||
|
if self.playlist_widget.currentItem():
|
||||||
|
self.play_selected_song(self.playlist_widget.currentItem())
|
||||||
|
|
||||||
|
def handle_pause(self):
|
||||||
|
if self.playing:
|
||||||
|
self.player.pause()
|
||||||
|
self.playing = False
|
||||||
|
else:
|
||||||
|
if self.current_index < len(self.player.playlist):
|
||||||
|
self.player.play_midi(self.player.playlist[self.current_index])
|
||||||
|
self.playing = True
|
||||||
|
|
||||||
|
def handle_stop(self):
|
||||||
|
self.player.stop_midi()
|
||||||
|
self.playing = False
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
|
||||||
|
def handle_next(self):
|
||||||
|
if not self.player.playlist:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.playing = False
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
self.player.next_song()
|
||||||
|
|
||||||
|
next_index = (self.current_index + 1) % len(self.player.playlist)
|
||||||
|
self.current_index = next_index
|
||||||
|
self.playlist_widget.setCurrentRow(next_index)
|
||||||
|
|
||||||
|
# Update the current MIDI label
|
||||||
|
filename = os.path.basename(self.player.playlist[next_index])
|
||||||
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
|
self.playing = True
|
||||||
|
|
||||||
|
def handle_previous(self):
|
||||||
|
if not self.player.playlist:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.playing = False
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
self.player.previous_song()
|
||||||
|
|
||||||
|
prev_index = (self.current_index - 1) % len(self.player.playlist)
|
||||||
|
self.current_index = prev_index
|
||||||
|
self.playlist_widget.setCurrentRow(prev_index)
|
||||||
|
|
||||||
|
# Update the current MIDI label
|
||||||
|
filename = os.path.basename(self.player.playlist[prev_index])
|
||||||
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
|
self.playing = True
|
||||||
|
|
||||||
|
def handle_song_end(self):
|
||||||
|
self.handle_next()
|
||||||
|
|
||||||
|
def play_selected_song(self, item):
|
||||||
|
if not item or not self.player.playlist:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.playing = False
|
||||||
|
index = self.playlist_widget.row(item)
|
||||||
|
|
||||||
|
if 0 <= index < len(self.player.playlist):
|
||||||
|
self.current_index = index
|
||||||
|
filepath = self.player.playlist[self.current_index]
|
||||||
|
self.player.load_midi(filepath)
|
||||||
|
self.player.play_midi(filepath)
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
|
self.playing = True
|
||||||
|
self.progress_bar.setValue(0)
|
||||||
|
self.player.midi_start_time = time.time()
|
||||||
|
|
||||||
|
def load_midi_file(self):
|
||||||
|
filepath, _ = QFileDialog.getOpenFileName(
|
||||||
|
self, "Select MIDI File", filter="MIDI files (*.mid *.midi)"
|
||||||
|
)
|
||||||
|
if filepath:
|
||||||
|
filename = os.path.basename(filepath)
|
||||||
|
self.player.add_to_playlist(filepath)
|
||||||
|
self.playlist_widget.addItem(filename)
|
||||||
|
|
||||||
|
# If this is the first file added, load it
|
||||||
|
if len(self.player.playlist) == 1:
|
||||||
|
self.player.load_midi(filepath)
|
||||||
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
|
|
||||||
|
def load_folder(self):
|
||||||
|
folder_path = QFileDialog.getExistingDirectory(self, "Select Folder")
|
||||||
|
if folder_path:
|
||||||
|
filenames = [
|
||||||
|
f
|
||||||
|
for f in os.listdir(folder_path)
|
||||||
|
if f.lower().endswith(".mid") or f.lower().endswith(".midi")
|
||||||
|
]
|
||||||
|
for filename in filenames:
|
||||||
|
filepath = os.path.join(folder_path, filename)
|
||||||
|
self.player.add_to_playlist(filepath)
|
||||||
|
self.playlist_widget.addItem(filename)
|
||||||
|
|
||||||
|
# If this is the first file added, load it
|
||||||
|
if len(self.player.playlist) == 1:
|
||||||
|
self.player.load_midi(filepath)
|
||||||
|
self.current_midi_label.setText(f"Current MIDI: {filename}")
|
||||||
|
|
||||||
|
def clear_playlist(self):
|
||||||
|
self.player.playlist = []
|
||||||
|
self.playlist_widget.clear()
|
||||||
|
self.current_midi_label.setText("Current MIDI: None")
|
||||||
|
self.current_index = -1
|
||||||
651
ScanOrg101.py
Normal file
651
ScanOrg101.py
Normal file
@ -0,0 +1,651 @@
|
|||||||
|
"""
|
||||||
|
ScanOrg101.py - Enhanced file scanning and organization module
|
||||||
|
"""
|
||||||
|
|
||||||
|
# flake8: noqa: E501
|
||||||
|
import os
|
||||||
|
import concurrent.futures
|
||||||
|
import zipfile
|
||||||
|
import py7zr
|
||||||
|
import rarfile # typed: ignore
|
||||||
|
import mutagen
|
||||||
|
from PyQt6.QtCore import Qt, QThread, QSortFilterProxyModel, pyqtSignal
|
||||||
|
|
||||||
|
|
||||||
|
# Directory Filter Proxy Model
|
||||||
|
class DirectoryFilterProxyModel(QSortFilterProxyModel):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
|
self.setFilterKeyColumn(0)
|
||||||
|
|
||||||
|
def filterAcceptsRow(self, source_row, source_parent):
|
||||||
|
source_model = self.sourceModel()
|
||||||
|
if source_model is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
index = source_model.index(source_row, 0, source_parent)
|
||||||
|
|
||||||
|
if hasattr(source_model, "isDir"):
|
||||||
|
return source_model.isDir(index) # type: ignore
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# File Filter Proxy Model
|
||||||
|
class FileFilterProxyModel(QSortFilterProxyModel):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setFilterCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||||
|
self.setFilterKeyColumn(0)
|
||||||
|
self.allowed_extensions = [
|
||||||
|
".zip",
|
||||||
|
".mp3",
|
||||||
|
".wav",
|
||||||
|
".flac",
|
||||||
|
".mid",
|
||||||
|
".midi",
|
||||||
|
".aiff",
|
||||||
|
".aif",
|
||||||
|
".aifc",
|
||||||
|
".au",
|
||||||
|
".snd",
|
||||||
|
".wv",
|
||||||
|
".wma",
|
||||||
|
".m4a",
|
||||||
|
".7z",
|
||||||
|
".rar",
|
||||||
|
]
|
||||||
|
|
||||||
|
def filterAcceptsRow(self, source_row, source_parent):
|
||||||
|
source_model = self.sourceModel()
|
||||||
|
if source_model is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
index = source_model.index(source_row, 0, source_parent)
|
||||||
|
|
||||||
|
if hasattr(source_model, "isDir") and source_model.isDir(index): # type: ignore
|
||||||
|
return True
|
||||||
|
|
||||||
|
if hasattr(source_model, "fileName"):
|
||||||
|
return source_model.fileName(index).endswith( # type: ignore
|
||||||
|
tuple(self.allowed_extensions)
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# Enhanced File Scanner with optimizations
|
||||||
|
class FileScanner(QThread):
|
||||||
|
items_found = pyqtSignal(list) # Now emits batches of items
|
||||||
|
scan_complete = pyqtSignal()
|
||||||
|
progress_update = pyqtSignal(int)
|
||||||
|
directory_scanned = pyqtSignal(str) # New signal for lazy loading
|
||||||
|
|
||||||
|
def __init__(self, path, batch_size=500, max_workers=4):
|
||||||
|
"""
|
||||||
|
Initialize the file scanner with performance optimizations
|
||||||
|
|
||||||
|
Args:
|
||||||
|
path: Starting path to scan
|
||||||
|
batch_size: Number of items to collect before emitting a batch
|
||||||
|
max_workers: Maximum number of parallel scanning threads
|
||||||
|
"""
|
||||||
|
super().__init__()
|
||||||
|
self.path = path
|
||||||
|
self.stop_requested = False
|
||||||
|
self.cache = {}
|
||||||
|
self.scanned_directories = (
|
||||||
|
set()
|
||||||
|
) # Track which directories have been scanned
|
||||||
|
self.batch_size = batch_size
|
||||||
|
self.max_workers = max_workers
|
||||||
|
self.allowed_extensions = {
|
||||||
|
".mid",
|
||||||
|
".midi",
|
||||||
|
".mp3",
|
||||||
|
".wav",
|
||||||
|
".ogg",
|
||||||
|
".flac",
|
||||||
|
".aac",
|
||||||
|
".m4a",
|
||||||
|
".wma",
|
||||||
|
".flp",
|
||||||
|
".als",
|
||||||
|
".logic",
|
||||||
|
".logicx",
|
||||||
|
".ptx",
|
||||||
|
".pts",
|
||||||
|
".cpr",
|
||||||
|
".rpp",
|
||||||
|
".reason",
|
||||||
|
".sng",
|
||||||
|
".ardour",
|
||||||
|
".bwproject",
|
||||||
|
".zip",
|
||||||
|
".7z",
|
||||||
|
".rar",
|
||||||
|
}
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Main thread run method - only scans the root path initially"""
|
||||||
|
# Check cache first
|
||||||
|
if self.path in self.cache:
|
||||||
|
self.items_found.emit(self.cache[self.path])
|
||||||
|
self.scan_complete.emit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Only scan the top level directory initially (lazy loading)
|
||||||
|
self.scan_single_directory(self.path)
|
||||||
|
self.scan_complete.emit()
|
||||||
|
|
||||||
|
def scan_directory_recursive(self, path):
|
||||||
|
"""
|
||||||
|
Recursively scan a directory - used when explicitly requesting
|
||||||
|
a full scan of all subdirectories
|
||||||
|
"""
|
||||||
|
if path in self.cache:
|
||||||
|
return self.cache[path]
|
||||||
|
|
||||||
|
items = []
|
||||||
|
batch = []
|
||||||
|
dirs_to_scan = deque([path]) # type: ignore
|
||||||
|
|
||||||
|
# For progress estimation
|
||||||
|
start_time = time.time() # type: ignore
|
||||||
|
progress_update_interval = 0.2 # seconds
|
||||||
|
last_update_time = start_time
|
||||||
|
entries_processed = 0
|
||||||
|
|
||||||
|
# Estimate total number of items
|
||||||
|
try:
|
||||||
|
sample_count = len(list(os.scandir(path)))
|
||||||
|
estimated_total = sample_count * 10 # Simple heuristic
|
||||||
|
except (PermissionError, OSError):
|
||||||
|
estimated_total = 1000 # Fallback estimate
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor(
|
||||||
|
max_workers=self.max_workers
|
||||||
|
) as executor:
|
||||||
|
futures = {}
|
||||||
|
|
||||||
|
while dirs_to_scan and not self.stop_requested:
|
||||||
|
# Process directories in parallel
|
||||||
|
while dirs_to_scan and len(futures) < self.max_workers:
|
||||||
|
dir_path = dirs_to_scan.popleft()
|
||||||
|
if dir_path not in self.scanned_directories:
|
||||||
|
futures[
|
||||||
|
executor.submit(
|
||||||
|
self.scan_single_directory_helper, dir_path
|
||||||
|
)
|
||||||
|
] = dir_path
|
||||||
|
|
||||||
|
# Process completed directories
|
||||||
|
for future in list(
|
||||||
|
concurrent.futures.as_completed(futures.keys())
|
||||||
|
):
|
||||||
|
dir_path = futures.pop(future)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dir_items, subdirs = future.result()
|
||||||
|
entries_processed += len(dir_items)
|
||||||
|
|
||||||
|
# Add results to our list
|
||||||
|
items.extend(dir_items)
|
||||||
|
batch.extend(dir_items)
|
||||||
|
|
||||||
|
# Add subdirectories to our queue
|
||||||
|
dirs_to_scan.extend(subdirs)
|
||||||
|
|
||||||
|
# Mark directory as scanned
|
||||||
|
self.scanned_directories.add(dir_path)
|
||||||
|
self.directory_scanned.emit(dir_path)
|
||||||
|
|
||||||
|
# Emit batch if it's full
|
||||||
|
if len(batch) >= self.batch_size:
|
||||||
|
self.items_found.emit(batch)
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
# Update progress periodically
|
||||||
|
current_time = time.time() # type: ignore
|
||||||
|
if (
|
||||||
|
current_time - last_update_time
|
||||||
|
> progress_update_interval
|
||||||
|
):
|
||||||
|
# Simple progress estimation
|
||||||
|
progress = min(
|
||||||
|
99,
|
||||||
|
int(entries_processed / estimated_total * 100),
|
||||||
|
)
|
||||||
|
self.progress_update.emit(progress)
|
||||||
|
last_update_time = current_time
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error scanning directory {dir_path}: {e}")
|
||||||
|
|
||||||
|
# Emit any remaining items in the final batch
|
||||||
|
if batch and not self.stop_requested:
|
||||||
|
self.items_found.emit(batch)
|
||||||
|
|
||||||
|
# Store in cache
|
||||||
|
self.cache[path] = items
|
||||||
|
self.progress_update.emit(100) # Final update
|
||||||
|
return items
|
||||||
|
|
||||||
|
def scan_single_directory(self, path):
|
||||||
|
"""
|
||||||
|
Scan a single directory without recursion - supports lazy loading
|
||||||
|
"""
|
||||||
|
if self.stop_requested:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if path in self.cache:
|
||||||
|
items = self.cache[path]
|
||||||
|
self.items_found.emit(items)
|
||||||
|
return items
|
||||||
|
|
||||||
|
try:
|
||||||
|
items = []
|
||||||
|
with os.scandir(path) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
# For directories, just add them to the list
|
||||||
|
# but don't scan them yet (lazy loading)
|
||||||
|
items.append((entry.path, True))
|
||||||
|
elif entry.is_file() and entry.name.lower().endswith(
|
||||||
|
tuple(self.allowed_extensions)
|
||||||
|
):
|
||||||
|
items.append((entry.path, False))
|
||||||
|
|
||||||
|
# Store in cache and emit
|
||||||
|
self.cache[path] = items
|
||||||
|
self.items_found.emit(items)
|
||||||
|
self.scanned_directories.add(path)
|
||||||
|
self.directory_scanned.emit(path)
|
||||||
|
self.progress_update.emit(100) # Show complete for this directory
|
||||||
|
return items
|
||||||
|
except PermissionError:
|
||||||
|
print(f"Permission denied: {path}")
|
||||||
|
return []
|
||||||
|
except OSError as e:
|
||||||
|
print(f"Error accessing {path}: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def scan_single_directory_helper(self, path):
|
||||||
|
"""Helper method for parallel directory scanning"""
|
||||||
|
items = []
|
||||||
|
subdirs = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
with os.scandir(path) as entries:
|
||||||
|
for entry in entries:
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
|
||||||
|
if entry.is_dir():
|
||||||
|
items.append((entry.path, True))
|
||||||
|
subdirs.append(entry.path)
|
||||||
|
elif entry.is_file() and entry.name.lower().endswith(
|
||||||
|
tuple(self.allowed_extensions)
|
||||||
|
):
|
||||||
|
items.append((entry.path, False))
|
||||||
|
except (PermissionError, OSError) as e:
|
||||||
|
print(f"Error accessing {path}: {e}")
|
||||||
|
|
||||||
|
return items, subdirs
|
||||||
|
|
||||||
|
def request_directory_scan(self, path):
|
||||||
|
"""Request a scan of a specific directory (for lazy loading)"""
|
||||||
|
if path in self.scanned_directories:
|
||||||
|
return
|
||||||
|
|
||||||
|
items = self.scan_single_directory(path)
|
||||||
|
return items
|
||||||
|
|
||||||
|
def request_full_scan(self):
|
||||||
|
"""Request a full recursive scan of all subdirectories"""
|
||||||
|
items = self.scan_directory_recursive(self.path)
|
||||||
|
self.scan_complete.emit()
|
||||||
|
return items
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stop_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
# Metadata Extractor
|
||||||
|
class MetadataExtractor(QThread):
|
||||||
|
metadata_extracted = pyqtSignal(dict)
|
||||||
|
extraction_complete = pyqtSignal()
|
||||||
|
progress_update = pyqtSignal(int)
|
||||||
|
|
||||||
|
def __init__(self, file_list):
|
||||||
|
super().__init__()
|
||||||
|
self.file_list = file_list
|
||||||
|
self.stop_requested = False
|
||||||
|
self.metadata_cache = {}
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
total_files = len(self.file_list)
|
||||||
|
processed_files = 0
|
||||||
|
|
||||||
|
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||||
|
futures = []
|
||||||
|
for file_path in self.file_list:
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
if file_path in self.metadata_cache:
|
||||||
|
self.metadata_extracted.emit(
|
||||||
|
self.metadata_cache[file_path]
|
||||||
|
)
|
||||||
|
processed_files += 1
|
||||||
|
self.progress_update.emit(
|
||||||
|
int(processed_files / total_files * 100)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
futures.append(
|
||||||
|
executor.submit(self.extract_metadata, file_path)
|
||||||
|
)
|
||||||
|
|
||||||
|
for future in concurrent.futures.as_completed(futures):
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
metadata = future.result()
|
||||||
|
if metadata:
|
||||||
|
self.metadata_extracted.emit(metadata)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error extracting metadata: {e}")
|
||||||
|
|
||||||
|
processed_files += 1
|
||||||
|
self.progress_update.emit(
|
||||||
|
int(processed_files / total_files * 100)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.extraction_complete.emit()
|
||||||
|
|
||||||
|
def extract_metadata(self, file_path):
|
||||||
|
try:
|
||||||
|
if not os.path.isfile(file_path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Skip non-audio files
|
||||||
|
if not file_path.lower().endswith(
|
||||||
|
(".mp3", ".wav", ".flac", ".m4a", ".wma", ".mid", ".midi")
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
audio = mutagen.File(file_path) # type: ignore
|
||||||
|
if not audio:
|
||||||
|
return None
|
||||||
|
|
||||||
|
metadata = {
|
||||||
|
"file_path": file_path,
|
||||||
|
"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 result
|
||||||
|
self.metadata_cache[file_path] = metadata
|
||||||
|
return metadata
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error processing {file_path}: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
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 stop(self):
|
||||||
|
self.stop_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
# Archive Extractor not fully tested or implemented
|
||||||
|
class ArchiveExtractor(QThread):
|
||||||
|
extraction_progress = pyqtSignal(int)
|
||||||
|
extraction_complete = pyqtSignal(list) # Emits list of extracted files
|
||||||
|
extraction_error = pyqtSignal(str)
|
||||||
|
|
||||||
|
def __init__(self, archive_path, extraction_dir):
|
||||||
|
super().__init__()
|
||||||
|
self.archive_path = archive_path
|
||||||
|
self.extraction_dir = extraction_dir
|
||||||
|
self.stop_requested = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
extracted_files = []
|
||||||
|
|
||||||
|
if self.archive_path.lower().endswith(".zip"):
|
||||||
|
extracted_files = self._extract_zip()
|
||||||
|
elif self.archive_path.lower().endswith(".7z"):
|
||||||
|
extracted_files = self._extract_7z()
|
||||||
|
elif self.archive_path.lower().endswith(".rar"):
|
||||||
|
extracted_files = self._extract_rar()
|
||||||
|
else:
|
||||||
|
self.extraction_error.emit(
|
||||||
|
f"Unsupported archive format: {self.archive_path}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.extraction_complete.emit(extracted_files)
|
||||||
|
except Exception as e:
|
||||||
|
self.extraction_error.emit(f"Extraction error: {str(e)}")
|
||||||
|
|
||||||
|
def _extract_zip(self):
|
||||||
|
extracted_files = []
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(self.archive_path, "r") as zip_ref:
|
||||||
|
file_list = zip_ref.namelist()
|
||||||
|
total_files = len(file_list)
|
||||||
|
|
||||||
|
for i, file in enumerate(file_list):
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
zip_ref.extract(file, self.extraction_dir)
|
||||||
|
extracted_files.append(
|
||||||
|
os.path.join(self.extraction_dir, file)
|
||||||
|
)
|
||||||
|
self.extraction_progress.emit(
|
||||||
|
int((i + 1) / total_files * 100)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.extraction_error.emit(f"ZIP extraction error: {str(e)}")
|
||||||
|
|
||||||
|
return extracted_files
|
||||||
|
|
||||||
|
def _extract_7z(self):
|
||||||
|
extracted_files = []
|
||||||
|
try:
|
||||||
|
with py7zr.SevenZipFile(self.archive_path, mode="r") as z:
|
||||||
|
file_list = z.getnames()
|
||||||
|
total_files = len(file_list)
|
||||||
|
|
||||||
|
for i, file in enumerate(file_list):
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
z.extract(self.extraction_dir, [file])
|
||||||
|
extracted_files.append(
|
||||||
|
os.path.join(self.extraction_dir, file)
|
||||||
|
)
|
||||||
|
self.extraction_progress.emit(
|
||||||
|
int((i + 1) / total_files * 100)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.extraction_error.emit(f"7Z extraction error: {str(e)}")
|
||||||
|
|
||||||
|
return extracted_files
|
||||||
|
|
||||||
|
def _extract_rar(self):
|
||||||
|
extracted_files = []
|
||||||
|
try:
|
||||||
|
with rarfile.RarFile(self.archive_path) as rf:
|
||||||
|
file_list = rf.namelist()
|
||||||
|
total_files = len(file_list)
|
||||||
|
|
||||||
|
for i, file in enumerate(file_list):
|
||||||
|
if self.stop_requested:
|
||||||
|
break
|
||||||
|
rf.extract(file, self.extraction_dir)
|
||||||
|
extracted_files.append(
|
||||||
|
os.path.join(self.extraction_dir, file)
|
||||||
|
)
|
||||||
|
self.extraction_progress.emit(
|
||||||
|
int((i + 1) / total_files * 100)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self.extraction_error.emit(f"RAR extraction error: {str(e)}")
|
||||||
|
|
||||||
|
return extracted_files
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.stop_requested = True
|
||||||
|
|
||||||
|
|
||||||
|
# Main Organizer class
|
||||||
|
class Organizer:
|
||||||
|
def __init__(self):
|
||||||
|
self.file_list = []
|
||||||
|
self.dir_list = []
|
||||||
|
self.scanner = None
|
||||||
|
self.metadata_extractor = None
|
||||||
|
self.archive_extractor = None
|
||||||
|
|
||||||
|
# Metadata organization
|
||||||
|
self.artists = set()
|
||||||
|
self.albums = set()
|
||||||
|
self.genres = set()
|
||||||
|
self.years = set()
|
||||||
|
|
||||||
|
# Signals for UI updates
|
||||||
|
self.on_scan_complete = None
|
||||||
|
self.on_progress_update = None
|
||||||
|
self.on_metadata_complete = None
|
||||||
|
|
||||||
|
def start_scan(self, path):
|
||||||
|
"""Start scanning a directory for files"""
|
||||||
|
self.file_list.clear()
|
||||||
|
self.dir_list.clear()
|
||||||
|
|
||||||
|
self.scanner = FileScanner(path)
|
||||||
|
self.scanner.items_found.connect(self.add_items)
|
||||||
|
self.scanner.scan_complete.connect(self.scan_finished)
|
||||||
|
|
||||||
|
# Connect progress signal if handler exists
|
||||||
|
if self.on_progress_update:
|
||||||
|
self.scanner.progress_update.connect(self.on_progress_update)
|
||||||
|
|
||||||
|
self.scanner.start()
|
||||||
|
|
||||||
|
def add_items(self, items):
|
||||||
|
"""Process items found during scanning"""
|
||||||
|
for path, is_dir in items:
|
||||||
|
if is_dir:
|
||||||
|
self.dir_list.append(path)
|
||||||
|
else:
|
||||||
|
self.file_list.append(path)
|
||||||
|
|
||||||
|
def scan_finished(self):
|
||||||
|
"""Handle scan completion"""
|
||||||
|
print(
|
||||||
|
f"Scan complete. Found {len(self.dir_list)} directories\
|
||||||
|
and {len(self.file_list)} files."
|
||||||
|
)
|
||||||
|
if self.on_scan_complete:
|
||||||
|
self.on_scan_complete()
|
||||||
|
|
||||||
|
def stop_scan(self):
|
||||||
|
"""Stop the current scan operation"""
|
||||||
|
if self.scanner:
|
||||||
|
self.scanner.stop()
|
||||||
|
self.scanner.wait()
|
||||||
|
|
||||||
|
def extract_metadata(self):
|
||||||
|
"""Extract metadata from audio files"""
|
||||||
|
if not self.file_list:
|
||||||
|
print("No files to extract metadata from")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.metadata_extractor = MetadataExtractor(self.file_list)
|
||||||
|
self.metadata_extractor.metadata_extracted.connect(
|
||||||
|
self.process_metadata
|
||||||
|
)
|
||||||
|
self.metadata_extractor.extraction_complete.connect(
|
||||||
|
self.metadata_extraction_complete
|
||||||
|
)
|
||||||
|
|
||||||
|
# Connect progress signal if handler exists
|
||||||
|
if self.on_progress_update:
|
||||||
|
self.metadata_extractor.progress_update.connect(
|
||||||
|
self.on_progress_update
|
||||||
|
)
|
||||||
|
|
||||||
|
self.metadata_extractor.start()
|
||||||
|
|
||||||
|
def process_metadata(self, metadata):
|
||||||
|
"""Process extracted metadata"""
|
||||||
|
if "artist" in metadata and metadata["artist"]:
|
||||||
|
self.artists.add(metadata["artist"])
|
||||||
|
if "album" in metadata and metadata["album"]:
|
||||||
|
self.albums.add(metadata["album"])
|
||||||
|
if "genre" in metadata and metadata["genre"]:
|
||||||
|
self.genres.add(metadata["genre"])
|
||||||
|
if "year" in metadata and metadata["year"]:
|
||||||
|
self.years.add(metadata["year"])
|
||||||
|
|
||||||
|
def metadata_extraction_complete(self):
|
||||||
|
"""Handle metadata extraction completion"""
|
||||||
|
print(
|
||||||
|
f"Metadata extraction complete. Artists: {len(self.artists)},\
|
||||||
|
Albums: {len(self.albums)}, Genres: {len(self.genres)},\
|
||||||
|
Years: {len(self.years)}"
|
||||||
|
)
|
||||||
|
if self.on_metadata_complete:
|
||||||
|
self.on_metadata_complete()
|
||||||
|
|
||||||
|
def extract_archives(self):
|
||||||
|
"""Extract archives"""
|
||||||
|
if not self.file_list:
|
||||||
|
print("No files to extract archives from")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.archive_extractor = ArchiveExtractor(
|
||||||
|
self.file_list, extraction_dir=None
|
||||||
|
)
|
||||||
|
self.archive_extractor.extraction_complete.connect(
|
||||||
|
self.archive_extraction_complete
|
||||||
|
)
|
||||||
|
|
||||||
|
# Connect progress signal if handler exists
|
||||||
|
if self.on_progress_update:
|
||||||
|
self.archive_extractor.extraction_progress.connect(
|
||||||
|
self.on_progress_update
|
||||||
|
)
|
||||||
|
|
||||||
|
self.archive_extractor.start()
|
||||||
|
|
||||||
|
def archive_extraction_complete(self):
|
||||||
|
"""Handle archive extraction completion"""
|
||||||
|
print("Archive extraction complete.")
|
||||||
|
|
||||||
|
if self.on_progress_update:
|
||||||
|
self.on_progress_update(100)
|
||||||
|
|
||||||
|
def stop_extraction(self):
|
||||||
|
"""Stop the current extraction operation"""
|
||||||
|
if self.archive_extractor:
|
||||||
|
self.archive_extractor.stop()
|
||||||
|
self.archive_extractor.wait()
|
||||||
127
readme.md
Normal file
127
readme.md
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
# FBrowser - Enhanced File Browser with Audio Tools
|
||||||
|
|
||||||
|
A powerful file browser application with special focus on audio file handling, MIDI playback, and archive management.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Advanced File Browser**: Browse directories with tree view and file list
|
||||||
|
- **Audio Playback**: Built-in player for various audio formats
|
||||||
|
- **MIDI Player**: Full-featured MIDI player with playlist management
|
||||||
|
- **Metadata Extraction**: View and explore audio file metadata
|
||||||
|
- **Archive Management**: Extract ZIP, RAR, and 7z archives
|
||||||
|
- **Timer Utility**: Built-in countdown timer with audio alerts
|
||||||
|
- **Hover Tooltips**: Quick metadata preview by hovering over audio files
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Python 3.6+
|
||||||
|
- PyQt6
|
||||||
|
- FluidSynth (for MIDI playback)
|
||||||
|
- Mido (MIDI handling library)
|
||||||
|
- Mutagen (audio metadata extraction)
|
||||||
|
- py7zr and rarfile (archive handling)
|
||||||
|
- SoundFont (.sf2) files for MIDI playback
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
### 1. Clone the repository
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/yourusername/fbroswer-master.git
|
||||||
|
cd fbroswer-master
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Install dependencies
|
||||||
|
```bash
|
||||||
|
pip install pyqt6 fluidsynth-midi mido mutagen py7zr rarfile numpy sounddevice
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Install FluidSynth
|
||||||
|
|
||||||
|
- **Windows**: Download from [FluidSynth website](https://www.fluidsynth.org/)
|
||||||
|
- **macOS**: `brew install fluid-synth`
|
||||||
|
- **Linux**: `sudo apt-get install fluidsynth`
|
||||||
|
|
||||||
|
### 4. Download a SoundFont
|
||||||
|
|
||||||
|
- Download a .sf2 file (like FluidR3_GM.sf2)
|
||||||
|
- Place it in a "SoundFonts" directory in the application folder
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Starting the Application
|
||||||
|
```bash
|
||||||
|
python Fbrowser.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### Basic Navigation
|
||||||
|
- Use the tree view on the left to navigate directories
|
||||||
|
- Double-click files in the right panel to open/play them
|
||||||
|
- Use address bar for direct navigation
|
||||||
|
- Back/Forward/Up buttons for quick navigation
|
||||||
|
|
||||||
|
### Audio and MIDI Playback
|
||||||
|
- Double-click audio files to play them
|
||||||
|
- Use media controls at the bottom for playback
|
||||||
|
- Click "Open MidPlay" for advanced MIDI playback
|
||||||
|
|
||||||
|
### Advanced Features
|
||||||
|
- Right-click on files/folders for context menu options
|
||||||
|
- Extract archives directly from the browser
|
||||||
|
- Scan directories to find audio files
|
||||||
|
- Extract and view metadata
|
||||||
|
|
||||||
|
## Components Overview
|
||||||
|
|
||||||
|
### Fbrowser.py
|
||||||
|
The main application file implementing the file browser interface with audio playback capabilities.
|
||||||
|
|
||||||
|
### MidPlay.py
|
||||||
|
A specialized MIDI player with:
|
||||||
|
- Playlist management
|
||||||
|
- SoundFont selection
|
||||||
|
- Volume control
|
||||||
|
- Playback controls (play, pause, next, previous)
|
||||||
|
|
||||||
|
### ScanOrg101.py
|
||||||
|
Handles file scanning, metadata extraction, and archive operations:
|
||||||
|
- Recursive directory scanning
|
||||||
|
- Audio metadata extraction using Mutagen
|
||||||
|
- Archive extraction (ZIP, RAR, 7z)
|
||||||
|
|
||||||
|
### timer_m.py
|
||||||
|
A countdown timer with:
|
||||||
|
- Customizable hours, minutes, seconds
|
||||||
|
- Audio alerts on completion
|
||||||
|
- Start/stop/reset functionality
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Changing Default SoundFont
|
||||||
|
The application will attempt to find a suitable SoundFont file automatically. If none is found, you'll be prompted to select one. You can change it anytime via the MidPlay interface.
|
||||||
|
|
||||||
|
### File Type Filtering
|
||||||
|
Edit the `allowed_extensions` list in `FileFilterProxyModel` class in ScanOrg101.py to change which file types are displayed.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### No Sound in MIDI Playback
|
||||||
|
- Ensure FluidSynth is properly installed
|
||||||
|
- Verify a valid SoundFont (.sf2) file is loaded
|
||||||
|
- Check system audio settings
|
||||||
|
|
||||||
|
### Archive Extraction Issues
|
||||||
|
- Ensure you have proper permissions in the target directory
|
||||||
|
- For RAR files, make sure UnRAR is installed on your system
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
## Credits
|
||||||
|
- FluidSynth for MIDI synthesis
|
||||||
|
- PyQt6 for the user interface
|
||||||
|
- Mutagen for audio metadata handling
|
||||||
|
- Stanton for Project fbrowser, Timer_m, and MidPlay the fbrowser is the foundation of this project.
|
||||||
|
---
|
||||||
|
|
||||||
|
*Note: This project is under development. Feedback and contributions are welcome!*
|
||||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
PyQt6
|
||||||
|
matplotlib
|
||||||
|
rarfile
|
||||||
|
py7zr
|
||||||
|
mido
|
||||||
|
numpy
|
||||||
|
pyFluidSynth
|
||||||
|
mutagen
|
||||||
|
sounddevice
|
||||||
204
timer_m.py
Normal file
204
timer_m.py
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
# flake8: noqa: E501
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PyQt6.QtWidgets import (
|
||||||
|
QWidget,
|
||||||
|
QVBoxLayout,
|
||||||
|
QPushButton,
|
||||||
|
QLabel,
|
||||||
|
QHBoxLayout,
|
||||||
|
QLineEdit,
|
||||||
|
)
|
||||||
|
from PyQt6.QtCore import QTimer
|
||||||
|
import numpy as np
|
||||||
|
import sounddevice as sd # type: ignore
|
||||||
|
|
||||||
|
|
||||||
|
class Timer:
|
||||||
|
def __init__(self):
|
||||||
|
self.remaining = 0
|
||||||
|
self.running = False
|
||||||
|
self.timer_thread = None
|
||||||
|
|
||||||
|
def set(self, hours, minutes, seconds):
|
||||||
|
self.remaining = hours * 3600 + minutes * 60 + seconds
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.remaining > 0:
|
||||||
|
self.running = True
|
||||||
|
self.timer_thread = threading.Thread(target=self._run_timer)
|
||||||
|
self.timer_thread.start()
|
||||||
|
|
||||||
|
def _run_timer(self):
|
||||||
|
while self.running and self.remaining > 0:
|
||||||
|
time.sleep(1)
|
||||||
|
self.remaining -= 1
|
||||||
|
if self.running:
|
||||||
|
self._alarm()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.running = False
|
||||||
|
if self.timer_thread:
|
||||||
|
self.timer_thread.join()
|
||||||
|
|
||||||
|
def get_remaining_time(self):
|
||||||
|
return self.remaining
|
||||||
|
|
||||||
|
def _alarm(self):
|
||||||
|
print("Time's up!")
|
||||||
|
try:
|
||||||
|
self._play_tone()
|
||||||
|
# sleep = 1.2
|
||||||
|
self._play_tone2()
|
||||||
|
# sleep = 1
|
||||||
|
self._play_tone3()
|
||||||
|
# sleep = 1
|
||||||
|
self._play_tone4()
|
||||||
|
# sleep = 1
|
||||||
|
self._play_tone5()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error generating alarm tone: {e}")
|
||||||
|
|
||||||
|
def _play_tone(self, frequency=587, duration=0.2, repeat=4):
|
||||||
|
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||||
|
sample_rate = 44100
|
||||||
|
t = np.linspace(
|
||||||
|
0, duration, int(sample_rate * duration), endpoint=False
|
||||||
|
)
|
||||||
|
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||||
|
for _ in range(repeat):
|
||||||
|
sd.play(tone, samplerate=sample_rate)
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
|
def _play_tone2(self, frequency=698, duration=0.2, repeat=3):
|
||||||
|
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||||
|
sample_rate = 44100
|
||||||
|
t = np.linspace(
|
||||||
|
0, duration, int(sample_rate * duration), endpoint=False
|
||||||
|
)
|
||||||
|
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||||
|
for _ in range(repeat):
|
||||||
|
sd.play(tone, samplerate=sample_rate)
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
|
def _play_tone3(self, frequency=659, duration=0.2, repeat=3):
|
||||||
|
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||||
|
sample_rate = 44100
|
||||||
|
t = np.linspace(
|
||||||
|
0, duration, int(sample_rate * duration), endpoint=False
|
||||||
|
)
|
||||||
|
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||||
|
for _ in range(repeat):
|
||||||
|
sd.play(tone, samplerate=sample_rate)
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
|
def _play_tone4(self, frequency=554, duration=0.4, repeat=1):
|
||||||
|
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||||
|
sample_rate = 44100
|
||||||
|
t = np.linspace(
|
||||||
|
0, duration, int(sample_rate * duration), endpoint=False
|
||||||
|
)
|
||||||
|
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||||
|
for _ in range(repeat):
|
||||||
|
sd.play(tone, samplerate=sample_rate)
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
|
def _play_tone5(self, frequency=554, duration=0.8, repeat=1):
|
||||||
|
"""Generate and play a tone with given frequency, duration, and repeat count."""
|
||||||
|
sample_rate = 44100
|
||||||
|
t = np.linspace(
|
||||||
|
0, duration, int(sample_rate * duration), endpoint=False
|
||||||
|
)
|
||||||
|
tone = 0.5 * np.sin(2 * np.pi * frequency * t)
|
||||||
|
for _ in range(repeat):
|
||||||
|
sd.play(tone, samplerate=sample_rate)
|
||||||
|
sd.wait()
|
||||||
|
|
||||||
|
|
||||||
|
class Timer_Ui(QWidget):
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("Timer")
|
||||||
|
self.setFixedWidth(400)
|
||||||
|
self.setFixedHeight(200)
|
||||||
|
self.timer = Timer()
|
||||||
|
self.init_ui()
|
||||||
|
|
||||||
|
def init_ui(self):
|
||||||
|
layout = QVBoxLayout()
|
||||||
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
self.timer_label = QLabel("Timer: Not Set")
|
||||||
|
layout.addWidget(self.timer_label)
|
||||||
|
|
||||||
|
timer_input_layout = QHBoxLayout()
|
||||||
|
self.hours_input = QLineEdit()
|
||||||
|
self.minutes_input = QLineEdit()
|
||||||
|
self.seconds_input = QLineEdit()
|
||||||
|
timer_input_layout.addWidget(QLabel("Hours:"))
|
||||||
|
timer_input_layout.addWidget(self.hours_input)
|
||||||
|
timer_input_layout.addWidget(QLabel("Minutes:"))
|
||||||
|
timer_input_layout.addWidget(self.minutes_input)
|
||||||
|
timer_input_layout.addWidget(QLabel("Seconds:"))
|
||||||
|
timer_input_layout.addWidget(self.seconds_input)
|
||||||
|
layout.addLayout(timer_input_layout)
|
||||||
|
|
||||||
|
self.set_timer_button = QPushButton("Set Timer")
|
||||||
|
self.set_timer_button.clicked.connect(self.set_timer)
|
||||||
|
layout.addWidget(self.set_timer_button)
|
||||||
|
|
||||||
|
self.start_timer_button = QPushButton("Start Timer")
|
||||||
|
self.start_timer_button.clicked.connect(self.start_timer)
|
||||||
|
layout.addWidget(self.start_timer_button)
|
||||||
|
|
||||||
|
self.stop_timer_button = QPushButton("Stop Timer")
|
||||||
|
self.stop_timer_button.clicked.connect(self.stop_timer)
|
||||||
|
layout.addWidget(self.stop_timer_button)
|
||||||
|
|
||||||
|
self.update_timer = QTimer()
|
||||||
|
self.update_timer.timeout.connect(self.update_timer_display)
|
||||||
|
self.update_timer.start(1000)
|
||||||
|
|
||||||
|
def set_timer(self):
|
||||||
|
try:
|
||||||
|
hours = int(self.hours_input.text() or 0)
|
||||||
|
minutes = int(self.minutes_input.text() or 0)
|
||||||
|
seconds = int(self.seconds_input.text() or 0)
|
||||||
|
self.timer.set(hours, minutes, seconds)
|
||||||
|
self.timer_label.setText(
|
||||||
|
f"Timer: Set to {hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
self.timer_label.setText("Timer: Invalid input")
|
||||||
|
|
||||||
|
def start_timer(self):
|
||||||
|
if self.timer.get_remaining_time() > 0:
|
||||||
|
self.timer.start()
|
||||||
|
else:
|
||||||
|
self.timer_label.setText("Timer: Not set")
|
||||||
|
|
||||||
|
def stop_timer(self):
|
||||||
|
self.timer.stop()
|
||||||
|
self.timer_label.setText("Timer: Stopped")
|
||||||
|
|
||||||
|
def update_timer_display(self):
|
||||||
|
if self.timer.running:
|
||||||
|
remaining = self.timer.get_remaining_time()
|
||||||
|
hours, remainder = divmod(remaining, 3600)
|
||||||
|
minutes, seconds = divmod(remainder, 60)
|
||||||
|
self.timer_label.setText(
|
||||||
|
f"Timer: {hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||||
|
)
|
||||||
|
elif (
|
||||||
|
self.timer.get_remaining_time() == 0
|
||||||
|
and self.timer_label.text() != "Timer: Not Set"
|
||||||
|
):
|
||||||
|
self.timer_label.setText("Timer: Time's up!")
|
||||||
|
|
||||||
|
def showUI(self):
|
||||||
|
self.setWindowTitle("Timer")
|
||||||
|
self.setGeometry(100, 100, 300, 200)
|
||||||
|
super().show()
|
||||||
Loading…
x
Reference in New Issue
Block a user