[multimedia/haruna] src/transcript: transcript: add TranscriptModel class
George Florea Bănuș <[email protected]>
| Newsgroups | gmane.comp.kde.cvs |
|---|---|
| Message-ID | <[email protected]> |
Git commit b1a0166f45bc216bec76113ab8aed4f46554fff1 by George Florea Bănuș, on behalf of M. Sadık Uğursoy.
Committed on 06/08/2026 at 16:04.
Pushed by georgefb into branch 'master'.
transcript: add TranscriptModel class
and make SubtitleParser::parseSubtitle cancelable
M +2 -0 src/transcript/CMakeLists.txt
M +7 -2 src/transcript/Transcript.qml
M +4 -0 src/transcript/subtitleline.h
M +37 -2 src/transcript/subtitleparser.cpp
M +2 -2 src/transcript/subtitleparser.h
A +136 -0 src/transcript/transcriptmodel.cpp [License: GPL(v3.0+)]
A +65 -0 src/transcript/transcriptmodel.h [License: GPL(v3.0+)]
https://invent.kde.org/multimedia/haruna/-/commit/b1a0166f45bc216bec76113ab8aed4f46554fff1
diff --git a/src/transcript/CMakeLists.txt b/src/transcript/CMakeLists.txt
index 236c0cca..cedc56ad 100644
--- a/src/transcript/CMakeLists.txt
+++ b/src/transcript/CMakeLists.txt
@@ -16,6 +16,8 @@ qt_add_qml_module(transcript
subtitleline.h
subtitleparser.h
subtitleparser.cpp
+ transcriptmodel.h
+ transcriptmodel.cpp
)
target_include_directories(transcript
diff --git a/src/transcript/Transcript.qml b/src/transcript/Transcript.qml
index 5b88b2f2..380f8015 100644
--- a/src/transcript/Transcript.qml
+++ b/src/transcript/Transcript.qml
@@ -20,6 +20,8 @@ import org.kde.haruna.settings
ResizeablePage {
id: root
+ property TranscriptModel transcriptModel: TranscriptModel {}
+
edge: PlaylistSettings.position === "right" ? Qt.LeftEdge : Qt.RightEdge
customWidth: 380
width: limitWidth(customWidth * fsScale)
@@ -65,7 +67,7 @@ ResizeablePage {
ScrollView {
id: transcriptScrollView
- z: 1
+ z: 20
anchors.fill: parent
anchors {
leftMargin: root.pageEdgeBorder.width
@@ -76,7 +78,10 @@ ResizeablePage {
ListView {
id: transcriptView
- model: 0
+ model: root.transcriptModel
+ reuseItems: true
+ spacing: 1
+
delegate: Item {}
}
}
diff --git a/src/transcript/subtitleline.h b/src/transcript/subtitleline.h
index 459a1677..09d356aa 100644
--- a/src/transcript/subtitleline.h
+++ b/src/transcript/subtitleline.h
@@ -8,8 +8,12 @@
#define SUBTITLELINE_H
#include <QString>
+#include <qobjectdefs.h>
struct SubtitleLine {
+ Q_GADGET
+
+public:
QString text;
double startTime;
double endTime;
diff --git a/src/transcript/subtitleparser.cpp b/src/transcript/subtitleparser.cpp
index a39464e4..8d40d872 100644
--- a/src/transcript/subtitleparser.cpp
+++ b/src/transcript/subtitleparser.cpp
@@ -24,7 +24,7 @@ SubtitleParser::SubtitleParser(QObject *parent)
{
}
-void SubtitleParser::parseSubtitle(const QUrl &url, const int streamIndex)
+void SubtitleParser::parseSubtitle(const QUrl &url, const int streamIndex, const int transcriptModelVersion, const std::atomic<bool> &cancelRequested)
{
if (!url.isLocalFile()) {
return;
@@ -47,6 +47,11 @@ void SubtitleParser::parseSubtitle(const QUrl &url, const int streamIndex)
return;
}
+ if (cancelRequested) {
+ avformat_close_input(&fmt_ctx);
+ return;
+ }
+
// Initialize the subtitle decoder
const AVCodecParameters *codecpar = fmt_ctx->streams[streamIndex]->codecpar;
const AVCodec *decoder = avcodec_find_decoder(codecpar->codec_id);
@@ -86,6 +91,14 @@ void SubtitleParser::parseSubtitle(const QUrl &url, const int streamIndex)
double timeMultiplier = 1.0 * timeBase.num / timeBase.den;
int gotSub = 0;
while (av_read_frame(fmt_ctx, packet) >= 0) {
+ if (cancelRequested) {
+ av_packet_unref(packet);
+ av_packet_free(&packet);
+ avcodec_free_context(&codecContext);
+ avformat_close_input(&fmt_ctx);
+ return;
+ }
+
if (packet->stream_index == int(streamIndex)) {
// Decode the subtitle packet
int ret = avcodec_decode_subtitle2(codecContext, &subtitle, &gotSub, packet);
@@ -118,9 +131,19 @@ void SubtitleParser::parseSubtitle(const QUrl &url, const int streamIndex)
}
}
+ if (line.isEmpty()) {
+ continue;
+ }
+
text.append(line);
}
+ if (text.size() == 0) {
+ avsubtitle_free(&subtitle);
+ av_packet_unref(packet);
+ continue;
+ }
+
SubtitleLine transcriptItem;
transcriptItem.text = text;
transcriptItem.duration = duration;
@@ -128,7 +151,7 @@ void SubtitleParser::parseSubtitle(const QUrl &url, const int streamIndex)
transcriptItem.endTime = endSecs;
transcriptItem.formattedStartTime = MiscUtils::formatTime(startSecs * timeMultiplier);
transcriptItem.formattedEndTime = MiscUtils::formatTime(endSecs * timeMultiplier);
- Q_EMIT transcriptItemReady(transcriptItem, streamIndex);
+ Q_EMIT transcriptItemReady(transcriptItem, transcriptModelVersion);
// must be freed when gotSub is set
avsubtitle_free(&subtitle);
@@ -149,6 +172,18 @@ QString SubtitleParser::formatASS(const QString in)
// We need to skip the first 8 columns and join the rest.
QString text = in.section(QChar::fromLatin1(','), 8);
+ // Remove background drawings. They are sandwiched between \pn and \p0 where n>0 and is a scaling factor. p tag can also be inside any other number of other
+ // tags. Text can also be appended or prepended before the background drawing, therefore we need to carve out the drawing part. If p0 tag does not exist,
+ // the whole line is a drawing and should be discarded.
+ QRegularExpression drawing(u"\\{[^}]*\\\\p[1-9][^}]*\\}[^{]*(\\{[^}]*\\\\p0\\})*"_s);
+ while (drawing.match(text).hasMatch()) {
+ text.remove(drawing);
+ }
+
+ if (text.isEmpty()) {
+ return QString::fromUtf8(text.toUtf8());
+ }
+
// Remove markup text
QRegularExpression re(u"\\{[^}]*\\}"_s);
while (re.match(text).hasMatch()) {
diff --git a/src/transcript/subtitleparser.h b/src/transcript/subtitleparser.h
index d2f9a64c..3a3342a1 100644
--- a/src/transcript/subtitleparser.h
+++ b/src/transcript/subtitleparser.h
@@ -18,10 +18,10 @@ class SubtitleParser : public QObject
public:
explicit SubtitleParser(QObject *parent = nullptr);
- void parseSubtitle(const QUrl &url, const int streamIndex);
+ void parseSubtitle(const QUrl &url, const int streamIndex, const int transcriptModelVersion, const std::atomic<bool> &cancelRequested);
Q_SIGNALS:
- void transcriptItemReady(const SubtitleLine &item, const int streamIndex);
+ void transcriptItemReady(const SubtitleLine &item, const int transcriptModelVersion);
private:
QString formatASS(const QString in);
diff --git a/src/transcript/transcriptmodel.cpp b/src/transcript/transcriptmodel.cpp
new file mode 100644
index 00000000..9e7710de
--- /dev/null
+++ b/src/transcript/transcriptmodel.cpp
@@ -0,0 +1,136 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Muhammet Sadık Uğursoy <[email protected]>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#include "transcriptmodel.h"
+
+#include <QUrl>
+
+#include "subtitleline.h"
+#include "subtitleparser.h"
+
+using namespace Qt::StringLiterals;
+
+TranscriptModel::TranscriptModel(QObject *parent)
+ : QAbstractListModel{parent}
+ , m_parser{std::make_unique<SubtitleParser>()}
+{
+ connect(m_parser.get(), &SubtitleParser::transcriptItemReady, this, &TranscriptModel::addItem, Qt::QueuedConnection);
+}
+
+TranscriptModel::~TranscriptModel()
+{
+ m_threadPool.clear();
+ m_threadPool.waitForDone();
+}
+
+int TranscriptModel::rowCount(const QModelIndex &parent) const
+{
+ Q_UNUSED(parent)
+ return m_transcript.size();
+}
+
+QVariant TranscriptModel::data(const QModelIndex &index, int role) const
+{
+ if (!index.isValid()) {
+ return QVariant();
+ }
+
+ auto item = m_transcript.at(index.row());
+ switch (role) {
+ case TextRole:
+ return item.text;
+ case DurationRole:
+ return item.duration;
+ case StartTimeRole:
+ return item.startTime;
+ case EndTimeRole:
+ return item.endTime;
+ case FormattedStartTimeRole:
+ return item.formattedStartTime;
+ case FormattedEndTimeRole:
+ return item.formattedEndTime;
+ case CurrentRole:
+ return false;
+ default:
+ return QVariant();
+ }
+}
+
+QHash<int, QByteArray> TranscriptModel::roleNames() const
+{
+ // clang-format off
+ QHash<int, QByteArray> roles = {
+ {TextRole, QByteArrayLiteral("text")},
+ {DurationRole, QByteArrayLiteral("duration")},
+ {StartTimeRole, QByteArrayLiteral("startTime")},
+ {EndTimeRole, QByteArrayLiteral("endTime")},
+ {FormattedStartTimeRole, QByteArrayLiteral("formattedStartTime")},
+ {FormattedEndTimeRole, QByteArrayLiteral("formattedEndTime")},
+ {CurrentRole, QByteArrayLiteral("isCurrent")},
+ };
+ // clang-format on
+
+ return roles;
+}
+
+void TranscriptModel::loadSubtitle(QUrl filePath, int streamIndex)
+{
+ clearSubtitle();
+ m_streamIndex = streamIndex;
+ m_cancelRequested = true;
+
+ m_threadPool.clear();
+ m_threadPool.waitForDone();
+
+ m_cancelRequested = false;
+
+ const auto expectedTranscriptModelVersion = m_transcriptModelVersion.load();
+ m_threadPool.start([this, filePath, streamIndex, expectedTranscriptModelVersion]() {
+ m_parser->parseSubtitle(filePath, streamIndex, expectedTranscriptModelVersion, m_cancelRequested);
+ });
+}
+
+void TranscriptModel::clearSubtitle()
+{
+ m_transcriptModelVersion++;
+
+ if (m_transcript.isEmpty()) {
+ return;
+ }
+
+ beginResetModel();
+ m_transcript.clear();
+ endResetModel();
+}
+
+void TranscriptModel::addItem(const SubtitleLine &item, const int transcriptModelVersion)
+{
+ if (transcriptModelVersion != m_transcriptModelVersion) {
+ return;
+ }
+
+ beginInsertRows(QModelIndex(), m_transcript.size(), m_transcript.size());
+ m_transcript.push_back(item);
+ endInsertRows();
+}
+
+int TranscriptModel::currentIndex()
+{
+ return m_currentIndex;
+}
+
+void TranscriptModel::setCurrentIndex(int index)
+{
+ if (m_currentIndex == index) {
+ return;
+ }
+
+ m_currentIndex = index;
+
+ Q_EMIT currentIndexChanged();
+}
+
+#include "moc_transcriptmodel.cpp"
diff --git a/src/transcript/transcriptmodel.h b/src/transcript/transcriptmodel.h
new file mode 100644
index 00000000..ba2bffec
--- /dev/null
+++ b/src/transcript/transcriptmodel.h
@@ -0,0 +1,65 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Muhammet Sadık Uğursoy <[email protected]>
+ *
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ */
+
+#ifndef TRANSCRIPTMODEL_H
+#define TRANSCRIPTMODEL_H
+
+#include <QAbstractListModel>
+#include <QThreadPool>
+#include <QtQml/qqmlregistration.h>
+
+struct SubtitleLine;
+class SubtitleParser;
+
+class TranscriptModel : public QAbstractListModel
+{
+ Q_OBJECT
+ QML_ELEMENT
+
+public:
+ explicit TranscriptModel(QObject *parent = nullptr);
+ ~TranscriptModel();
+
+ enum Roles {
+ TextRole = Qt::UserRole,
+ DurationRole,
+ StartTimeRole,
+ EndTimeRole,
+ FormattedStartTimeRole,
+ FormattedEndTimeRole,
+ CurrentRole,
+ };
+ Q_ENUM(Roles)
+
+ int rowCount(const QModelIndex &parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override;
+ QHash<int, QByteArray> roleNames() const override;
+ int currentIndex();
+
+ Q_INVOKABLE void loadSubtitle(QUrl filePath, int streamIndex);
+ Q_INVOKABLE void clearSubtitle();
+
+Q_SIGNALS:
+ void currentIndexChanged();
+
+private:
+ void addItem(const SubtitleLine &item, const int transcriptModelVersion);
+ void setCurrentIndex(int index);
+
+ // Index for currently displayed subtitle line in the m_transcript. -1 if nothing is displayed at the current timeframe.
+ int m_currentIndex{-1};
+ // Index for subtitle stream in the list of loaded subtitles
+ int m_streamIndex{-1};
+ QList<SubtitleLine> m_transcript;
+ std::unique_ptr<SubtitleParser> m_parser;
+ QThreadPool m_threadPool;
+ // incremented when parser is cancelled, SubtitleLine items with mismatching version are ignored inside addItem
+ std::atomic<int> m_transcriptModelVersion{0};
+ // abort worker threads if this value is true
+ std::atomic<bool> m_cancelRequested{false};
+};
+
+#endif // TRANSCRIPTMODEL_H