// SPDX-FileCopyrightText: 2025 Gary Wang // // SPDX-License-Identifier: MIT #include "playbackprogressindicator.h" #include #include #include #include #include #include #ifdef HAVE_FFMPEG extern "C" { #include #include #include #include // Contains AV_TIME_BASE and AV_TIME_BASE_Q } // extern "C" #endif // HAVE_FFMPEG PlaybackProgressIndicator::PlaybackProgressIndicator(QWidget *parent) : QWidget(parent) { } void PlaybackProgressIndicator::setPosition(qint64 pos) { m_position = pos; emit positionChanged(m_position); } void PlaybackProgressIndicator::setDuration(qint64 dur) { m_duration = dur; emit durationChanged(m_duration); } void PlaybackProgressIndicator::setChapters(QList > chapters) { qDebug() << chapters; m_chapterModel.clear(); for (const std::pair & chapter : chapters) { QStandardItem * chapterItem = new QStandardItem(chapter.second); chapterItem->setData(chapter.first, StartTimeMsRole); m_chapterModel.appendRow(chapterItem); } update(); } QList > PlaybackProgressIndicator::tryLoadChapters(const QString &filePath) { auto chapters = tryLoadSidecarChapterFile(filePath); if (chapters.size() == 0) { chapters = tryLoadChaptersFromMetadata(filePath); } return chapters; } QList > PlaybackProgressIndicator::tryLoadSidecarChapterFile(const QString &filePath) { if (filePath.endsWith(".chp", Qt::CaseInsensitive)) { return parseCHPChapterFile(filePath); } else if (filePath.endsWith(".pbf", Qt::CaseInsensitive)) { return parsePBFChapterFile(filePath); } QFileInfo fileInfo(filePath); fileInfo.setFile(fileInfo.dir().filePath(fileInfo.completeBaseName() + ".chp")); if (fileInfo.exists()) { return parseCHPChapterFile(fileInfo.absoluteFilePath()); } fileInfo.setFile(fileInfo.dir().filePath(fileInfo.completeBaseName() + ".pbf")); if (fileInfo.exists()) { return parsePBFChapterFile(fileInfo.absoluteFilePath()); } fileInfo.setFile(filePath + ".chp"); if (fileInfo.exists()) { return parseCHPChapterFile(fileInfo.absoluteFilePath()); } return {}; } #ifdef HAVE_FFMPEG // Helper function to convert FFmpeg time (in time_base units) to milliseconds qint64 convertTimestampToMilliseconds(int64_t timestamp, AVRational time_base) { // Convert to seconds first, then to milliseconds and cast to qint64 return static_cast((double)timestamp * av_q2d(time_base) * 1000.0); } // Helper function to print FFmpeg errors void printFFmpegError(int errnum) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror(errnum, errbuf, sizeof(errbuf)); qCritical() << "FFmpeg error:" << errbuf; } #endif // HAVE_FFMPEG QList > PlaybackProgressIndicator::tryLoadChaptersFromMetadata(const QString &filePath) { #ifdef HAVE_FFMPEG if (!QFile::exists(filePath)) { qCritical() << "Error: File not found" << filePath; return {}; } AVFormatContext* format_ctx = nullptr; // FFmpeg format context int ret = 0; // Return value for FFmpeg functions qInfo() << "Attempting to open file:" << filePath; // Open the input file and read the header. // The last two arguments (AVInputFormat*, AVDictionary**) are optional. // Passing nullptr for them means FFmpeg will try to guess the format // and no options will be passed to the demuxer. ret = avformat_open_input(&format_ctx, filePath.toUtf8().constData(), nullptr, nullptr); if (ret < 0) { qCritical() << "Could not open input file:" << filePath; printFFmpegError(ret); return {}; } qInfo() << "File opened successfully."; // Read stream information from the file. // This populates format_ctx->streams and other metadata, including chapters. ret = avformat_find_stream_info(format_ctx, nullptr); if (ret < 0) { qCritical() << "Could not find stream information for file:" << filePath; printFFmpegError(ret); avformat_close_input(&format_ctx); // Close the context before returning return {}; } qInfo() << "Stream information found."; QList> chapterList; // Check if there are any chapters if (format_ctx->nb_chapters == 0) { qInfo() << "No chapters found in file:" << filePath; } else { qInfo() << "Found" << format_ctx->nb_chapters << "chapters."; // Iterate through each chapter for (unsigned int i = 0; i < format_ctx->nb_chapters; ++i) { AVChapter* chapter = format_ctx->chapters[i]; // Chapter timestamps are typically in AV_TIME_BASE units by default // unless the chapter itself has a specific time_base. // For simplicity and common cases, we use AV_TIME_BASE_Q. qint64 start_ms = convertTimestampToMilliseconds(chapter->start, chapter->time_base); // Get the chapter title from its metadata. // av_dict_get(dictionary, key, prev, flags) // prev is used for iterating through multiple entries with the same key, // we want the first one so we pass nullptr. flags=0 for case-insensitive. AVDictionaryEntry* title_tag = av_dict_get(chapter->metadata, "title", nullptr, 0); QString chapter_title = (title_tag && title_tag->value) ? QString::fromUtf8(title_tag->value) : "Untitled Chapter"; chapterList.append(std::make_pair(start_ms, chapter_title)); } } // Close the input file. // This also frees the format_ctx and associated data. avformat_close_input(&format_ctx); qInfo() << "File closed."; return chapterList; #else qInfo() << "FFmpeg not found during build."; return {}; #endif // HAVE_FFMPEG } QList > PlaybackProgressIndicator::parseCHPChapterFile(const QString &filePath) { QList> chapters; QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return chapters; } QTextStream in(&file); QRegularExpression timeRegex(R"((\d{1,2}):(\d{2})(?::(\d{2}))?(?:\.(\d{1,3}))?)"); while (!in.atEnd()) { QString line = in.readLine().trimmed(); QRegularExpressionMatch match = timeRegex.match(line); if (match.hasMatch()) { int hours = match.capturedView(3).isEmpty() ? 0 : match.capturedView(1).toInt(); int minutes = match.capturedView(3).isEmpty() ? match.capturedView(1).toInt() : match.capturedView(2).toInt(); int seconds = match.capturedView(3).isEmpty() ? match.capturedView(2).toInt() : match.capturedView(3).toInt(); int milliseconds = 0; QStringView millisecondsStr(match.capturedView(4)); if (!millisecondsStr.isEmpty()) { milliseconds = millisecondsStr.toInt() * pow(10, 3 - millisecondsStr.length()); } qint64 totalMilliseconds = (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds; QString chapterTitle = line.mid(match.capturedLength()).trimmed(); chapters.append(std::make_pair(totalMilliseconds, chapterTitle)); } } file.close(); return chapters; } QList > PlaybackProgressIndicator::parsePBFChapterFile(const QString &filePath) { QList> chapters; QFile file(filePath); if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { return chapters; } QTextStream in(&file); QRegularExpression chapterRegex(R"(^\d+=(\d+)\*([^*]*)\*.*$)"); while (!in.atEnd()) { QString line = in.readLine().trimmed(); QRegularExpressionMatch match = chapterRegex.match(line); if (match.hasMatch()) { qint64 timestamp = match.captured(1).toLongLong(); QString title = match.captured(2).trimmed(); chapters.append(std::make_pair(timestamp, title)); } } file.close(); return chapters; } void PlaybackProgressIndicator::paintEvent(QPaintEvent *event) { constexpr int progressBarHeight = 6; constexpr QColor activeColor = QColor(85, 170, 0); const QPointF topLeft(0, height() / 2.0 - progressBarHeight / 2.0); const QSizeF barSize(width(), progressBarHeight); const float currentProgress = m_duration <= 0 ? 0 : (m_seekingPosition >= 0 ? m_seekingPosition : m_position) / (float)m_duration; const QSizeF progressSize(width() * currentProgress, progressBarHeight); QPainterPath theProgress; theProgress.addRoundedRect(QRectF(topLeft, progressSize), progressBarHeight / 2, progressBarHeight / 2); QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing); painter.save(); // the bar itself painter.setPen(Qt::gray); painter.drawRoundedRect(QRectF(topLeft, barSize), progressBarHeight / 2, progressBarHeight / 2); painter.fillPath(theProgress, activeColor); // progress painter.setPen(activeColor); painter.drawPath(theProgress); // chapter markers if (m_duration > 0) { painter.setPen(Qt::lightGray); for (int i = 0; i < m_chapterModel.rowCount(); i++) { qint64 chapterStartTime = m_chapterModel.item(i)->data(StartTimeMsRole).toInt(); if (chapterStartTime == 0) continue; if (chapterStartTime > m_duration) break; float chapterPercent = chapterStartTime / (float)m_duration; float chapterPosX = width() * chapterPercent; painter.drawLine(topLeft + QPoint(chapterPosX, 0), topLeft + QPoint(chapterPosX, progressBarHeight)); } } painter.restore(); } void PlaybackProgressIndicator::mousePressEvent(QMouseEvent *event) { if (m_duration > 0) { event->accept(); } else { return QWidget::mousePressEvent(event); } } void PlaybackProgressIndicator::mouseMoveEvent(QMouseEvent *event) { if (m_duration > 0) { m_seekingPosition = event->position().x() * m_duration / width(); if (m_seekOnMove) { emit seekingRequested(m_seekingPosition); } update(); } return QWidget::mouseMoveEvent(event); } void PlaybackProgressIndicator::mouseReleaseEvent(QMouseEvent *event) { if (m_duration > 0) { int seekingPosition = event->position().x() * m_duration / width(); m_seekingPosition = -1; emit seekingRequested(seekingPosition); } update(); return QWidget::mouseReleaseEvent(event); }