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