[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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.