File indexing completed on 2025-02-23 04:34:25

0001 /**
0002  * \file timeeventeditor.cpp
0003  * Editor for time events (synchronized lyrics and event timing codes).
0004  *
0005  * \b Project: Kid3
0006  * \author Urs Fleisch
0007  * \date 15 Mar 2014
0008  *
0009  * Copyright (C) 2014-2024  Urs Fleisch
0010  *
0011  * This file is part of Kid3.
0012  *
0013  * Kid3 is free software; you can redistribute it and/or modify
0014  * it under the terms of the GNU General Public License as published by
0015  * the Free Software Foundation; either version 2 of the License, or
0016  * (at your option) any later version.
0017  *
0018  * Kid3 is distributed in the hope that it will be useful,
0019  * but WITHOUT ANY WARRANTY; without even the implied warranty of
0020  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
0021  * GNU General Public License for more details.
0022  *
0023  * You should have received a copy of the GNU General Public License
0024  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
0025  */
0026 
0027 #include "timeeventeditor.h"
0028 #include <QCoreApplication>
0029 #include <QVBoxLayout>
0030 #include <QLabel>
0031 #include <QPushButton>
0032 #include <QTableView>
0033 #include <QItemDelegate>
0034 #include <QTimer>
0035 #include <QAction>
0036 #include <QMenu>
0037 #include <QInputDialog>
0038 #include <QFile>
0039 #include <QTextStream>
0040 #include <QKeyEvent>
0041 #include <QApplication>
0042 #include <QClipboard>
0043 #include <QMimeData>
0044 #include <QHeaderView>
0045 #include "config.h"
0046 #include "fileconfig.h"
0047 #include "timeeventmodel.h"
0048 #include "timestampdelegate.h"
0049 #include "eventcodedelegate.h"
0050 #include "kid3application.h"
0051 #ifdef HAVE_QTMULTIMEDIA
0052 #include "audioplayer.h"
0053 #endif
0054 #include "contexthelp.h"
0055 #include "iplatformtools.h"
0056 
0057 /** Table to edit time events. */
0058 class TimeEventTableView : public QTableView {
0059 public:
0060   /** Constructor. */
0061   explicit TimeEventTableView(QWidget* parent = nullptr) : QTableView(parent) {}
0062   /** Destructor. */
0063   ~TimeEventTableView() override = default;
0064 
0065 protected:
0066   /**
0067    * Handle key events, delete cell contents if Delete key is pressed.
0068    * @param event key event
0069    */
0070   void keyPressEvent(QKeyEvent* event) override;
0071 
0072 private:
0073   Q_DISABLE_COPY(TimeEventTableView)
0074 };
0075 
0076 void TimeEventTableView::keyPressEvent(QKeyEvent* event)
0077 {
0078   if (event->key() == Qt::Key_Delete) {
0079     QModelIndex idx = currentIndex();
0080     if (QAbstractItemModel* mdl = model(); mdl && idx.isValid()) {
0081 #if QT_VERSION >= 0x060000
0082       mdl->setData(idx, QVariant(idx.data().metaType()));
0083 #else
0084       mdl->setData(idx, QVariant(idx.data().type()));
0085 #endif
0086       return;
0087     }
0088   }
0089   QTableView::keyPressEvent(event);
0090 }
0091 
0092 
0093 /**
0094  * Constructor.
0095  *
0096  * @param platformTools platform tools
0097  * @param app application context
0098  * @param parent parent widget
0099  * @param field  field containing binary data
0100  * @param taggedFile tagged file
0101  * @param tagNr tag number
0102  */
0103 TimeEventEditor::TimeEventEditor(IPlatformTools* platformTools,
0104                                  Kid3Application* app,
0105                                  QWidget* parent, const Frame::Field& field,
0106                                  const TaggedFile* taggedFile,
0107                                  Frame::TagNumber tagNr)
0108   : QWidget(parent),
0109     m_platformTools(platformTools), m_app(app), m_eventCodeDelegate(nullptr),
0110     m_model(nullptr), m_taggedFile(taggedFile), m_tagNr(tagNr),
0111     m_byteArray(field.m_value.toByteArray()), m_fileIsPlayed(false)
0112 {
0113   setObjectName(QLatin1String("TimeEventEditor"));
0114   auto vlayout = new QVBoxLayout(this);
0115   m_label = new QLabel(this);
0116   vlayout->addWidget(m_label);
0117   vlayout->setContentsMargins(0, 0, 0, 0);
0118   auto buttonLayout = new QHBoxLayout;
0119   auto addButton = new QPushButton(tr("&Add"), this);
0120   addButton->setAutoDefault(false);
0121   auto deleteButton = new QPushButton(tr("&Delete"), this);
0122   deleteButton->setAutoDefault(false);
0123   auto clipButton = new QPushButton(tr("From Clip&board"), this);
0124   clipButton->setAutoDefault(false);
0125   auto importButton = new QPushButton(tr("&Import..."), this);
0126   importButton->setAutoDefault(false);
0127   auto exportButton = new QPushButton(tr("&Export..."), this);
0128   exportButton->setAutoDefault(false);
0129   auto helpButton = new QPushButton(tr("Help"), this);
0130   helpButton->setAutoDefault(false);
0131   buttonLayout->setContentsMargins(0, 0, 0, 0);
0132   buttonLayout->addWidget(addButton);
0133   buttonLayout->addWidget(deleteButton);
0134   buttonLayout->addWidget(clipButton);
0135   buttonLayout->addWidget(importButton);
0136   buttonLayout->addWidget(exportButton);
0137   buttonLayout->addWidget(helpButton);
0138   buttonLayout->addStretch();
0139   connect(addButton, &QAbstractButton::clicked, this, &TimeEventEditor::addItem);
0140   connect(deleteButton, &QAbstractButton::clicked, this, &TimeEventEditor::deleteRows);
0141   connect(clipButton, &QAbstractButton::clicked, this, &TimeEventEditor::clipData);
0142   connect(importButton, &QAbstractButton::clicked, this, &TimeEventEditor::importData);
0143   connect(exportButton, &QAbstractButton::clicked, this, &TimeEventEditor::exportData);
0144   connect(helpButton, &QAbstractButton::clicked, this, &TimeEventEditor::showHelp);
0145   vlayout->addLayout(buttonLayout);
0146   m_tableView = new TimeEventTableView;
0147   m_tableView->verticalHeader()->hide();
0148   m_tableView->horizontalHeader()->setStretchLastSection(true);
0149   m_tableView->setItemDelegateForColumn(0, new TimeStampDelegate(this));
0150   m_tableView->setContextMenuPolicy(Qt::CustomContextMenu);
0151   connect(m_tableView, &QWidget::customContextMenuRequested,
0152       this, &TimeEventEditor::customContextMenu);
0153   vlayout->addWidget(m_tableView);
0154 }
0155 
0156 /**
0157  * Connect to player when editor is shown.
0158  * @param event event
0159  */
0160 void TimeEventEditor::showEvent(QShowEvent* event)
0161 {
0162   QTimer::singleShot(0, this, &TimeEventEditor::preparePlayer);
0163   QWidget::showEvent(event);
0164 }
0165 
0166 /**
0167  * Disconnect from player when editor is hidden.
0168  * @param event event
0169  */
0170 void TimeEventEditor::hideEvent(QHideEvent* event)
0171 {
0172   if (QObject* player = m_app->getAudioPlayer()) {
0173     disconnect(player, nullptr, this, nullptr);
0174     m_fileIsPlayed = false;
0175     QWidget::hideEvent(event);
0176   }
0177 }
0178 
0179 /**
0180  * Set time event model.
0181  * @param model time event model
0182  */
0183 void TimeEventEditor::setModel(TimeEventModel* model)
0184 {
0185   m_model = model;
0186   if (m_model->getType() == TimeEventModel::EventTimingCodes) {
0187     m_label->setText(tr("Events"));
0188     if (!m_eventCodeDelegate) {
0189       m_eventCodeDelegate = new EventCodeDelegate(this);
0190     }
0191     m_tableView->setItemDelegateForColumn(1, m_eventCodeDelegate);
0192   } else {
0193     m_label->setText(tr("Lyrics"));
0194     m_tableView->setItemDelegateForColumn(1, nullptr);
0195   }
0196   m_tableView->setModel(m_model);
0197 }
0198 
0199 /**
0200  * Make sure that player is visible and in the edited file.
0201  */
0202 void TimeEventEditor::preparePlayer()
0203 {
0204 #ifdef HAVE_QTMULTIMEDIA
0205   m_app->showAudioPlayer();
0206   if (auto player =
0207       qobject_cast<AudioPlayer*>(m_app->getAudioPlayer())) {
0208     if (QString filePath = m_taggedFile->getAbsFilename();
0209         player->getFileName() != filePath) {
0210       player->setFiles({filePath}, -1);
0211     }
0212     m_fileIsPlayed = true;
0213     connect(player, &AudioPlayer::trackChanged,
0214             this, &TimeEventEditor::onTrackChanged, Qt::UniqueConnection);
0215     connect(player, &AudioPlayer::positionChanged,
0216             this, &TimeEventEditor::onPositionChanged, Qt::UniqueConnection);
0217   }
0218 #endif
0219 }
0220 
0221 /**
0222  * Add a time event at the current player position.
0223  */
0224 void TimeEventEditor::addItem()
0225 {
0226 #ifdef HAVE_QTMULTIMEDIA
0227   preparePlayer();
0228   if (auto player =
0229       qobject_cast<AudioPlayer*>(m_app->getAudioPlayer())) {
0230     QTime timeStamp = QTime(0, 0).addMSecs(player->getCurrentPosition());
0231     if (m_model) {
0232       // If the current row is empty, set the time stamp there, else insert a new
0233       // row sorted by time stamps or use the first empty row.
0234       QModelIndex index = m_tableView->currentIndex();
0235       if (!(index.isValid() &&
0236             (index = index.sibling(index.row(), TimeEventModel::CI_Time))
0237             .data().isNull())) {
0238         int row = 0;
0239         bool insertRow = true;
0240         while (row < m_model->rowCount()) {
0241           if (QTime time = m_model->index(row, TimeEventModel::CI_Time)
0242                                   .data().toTime();
0243               time.isNull()) {
0244             insertRow = false;
0245             break;
0246           } else if (time > timeStamp) {
0247             break;
0248           }
0249           ++row;
0250         }
0251         if (insertRow) {
0252           m_model->insertRow(row);
0253         }
0254         index = m_model->index(row, TimeEventModel::CI_Time);
0255       }
0256       m_model->setData(index, timeStamp);
0257       m_tableView->scrollTo(index);
0258     }
0259   }
0260 #endif
0261 }
0262 
0263 /**
0264  * Load LRC data from clipboard.
0265  */
0266 void TimeEventEditor::clipData()
0267 {
0268   if (QClipboard* cb = QApplication::clipboard();
0269       cb && cb->mimeData()->hasText()) {
0270     QString text = cb->text();
0271     QTextStream stream(&text, QIODevice::ReadOnly);
0272     m_model->fromLrcFile(stream);
0273   }
0274 }
0275 
0276 /**
0277  * Import data in LRC format.
0278  */
0279 void TimeEventEditor::importData()
0280 {
0281   if (!m_model)
0282     return;
0283 
0284   if (QString loadFileName = m_platformTools->getOpenFileName(this, QString(),
0285         m_taggedFile->getDirname(), getLrcNameFilter(), nullptr);
0286       !loadFileName.isEmpty()) {
0287     QFile file(loadFileName);
0288     if (file.open(QIODevice::ReadOnly)) {
0289       QTextStream stream(&file);
0290       m_model->fromLrcFile(stream);
0291       file.close();
0292     }
0293   }
0294 }
0295 
0296 /**
0297  * Export data in LRC format.
0298  */
0299 void TimeEventEditor::exportData()
0300 {
0301   if (!m_model)
0302     return;
0303 
0304   QString suggestedFileName = m_taggedFile->getAbsFilename();
0305   if (int dotPos = suggestedFileName.lastIndexOf(QLatin1Char('.'));
0306       dotPos >= 0 && dotPos >= suggestedFileName.length() - 5) {
0307     suggestedFileName.truncate(dotPos);
0308   }
0309   suggestedFileName += QLatin1String(".lrc");
0310   if (QString saveFileName = m_platformTools->getSaveFileName(
0311         this, QString(), suggestedFileName, getLrcNameFilter(), nullptr);
0312       !saveFileName.isEmpty()) {
0313     QFile file(saveFileName);
0314     if (file.open(QIODevice::WriteOnly)) {
0315       QTextStream stream(&file);
0316       if (QString codecName = FileConfig::instance().textEncoding();
0317           codecName != QLatin1String("System")) {
0318 #if QT_VERSION >= 0x060000
0319         if (auto encoding = QStringConverter::encodingForName(codecName.toLatin1())) {
0320           stream.setEncoding(*encoding);
0321         }
0322 #else
0323         stream.setCodec(codecName.toLatin1());
0324 #endif
0325       }
0326       QString title, artist, album;
0327       Frame frame;
0328       if (m_taggedFile->getFrame(m_tagNr, Frame::FT_Title, frame)) {
0329         title = frame.getValue();
0330       }
0331       if (m_taggedFile->getFrame(m_tagNr, Frame::FT_Artist, frame)) {
0332         artist = frame.getValue();
0333       }
0334       if (m_taggedFile->getFrame(m_tagNr, Frame::FT_Album, frame)) {
0335         album = frame.getValue();
0336       }
0337       m_model->toLrcFile(stream, title, artist, album);
0338       file.close();
0339     }
0340   }
0341 }
0342 
0343 /**
0344  * Get file name filter string for LRC files.
0345  * @return filter string.
0346  */
0347 QString TimeEventEditor::getLrcNameFilter() const
0348 {
0349   const char* const lyricsStr = QT_TRANSLATE_NOOP("@default", "Lyrics");
0350   const char* const allFilesStr = QT_TRANSLATE_NOOP("@default", "All Files");
0351   return m_platformTools->fileDialogNameFilter({
0352         qMakePair(QCoreApplication::translate("@default", lyricsStr),
0353                   QString(QLatin1String("*.lrc"))),
0354         qMakePair(QCoreApplication::translate("@default", allFilesStr),
0355                   QString(QLatin1Char('*')))
0356   });
0357 }
0358 
0359 /**
0360  * Insert a new row after the current row.
0361  */
0362 void TimeEventEditor::insertRow()
0363 {
0364   if (!m_model)
0365     return;
0366 
0367   m_model->insertRow(m_tableView->currentIndex().isValid()
0368                      ? m_tableView->currentIndex().row() + 1 : 0);
0369 }
0370 
0371 /**
0372  * Delete the selected rows.
0373  */
0374 void TimeEventEditor::deleteRows()
0375 {
0376   if (!m_model)
0377     return;
0378 
0379   QMap<int, int> rows;
0380   if (QItemSelectionModel* selModel = m_tableView->selectionModel()) {
0381     const auto indexes = selModel->selectedIndexes();
0382     for (const QModelIndex& index : indexes) {
0383       rows.insert(index.row(), 0);
0384     }
0385   }
0386 
0387   QMapIterator it(rows);
0388   it.toBack();
0389   while (it.hasPrevious()) {
0390     it.previous();
0391     m_model->removeRow(it.key());
0392   }
0393 }
0394 
0395 /**
0396  * Clear the selected cells.
0397  */
0398 void TimeEventEditor::clearCells()
0399 {
0400   if (!m_model)
0401     return;
0402 
0403 #if QT_VERSION >= 0x060000
0404   QVariant emptyData(m_model->getType() == TimeEventModel::EventTimingCodes
0405                      ? QMetaType(QMetaType::Int) : QMetaType(QMetaType::QString));
0406   QVariant emptyTime((QMetaType(QMetaType::QTime)));
0407 #else
0408   QVariant emptyData(m_model->getType() == TimeEventModel::EventTimingCodes
0409                      ? QVariant::Int : QVariant::String);
0410   QVariant emptyTime(QVariant::Time);
0411 #endif
0412   if (QItemSelectionModel* selModel = m_tableView->selectionModel()) {
0413     const auto indexes = selModel->selectedIndexes();
0414     for (const QModelIndex& index : indexes) {
0415       m_model->setData(index, index.column() == TimeEventModel::CI_Time
0416                        ? emptyTime : emptyData);
0417     }
0418   }
0419 }
0420 
0421 /**
0422  * Add offset to time stamps.
0423  */
0424 void TimeEventEditor::addOffset()
0425 {
0426   if (!m_model)
0427     return;
0428 
0429   int offset = QInputDialog::getInt(this, tr("Offset"), tr("Milliseconds"));
0430   if (QItemSelectionModel* selModel = m_tableView->selectionModel()) {
0431     const auto indexes = selModel->selectedIndexes();
0432     for (const QModelIndex& index : indexes) {
0433       if (index.column() == TimeEventModel::CI_Time) {
0434         m_model->setData(index, index.data().toTime().addMSecs(offset));
0435       }
0436     }
0437   }
0438 }
0439 
0440 /**
0441  * Seek to position of current time stamp.
0442  */
0443 void TimeEventEditor::seekPosition()
0444 {
0445 #ifdef HAVE_QTMULTIMEDIA
0446   if (QModelIndex index = m_tableView->currentIndex();
0447       index.isValid() && m_fileIsPlayed) {
0448     if (QTime timeStamp =
0449           index.sibling(index.row(), TimeEventModel::CI_Time).data().toTime();
0450         timeStamp.isValid()) {
0451       if (auto player =
0452           qobject_cast<AudioPlayer*>(m_app->getAudioPlayer())) {
0453         player->setCurrentPosition(QTime(0, 0).msecsTo(timeStamp));
0454       }
0455     }
0456   }
0457 #endif
0458 }
0459 
0460 /**
0461  * Display custom context menu.
0462  *
0463  * @param pos position where context menu is drawn on screen
0464  */
0465 void TimeEventEditor::customContextMenu(const QPoint& pos)
0466 {
0467   QMenu menu(this);
0468   QAction* action = menu.addAction(tr("&Insert row"));
0469   connect(action, &QAction::triggered, this, &TimeEventEditor::insertRow);
0470   if (QModelIndex index = m_tableView->indexAt(pos); index.isValid()) {
0471     action = menu.addAction(tr("&Delete rows"));
0472     connect(action, &QAction::triggered, this, &TimeEventEditor::deleteRows);
0473     action = menu.addAction(tr("C&lear"));
0474     connect(action, &QAction::triggered, this, &TimeEventEditor::clearCells);
0475     action = menu.addAction(tr("&Add offset..."));
0476     connect(action, &QAction::triggered, this, &TimeEventEditor::addOffset);
0477     action = menu.addAction(tr("&Seek to position"));
0478     connect(action, &QAction::triggered, this, &TimeEventEditor::seekPosition);
0479   }
0480   menu.setMouseTracking(true);
0481   menu.exec(m_tableView->mapToGlobal(pos));
0482 }
0483 
0484 /**
0485  * Called when the played track changed.
0486  * @param filePath path to file being played
0487  */
0488 void TimeEventEditor::onTrackChanged(const QString& filePath)
0489 {
0490   m_fileIsPlayed = filePath == m_taggedFile->getAbsFilename();
0491   if (m_model) {
0492     m_model->clearMarkedRow();
0493   }
0494 }
0495 
0496 /**
0497  * Called when the player position changed.
0498  * @param position time in ms
0499  */
0500 void TimeEventEditor::onPositionChanged(qint64 position)
0501 {
0502   if (!m_fileIsPlayed || !m_model)
0503     return;
0504 
0505   int oldRow = m_model->getMarkedRow();
0506   m_model->markRowForTimeStamp(QTime(0, 0).addMSecs(position));
0507   if (int row = m_model->getMarkedRow(); row != oldRow && row != -1) {
0508     m_tableView->scrollTo(m_model->index(row, TimeEventModel::CI_Time),
0509                           QAbstractItemView::PositionAtCenter);
0510   }
0511 }
0512 
0513 /**
0514  * Show help.
0515  */
0516 void TimeEventEditor::showHelp()
0517 {
0518   ContextHelp::displayHelp(QLatin1String("synchronized-lyrics"));
0519 }