From b6b3307e32e1275d14d1a38733deeb8a9d28375c Mon Sep 17 00:00:00 2001 From: Thomas Lange Date: Wed, 8 Oct 2025 02:32:58 +0200 Subject: [PATCH] filesystem-qt: Introduce new plugin to manage local audio files It basically serves as an embedded file manager view - allowing to create or extend playlists - opening the music folders and cover art - supporting drag and drop --- configure.ac | 3 +- meson.build | 1 + src/filesystem-qt/Makefile | 13 + src/filesystem-qt/filesystem-qt.cc | 454 +++++++++++++++++++++++++++++ src/filesystem-qt/meson.build | 7 + src/meson.build | 1 + 6 files changed, 478 insertions(+), 1 deletion(-) create mode 100644 src/filesystem-qt/Makefile create mode 100644 src/filesystem-qt/filesystem-qt.cc create mode 100644 src/filesystem-qt/meson.build diff --git a/configure.ac b/configure.ac index fe01953ab..92abf7f30 100644 --- a/configure.ac +++ b/configure.ac @@ -89,7 +89,7 @@ if test "x$USE_GTK" = "xyes" ; then fi if test "x$USE_QT" = "xyes" ; then - GENERAL_PLUGINS="$GENERAL_PLUGINS albumart-qt lyrics-qt playback-history-qt playlist-manager-qt search-tool-qt song-info-qt statusicon-qt" + GENERAL_PLUGINS="$GENERAL_PLUGINS albumart-qt filesystem-qt lyrics-qt playback-history-qt playlist-manager-qt search-tool-qt song-info-qt statusicon-qt" GENERAL_PLUGINS="$GENERAL_PLUGINS qtui skins-qt" VISUALIZATION_PLUGINS="$VISUALIZATION_PLUGINS blur_scope-qt qt-spectrum vumeter-qt" fi @@ -893,6 +893,7 @@ if test "x$USE_QT" = "xyes" ; then echo " Winamp Classic Interface: yes" echo " Album Art: yes" echo " Blur Scope: yes" + echo " Filesystem Manager: yes" echo " OpenGL Spectrum Analyzer: $have_qtglspectrum" echo " Playback History: yes" echo " Playlist Manager: yes" diff --git a/meson.build b/meson.build index 777c7e79a..4a36c4678 100644 --- a/meson.build +++ b/meson.build @@ -314,6 +314,7 @@ if meson.version().version_compare('>= 0.53') 'Winamp Classic Interface': true, 'Album Art': true, 'Blur Scope': true, + 'Filesystem Manager': true, 'OpenGL Spectrum Analyzer': get_variable('have_qtglspectrum', false), 'Playback History': true, 'Playlist Manager': true, diff --git a/src/filesystem-qt/Makefile b/src/filesystem-qt/Makefile new file mode 100644 index 000000000..142ea9cbb --- /dev/null +++ b/src/filesystem-qt/Makefile @@ -0,0 +1,13 @@ +PLUGIN = filesystem-qt${PLUGIN_SUFFIX} + +SRCS = filesystem-qt.cc + +include ../../buildsys.mk +include ../../extra.mk + +plugindir := ${plugindir}/${GENERAL_PLUGIN_DIR} + +LD = ${CXX} +CPPFLAGS += -I../.. ${QT_CFLAGS} +CFLAGS += ${PLUGIN_CFLAGS} +LIBS += ${QT_LIBS} -laudqt diff --git a/src/filesystem-qt/filesystem-qt.cc b/src/filesystem-qt/filesystem-qt.cc new file mode 100644 index 000000000..845a8cc15 --- /dev/null +++ b/src/filesystem-qt/filesystem-qt.cc @@ -0,0 +1,454 @@ +/* + * Filesystem Manager Plugin for Audacious + * Copyright 2025 Thomas Lange + * + * Based on the implementation for Qmmp + * Copyright 2013-2025 Ilya Kotov + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions, and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions, and the following disclaimer in the documentation + * provided with the distribution. + * + * This software is provided "as is" and without any warranty, express or + * implied. In no event shall the authors be liable for any damages arising from + * the use of this software. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define CFG_ID "filesystem-qt" +#define CFG_FILE_PATH "file_path" +#define CFG_SHOW_FILTER "show_filter" +#define CFG_USE_TREE_VIEW "use_tree_view" + +class FilesystemQt : public GeneralPlugin +{ +public: + static const char * const defaults[]; + static const char about[]; + + static constexpr PluginInfo info = {N_("Filesystem Manager"), PACKAGE, + about, nullptr, PluginQtOnly}; + + constexpr FilesystemQt() : GeneralPlugin(info, false) {} + + bool init(); + void * get_qt_widget(); + int take_message(const char * code, const void * data, int size); +}; + +EXPORT FilesystemQt aud_plugin_instance; + +const char * const FilesystemQt::defaults[] = { + CFG_FILE_PATH, "", + CFG_USE_TREE_VIEW, "TRUE", + CFG_SHOW_FILTER, "FALSE", + nullptr +}; + +const char FilesystemQt::about[] = + N_("A dockable plugin that can be used to browse folders for music files. " + "Right-click on a folder or file for supported actions.\n\n" + "Copyright 2025 Thomas Lange\n\n" + "Based on the implementation for Qmmp\n" + "Copyright 2021-2025 Ilya Kotov"); + +class FileSystemFilterProxyModel : public QSortFilterProxyModel +{ +public: + explicit FileSystemFilterProxyModel(QObject * parent) + : QSortFilterProxyModel(parent) + { + } + +protected: + bool filterAcceptsRow(int sourceRow, + const QModelIndex & sourceParent) const override + { + auto model = qobject_cast(sourceModel()); + if (model->index(model->rootPath()) != sourceParent) + return true; + + return QSortFilterProxyModel::filterAcceptsRow(sourceRow, sourceParent); + } +}; + +class FileSystemWidget : public QWidget +{ +public: + FileSystemWidget(); + +protected: + void contextMenuEvent(QContextMenuEvent * event) override; + +private: + void addSelectedItems(bool play); + void replacePlaylist(); + void createPlaylist(); + void addToPlaylist(); + void openFolder(); + void openCover(); + void changeMusicDirectory(); + + bool hasMultiSelection() const; + bool searchCover(QString & result) const; + QString selectedPath() const; + QStringList selectedPaths() const; + QStringList supportedFileExtensions() const; + + void initMusicDirectory(); + void setCurrentDirectory(const QString & path); + void setTreeViewMode(bool enabled); + void onTreeViewActivated(const QModelIndex & index); + + QTreeView * m_treeView; + QFileSystemModel * m_fileSystemModel; + FileSystemFilterProxyModel * m_proxyModel; + QLineEdit * m_filterLineEdit; + QAction * m_treeModeAction, * m_showFilterAction; + QString m_coverPath; +}; + +static QPointer s_widget; + +FileSystemWidget::FileSystemWidget() +{ + m_treeView = new QTreeView(this); + m_treeView->setDragEnabled(true); + m_treeView->setFrameStyle(QFrame::NoFrame); + m_treeView->setSelectionMode(QAbstractItemView::ExtendedSelection); + + m_filterLineEdit = new QLineEdit(this); + m_filterLineEdit->setContentsMargins(5, 5, 5, 5); + m_filterLineEdit->setClearButtonEnabled(true); + m_filterLineEdit->setPlaceholderText(_("Search")); + m_filterLineEdit->setVisible(aud_get_bool(CFG_ID, CFG_SHOW_FILTER)); + + m_fileSystemModel = new QFileSystemModel(this); + m_fileSystemModel->setNameFilterDisables(false); + m_fileSystemModel->setNameFilters(supportedFileExtensions()); + m_fileSystemModel->setFilter(QDir::AllDirs | QDir::Files | QDir::NoDot); + + m_proxyModel = new FileSystemFilterProxyModel(this); + m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + m_proxyModel->setSourceModel(m_fileSystemModel); + + m_treeView->setModel(m_proxyModel); + m_treeView->setColumnHidden(1, true); + m_treeView->setColumnHidden(2, true); + m_treeView->setColumnHidden(3, true); + m_treeView->setHeaderHidden(true); + m_treeView->setUniformRowHeights(true); + m_treeView->header()->setStretchLastSection(false); + m_treeView->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + setTreeViewMode(aud_get_bool(CFG_ID, CFG_USE_TREE_VIEW)); + + QVBoxLayout * vbox = audqt::make_vbox(this, 0); + vbox->addWidget(m_filterLineEdit); + vbox->addWidget(m_treeView); + + m_treeModeAction = + new QAction(audqt::translate_str(N_("_Tree View Mode")), this); + m_treeModeAction->setCheckable(true); + m_treeModeAction->setChecked(aud_get_bool(CFG_ID, CFG_USE_TREE_VIEW)); + + m_showFilterAction = + new QAction(audqt::translate_str(N_("Quick _Search")), this); + m_showFilterAction->setCheckable(true); + m_showFilterAction->setChecked(aud_get_bool(CFG_ID, CFG_SHOW_FILTER)); + + connect(m_treeView, &QTreeView::activated, this, + &FileSystemWidget::onTreeViewActivated); + + connect(m_treeModeAction, &QAction::toggled, [this](bool checked) { + aud_set_bool(CFG_ID, CFG_USE_TREE_VIEW, checked); + setTreeViewMode(checked); + }); + + connect(m_showFilterAction, &QAction::toggled, [this](bool checked) { + aud_set_bool(CFG_ID, CFG_SHOW_FILTER, checked); + m_filterLineEdit->setVisible(checked); + }); + + connect(m_showFilterAction, &QAction::triggered, m_filterLineEdit, + &QLineEdit::clear); + + connect(m_filterLineEdit, &QLineEdit::textChanged, + [this](const QString & text) { + m_proxyModel->setFilterFixedString(text); + }); + + initMusicDirectory(); +} + +void FileSystemWidget::contextMenuEvent(QContextMenuEvent * event) +{ + auto newAction = [this](const char * text, const char * icon, + QWidget * parent, auto func) { + auto action = new QAction(QIcon::fromTheme(icon), + audqt::translate_str(text), parent); + connect(action, &QAction::triggered, this, func); + return action; + }; + + auto menu = new QMenu(this); + + menu->addAction(newAction(N_("_Play"), "media-playback-start", menu, + &FileSystemWidget::replacePlaylist)); + menu->addAction(newAction(N_("_Create Playlist"), "document-new", menu, + &FileSystemWidget::createPlaylist)); + menu->addAction(newAction(N_("_Add to Playlist"), "list-add", menu, + &FileSystemWidget::addToPlaylist)); + menu->addSeparator(); + + if (!hasMultiSelection()) + menu->addAction(newAction(N_("_Open Folder"), "folder", menu, + &FileSystemWidget::openFolder)); + if (searchCover(m_coverPath)) + menu->addAction(newAction(N_("Open Co_ver Art"), "image-x-generic", + menu, &FileSystemWidget::openCover)); + menu->addSeparator(); + + menu->addAction(m_treeModeAction); + menu->addAction(m_showFilterAction); + menu->addAction(newAction(N_("Change _Music Folder ..."), "folder-music", + menu, &FileSystemWidget::changeMusicDirectory)); + + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->popup(event->globalPos()); +} + +void FileSystemWidget::addSelectedItems(bool play) +{ + Playlist list = Playlist::active_playlist(); + Index items; + + for (const QString & path : selectedPaths()) + { + QUrl url = QUrl::fromLocalFile(path); + items.append(String(url.toEncoded().constData())); + } + + list.insert_items(-1, std::move(items), play); +} + +void FileSystemWidget::replacePlaylist() +{ + Playlist::temporary_playlist().activate(); + addSelectedItems(true); +} + +void FileSystemWidget::createPlaylist() +{ + Playlist::new_playlist(); + addSelectedItems(false); +} + +void FileSystemWidget::addToPlaylist() +{ + addSelectedItems(false); +} + +void FileSystemWidget::openFolder() +{ + QString path = selectedPath(); + if (QDir(path).exists()) + QDesktopServices::openUrl(QUrl::fromLocalFile(path)); +} + +void FileSystemWidget::openCover() +{ + if (QFile(m_coverPath).exists()) + QDesktopServices::openUrl(QUrl::fromLocalFile(m_coverPath)); +} + +void FileSystemWidget::changeMusicDirectory() +{ + QString oldDir = m_fileSystemModel->rootDirectory().canonicalPath(); + QString newDir = QFileDialog::getExistingDirectory( + this, _("Choose Folder"), oldDir, QFileDialog::ShowDirsOnly); + setCurrentDirectory(newDir); +} + +bool FileSystemWidget::hasMultiSelection() const +{ + return m_treeView->selectionModel()->selectedRows().size() > 1; +} + +bool FileSystemWidget::searchCover(QString & result) const +{ + QString path = selectedPath(); + if (path.isEmpty()) + { + result = QString(); + return false; + } + + String coverNames = aud_get_str("cover_name_include"); + QStringList nameFilter = QString(coverNames).remove(' ').split(','); + QStringList extFilter = {"*.jpg", "*.jpeg", "*.png", "*.webp"}; + QFileInfoList foundFiles = QDir(path).entryInfoList(extFilter, QDir::Files); + + for (const QFileInfo & info : foundFiles) + { + if (nameFilter.contains(info.baseName(), Qt::CaseInsensitive)) + { + result = info.canonicalFilePath(); + return true; + } + } + + result = QString(); + return false; +} + +QString FileSystemWidget::selectedPath() const +{ + QModelIndexList indexes = m_treeView->selectionModel()->selectedRows(); + auto nSelected = indexes.size(); + + if (nSelected == 0) + return m_fileSystemModel->rootDirectory().canonicalPath(); + + if (nSelected != 1) + return QString(); + + QModelIndex sourceIndex = m_proxyModel->mapToSource(indexes[0]); + QFileInfo info = m_fileSystemModel->fileInfo(sourceIndex); + return info.isDir() ? info.absoluteFilePath() : info.absolutePath(); +} + +QStringList FileSystemWidget::selectedPaths() const +{ + QStringList paths; + + for (const auto & index : m_treeView->selectionModel()->selectedIndexes()) + { + if (!index.isValid() || index.column() != 0) + continue; + + QModelIndex sourceIndex = m_proxyModel->mapToSource(index); + QString name = m_fileSystemModel->fileName(sourceIndex); + if (name == QLatin1String("..")) + continue; + + paths << m_fileSystemModel->filePath(sourceIndex); + } + + return paths; +} + +QStringList FileSystemWidget::supportedFileExtensions() const +{ + QStringList extensions; + + for (const char * ext : aud_plugin_get_supported_extensions()) + { + if (!ext) + break; + extensions << QString("*.%1").arg(ext); + } + + return extensions; +} + +void FileSystemWidget::initMusicDirectory() +{ + auto musicDir = QString(aud_get_str(CFG_ID, CFG_FILE_PATH)); + + if (musicDir.isEmpty() || !QDir(musicDir).exists()) + { + QStringList locations = + QStandardPaths::standardLocations(QStandardPaths::MusicLocation); + musicDir = locations.value(0, QDir::homePath()); + } + + setCurrentDirectory(musicDir); +} + +void FileSystemWidget::setCurrentDirectory(const QString & path) +{ + auto info = QFileInfo(path); + if (!info.isDir() || !info.isExecutable() || !info.isReadable()) + return; + + m_filterLineEdit->clear(); + + QModelIndex index = m_fileSystemModel->setRootPath(path); + if (index.isValid()) + m_treeView->setRootIndex(m_proxyModel->mapFromSource(index)); + + QString cleanPath = m_fileSystemModel->rootDirectory().canonicalPath(); + aud_set_str(CFG_ID, CFG_FILE_PATH, cleanPath.toUtf8().constData()); +} + +void FileSystemWidget::setTreeViewMode(bool enabled) +{ + QDir::Filters filter = m_fileSystemModel->filter(); + + if (enabled) + m_fileSystemModel->setFilter(filter | QDir::NoDotDot); + else + m_fileSystemModel->setFilter(filter & ~QDir::NoDotDot); + + m_treeView->setRootIsDecorated(enabled); + m_treeView->collapseAll(); +} + +void FileSystemWidget::onTreeViewActivated(const QModelIndex & index) +{ + if (!index.isValid() || m_treeView->rootIsDecorated()) + return; + + QModelIndex sourceIndex = m_proxyModel->mapToSource(index); + QString path = m_fileSystemModel->filePath(sourceIndex); + setCurrentDirectory(path); +} + +bool FilesystemQt::init() +{ + aud_config_set_defaults(CFG_ID, defaults); + return true; +} + +void * FilesystemQt::get_qt_widget() +{ + if (!s_widget) + s_widget = new FileSystemWidget; + + return s_widget; +} + +int FilesystemQt::take_message(const char * code, const void * data, int size) +{ + if (!strcmp(code, "grab focus") && s_widget) + { + s_widget->setFocus(Qt::OtherFocusReason); + return 0; + } + + return -1; +} diff --git a/src/filesystem-qt/meson.build b/src/filesystem-qt/meson.build new file mode 100644 index 000000000..db42aecda --- /dev/null +++ b/src/filesystem-qt/meson.build @@ -0,0 +1,7 @@ +shared_module('filesystem-qt', + 'filesystem-qt.cc', + dependencies: [audacious_dep, qt_dep, audqt_dep], + name_prefix: '', + install: true, + install_dir: general_plugin_dir +) diff --git a/src/meson.build b/src/meson.build index 15a67022d..78685451e 100644 --- a/src/meson.build +++ b/src/meson.build @@ -82,6 +82,7 @@ endif if conf.has('USE_QT') subdir('albumart-qt') subdir('blur_scope-qt') + subdir('filesystem-qt') subdir('lyrics-qt') subdir('playback-history-qt') subdir('playlist-manager-qt')