1 /**
2 * \file timeeventmodel.cpp
3 * Time event model (synchronized lyrics or event timing codes).
4 *
5 * \b Project: Kid3
6 * \author Urs Fleisch
7 * \date 14 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 "timeeventmodel.h"
28 #include <QTextStream>
29 #include <QRegularExpression>
30 #include "coretaggedfileiconprovider.h"
31 #include "eventtimingcode.h"
32
33 /**
34 * Constructor.
35 * @param colorProvider colorProvider
36 * @param parent parent widget
37 */
TimeEventModel(CoreTaggedFileIconProvider * colorProvider,QObject * parent)38 TimeEventModel::TimeEventModel(CoreTaggedFileIconProvider* colorProvider,
39 QObject* parent)
40 : QAbstractTableModel(parent), m_type(SynchronizedLyrics), m_markedRow(-1),
41 m_colorProvider(colorProvider)
42 {
43 setObjectName(QLatin1String("TimeEventModel"));
44 }
45
46 /**
47 * Get item flags for index.
48 * @param index model index
49 * @return item flags
50 */
flags(const QModelIndex & index) const51 Qt::ItemFlags TimeEventModel::flags(const QModelIndex& index) const
52 {
53 Qt::ItemFlags theFlags = QAbstractTableModel::flags(index);
54 if (index.isValid())
55 theFlags |= Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable;
56 return theFlags;
57 }
58
59 /**
60 * Get data for a given role.
61 * @param index model index
62 * @param role item data role
63 * @return data for role
64 */
data(const QModelIndex & index,int role) const65 QVariant TimeEventModel::data(const QModelIndex& index, int role) const
66 {
67 if (!index.isValid() ||
68 index.row() < 0 || index.row() >= m_timeEvents.size() ||
69 index.column() < 0 || index.column() >= CI_NumColumns)
70 return QVariant();
71 const TimeEvent& timeEvent = m_timeEvents.at(index.row());
72 if (role == Qt::DisplayRole || role == Qt::EditRole) {
73 if (index.column() == CI_Time)
74 return timeEvent.time;
75 else
76 return timeEvent.data;
77 } else if (role == Qt::BackgroundRole && index.column() == CI_Data &&
78 m_colorProvider) {
79 return m_colorProvider->colorForContext(index.row() == m_markedRow
80 ? ColorContext::Marked : ColorContext::None);
81 }
82 return QVariant();
83 }
84
85 /**
86 * Set data for a given role.
87 * @param index model index
88 * @param value data value
89 * @param role item data role
90 * @return true if successful
91 */
setData(const QModelIndex & index,const QVariant & value,int role)92 bool TimeEventModel::setData(const QModelIndex& index,
93 const QVariant& value, int role)
94 {
95 if (!index.isValid() || role != Qt::EditRole ||
96 index.row() < 0 || index.row() >= m_timeEvents.size() ||
97 index.column() < 0 || index.column() >= CI_NumColumns)
98 return false;
99 TimeEvent& timeEvent = m_timeEvents[index.row()]; // clazy:exclude=detaching-member
100 if (index.column() == CI_Time) {
101 timeEvent.time = value.toTime();
102 } else {
103 timeEvent.data = value;
104 }
105 emit dataChanged(index, index);
106 return true;
107 }
108
109 /**
110 * Get data for header section.
111 * @param section column or row
112 * @param orientation horizontal or vertical
113 * @param role item data role
114 * @return header data for role
115 */
headerData(int section,Qt::Orientation orientation,int role) const116 QVariant TimeEventModel::headerData(
117 int section, Qt::Orientation orientation, int role) const
118 {
119 if (role != Qt::DisplayRole)
120 return QVariant();
121 if (orientation == Qt::Horizontal && section < CI_NumColumns) {
122 if (section == CI_Time) {
123 return tr("Time");
124 } else if (m_type == EventTimingCodes) {
125 return tr("Event Code");
126 } else {
127 return tr("Text");
128 }
129 }
130 return section + 1;
131 }
132
133 /**
134 * Get number of rows.
135 * @param parent parent model index, invalid for table models
136 * @return number of rows,
137 * if parent is valid number of children (0 for table models)
138 */
rowCount(const QModelIndex & parent) const139 int TimeEventModel::rowCount(const QModelIndex& parent) const
140 {
141 return parent.isValid() ? 0 : m_timeEvents.size();
142 }
143
144 /**
145 * Get number of columns.
146 * @param parent parent model index, invalid for table models
147 * @return number of columns,
148 * if parent is valid number of children (0 for table models)
149 */
columnCount(const QModelIndex & parent) const150 int TimeEventModel::columnCount(const QModelIndex& parent) const
151 {
152 return parent.isValid() ? 0 : CI_NumColumns;
153 }
154
155 /**
156 * Insert rows.
157 * @param row rows are inserted before this row, if 0 at the begin,
158 * if rowCount() at the end
159 * @param count number of rows to insert
160 * @param parent parent model index, invalid for table models
161 * @return true if successful
162 */
insertRows(int row,int count,const QModelIndex &)163 bool TimeEventModel::insertRows(int row, int count,
164 const QModelIndex&)
165 {
166 if (count > 0) {
167 beginInsertRows(QModelIndex(), row, row + count - 1);
168 for (int i = 0; i < count; ++i)
169 m_timeEvents.insert(row, TimeEvent(QTime(), QVariant()));
170 endInsertRows();
171 }
172 return true;
173 }
174
175 /**
176 * Remove rows.
177 * @param row rows are removed starting with this row
178 * @param count number of rows to remove
179 * @param parent parent model index, invalid for table models
180 * @return true if successful
181 */
removeRows(int row,int count,const QModelIndex &)182 bool TimeEventModel::removeRows(int row, int count,
183 const QModelIndex&)
184 {
185 if (count > 0) {
186 beginRemoveRows(QModelIndex(), row, row + count - 1);
187 for (int i = 0; i < count; ++i)
188 m_timeEvents.removeAt(row);
189 endRemoveRows();
190 }
191 return true;
192 }
193
194 /**
195 * Set the model from a list of time events.
196 * @param events list of time events
197 */
setTimeEvents(const QList<TimeEvent> & events)198 void TimeEventModel::setTimeEvents(const QList<TimeEvent>& events)
199 {
200 beginResetModel();
201 m_timeEvents = events;
202 endResetModel();
203 }
204
205 /**
206 * Get time event list from the model.
207 * @return list of time events.
208 */
getTimeEvents() const209 QList<TimeEventModel::TimeEvent> TimeEventModel::getTimeEvents() const
210 {
211 return m_timeEvents;
212 }
213
214 /**
215 * Set the model from a SYLT frame.
216 * @param fields ID3v2 SYLT frame fields
217 */
fromSyltFrame(const Frame::FieldList & fields)218 void TimeEventModel::fromSyltFrame(const Frame::FieldList& fields)
219 {
220 QVariantList synchedData;
221 bool unitIsFrames = false;
222 for (auto it = fields.constBegin(); it != fields.constEnd(); ++it) {
223 const Frame::Field& fld = *it;
224 if (fld.m_id == Frame::ID_TimestampFormat) {
225 unitIsFrames = fld.m_value.toInt() == 1;
226 #if QT_VERSION >= 0x060000
227 } else if (fld.m_value.typeId() == QMetaType::QVariantList) {
228 #else
229 } else if (fld.m_value.type() == QVariant::List) {
230 #endif
231 synchedData = fld.m_value.toList();
232 }
233 }
234
235 bool newLinesStartWithLineBreak = false;
236 QList<TimeEvent> timeEvents;
237 QListIterator<QVariant> it(synchedData);
238 while (it.hasNext()) {
239 quint32 milliseconds = it.next().toUInt();
240 if (!it.hasNext())
241 break;
242
243 QString str = it.next().toString();
244 if (timeEvents.isEmpty() && str.startsWith(QLatin1Char('\n'))) {
245 // The first entry determines if new lines have to start with a new line
246 // character or if all entries are supposed to be new lines.
247 newLinesStartWithLineBreak = true;
248 }
249
250 bool isNewLine = !newLinesStartWithLineBreak;
251 if (str.startsWith(QLatin1Char('\n'))) {
252 // New lines start with a new line character, which is removed.
253 isNewLine = true;
254 str.remove(0, 1);
255 }
256 if (isNewLine) {
257 // If the resulting line starts with one of the special characters
258 // (' ', '-', '_'), it is escaped with '#'.
259 if (str.length() > 0) {
260 QChar ch = str.at(0);
261 if (ch == QLatin1Char(' ') || ch == QLatin1Char('-') ||
262 ch == QLatin1Char('_')) {
263 str.prepend(QLatin1Char('#'));
264 }
265 }
266 } else if (!(str.startsWith(QLatin1Char(' ')) ||
267 str.startsWith(QLatin1Char('-')))) {
268 // Continuations of the current line do not start with a new line
269 // character. They must start with ' ' or '-'. If the line starts with
270 // another character, it is escaped with '_'.
271 str.prepend(QLatin1Char('_'));
272 }
273
274 QVariant timeStamp = unitIsFrames
275 ? QVariant(milliseconds)
276 : QVariant(QTime(0, 0).addMSecs(milliseconds));
277 timeEvents.append(TimeEvent(timeStamp, str));
278 }
279 setTimeEvents(timeEvents);
280 }
281
282 /**
283 * Get the model as a SYLT frame.
284 * @param fields ID3v2 SYLT frame fields to set
285 */
toSyltFrame(Frame::FieldList & fields) const286 void TimeEventModel::toSyltFrame(Frame::FieldList& fields) const
287 {
288 auto timeStampFormatIt = fields.end();
289 auto dataIt = fields.end();
290 for (auto it = fields.begin(); it != fields.end(); ++it) {
291 if (it->m_id == Frame::ID_TimestampFormat) {
292 timeStampFormatIt = it;
293 #if QT_VERSION >= 0x060000
294 } else if (it->m_value.typeId() == QMetaType::QVariantList) {
295 #else
296 } else if (it->m_value.type() == QVariant::List) {
297 #endif
298 dataIt = it;
299 }
300 }
301
302 QVariantList synchedData;
303 bool hasMsTimeStamps = false;
304 const auto timeEvents = m_timeEvents;
305 for (const TimeEvent& timeEvent : timeEvents) {
306 if (!timeEvent.time.isNull()) {
307 QString str = timeEvent.data.toString();
308 // Remove escaping, restore new line characters.
309 if (str.startsWith(QLatin1Char('_'))) {
310 str.remove(0, 1);
311 } else if (str.startsWith(QLatin1Char('#'))) {
312 str.replace(0, 1, QLatin1Char('\n'));
313 } else if (!(str.startsWith(QLatin1Char(' ')) ||
314 str.startsWith(QLatin1Char('-')))) {
315 str.prepend(QLatin1Char('\n'));
316 }
317
318 quint32 milliseconds;
319 #if QT_VERSION >= 0x060000
320 if (timeEvent.time.typeId() == QMetaType::QTime) {
321 #else
322 if (timeEvent.time.type() == QVariant::Time) {
323 #endif
324 hasMsTimeStamps = true;
325 milliseconds = QTime(0, 0).msecsTo(timeEvent.time.toTime());
326 } else {
327 milliseconds = timeEvent.data.toUInt();
328 }
329 synchedData.append(milliseconds);
330 synchedData.append(str);
331 }
332 }
333
334 if (hasMsTimeStamps && timeStampFormatIt != fields.end()) {
335 timeStampFormatIt->m_value = 2;
336 }
337 if (dataIt != fields.end()) {
338 dataIt->m_value = synchedData;
339 }
340 }
341
342 /**
343 * Set the model from a ETCO frame.
344 * @param fields ID3v2 ETCO frame fields
345 */
346 void TimeEventModel::fromEtcoFrame(const Frame::FieldList& fields)
347 {
348 QVariantList synchedData;
349 bool unitIsFrames = false;
350 for (auto it = fields.constBegin(); it != fields.constEnd(); ++it) {
351 const Frame::Field& fld = *it;
352 if (fld.m_id == Frame::ID_TimestampFormat) {
353 unitIsFrames = fld.m_value.toInt() == 1;
354 #if QT_VERSION >= 0x060000
355 } else if (fld.m_value.typeId() == QMetaType::QVariantList) {
356 #else
357 } else if (fld.m_value.type() == QVariant::List) {
358 #endif
359 synchedData = fld.m_value.toList();
360 }
361 }
362
363 QList<TimeEvent> timeEvents;
364 QListIterator<QVariant> it(synchedData);
365 while (it.hasNext()) {
366 quint32 milliseconds = it.next().toUInt();
367 if (!it.hasNext())
368 break;
369
370 int code = it.next().toInt();
371 QVariant timeStamp = unitIsFrames
372 ? QVariant(milliseconds)
373 : QVariant(QTime(0, 0).addMSecs(milliseconds));
374 timeEvents.append(TimeEvent(timeStamp, code));
375 }
376 setTimeEvents(timeEvents);
377 }
378
379 /**
380 * Get the model as an ETCO frame.
381 * @param fields ID3v2 ETCO frame fields to set
382 */
383 void TimeEventModel::toEtcoFrame(Frame::FieldList& fields) const
384 {
385 auto timeStampFormatIt = fields.end();
386 auto dataIt = fields.end();
387 for (auto it = fields.begin(); it != fields.end(); ++it) {
388 if (it->m_id == Frame::ID_TimestampFormat) {
389 timeStampFormatIt = it;
390 #if QT_VERSION >= 0x060000
391 } else if (it->m_value.typeId() == QMetaType::QVariantList) {
392 #else
393 } else if (it->m_value.type() == QVariant::List) {
394 #endif
395 dataIt = it;
396 }
397 }
398
399 QVariantList synchedData;
400 bool hasMsTimeStamps = false;
401 const auto timeEvents = m_timeEvents;
402 for (const TimeEvent& timeEvent : timeEvents) {
403 if (!timeEvent.time.isNull()) {
404 int code = timeEvent.data.toInt();
405
406 quint32 milliseconds;
407 #if QT_VERSION >= 0x060000
408 if (timeEvent.time.typeId() == QMetaType::QTime) {
409 #else
410 if (timeEvent.time.type() == QVariant::Time) {
411 #endif
412 hasMsTimeStamps = true;
413 milliseconds = QTime(0, 0).msecsTo(timeEvent.time.toTime());
414 } else {
415 milliseconds = timeEvent.data.toUInt();
416 }
417 synchedData.append(milliseconds);
418 synchedData.append(code);
419 }
420 }
421
422 if (timeStampFormatIt != fields.end() && hasMsTimeStamps) {
423 timeStampFormatIt->m_value = 2;
424 }
425 if (dataIt != fields.end()) {
426 dataIt->m_value = synchedData;
427 }
428 }
429
430 /**
431 * Mark row for a time stamp.
432 * Marks the first row with time >= @a timeStamp.
433 * @param timeStamp time
434 * @see getMarkedRow()
435 */
436 void TimeEventModel::markRowForTimeStamp(const QTime& timeStamp)
437 {
438 int row = 0, oldRow = m_markedRow, newRow = -1;
439 for (auto it = m_timeEvents.constBegin(); it != m_timeEvents.constEnd(); ++it) {
440 const TimeEvent& timeEvent = *it;
441 QTime time = timeEvent.time.toTime();
442 if (time.isValid() && time >= timeStamp) {
443 if (timeStamp.msecsTo(time) > 1000 && row > 0) {
444 --row;
445 }
446 if (row == 0 && timeStamp == QTime(0, 0) &&
447 m_timeEvents.at(0).time.toTime() != timeStamp) {
448 row = -1;
449 }
450 newRow = row;
451 break;
452 }
453 ++row;
454 }
455 if (newRow != oldRow &&
456 !(newRow == -1 && oldRow == m_timeEvents.size() - 1)) {
457 m_markedRow = newRow;
458 if (oldRow != -1) {
459 QModelIndex idx = index(oldRow, CI_Data);
460 emit dataChanged(idx, idx);
461 }
462 if (newRow != -1) {
463 QModelIndex idx = index(newRow, CI_Data);
464 emit dataChanged(idx, idx);
465 }
466 }
467 }
468
469 /**
470 * Clear the marked row.
471 */
472 void TimeEventModel::clearMarkedRow()
473 {
474 if (m_markedRow != -1) {
475 QModelIndex idx = index(m_markedRow, CI_Data);
476 m_markedRow = -1;
477 emit dataChanged(idx, idx);
478 }
479 }
480
481 /**
482 * Set the model from an LRC file.
483 * @param stream LRC file stream
484 */
485 void TimeEventModel::fromLrcFile(QTextStream& stream)
486 {
487 QRegularExpression timeStampRe(QLatin1String(
488 R"(([[<])(\d\d):(\d\d)(?:\.(\d{1,3}))?([\]>]))"));
489 QList<TimeEvent> timeEvents;
490 bool isFirstLine = true;
491 forever {
492 QString line = stream.readLine();
493 if (line.isNull())
494 break;
495
496 if (line.isEmpty())
497 continue;
498
499 // If the first line does not contain a '[' character, assume that this is
500 // not an LRC file and only import lines without time stamps.
501 if (isFirstLine) {
502 if (line.contains(QLatin1Char('['))) {
503 isFirstLine = false;
504 } else {
505 stream.seek(0);
506 fromTextFile(stream);
507 return;
508 }
509 }
510
511 QList<QTime> emptyEvents;
512 char firstChar = '\0';
513 auto it = timeStampRe.globalMatch(line);
514 while (it.hasNext()) {
515 auto match = it.next();
516 bool newLine = match.captured(1) == QLatin1String("[");
517 QString millisecondsStr = match.captured(4);
518 int milliseconds = millisecondsStr.toInt();
519 if (millisecondsStr.length() == 2) {
520 milliseconds *= 10;
521 } else if (millisecondsStr.length() == 1) {
522 milliseconds *= 100;
523 }
524 QTime timeStamp(0,
525 match.captured(2).toInt(),
526 match.captured(3).toInt(),
527 milliseconds);
528 int pos = match.capturedStart();
529 int textBegin = pos + match.capturedLength();
530 int textLen = -1;
531 pos = -1;
532 if (it.hasNext()) {
533 match = it.peekNext();
534 pos = match.capturedStart();
535 textLen = pos - textBegin;
536 }
537 QString str = line.mid(textBegin, textLen);
538 if (m_type == EventTimingCodes) {
539 EventTimeCode etc =
540 EventTimeCode::fromString(str.toLatin1().constData());
541 if (etc.isValid()) {
542 timeEvents.append(TimeEvent(timeStamp, etc.getCode()));
543 }
544 } else {
545 if (firstChar != '\0') {
546 str.prepend(QChar::fromLatin1(firstChar));
547 firstChar = '\0';
548 }
549 if (newLine) {
550 if (str.startsWith(QLatin1Char(' ')) ||
551 str.startsWith(QLatin1Char('-')) ||
552 str.startsWith(QLatin1Char('_'))) {
553 str.prepend(QLatin1Char('#'));
554 }
555 } else if (!(str.startsWith(QLatin1Char(' ')) ||
556 str.startsWith(QLatin1Char('-')))) {
557 str.prepend(QLatin1Char('_'));
558 }
559 if (pos != -1) {
560 if (match.captured(1) == QLatin1String("<")) {
561 if (str.endsWith(QLatin1Char(' ')) ||
562 str.endsWith(QLatin1Char('-'))) {
563 firstChar = str.at(str.length() - 1).toLatin1();
564 str.truncate(str.length() - 1);
565 }
566 }
567 if (str.isEmpty()) {
568 // The next time stamp follows immediately with a common text.
569 emptyEvents.append(timeStamp);
570 continue;
571 }
572 }
573 const auto times = emptyEvents;
574 for (const QTime& time : times) {
575 timeEvents.append(TimeEvent(time, str));
576 }
577 timeEvents.append(TimeEvent(timeStamp, str));
578 }
579 }
580 }
581 std::sort(timeEvents.begin(), timeEvents.end());
582 setTimeEvents(timeEvents);
583 }
584
585 /**
586 * Set the model from a text file.
587 * @param stream text file stream
588 */
589 void TimeEventModel::fromTextFile(QTextStream& stream)
590 {
591 QList<TimeEvent> timeEvents;
592 forever {
593 QString line = stream.readLine();
594 if (line.isNull())
595 break;
596
597 timeEvents.append(TimeEvent(QTime(), line));
598 }
599 setTimeEvents(timeEvents);
600 }
601
602 /**
603 * Store the model in an LRC file.
604 * @param stream LRC file stream
605 * @param title optional title
606 * @param artist optional artist
607 * @param album optional album
608 */
609 void TimeEventModel::toLrcFile(QTextStream& stream, const QString& title,
610 const QString& artist, const QString& album)
611 {
612 bool atBegin = true;
613 if (!title.isEmpty()) {
614 stream << QLatin1String("[ti:") << title << QLatin1String("]\r\n");
615 atBegin = false;
616 }
617 if (!artist.isEmpty()) {
618 stream << QLatin1String("[ar:") << artist << QLatin1String("]\r\n");
619 atBegin = false;
620 }
621 if (!album.isEmpty()) {
622 stream << QLatin1String("[al:") << album << QLatin1String("]\r\n");
623 atBegin = false;
624 }
625 const auto timeEvents = m_timeEvents;
626 for (const TimeEvent& timeEvent : timeEvents) {
627 QTime time = timeEvent.time.toTime();
628 if (time.isValid()) {
629 char firstChar = '\0';
630 bool newLine = true;
631 QString str;
632 if (m_type == EventTimingCodes) {
633 str = EventTimeCode(timeEvent.data.toInt()).toString();
634 } else {
635 str = timeEvent.data.toString();
636 if (str.startsWith(QLatin1Char('_'))) {
637 str.remove(0, 1);
638 newLine = false;
639 } else if (str.startsWith(QLatin1Char('#'))) {
640 str.remove(0, 1);
641 } else if (str.startsWith(QLatin1Char(' ')) ||
642 str.startsWith(QLatin1Char('-'))) {
643 firstChar = str.at(0).toLatin1();
644 str.remove(0, 1);
645 newLine = false;
646 }
647 }
648
649 if (newLine) {
650 if (!atBegin) {
651 stream << QLatin1String("\r\n");
652 }
653 stream << QLatin1Char('[') << timeStampToString(time).toLatin1()
654 << QLatin1Char(']') << str.toLatin1();
655 } else {
656 if (firstChar != '\0') {
657 stream << firstChar;
658 }
659 stream << QLatin1Char('<') << timeStampToString(time).toLatin1()
660 << QLatin1Char('>') << str.toLatin1();
661 }
662 atBegin = false;
663 }
664 }
665 if (!atBegin) {
666 stream << QLatin1String("\r\n");
667 }
668 }
669
670 /**
671 * Format a time suitable for a time stamp.
672 * @param time time stamp
673 * @return string of the format "mm:ss.zz"
674 */
675 QString TimeEventModel::timeStampToString(const QTime& time)
676 {
677 int hour = time.hour();
678 int min = time.minute();
679 int sec = time.second();
680 int msec = time.msec();
681 if (hour < 0) hour = 0;
682 if (min < 0) min = 0;
683 if (sec < 0) sec = 0;
684 if (msec < 0) msec = 0;
685 QString text = QString(QLatin1String("%1:%2.%3"))
686 .arg(min, 2, 10, QLatin1Char('0'))
687 .arg(sec, 2, 10, QLatin1Char('0'))
688 .arg(msec / 10, 2, 10, QLatin1Char('0'));
689 if (hour != 0) {
690 text.prepend(QString::number(hour) + QLatin1Char(':'));
691 }
692 return text;
693 }
694