diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index abcf01fb..812ecf15 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -15,6 +15,7 @@ set(SRCS_base enclosuredownloadjob.cpp storagemanager.cpp storagemovejob.cpp + models/chaptermodel.cpp models/feedsmodel.cpp models/entriesmodel.cpp models/queuemodel.cpp diff --git a/src/database.cpp b/src/database.cpp index 811d30bd..73f0468f 100644 --- a/src/database.cpp +++ b/src/database.cpp @@ -47,6 +47,8 @@ bool Database::migrate() TRUE_OR_RETURN(migrateTo3()); if (dbversion < 4) TRUE_OR_RETURN(migrateTo4()); + if (dbversion < 5) + TRUE_OR_RETURN(migrateTo5()); return true; } @@ -110,6 +112,15 @@ bool Database::migrateTo4() return true; } +bool Database::migrateTo5() +{ + qDebug() << "Migrating database to version 5"; + TRUE_OR_RETURN(execute(QStringLiteral("BEGIN TRANSACTION;"))); + TRUE_OR_RETURN(execute(QStringLiteral("CREATE TABLE IF NOT EXISTS Chapters (feed TEXT, id TEXT, start INTEGER, title TEXT, link TEXT, image TEXT);"))); + TRUE_OR_RETURN(execute(QStringLiteral("PRAGMA user_version = 5;"))); + TRUE_OR_RETURN(execute(QStringLiteral("COMMIT;"))); + return true; +} bool Database::execute(const QString &query) { QSqlQuery q; diff --git a/src/database.h b/src/database.h index 831630ec..b022acfb 100644 --- a/src/database.h +++ b/src/database.h @@ -32,5 +32,6 @@ private: bool migrateTo2(); bool migrateTo3(); bool migrateTo4(); + bool migrateTo5(); void cleanup(); }; diff --git a/src/datamanager.cpp b/src/datamanager.cpp index 07df6128..a780bf6a 100644 --- a/src/datamanager.cpp +++ b/src/datamanager.cpp @@ -243,6 +243,11 @@ void DataManager::removeFeed(const int index) query.bindValue(QStringLiteral(":feed"), feedurl); Database::instance().execute(query); + // Delete Chapters + query.prepare(QStringLiteral("DELETE FROM Chapters WHERE feed=:feed;")); + query.bindValue(QStringLiteral(":feed"), feedurl); + Database::instance().execute(query); + // Delete Entries query.prepare(QStringLiteral("DELETE FROM Entries WHERE feed=:feed;")); query.bindValue(QStringLiteral(":feed"), feedurl); diff --git a/src/fetcher.cpp b/src/fetcher.cpp index 2418e60a..4172828d 100644 --- a/src/fetcher.cpp +++ b/src/fetcher.cpp @@ -19,7 +19,7 @@ #include #include #include - +#include #include #include "database.h" @@ -303,6 +303,22 @@ bool Fetcher::processEntry(Syndication::ItemPtr entry, const QString &url, bool processAuthor(url, entry->id(), authorName, QLatin1String(""), QLatin1String("")); } + /* Process chapters */ + if (otherItems.value(QStringLiteral("http://podlove.org/simple-chapterschapters")).hasChildNodes()) { + QDomNodeList nodelist = otherItems.value(QStringLiteral("http://podlove.org/simple-chapterschapters")).childNodes(); + for (int i = 0; i < nodelist.length(); i++) { + if (nodelist.item(i).nodeName() == QStringLiteral("psc:chapter")) { + QDomElement element = nodelist.at(i).toElement(); + QString title = element.attribute(QStringLiteral("title")); + QString start = element.attribute(QStringLiteral("start")); + QTime startString = QTime::fromString(start, QStringLiteral("hh:mm:ss.zzz")); + int startInt = startString.hour() * 60 * 60 + startString.minute() * 60 + startString.second(); + QString images = element.attribute(QStringLiteral("image")); + processChapter(url, entry->id(), startInt, title, entry->link(), images); + } + } + } + // only process first enclosure if there are multiple (e.g. mp3 and ogg); // the first one is probably the podcast author's preferred version // TODO: handle more than one enclosure? @@ -366,6 +382,25 @@ void Fetcher::processEnclosure(Syndication::EnclosurePtr enclosure, Syndication: Database::instance().execute(query); } +void Fetcher::processChapter(const QString &url, + const QString &entryId, + const int &start, + const QString &chapterTitle, + const QString &link, + const QString &image) +{ + QSqlQuery query; + + query.prepare(QStringLiteral("INSERT INTO Chapters VALUES(:feed, :id, :start, :title, :link, :image);")); + query.bindValue(QStringLiteral(":feed"), url); + query.bindValue(QStringLiteral(":id"), entryId); + query.bindValue(QStringLiteral(":start"), start); + query.bindValue(QStringLiteral(":title"), chapterTitle); + query.bindValue(QStringLiteral(":link"), link); + query.bindValue(QStringLiteral(":image"), image); + Database::instance().execute(query); +} + QString Fetcher::image(const QString &url) const { if (url.isEmpty()) { diff --git a/src/fetcher.h b/src/fetcher.h index 43e4300e..34fe22a6 100644 --- a/src/fetcher.h +++ b/src/fetcher.h @@ -77,6 +77,7 @@ private: bool processEntry(Syndication::ItemPtr entry, const QString &url, bool isNewFeed); // returns true if this is a new entry; false if it already existed void processAuthor(const QString &url, const QString &entryId, const QString &authorName, const QString &authorUri, const QString &authorEmail); void processEnclosure(Syndication::EnclosurePtr enclosure, Syndication::ItemPtr entry, const QString &feedUrl); + void processChapter(const QString &url, const QString &entryId, const int &start, const QString &chapterTitle, const QString &link, const QString &image); QNetworkReply *head(QNetworkRequest &request) const; void setHeader(QNetworkRequest &request) const; diff --git a/src/main.cpp b/src/main.cpp index a4270b37..8fde01e1 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -39,6 +39,7 @@ #include "feed.h" #include "fetcher.h" #include "kasts-version.h" +#include "models/chaptermodel.h" #include "models/downloadmodel.h" #include "models/entriesmodel.h" #include "models/episodemodel.h" @@ -124,6 +125,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.kasts", 1, 0, "EpisodeProxyModel"); qmlRegisterType("org.kde.kasts", 1, 0, "Mpris2"); qmlRegisterType("org.kde.kasts", 1, 0, "PodcastSearchModel"); + qmlRegisterType("org.kde.kasts", 1, 0, "ChapterModel"); qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "EntriesModel", QStringLiteral("Get from Feed")); qmlRegisterUncreatableType("org.kde.kasts", 1, 0, "Enclosure", QStringLiteral("Only for enums")); diff --git a/src/models/chaptermodel.cpp b/src/models/chaptermodel.cpp new file mode 100644 index 00000000..c1bfbed5 --- /dev/null +++ b/src/models/chaptermodel.cpp @@ -0,0 +1,95 @@ +/** + * SPDX-FileCopyrightText: 2021 Swapnil Tripathi + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#include "models/chaptermodel.h" + +#include +#include +#include + +#include "database.h" + +ChapterModel::ChapterModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +QVariant ChapterModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return QVariant(); + } + + int row = index.row(); + + switch (role) { + case Title: + return QVariant::fromValue(m_chapters.at(row).title); + case Link: + return QVariant::fromValue(m_chapters.at(row).link); + case Image: + return QVariant::fromValue(m_chapters.at(row).image); + case StartTime: + return QVariant::fromValue(m_chapters.at(row).start); + case FormattedStartTime: + return QVariant::fromValue(m_kformat.formatDuration(m_chapters.at(row).start * 1000)); + default: + return QVariant(); + } +} + +int ChapterModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + + return m_chapters.count(); +} + +QHash ChapterModel::roleNames() const +{ + return { + {Title, "title"}, + {Link, "link"}, + {Image, "image"}, + {StartTime, "start"}, + {FormattedStartTime, "formattedStart"}, + }; +} + +QString ChapterModel::enclosureId() const +{ + return m_enclosureId; +} + +void ChapterModel::setEnclosureId(QString newEnclosureId) +{ + m_enclosureId = newEnclosureId; + loadFromDatabase(); + Q_EMIT enclosureIdChanged(); +} + +void ChapterModel::loadFromDatabase() +{ + beginResetModel(); + + m_chapters = {}; + QSqlQuery query; + query.prepare(QStringLiteral("SELECT * FROM Chapters WHERE id=:id")); + query.bindValue(QStringLiteral(":id"), enclosureId()); + Database::instance().execute(query); + while (query.next()) { + ChapterEntry chapter{}; + chapter.title = query.value(QStringLiteral("title")).toString(); + chapter.link = query.value(QStringLiteral("link")).toString(); + chapter.image = query.value(QStringLiteral("image")).toString(); + chapter.start = query.value(QStringLiteral("start")).toInt(); + m_chapters << chapter; + } + + endResetModel(); +} diff --git a/src/models/chaptermodel.h b/src/models/chaptermodel.h new file mode 100644 index 00000000..fab1d681 --- /dev/null +++ b/src/models/chaptermodel.h @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2021 Swapnil Tripathi + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +#pragma once + +#include +#include + +struct ChapterEntry { + QString title; + QString link; + QString image; + int start; +}; + +class ChapterModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(QString enclosureId READ enclosureId WRITE setEnclosureId NOTIFY enclosureIdChanged) + +public: + enum RoleNames { + Title = Qt::UserRole, + Link, + Image, + StartTime, + FormattedStartTime, + }; + + explicit ChapterModel(QObject *parent = nullptr); + + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + QHash roleNames() const override; + int rowCount(const QModelIndex &parent) const override; + + void setEnclosureId(QString newEnclosureId); + QString enclosureId() const; + +Q_SIGNALS: + void enclosureIdChanged(); + +private: + void loadFromDatabase(); + + QString m_enclosureId; + QVector m_chapters; + KFormat m_kformat; +}; diff --git a/src/qml/ChapterListDelegate.qml b/src/qml/ChapterListDelegate.qml new file mode 100644 index 00000000..6ab87221 --- /dev/null +++ b/src/qml/ChapterListDelegate.qml @@ -0,0 +1,52 @@ +/** + * SPDX-FileCopyrightText: 2021 Bart De Vries + * + * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + */ + +import QtQuick 2.14 +import QtQuick.Controls 2.14 as Controls +import QtQuick.Layouts 1.14 +import QtMultimedia 5.15 + +import org.kde.kirigami 2.14 as Kirigami + +import org.kde.kasts 1.0 + +Kirigami.SwipeListItem { + alwaysVisibleActions: true + + property var entry: undefined + + ColumnLayout { + Controls.Label { + text: title + } + Controls.Label { + opacity: 0.7 + font: Kirigami.Theme.smallFont + text: formattedStart + } + } + + actions: [ + Kirigami.Action { + text: i18n("Play") + icon.name: "media-playback-start" + enabled: entry != undefined && entry.enclosure && entry.enclosure.status === Enclosure.Downloaded + onTriggered: { + if (AudioManager.entry != entry) { + AudioManager.entry = entry; + } + if (AudioManager.playbbackState !== Audio.PlayingState) { + AudioManager.play(); + } + AudioManager.position = start * 1000; + } + } + ] + + //onClicked: { + // AudioManager.position = start * 1000; + //} +} diff --git a/src/qml/EntryPage.qml b/src/qml/EntryPage.qml index 98f8b93a..dcddf671 100644 --- a/src/qml/EntryPage.qml +++ b/src/qml/EntryPage.qml @@ -86,6 +86,20 @@ Kirigami.ScrollablePage { onWidthChanged: { text = entry.adjustedContent(width, font.pixelSize) } font.pointSize: SettingsManager && !(SettingsManager.articleFontUseSystem) ? SettingsManager.articleFontSize : Kirigami.Theme.defaultFont.pointSize } + ListView { + Layout.fillWidth: true + height: contentHeight + interactive: false + Layout.leftMargin: Kirigami.Units.gridUnit + Layout.rightMargin: Kirigami.Units.gridUnit + Layout.bottomMargin: Kirigami.Units.gridUnit + model: ChapterModel { + enclosureId: entry.id + } + delegate: ChapterListDelegate { + entry: page.entry + } + } } actions.main: Kirigami.Action { diff --git a/src/qml/PlayerControls.qml b/src/qml/PlayerControls.qml index f08d493c..2316a31c 100644 --- a/src/qml/PlayerControls.qml +++ b/src/qml/PlayerControls.qml @@ -110,6 +110,28 @@ Kirigami.Page { } } } + Item { + Kirigami.PlaceholderMessage { + visible: chapterList.count === 0 + + width: parent.width + anchors.centerIn: parent + + text: i18n("No chapter marks found.") + } + ListView { + id: chapterList + model: ChapterModel { + enclosureId: AudioManager.entry.id + } + clip: true + visible: chapterList.count !== 0 + anchors.fill: parent + delegate: ChapterListDelegate { + entry: AudioManager.entry + } + } + } } Controls.PageIndicator { @@ -248,5 +270,4 @@ Kirigami.Page { } } } - } diff --git a/src/resources.qrc b/src/resources.qrc index 85edb215..4fb1d54d 100755 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -28,6 +28,7 @@ qml/PlaybackRateDialog.qml qml/ErrorNotification.qml qml/ConnectionCheckAction.qml + qml/ChapterListDelegate.qml qtquickcontrols2.conf ../kasts.svg