diff --git a/src/library/browse/browsetablemodel.cpp b/src/library/browse/browsetablemodel.cpp index b0c43a86835..368b544f736 100644 --- a/src/library/browse/browsetablemodel.cpp +++ b/src/library/browse/browsetablemodel.cpp @@ -367,7 +367,8 @@ TrackModel::Capabilities BrowseTableModel::getCapabilities() const { Capability::AddToAutoDJ | Capability::LoadToDeck | Capability::LoadToPreviewDeck | - Capability::LoadToSampler; + Capability::LoadToSampler | + Capability::RemoveFromDisk; } Qt::ItemFlags BrowseTableModel::flags(const QModelIndex& index) const { diff --git a/src/library/dlghidden.cpp b/src/library/dlghidden.cpp index d4f9fd674f5..79f324c0892 100644 --- a/src/library/dlghidden.cpp +++ b/src/library/dlghidden.cpp @@ -36,6 +36,7 @@ DlgHidden::DlgHidden( m_pHiddenTableModel = new HiddenTableModel(this, pLibrary->trackCollectionManager()); m_pTrackTableView->loadTrackModel(m_pHiddenTableModel); + // set up button connections connect(btnUnhide, &QPushButton::clicked, m_pTrackTableView, @@ -52,10 +53,19 @@ DlgHidden::DlgHidden( &QPushButton::clicked, this, &DlgHidden::clicked); + connect(btnDelete, + &QPushButton::clicked, + m_pTrackTableView, + &WTrackTableView::slotDeleteTracksFromDisk); + connect(btnDelete, + &QPushButton::clicked, + this, + &DlgHidden::clicked); connect(btnSelect, &QPushButton::clicked, this, &DlgHidden::selectAll); + // set up common track table view connections connect(m_pTrackTableView->selectionModel(), &QItemSelectionModel::selectionChanged, this, @@ -110,8 +120,9 @@ void DlgHidden::selectAll() { } void DlgHidden::activateButtons(bool enable) { - btnPurge->setEnabled(enable); btnUnhide->setEnabled(enable); + btnPurge->setEnabled(enable); + btnDelete->setEnabled(enable); } void DlgHidden::selectionChanged(const QItemSelection &selected, diff --git a/src/library/dlghidden.ui b/src/library/dlghidden.ui index 3cbea274e82..18bab2a20b6 100644 --- a/src/library/dlghidden.ui +++ b/src/library/dlghidden.ui @@ -95,6 +95,22 @@ + + + + Qt::NoFocus + + + Purge selected tracks from the library and delete files from disk. + + + Purge And Delete Files + + + false + + + diff --git a/src/library/hiddentablemodel.cpp b/src/library/hiddentablemodel.cpp index db1a488d387..cae49ea940a 100644 --- a/src/library/hiddentablemodel.cpp +++ b/src/library/hiddentablemodel.cpp @@ -89,5 +89,7 @@ Qt::ItemFlags HiddenTableModel::flags(const QModelIndex& index) const { } TrackModel::Capabilities HiddenTableModel::getCapabilities() const { - return Capability::Purge | Capability::Unhide; + return Capability::Purge | + Capability::Unhide | + Capability::RemoveFromDisk; } diff --git a/src/library/librarytablemodel.cpp b/src/library/librarytablemodel.cpp index b3bef3c4f33..b232288a048 100644 --- a/src/library/librarytablemodel.cpp +++ b/src/library/librarytablemodel.cpp @@ -96,5 +96,6 @@ TrackModel::Capabilities LibraryTableModel::getCapabilities() const { Capability::LoadToSampler | Capability::LoadToPreviewDeck | Capability::Hide | - Capability::ResetPlayed; + Capability::ResetPlayed | + Capability::RemoveFromDisk; } diff --git a/src/library/trackmodel.h b/src/library/trackmodel.h index d0425601200..5c2d8da523c 100644 --- a/src/library/trackmodel.h +++ b/src/library/trackmodel.h @@ -49,6 +49,7 @@ class TrackModel { Purge = 1u << 13u, RemovePlaylist = 1u << 14u, RemoveCrate = 1u << 15u, + RemoveFromDisk = 1u << 16u, }; Q_DECLARE_FLAGS(Capabilities, Capability) diff --git a/src/library/trackset/crate/cratetablemodel.cpp b/src/library/trackset/crate/cratetablemodel.cpp index e024fc36156..5740f693f68 100644 --- a/src/library/trackset/crate/cratetablemodel.cpp +++ b/src/library/trackset/crate/cratetablemodel.cpp @@ -109,7 +109,8 @@ TrackModel::Capabilities CrateTableModel::getCapabilities() const { Capability::LoadToSampler | Capability::LoadToPreviewDeck | Capability::RemoveCrate | - Capability::ResetPlayed; + Capability::ResetPlayed | + Capability::RemoveFromDisk; if (m_selectedCrate.isValid()) { Crate crate; diff --git a/src/util/widgethelper.cpp b/src/util/widgethelper.cpp index d0fda107b17..ccaa6b90dc4 100644 --- a/src/util/widgethelper.cpp +++ b/src/util/widgethelper.cpp @@ -1,6 +1,7 @@ #include "util/widgethelper.h" #include +#include #include "util/math.h" @@ -47,6 +48,33 @@ QWindow* getWindow( return nullptr; } +void growListWidget(QListWidget& listWidget, const QWidget& parent) { + // Try to display all files and the complete file locations to avoid + // horizontal scrolling. + // Get the screen dimensions + QScreen* const pScreen = getScreen(parent); + QSize screenSpace; + VERIFY_OR_DEBUG_ASSERT(pScreen) { + qWarning() << "Screen not detected. Assuming screen size of 800x600px."; + screenSpace = QSize(800, 600); + } + else { + screenSpace = pScreen->size(); + } + // Calculate the dimensions of the file list to show all. + int margin = 2 * listWidget.frameWidth() + + listWidget.style()->pixelMetric(QStyle::PM_ScrollBarExtent); + int minW = listWidget.sizeHintForColumn(0) + margin; + int minH = listWidget.sizeHintForRow(0) * listWidget.count() + margin; + // The file list should fit into the window, but clamp to 90% of screen size. + int newW = std::min(minW, static_cast(screenSpace.width() * 0.9)); + int newH = std::min(minH, static_cast(screenSpace.height() * 0.9)); + // Apply new size + if (newW > 0 && newH > 0) { + listWidget.setMinimumSize(newW, newH); + } +} + } // namespace widgethelper } // namespace mixxx diff --git a/src/util/widgethelper.h b/src/util/widgethelper.h index 7ede58dfb31..4eb1473ee2e 100644 --- a/src/util/widgethelper.h +++ b/src/util/widgethelper.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -49,6 +50,9 @@ inline QScreen* getScreen( #endif } +/// QSize for stretching a list widget attempting to show entire column +void growListWidget(QListWidget& listWidget, const QWidget& parent); + } // namespace widgethelper } // namespace mixxx diff --git a/src/widget/wtrackmenu.cpp b/src/widget/wtrackmenu.cpp index 448313c8e2c..1bec5e1fb84 100644 --- a/src/widget/wtrackmenu.cpp +++ b/src/widget/wtrackmenu.cpp @@ -1,8 +1,11 @@ #include "widget/wtrackmenu.h" #include +#include #include +#include #include +#include #include "control/controlobject.h" #include "control/controlproxy.h" @@ -30,6 +33,7 @@ #include "util/desktophelper.h" #include "util/parented_ptr.h" #include "util/qt.h" +#include "util/widgethelper.h" #include "widget/wcolorpickeraction.h" #include "widget/wcoverartlabel.h" #include "widget/wcoverartmenu.h" @@ -167,6 +171,11 @@ void WTrackMenu::createMenus() { m_pLibrary->searchTracksInCollection(searchQuery); }); } + + if (featureIsEnabled(Feature::RemoveFromDisk)) { + m_pRemoveFromDiskMenu = new QMenu(this); + m_pRemoveFromDiskMenu->setTitle(tr("Delete Track Files")); + } } void WTrackMenu::createActions() { @@ -221,6 +230,14 @@ void WTrackMenu::createActions() { connect(m_pPurgeAct, &QAction::triggered, this, &WTrackMenu::slotPurge); } + if (featureIsEnabled(Feature::RemoveFromDisk)) { + m_pRemoveFromDiskAct = new QAction(tr("Delete Files from Disk"), m_pRemoveFromDiskMenu); + connect(m_pRemoveFromDiskAct, + &QAction::triggered, + this, + &WTrackMenu::slotRemoveFromDisk); + } + if (featureIsEnabled(Feature::Properties)) { m_pPropertiesAct = new QAction(tr("Properties"), this); // This is just for having the shortcut displayed next to the action @@ -518,6 +535,12 @@ void WTrackMenu::setupActions() { } } + if (featureIsEnabled(Feature::RemoveFromDisk) && + m_pTrackModel->hasCapabilities(TrackModel::Capability::RemoveFromDisk)) { + m_pRemoveFromDiskMenu->addAction(m_pRemoveFromDiskAct); + addMenu(m_pRemoveFromDiskMenu); + } + if (featureIsEnabled(Feature::FileBrowser)) { addAction(m_pFileBrowserAct); } @@ -766,6 +789,13 @@ void WTrackMenu::updateMenus() { } } + if (featureIsEnabled(Feature::RemoveFromDisk)) { + bool locked = m_pTrackModel->hasCapabilities(TrackModel::Capability::Locked); + if (m_pTrackModel->hasCapabilities(TrackModel::Capability::RemoveFromDisk)) { + m_pRemoveFromDiskAct->setEnabled(!locked); + } + } + if (featureIsEnabled(Feature::Properties)) { m_pPropertiesAct->setEnabled(singleTrackSelected); } @@ -1627,6 +1657,181 @@ void WTrackMenu::slotClearAllMetadata() { &trackOperator); } +namespace { + +class RemoveTrackFilesFromDiskTrackPointerOperation : public mixxx::TrackPointerOperation { + public: + const QList& getTracksToPurge() const { + return mTracksToPurge; + } + const QList& getTracksToKeep() const { + return mTracksToKeep; + } + + private: + mutable QList mTracksToPurge; + mutable QList mTracksToKeep; + + void doApply( + const TrackPointer& pTrack) const override { + auto trackRef = TrackRef::fromFileInfo( + pTrack->getFileInfo(), + pTrack->getId()); + VERIFY_OR_DEBUG_ASSERT(trackRef.isValid()) { + return; + } + QString location = pTrack->getLocation(); + QFile file(location); + if (file.exists() && !file.remove()) { + // Deletion failed, log warning and queue location for the + // Failed Deletions warning. + qWarning() + << "Queued file" + << location + << "could not be deleted. Track is not purged"; + mTracksToKeep.append(location); + } else { + // File doesn't exist or was deleted. + // Note: we must NOT purge every single track here since + // TrackDAO::afterPurgingTracks would enforce a track model update (select()) + // So we add it to the purge queue and purge all tracks at once + // in slotRemoveFromDisk() afterwards. + mTracksToPurge.append(trackRef); + } + } +}; + +} // anonymous namespace + +void WTrackMenu::slotRemoveFromDisk() { + const auto trackRefs = getTrackRefs(); + QStringList locations; + locations.reserve(trackRefs.size()); + for (const auto& trackRef : trackRefs) { + QString location = trackRef.getLocation(); + locations.append(location); + } + locations.removeDuplicates(); + + { + // Prepare the delete confirmation dialog + // List view for the files to be deleted + // NOTE(ronso0) We could also make this a table to allow showing + // artist and title if file names don't suffice to identify tracks. + QListWidget* delListWidget = new QListWidget(); + delListWidget->setSizePolicy(QSizePolicy(QSizePolicy::Minimum, + QSizePolicy::MinimumExpanding)); + delListWidget->addItems(locations); + mixxx::widgethelper::growListWidget(*delListWidget, *this); + // Warning text + QLabel* delWarning = new QLabel(); + delWarning->setText(tr("Permanently delete these files from disk?") + + QString("

") + + tr("This can not be undone!") + QString("")); + delWarning->setTextFormat(Qt::RichText); + delWarning->setSizePolicy(QSizePolicy(QSizePolicy::Minimum, + QSizePolicy::Minimum)); + // Buttons + QDialogButtonBox* delButtons = new QDialogButtonBox(); + QPushButton* cancelBtn = delButtons->addButton( + tr("Cancel"), + QDialogButtonBox::RejectRole); + QPushButton* deleteBtn = delButtons->addButton( + tr("Delete Files"), + QDialogButtonBox::AcceptRole); + cancelBtn->setDefault(true); + + // Populate the main layout + QVBoxLayout* delLayout = new QVBoxLayout(); + delLayout->addWidget(delListWidget); + delLayout->addWidget(delWarning); + delLayout->addWidget(delButtons); + + QDialog dlgDelConfirm; + dlgDelConfirm.setModal(true); // just to be sure + dlgDelConfirm.setWindowTitle(tr("Delete Track Files")); + // This is required after customizing the buttons, otherwise neither button + // would close the dialog. + connect(cancelBtn, &QPushButton::clicked, &dlgDelConfirm, &QDialog::reject); + connect(deleteBtn, &QPushButton::clicked, &dlgDelConfirm, &QDialog::accept); + dlgDelConfirm.setLayout(delLayout); + + if (dlgDelConfirm.exec() == QDialog::Rejected) { + return; + } + } + + // Set up and initiate the track batch operation + const auto progressLabelText = + tr("Removing %1 track file(s) from disk...", + "", + getTrackCount()); + const auto trackOperator = + RemoveTrackFilesFromDiskTrackPointerOperation(); + applyTrackPointerOperation( + progressLabelText, + &trackOperator); + + // Purge deleted tracks and show deletion summary message. + const QList tracksToPurge(trackOperator.getTracksToPurge()); + if (!tracksToPurge.isEmpty()) { + // Purge only those tracks whose files have actually been deleted. + m_pLibrary->trackCollectionManager()->purgeTracks(tracksToPurge); + + // Show purge summary message + QMessageBox msgBoxPurgeTracks; + msgBoxPurgeTracks.setIcon(QMessageBox::Information); + msgBoxPurgeTracks.setWindowTitle(tr("Track Files Deleted")); + msgBoxPurgeTracks.setText( + tr("%1 track files were deleted from disk and purged " + "from the Mixxx database.") + .arg(QString::number(tracksToPurge.length())) + + QString("

") + + tr("Note: if you are in Browse or Recording you need to " + "click the current view again to see changes.")); + msgBoxPurgeTracks.setTextFormat(Qt::RichText); + msgBoxPurgeTracks.setStandardButtons(QMessageBox::Ok); + msgBoxPurgeTracks.exec(); + } + + const QList tracksToKeep(trackOperator.getTracksToKeep()); + if (!tracksToKeep.isEmpty()) { + return; + } + + { + // Else show a message with a list of tracks that could not be deleted. + QLabel* notDeletedLabel = new QLabel; + notDeletedLabel->setText( + tr("The following %1 files could not be deleted from disk") + .arg(QString::number( + tracksToKeep.length()))); + notDeletedLabel->setTextFormat(Qt::RichText); + + QListWidget* notDeletedListWidget = new QListWidget; + notDeletedListWidget->addItems(tracksToKeep); + mixxx::widgethelper::growListWidget(*notDeletedListWidget, *this); + + QDialogButtonBox* notDeletedButtons = new QDialogButtonBox(); + QPushButton* closeBtn = notDeletedButtons->addButton( + tr("Close"), + QDialogButtonBox::AcceptRole); + + QVBoxLayout* notDeletedLayout = new QVBoxLayout; + notDeletedLayout->addWidget(notDeletedLabel); + notDeletedLayout->addWidget(notDeletedListWidget); + notDeletedLayout->addWidget(notDeletedButtons); + + QDialog dlgNotDeleted; + dlgNotDeleted.setModal(true); + dlgNotDeleted.setWindowTitle(tr("Remaining Track Files")); + dlgNotDeleted.setLayout(notDeletedLayout); + // Required for being able to close the dialog + connect(closeBtn, &QPushButton::clicked, &dlgNotDeleted, &QDialog::close); + dlgNotDeleted.exec(); + } +} + void WTrackMenu::slotShowDlgTrackInfo() { if (isEmpty()) { return; @@ -1836,6 +2041,8 @@ bool WTrackMenu::featureIsEnabled(Feature flag) const { return m_pTrackModel->hasCapabilities(TrackModel::Capability::Hide) || m_pTrackModel->hasCapabilities(TrackModel::Capability::Unhide) || m_pTrackModel->hasCapabilities(TrackModel::Capability::Purge); + case Feature::RemoveFromDisk: + return m_pTrackModel->hasCapabilities(TrackModel::Capability::RemoveFromDisk); case Feature::FileBrowser: return true; case Feature::Properties: diff --git a/src/widget/wtrackmenu.h b/src/widget/wtrackmenu.h index df627b32f4b..444eb6f5a3e 100644 --- a/src/widget/wtrackmenu.h +++ b/src/widget/wtrackmenu.h @@ -17,6 +17,7 @@ class ControlProxy; class DlgTagFetcher; class DlgTrackInfo; +//class DlgDeleteFilesConfirmation; class ExternalTrackCollection; class Library; class TrackModel; @@ -44,14 +45,15 @@ class WTrackMenu : public QMenu { BPM = 1 << 7, Color = 1 << 8, HideUnhidePurge = 1 << 9, - FileBrowser = 1 << 10, - Properties = 1 << 11, - SearchRelated = 1 << 12, - UpdateReplayGain = 1 << 13, + RemoveFromDisk = 1 << 10, + FileBrowser = 1 << 11, + Properties = 1 << 12, + SearchRelated = 1 << 13, + UpdateReplayGain = 1 << 14, TrackModelFeatures = Remove | HideUnhidePurge, All = AutoDJ | LoadTo | Playlist | Crate | Remove | Metadata | Reset | - BPM | Color | HideUnhidePurge | FileBrowser | Properties | - SearchRelated + BPM | Color | HideUnhidePurge | RemoveFromDisk | FileBrowser | + Properties | SearchRelated }; Q_DECLARE_FLAGS(Features, Feature) @@ -76,6 +78,8 @@ class WTrackMenu : public QMenu { // This has been done on purpose to ensure menu doesn't popup without loaded track(s). void popup(const QPoint& pos, QAction* at = nullptr); void slotShowDlgTrackInfo(); + // Library management + void slotRemoveFromDisk(); signals: void loadTrackToPlayer(TrackPointer pTrack, const QString& group, bool play = false); @@ -211,6 +215,7 @@ class WTrackMenu : public QMenu { QMenu* m_pColorMenu{}; WCoverArtMenu* m_pCoverMenu{}; parented_ptr m_pSearchRelatedMenu; + QMenu* m_pRemoveFromDiskMenu{}; // Update ReplayGain from Track QAction* m_pUpdateReplayGain{}; @@ -237,6 +242,7 @@ class WTrackMenu : public QMenu { QAction* m_pHideAct{}; QAction* m_pUnhideAct{}; QAction* m_pPurgeAct{}; + QAction* m_pRemoveFromDiskAct{}; // Show track-editor action QAction* m_pPropertiesAct{}; diff --git a/src/widget/wtracktableview.cpp b/src/widget/wtracktableview.cpp index 1a046d0dd39..0d37756668a 100644 --- a/src/widget/wtracktableview.cpp +++ b/src/widget/wtracktableview.cpp @@ -399,7 +399,7 @@ TrackModel::SortColumnId WTrackTableView::getColumnIdFromCurrentIndex() { void WTrackTableView::assignPreviousTrackColor() { QModelIndexList indices = selectionModel()->selectedRows(); - if (indices.size() <= 0) { + if (indices.isEmpty()) { return; } @@ -420,7 +420,7 @@ void WTrackTableView::assignPreviousTrackColor() { void WTrackTableView::assignNextTrackColor() { QModelIndexList indices = selectionModel()->selectedRows(); - if (indices.size() <= 0) { + if (indices.isEmpty()) { return; } @@ -441,22 +441,32 @@ void WTrackTableView::assignNextTrackColor() { void WTrackTableView::slotPurge() { QModelIndexList indices = selectionModel()->selectedRows(); - if (indices.size() > 0) { - TrackModel* trackModel = getTrackModel(); - if (trackModel) { - trackModel->purgeTracks(indices); - } + if (indices.isEmpty()) { + return; + } + TrackModel* trackModel = getTrackModel(); + if (trackModel) { + trackModel->purgeTracks(indices); } } -void WTrackTableView::slotUnhide() { +void WTrackTableView::slotDeleteTracksFromDisk() { QModelIndexList indices = selectionModel()->selectedRows(); + if (indices.isEmpty()) { + return; + } + m_pTrackMenu->loadTrackModelIndices(indices); + m_pTrackMenu->slotRemoveFromDisk(); +} - if (indices.size() > 0) { - TrackModel* trackModel = getTrackModel(); - if (trackModel) { - trackModel->unhideTracks(indices); - } +void WTrackTableView::slotUnhide() { + QModelIndexList indices = selectionModel()->selectedRows(); + if (indices.isEmpty()) { + return; + } + TrackModel* trackModel = getTrackModel(); + if (trackModel) { + trackModel->unhideTracks(indices); } } @@ -817,33 +827,35 @@ void WTrackTableView::hideOrRemoveSelectedTracks() { void WTrackTableView::loadSelectedTrack() { auto indices = selectionModel()->selectedRows(); - if (indices.size() > 0) { - slotMouseDoubleClicked(indices.at(0)); + if (indices.isEmpty()) { + return; } + slotMouseDoubleClicked(indices.at(0)); } void WTrackTableView::loadSelectedTrackToGroup(const QString& group, bool play) { auto indices = selectionModel()->selectedRows(); - if (indices.size() > 0) { - // If the track load override is disabled, check to see if a track is - // playing before trying to load it - if (!(m_pConfig->getValueString( - ConfigKey("[Controls]", "AllowTrackLoadToPlayingDeck")) - .toInt())) { - // TODO(XXX): Check for other than just the first preview deck. - if (group != "[PreviewDeck1]" && - ControlObject::get(ConfigKey(group, "play")) > 0.0) { - return; - } - } - auto index = indices.at(0); - auto* trackModel = getTrackModel(); - TrackPointer pTrack; - if (trackModel && - (pTrack = trackModel->getTrack(index))) { - emit loadTrackToPlayer(pTrack, group, play); + if (indices.isEmpty()) { + return; + } + // If the track load override is disabled, check to see if a track is + // playing before trying to load it + if (!(m_pConfig->getValueString( + ConfigKey("[Controls]", "AllowTrackLoadToPlayingDeck")) + .toInt())) { + // TODO(XXX): Check for other than just the first preview deck. + if (group != "[PreviewDeck1]" && + ControlObject::get(ConfigKey(group, "play")) > 0.0) { + return; } } + auto index = indices.at(0); + auto* trackModel = getTrackModel(); + TrackPointer pTrack; + if (trackModel && + (pTrack = trackModel->getTrack(index))) { + emit loadTrackToPlayer(pTrack, group, play); + } } QList WTrackTableView::getSelectedTrackIds() const { diff --git a/src/widget/wtracktableview.h b/src/widget/wtracktableview.h index 9a240bd13b6..3cb326d6efe 100644 --- a/src/widget/wtracktableview.h +++ b/src/widget/wtracktableview.h @@ -62,6 +62,7 @@ class WTrackTableView : public WLibraryTableView { void slotMouseDoubleClicked(const QModelIndex &); void slotUnhide(); void slotPurge(); + void slotDeleteTracksFromDisk(); void slotAddToAutoDJBottom() override; void slotAddToAutoDJTop() override;