1 /**
2  * \file timeeventeditor.cpp
3  * Editor for time events (synchronized lyrics and event timing codes).
4  *
5  * \b Project: Kid3
6  * \author Urs Fleisch
7  * \date 15 Mar 2014
8  *
9  * Copyright (C) 2014-2018  Urs Fleisch
10  *
11  * This file is part of Kid3.
12  *
13  * Kid3 is free software; you can redistribute it and/or modify
14  * it under the terms of the GNU General Public License as published by
15  * the Free Software Foundation; either version 2 of the License, or
16  * (at your option) any later version.
17  *
18  * Kid3 is distributed in the hope that it will be useful,
19  * but WITHOUT ANY WARRANTY; without even the implied warranty of
20  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
21  * GNU General Public License for more details.
22  *
23  * You should have received a copy of the GNU General Public License
24  * along with this program.  If not, see <http://www.gnu.org/licenses/>.
25  */
26 
27 #include "timeeventeditor.h"
28 #include <QCoreApplication>
29 #include <QVBoxLayout>
30 #include <QLabel>
31 #include <QPushButton>
32 #include <QTableView>
33 #include <QItemDelegate>
34 #include <QTimer>
35 #include <QAction>
36 #include <QMenu>
37 #include <QInputDialog>
38 #include <QFile>
39 #include <QTextStream>
40 #include <QKeyEvent>
41 #include <QApplication>
42 #include <QClipboard>
43 #include <QMimeData>
44 #include <QHeaderView>
45 #include "config.h"
46 #include "fileconfig.h"
47 #include "timeeventmodel.h"
48 #include "timestampdelegate.h"
49 #include "eventcodedelegate.h"
50 #include "kid3application.h"
51 #ifdef HAVE_QTMULTIMEDIA
52 #include "audioplayer.h"
53 #endif
54 #include "contexthelp.h"
55 #include "iplatformtools.h"
56 
57 /** Table to edit time events. */
58 class TimeEventTableView : public QTableView {
59 public:
60   /** Constructor. */
TimeEventTableView(QWidget * parent=nullptr)61   TimeEventTableView(QWidget* parent = nullptr) : QTableView(parent) {}
62   /** Destructor. */
63   virtual ~TimeEventTableView() override = default;
64 
65 protected:
66   /**
67    * Handle key events, delete cell contents if Delete key is pressed.
68    * @param event key event
69    */
70   virtual void keyPressEvent(QKeyEvent* event) override;
71 
72 private:
73   Q_DISABLE_COPY(TimeEventTableView)
74 };
75 
keyPressEvent(QKeyEvent * event)76 void TimeEventTableView::keyPressEvent(QKeyEvent* event)
77 {
78   if (event->key() == Qt::Key_Delete) {
79     QModelIndex idx = currentIndex();
80     QAbstractItemModel* mdl = model();
81     if (mdl && idx.isValid()) {
82 #if QT_VERSION >= 0x060000
83       mdl->setData(idx, QVariant(idx.data().metaType()));
84 #else
85       mdl->setData(idx, QVariant(idx.data().type()));
86 #endif
87       return;
88     }
89   }
90   QTableView::keyPressEvent(event);
91 }
92 
93 
94 /**
95  * Constructor.
96  *
97  * @param platformTools platform tools
98  * @param app application context
99  * @param parent parent widget
100  * @param field  field containing binary data
101  * @param taggedFile tagged file
102  * @param tagNr tag number
103  */
TimeEventEditor(IPlatformTools * platformTools,Kid3Application * app,QWidget * parent,const Frame::Field & field,const TaggedFile * taggedFile,Frame::TagNumber tagNr)104 TimeEventEditor::TimeEventEditor(IPlatformTools* platformTools,
105                                  Kid3Application* app,
106                                  QWidget* parent, const Frame::Field& field,
107                                  const TaggedFile* taggedFile,
108                                  Frame::TagNumber tagNr)
109   : QWidget(parent),
110     m_platformTools(platformTools), m_app(app), m_eventCodeDelegate(nullptr),
111     m_model(nullptr), m_taggedFile(taggedFile), m_tagNr(tagNr),
112     m_byteArray(field.m_value.toByteArray()), m_fileIsPlayed(false)
113 {
114   setObjectName(QLatin1String("TimeEventEditor"));
115   auto vlayout = new QVBoxLayout(this);
116   m_label = new QLabel(this);
117   vlayout->addWidget(m_label);
118   vlayout->setContentsMargins(0, 0, 0, 0);
119   auto buttonLayout = new QHBoxLayout;
120   QPushButton* addButton = new QPushButton(tr("&Add"), this);
121   addButton->setAutoDefault(false);
122   QPushButton* deleteButton = new QPushButton(tr("&Delete"), this);
123   deleteButton->setAutoDefault(false);
124   QPushButton* clipButton = new QPushButton(tr("From Clip&board"), this);
125   clipButton->setAutoDefault(false);
126   QPushButton* importButton = new QPushButton(tr("&Import..."), this);
127   importButton->setAutoDefault(false);
128   QPushButton* exportButton = new QPushButton(tr("&Export..."), this);
129   exportButton->setAutoDefault(false);
130   QPushButton* helpButton = new QPushButton(tr("Help"), this);
131   helpButton->setAutoDefault(false);
132   buttonLayout->setContentsMargins(0, 0, 0, 0);
133   buttonLayout->addWidget(addButton);
134   buttonLayout->addWidget(deleteButton);
135   buttonLayout->addWidget(clipButton);
136   buttonLayout->addWidget(importButton);
137   buttonLayout->addWidget(exportButton);
138   buttonLayout->addWidget(helpButton);
139   buttonLayout->addStretch();
140   connect(addButton, &QAbstractButton::clicked, this, &TimeEventEditor::addItem);
141   connect(deleteButton, &QAbstractButton::clicked, this, &TimeEventEditor::deleteRows);
142   connect(clipButton, &QAbstractButton::clicked, this, &TimeEventEditor::clipData);
143   connect(importButton, &QAbstractButton::clicked, this, &TimeEventEditor::importData);
144   connect(exportButton, &QAbstractButton::clicked, this, &TimeEventEditor::exportData);
145   connect(helpButton, &QAbstractButton::clicked, this, &TimeEventEditor::showHelp);
146   vlayout->addLayout(buttonLayout);
147   m_tableView = new TimeEventTableView;
148   m_tableView->verticalHeader()->hide();
149   m_tableView->horizontalHeader()->setStretchLastSection(true);
150   m_tableView->setItemDelegateForColumn(0, new TimeStampDelegate(this));
151   m_tableView->setContextMenuPolicy(Qt::CustomContextMenu);
152   connect(m_tableView, &QWidget::customContextMenuRequested,
153       this, &TimeEventEditor::customContextMenu);
154   vlayout->addWidget(m_tableView);
155 }
156 
157 /**
158  * Connect to player when editor is shown.
159  * @param event event
160  */
showEvent(QShowEvent * event)161 void TimeEventEditor::showEvent(QShowEvent* event)
162 {
163   QTimer::singleShot(0, this, &TimeEventEditor::preparePlayer);
164   QWidget::showEvent(event);
165 }
166 
167 /**
168  * Disconnect from player when editor is hidden.
169  * @param event event
170  */
hideEvent(QHideEvent * event)171 void TimeEventEditor::hideEvent(QHideEvent* event)
172 {
173   if (QObject* player = m_app->getAudioPlayer()) {
174     disconnect(player, nullptr, this, nullptr);
175     m_fileIsPlayed = false;
176     QWidget::hideEvent(event);
177   }
178 }
179 
180 /**
181  * Set time event model.
182  * @param model time event model
183  */
setModel(TimeEventModel * model)184 void TimeEventEditor::setModel(TimeEventModel* model)
185 {
186   m_model = model;
187   if (m_model->getType() == TimeEventModel::EventTimingCodes) {
188     m_label->setText(tr("Events"));
189     if (!m_eventCodeDelegate) {
190       m_eventCodeDelegate = new EventCodeDelegate(this);
191     }
192     m_tableView->setItemDelegateForColumn(1, m_eventCodeDelegate);
193   } else {
194     m_label->setText(tr("Lyrics"));
195     m_tableView->setItemDelegateForColumn(1, nullptr);
196   }
197   m_tableView->setModel(m_model);
198 }
199 
200 /**
201  * Make sure that player is visible and in the edited file.
202  */
preparePlayer()203 void TimeEventEditor::preparePlayer()
204 {
205 #ifdef HAVE_QTMULTIMEDIA
206   m_app->showAudioPlayer();
207   if (AudioPlayer* player =
208       qobject_cast<AudioPlayer*>(m_app->getAudioPlayer())) {
209     QString filePath = m_taggedFile->getAbsFilename();
210     if (player->getFileName() != filePath) {
211       player->setFiles({filePath}, -1);
212     }
213     m_fileIsPlayed = true;
214     connect(player, &AudioPlayer::trackChanged,
215             this, &TimeEventEditor::onTrackChanged, Qt::UniqueConnection);
216     connect(player, &AudioPlayer::positionChanged,
217             this, &TimeEventEditor::onPositionChanged, Qt::UniqueConnection);
218   }
219 #endif
220 }
221 
222 /**
223  * Add a time event at the current player position.
224  */
addItem()225 void TimeEventEditor::addItem()
226 {
227 #ifdef HAVE_QTMULTIMEDIA
228   QTime timeStamp;
229   preparePlayer();
230   if (AudioPlayer* player =
231       qobject_cast<AudioPlayer*>(m_app->getAudioPlayer())) {
232     timeStamp = QTime(0, 0).addMSecs(player->getCurrentPosition());
233     if (m_model) {
234       // If the current row is empty, set the time stamp there, else insert a new
235       // row sorted by time stamps or use the first empty row.
236       QModelIndex index = m_tableView->currentIndex();
237       if (!(index.isValid() &&
238             (index = index.sibling(index.row(), TimeEventModel::CI_Time))
239             .data().isNull())) {
240         int row = 0;
241         bool insertRow = true;
242         while (row < m_model->rowCount()) {
243           QTime time = m_model->index(row, TimeEventModel::CI_Time)
244               .data().toTime();
245           if (time.isNull()) {
246             insertRow = false;
247             break;
248           } else if (time > timeStamp) {
249             break;
250           }
251           ++row;
252         }
253         if (insertRow) {
254           m_model->insertRow(row);
255         }
256         index = m_model->index(row, TimeEventModel::CI_Time);
257       }
258       m_model->setData(index, timeStamp);
259       m_tableView->scrollTo(index);
260     }
261   }
262 #endif
263 }
264 
265 /**
266  * Load LRC data from clipboard.
267  */
clipData()268 void TimeEventEditor::clipData()
269 {
270   QClipboard* cb = QApplication::clipboard();
271   if (cb && cb->mimeData()->hasText()) {
272     QString text = cb->text();
273     QTextStream stream(&text, QIODevice::ReadOnly);
274     m_model->fromLrcFile(stream);
275   }
276 }
277 
278 /**
279  * Import data in LRC format.
280  */
importData()281 void TimeEventEditor::importData()
282 {
283   if (!m_model)
284     return;
285 
286   QString loadFileName = m_platformTools->getOpenFileName(this, QString(),
287         m_taggedFile->getDirname(), getLrcNameFilter(), nullptr);
288   if (!loadFileName.isEmpty()) {
289     QFile file(loadFileName);
290     if (file.open(QIODevice::ReadOnly)) {
291       QTextStream stream(&file);
292       m_model->fromLrcFile(stream);
293       file.close();
294     }
295   }
296 }
297 
298 /**
299  * Export data in LRC format.
300  */
exportData()301 void TimeEventEditor::exportData()
302 {
303   if (!m_model)
304     return;
305 
306   QString suggestedFileName = m_taggedFile->getAbsFilename();
307   int dotPos = suggestedFileName.lastIndexOf(QLatin1Char('.'));
308   if (dotPos >= 0 && dotPos >= suggestedFileName.length() - 5) {
309     suggestedFileName.truncate(dotPos);
310   }
311   suggestedFileName += QLatin1String(".lrc");
312   QString saveFileName = m_platformTools->getSaveFileName(
313         this, QString(), suggestedFileName, getLrcNameFilter(), nullptr);
314   if (!saveFileName.isEmpty()) {
315     QFile file(saveFileName);
316     if (file.open(QIODevice::WriteOnly)) {
317       QTextStream stream(&file);
318       QString codecName = FileConfig::instance().textEncoding();
319       if (codecName != QLatin1String("System")) {
320 #if QT_VERSION >= 0x060000
321         if (auto encoding = QStringConverter::encodingForName(codecName.toLatin1())) {
322           stream.setEncoding(encoding.value());
323         }
324 #else
325         stream.setCodec(codecName.toLatin1());
326 #endif
327       }
328       QString title, artist, album;
329       Frame frame;
330       if (m_taggedFile->getFrame(m_tagNr, Frame::FT_Title, frame)) {
331         title = frame.getValue();
332       }
333       if (m_taggedFile->getFrame(m_tagNr, Frame::FT_Artist, frame)) {
334         artist = frame.getValue();
335       }
336       if (m_taggedFile->getFrame(m_tagNr, Frame::FT_Album, frame)) {
337         album = frame.getValue();
338       }
339       m_model->toLrcFile(stream, title, artist, album);
340       file.close();
341     }
342   }
343 }
344 
345 /**
346  * Get file name filter string for LRC files.
347  * @return filter string.
348  */
getLrcNameFilter() const349 QString TimeEventEditor::getLrcNameFilter() const
350 {
351   const char* const lyricsStr = QT_TRANSLATE_NOOP("@default", "Lyrics");
352   const char* const allFilesStr = QT_TRANSLATE_NOOP("@default", "All Files");
353   return m_platformTools->fileDialogNameFilter({
354         qMakePair(QCoreApplication::translate("@default", lyricsStr),
355                   QString(QLatin1String("*.lrc"))),
356         qMakePair(QCoreApplication::translate("@default", allFilesStr),
357                   QString(QLatin1Char('*')))
358   });
359 }
360 
361 /**
362  * Insert a new row after the current row.
363  */
insertRow()364 void TimeEventEditor::insertRow()
365 {
366   if (!m_model)
367     return;
368 
369   m_model->insertRow(m_tableView->currentIndex().isValid()
370                      ? m_tableView->currentIndex().row() + 1 : 0);
371 }
372 
373 /**
374  * Delete the selected rows.
375  */
deleteRows()376 void TimeEventEditor::deleteRows()
377 {
378   if (!m_model)
379     return;
380 
381   QMap<int, int> rows;
382   if (QItemSelectionModel* selModel = m_tableView->selectionModel()) {
383     const auto indexes = selModel->selectedIndexes();
384     for (const QModelIndex& index : indexes) {
385       rows.insert(index.row(), 0);
386     }
387   }
388 
389   QMapIterator<int, int> it(rows);
390   it.toBack();
391   while (it.hasPrevious()) {
392     it.previous();
393     m_model->removeRow(it.key());
394   }
395 }
396 
397 /**
398  * Clear the selected cells.
399  */
clearCells()400 void TimeEventEditor::clearCells()
401 {
402   if (!m_model)
403     return;
404 
405 #if QT_VERSION >= 0x060000
406   QVariant emptyData(m_model->getType() == TimeEventModel::EventTimingCodes
407                      ? QMetaType(QMetaType::Int) : QMetaType(QMetaType::QString));
408   QVariant emptyTime(QMetaType(QMetaType::QTime));
409 #else
410   QVariant emptyData(m_model->getType() == TimeEventModel::EventTimingCodes
411                      ? QVariant::Int : QVariant::String);
412   QVariant emptyTime(QVariant::Time);
413 #endif
414   if (QItemSelectionModel* selModel = m_tableView->selectionModel()) {
415     const auto indexes = selModel->selectedIndexes();
416     for (const QModelIndex& index : indexes) {
417       m_model->setData(index, index.column() == TimeEventModel::CI_Time
418                        ? emptyTime : emptyData);
419     }
420   }
421 }
422 
423 /**
424  * Add offset to time stamps.
425  */
addOffset()426 void TimeEventEditor::addOffset()
427 {
428   if (!m_model)
429     return;
430 
431   int offset = QInputDialog::getInt(this, tr("Offset"), tr("Milliseconds"));
432   if (QItemSelectionModel* selModel = m_tableView->selectionModel()) {
433     const auto indexes = selModel->selectedIndexes();
434     for (const QModelIndex& index : indexes) {
435       if (index.column() == TimeEventModel::CI_Time) {
436         m_model->setData(index, index.data().toTime().addMSecs(offset));
437       }
438     }
439   }
440 }
441 
442 /**
443  * Seek to position of current time stamp.
444  */
seekPosition()445 void TimeEventEditor::seekPosition()
446 {
447 #ifdef HAVE_QTMULTIMEDIA
448   QModelIndex index = m_tableView->currentIndex();
449   if (index.isValid() && m_fileIsPlayed) {
450     QTime timeStamp =
451         index.sibling(index.row(), TimeEventModel::CI_Time).data().toTime();
452     if (timeStamp.isValid()) {
453       if (AudioPlayer* player =
454           qobject_cast<AudioPlayer*>(m_app->getAudioPlayer())) {
455         player->setCurrentPosition(QTime(0, 0).msecsTo(timeStamp));
456       }
457     }
458   }
459 #endif
460 }
461 
462 /**
463  * Display custom context menu.
464  *
465  * @param pos position where context menu is drawn on screen
466  */
customContextMenu(const QPoint & pos)467 void TimeEventEditor::customContextMenu(const QPoint& pos)
468 {
469   QMenu menu(this);
470   QAction* action = menu.addAction(tr("&Insert row"));
471   connect(action, &QAction::triggered, this, &TimeEventEditor::insertRow);
472   QModelIndex index = m_tableView->indexAt(pos);
473   if (index.isValid()) {
474     action = menu.addAction(tr("&Delete rows"));
475     connect(action, &QAction::triggered, this, &TimeEventEditor::deleteRows);
476     action = menu.addAction(tr("C&lear"));
477     connect(action, &QAction::triggered, this, &TimeEventEditor::clearCells);
478     action = menu.addAction(tr("&Add offset..."));
479     connect(action, &QAction::triggered, this, &TimeEventEditor::addOffset);
480     action = menu.addAction(tr("&Seek to position"));
481     connect(action, &QAction::triggered, this, &TimeEventEditor::seekPosition);
482   }
483   menu.setMouseTracking(true);
484   menu.exec(m_tableView->mapToGlobal(pos));
485 }
486 
487 /**
488  * Called when the played track changed.
489  * @param filePath path to file being played
490  */
onTrackChanged(const QString & filePath)491 void TimeEventEditor::onTrackChanged(const QString& filePath)
492 {
493   m_fileIsPlayed = filePath == m_taggedFile->getAbsFilename();
494   if (m_model) {
495     m_model->clearMarkedRow();
496   }
497 }
498 
499 /**
500  * Called when the player position changed.
501  * @param position time in ms
502  */
onPositionChanged(qint64 position)503 void TimeEventEditor::onPositionChanged(qint64 position)
504 {
505   if (!m_fileIsPlayed || !m_model)
506     return;
507 
508   int oldRow = m_model->getMarkedRow();
509   m_model->markRowForTimeStamp(QTime(0, 0).addMSecs(position));
510   int row = m_model->getMarkedRow();
511   if (row != oldRow && row != -1) {
512     m_tableView->scrollTo(m_model->index(row, TimeEventModel::CI_Time),
513                           QAbstractItemView::PositionAtCenter);
514   }
515 }
516 
517 /**
518  * Show help.
519  */
showHelp()520 void TimeEventEditor::showHelp()
521 {
522   ContextHelp::displayHelp(QLatin1String("synchronized-lyrics"));
523 }
524