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