Load chapter images from tags and show in user interface

This commit is contained in:
Tobias Fella 2022-09-25 23:35:52 +02:00 committed by Bart De Vries
parent b273d5ffa9
commit 6a6962e2d3
8 changed files with 147 additions and 85 deletions

View File

@ -115,6 +115,10 @@ QString Fetcher::image(const QString &url)
return QLatin1String("no-image"); return QLatin1String("no-image");
} }
if (url.startsWith(QStringLiteral("/"))) {
return url;
}
// if image is already cached, then return the path // if image is already cached, then return the path
QString path = StorageManager::instance().imagePath(url); QString path = StorageManager::instance().imagePath(url);
if (QFileInfo::exists(path)) { if (QFileInfo::exists(path)) {

View File

@ -1,23 +1,45 @@
/** /**
* SPDX-FileCopyrightText: 2021 Swapnil Tripathi <swapnil06.st@gmail.com> * SPDX-FileCopyrightText: 2021 Swapnil Tripathi <swapnil06.st@gmail.com>
* SPDX-FileCopyrightText: 2022 Bart De Vries <bart@mogwai.be>
* *
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL * SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/ */
#include "models/chaptermodel.h" #include "models/chaptermodel.h"
#include <QCryptographicHash>
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QFileInfo>
#include <QMimeDatabase> #include <QMimeDatabase>
#include <QObject> #include <QObject>
#include <QSqlQuery> #include <QSqlQuery>
#include <attachedpictureframe.h>
#include <chapterframe.h> #include <chapterframe.h>
#include "audiomanager.h"
#include "database.h" #include "database.h"
#include "storagemanager.h"
ChapterModel::ChapterModel(QObject *parent) ChapterModel::ChapterModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
{ {
connect(&AudioManager::instance(), &AudioManager::positionChanged, this, [this]() {
if (!m_entry || m_entry != AudioManager::instance().entry() || m_chapters.isEmpty()) {
return;
}
if (m_chapters[m_currentChapter].start > AudioManager::instance().position() / 1000
|| (m_currentChapter < m_chapters.size() - 1 && m_chapters[m_currentChapter + 1].start < AudioManager::instance().position() / 1000)) {
for (int i = 0; i < m_chapters.size(); i++) {
if (m_chapters[i].start < AudioManager::instance().position() / 1000
&& (i == m_chapters.size() - 1 || m_chapters[i + 1].start > AudioManager::instance().position() / 1000)) {
m_currentChapter = i;
Q_EMIT currentChapterChanged();
}
}
}
});
} }
QVariant ChapterModel::data(const QModelIndex &index, int role) const QVariant ChapterModel::data(const QModelIndex &index, int role) const
@ -64,35 +86,41 @@ QHash<int, QByteArray> ChapterModel::roleNames() const
}; };
} }
QString ChapterModel::enclosureId() const Entry *ChapterModel::entry() const
{ {
return m_enclosureId; return m_entry;
} }
void ChapterModel::setEnclosureId(QString newEnclosureId) void ChapterModel::setEntry(Entry *entry)
{ {
m_enclosureId = newEnclosureId; if (entry) {
m_entry = entry;
} else {
m_entry = nullptr;
}
load(); load();
Q_EMIT enclosureIdChanged(); Q_EMIT entryChanged();
} }
void ChapterModel::load() void ChapterModel::load()
{ {
beginResetModel(); beginResetModel();
m_chapters = {}; m_chapters = {};
m_currentChapter = 0;
if (m_entry) {
loadFromDatabase(); loadFromDatabase();
if (m_chapters.isEmpty()) {
loadChaptersFromFile(); loadChaptersFromFile();
} }
endResetModel(); endResetModel();
Q_EMIT currentChapterChanged();
} }
void ChapterModel::loadFromDatabase() void ChapterModel::loadFromDatabase()
{ {
if (m_entry) {
QSqlQuery query; QSqlQuery query;
query.prepare(QStringLiteral("SELECT * FROM Chapters WHERE id=:id ORDER BY start ASC;")); query.prepare(QStringLiteral("SELECT * FROM Chapters WHERE id=:id ORDER BY start ASC;"));
query.bindValue(QStringLiteral(":id"), enclosureId()); query.bindValue(QStringLiteral(":id"), m_entry->id());
Database::instance().execute(query); Database::instance().execute(query);
while (query.next()) { while (query.next()) {
ChapterEntry chapter{}; ChapterEntry chapter{};
@ -102,6 +130,7 @@ void ChapterModel::loadFromDatabase()
chapter.start = query.value(QStringLiteral("start")).toInt(); chapter.start = query.value(QStringLiteral("start")).toInt();
m_chapters << chapter; m_chapters << chapter;
} }
}
} }
void ChapterModel::loadMPEGChapters(TagLib::MPEG::File &f) void ChapterModel::loadMPEGChapters(TagLib::MPEG::File &f)
@ -113,12 +142,34 @@ void ChapterModel::loadMPEGChapters(TagLib::MPEG::File &f)
auto chapterFrame = dynamic_cast<TagLib::ID3v2::ChapterFrame *>(frame); auto chapterFrame = dynamic_cast<TagLib::ID3v2::ChapterFrame *>(frame);
ChapterEntry chapter{}; ChapterEntry chapter{};
const auto &apicList = chapterFrame->embeddedFrameListMap()["APIC"];
auto hash = QString::fromLatin1(
QCryptographicHash::hash(QStringLiteral("%1,%2").arg(m_entry->id(), chapterFrame->startTime()).toLatin1(), QCryptographicHash::Md5).toHex());
auto path = QStringLiteral("%1/images/%2").arg(StorageManager::instance().storagePath(), hash);
if (!apicList.isEmpty()) {
if (!QFileInfo(path).exists()) {
QFile file(path);
const auto apic = dynamic_cast<TagLib::ID3v2::AttachedPictureFrame *>(apicList.front())->picture();
file.open(QFile::WriteOnly);
file.write(QByteArray(apic.data(), apic.size()));
file.close();
}
chapter.image = path;
} else {
chapter.image = QString();
}
chapter.title = QString::fromStdString(chapterFrame->embeddedFrameListMap()["TIT2"].front()->toString().to8Bit(true)); chapter.title = QString::fromStdString(chapterFrame->embeddedFrameListMap()["TIT2"].front()->toString().to8Bit(true));
chapter.link = QString(); chapter.link = QString();
chapter.image = QString();
chapter.start = chapterFrame->startTime() / 1000; chapter.start = chapterFrame->startTime() / 1000;
auto originalChapter = std::find_if(m_chapters.begin(), m_chapters.end(), [chapter](auto it) {
return chapter.start == it.start;
});
if (originalChapter != m_chapters.end()) {
originalChapter->image = chapter.image;
} else {
m_chapters << chapter; m_chapters << chapter;
} }
}
std::sort(m_chapters.begin(), m_chapters.end(), [](const ChapterEntry &a, const ChapterEntry &b) { std::sort(m_chapters.begin(), m_chapters.end(), [](const ChapterEntry &a, const ChapterEntry &b) {
return a.start < b.start; return a.start < b.start;
}); });
@ -126,24 +177,31 @@ void ChapterModel::loadMPEGChapters(TagLib::MPEG::File &f)
void ChapterModel::loadChaptersFromFile() void ChapterModel::loadChaptersFromFile()
{ {
if (m_enclosurePath.isEmpty()) { if (!m_entry || !m_entry->hasEnclosure() || m_entry->enclosure()->path().isEmpty()) {
return; return;
} }
const auto mime = QMimeDatabase().mimeTypeForFile(m_enclosurePath).name(); const auto mime = QMimeDatabase().mimeTypeForFile(m_entry->enclosure()->path()).name();
if (mime == QStringLiteral("audio/mpeg")) { if (mime == QStringLiteral("audio/mpeg")) {
TagLib::MPEG::File f(m_enclosurePath.toLatin1().data()); TagLib::MPEG::File f(m_entry->enclosure()->path().toLatin1().data());
loadMPEGChapters(f); loadMPEGChapters(f);
} // TODO else... } // TODO else...
} }
void ChapterModel::setEnclosurePath(const QString &enclosurePath) int ChapterModel::currentChapter() const
{ {
m_enclosurePath = enclosurePath; for (int i = 0; i < m_chapters.size(); i++) {
Q_EMIT enclosureIdChanged(); if (m_chapters[i].start < AudioManager::instance().position() / 1000
load(); && (i == m_chapters.size() - 1 || m_chapters[i + 1].start > AudioManager::instance().position() / 1000)) {
return i;
}
}
return 0;
} }
QString ChapterModel::enclosurePath() const QString ChapterModel::currentChapterImage() const
{ {
return m_enclosurePath; if (m_chapters.size() <= m_currentChapter) {
return {};
}
return m_chapters[m_currentChapter].image;
} }

View File

@ -11,6 +11,8 @@
#include <mpegfile.h> #include <mpegfile.h>
#include "entry.h"
struct ChapterEntry { struct ChapterEntry {
QString title; QString title;
QString link; QString link;
@ -22,17 +24,19 @@ class ChapterModel : public QAbstractListModel
{ {
Q_OBJECT Q_OBJECT
Q_PROPERTY(QString enclosureId READ enclosureId WRITE setEnclosureId NOTIFY enclosureIdChanged) Q_PROPERTY(Entry *entry READ entry WRITE setEntry NOTIFY entryChanged)
Q_PROPERTY(QString enclosurePath READ enclosurePath WRITE setEnclosurePath NOTIFY enclosurePathChanged) Q_PROPERTY(int currentChapter READ currentChapter NOTIFY currentChapterChanged)
Q_PROPERTY(QString currentChapterImage READ currentChapterImage NOTIFY currentChapterChanged)
public: public:
enum RoleNames { enum RoleNames {
Title = Qt::DisplayRole, Title = Qt::DisplayRole,
Link, Link = Qt::UserRole + 1,
Image, Image,
StartTime, StartTime,
FormattedStartTime, FormattedStartTime,
}; };
Q_ENUM(RoleNames);
explicit ChapterModel(QObject *parent = nullptr); explicit ChapterModel(QObject *parent = nullptr);
@ -40,15 +44,15 @@ public:
QHash<int, QByteArray> roleNames() const override; QHash<int, QByteArray> roleNames() const override;
int rowCount(const QModelIndex &parent) const override; int rowCount(const QModelIndex &parent) const override;
void setEnclosureId(QString newEnclosureId); void setEntry(Entry *entry);
QString enclosureId() const; Entry *entry() const;
void setEnclosurePath(const QString &enclosurePath); int currentChapter() const;
QString enclosurePath() const; QString currentChapterImage() const;
Q_SIGNALS: Q_SIGNALS:
void enclosureIdChanged(); void entryChanged();
void enclosurePathChanged(); void currentChapterChanged();
private: private:
void load(); void load();
@ -56,8 +60,8 @@ private:
void loadChaptersFromFile(); void loadChaptersFromFile();
void loadMPEGChapters(TagLib::MPEG::File &f); void loadMPEGChapters(TagLib::MPEG::File &f);
QString m_enclosureId; Entry *m_entry = nullptr;
QVector<ChapterEntry> m_chapters; QVector<ChapterEntry> m_chapters;
KFormat m_kformat; KFormat m_kformat;
QString m_enclosurePath; int m_currentChapter = 0;
}; };

View File

@ -14,9 +14,8 @@ import org.kde.kasts.solidextras 1.0
import org.kde.kasts 1.0 import org.kde.kasts 1.0
Kirigami.SwipeListItem { Kirigami.BasicListItem {
id: root id: root
alwaysVisibleActions: true
property var entry: undefined property var entry: undefined
property var overlay: undefined property var overlay: undefined
@ -24,27 +23,21 @@ Kirigami.SwipeListItem {
property bool streamingAllowed: (NetworkStatus.connectivity !== NetworkStatus.No && (SettingsManager.allowMeteredStreaming || NetworkStatus.metered !== NetworkStatus.Yes)) property bool streamingAllowed: (NetworkStatus.connectivity !== NetworkStatus.No && (SettingsManager.allowMeteredStreaming || NetworkStatus.metered !== NetworkStatus.Yes))
property bool streamingButtonVisible: entry != undefined && entry.enclosure && (entry.enclosure.status !== Enclosure.Downloaded) && streamingAllowed && (SettingsManager.prioritizeStreaming || AudioManager.entry === entry) property bool streamingButtonVisible: entry != undefined && entry.enclosure && (entry.enclosure.status !== Enclosure.Downloaded) && streamingAllowed && (SettingsManager.prioritizeStreaming || AudioManager.entry === entry)
contentItem: ColumnLayout { text: model.title
Controls.Label { subtitle: model.formattedStart
Layout.fillWidth: true
text: title leading: Kirigami.Icon {
elide: Text.ElideRight width: height
} height: parent.height
Controls.Label { source: Fetcher.image(model.image.length > 0 ? model.image : root.entry.image)
Layout.fillWidth: true
opacity: 0.7
font: Kirigami.Theme.smallFont
text: formattedStart
elide: Text.ElideRight
}
} }
actions: [ trailing: Controls.ToolButton {
Kirigami.Action {
text: i18n("Play")
icon.name: streamingButtonVisible ? ":/media-playback-start-cloud" : "media-playback-start" icon.name: streamingButtonVisible ? ":/media-playback-start-cloud" : "media-playback-start"
text: i18n("Play")
enabled: entry != undefined && entry.enclosure && (entry.enclosure.status === Enclosure.Downloaded || streamingButtonVisible) enabled: entry != undefined && entry.enclosure && (entry.enclosure.status === Enclosure.Downloaded || streamingButtonVisible)
onTriggered: { display: Controls.Button.IconOnly
onClicked: {
if (!entry.queueStatus) { if (!entry.queueStatus) {
entry.queueStatus = true; entry.queueStatus = true;
} }
@ -60,5 +53,4 @@ Kirigami.SwipeListItem {
} }
} }
} }
]
} }

View File

@ -254,8 +254,7 @@ Kirigami.ScrollablePage {
Layout.rightMargin: Kirigami.Units.gridUnit Layout.rightMargin: Kirigami.Units.gridUnit
Layout.bottomMargin: Kirigami.Units.gridUnit Layout.bottomMargin: Kirigami.Units.gridUnit
model: ChapterModel { model: ChapterModel {
enclosureId: entry.id entry: page.entry
enclosurePath: entry.enclosure.path
} }
delegate: ChapterListDelegate { delegate: ChapterListDelegate {
entry: page.entry entry: page.entry

View File

@ -50,7 +50,7 @@ Rectangle {
spacing: Kirigami.Units.largeSpacing spacing: Kirigami.Units.largeSpacing
ImageWithFallback { ImageWithFallback {
id: mainImage id: mainImage
imageSource: AudioManager.entry ? AudioManager.entry.cachedImage : "no-image" imageSource: AudioManager.entry ? ((chapterModel.currentChapterImage && chapterModel.currentChapterImage !== "") ? "file://" + Fetcher.image(chapterModel.currentChapterImage) : AudioManager.entry.cachedImage) : "no-image"
height: controlsLayout.height height: controlsLayout.height
width: height width: height
absoluteRadius: 5 absoluteRadius: 5
@ -278,8 +278,8 @@ Rectangle {
ListView { ListView {
id: chapterList id: chapterList
model: ChapterModel { model: ChapterModel {
enclosureId: AudioManager.entry ? AudioManager.entry.id : "" id: chapterModel
enclosurePath: AudioManager.entry ? AudioManager.entry.enclosure.path : "" entry: AudioManager.entry ? AudioManager.entry : undefined
} }
delegate: ChapterListDelegate { delegate: ChapterListDelegate {
id: chapterDelegate id: chapterDelegate

View File

@ -35,6 +35,11 @@ Item {
visible: true visible: true
} }
ChapterModel {
id: chapterModel
entry: AudioManager.entry ?? undefined
}
RowLayout { RowLayout {
id: footerrowlayout id: footerrowlayout
anchors.fill: parent anchors.fill: parent
@ -51,7 +56,7 @@ Item {
anchors.fill: parent anchors.fill: parent
ImageWithFallback { ImageWithFallback {
imageSource: AudioManager.entry.cachedImage imageSource: AudioManager.entry ? ((chapterModel.currentChapterImage && chapterModel.currentChapterImage !== "") ? "file://" + Fetcher.image(chapterModel.currentChapterImage) : AudioManager.entry.cachedImage) : "no-image"
Layout.fillHeight: true Layout.fillHeight: true
Layout.preferredWidth: height Layout.preferredWidth: height
} }

View File

@ -117,7 +117,7 @@ Kirigami.Page {
width: root.isWidescreen ? Math.min(parent.height, parent.width / 2) : Math.min(parent.width, height) width: root.isWidescreen ? Math.min(parent.height, parent.width / 2) : Math.min(parent.width, height)
ImageWithFallback { ImageWithFallback {
imageSource: AudioManager.entry ? AudioManager.entry.cachedImage : "no-image" imageSource: AudioManager.entry ? ((chapterModel.currentChapterImage && chapterModel.currentChapterImage !== "") ? "file://" + Fetcher.image(chapterModel.currentChapterImage) : AudioManager.entry.cachedImage) : "no-image"
imageFillMode: Image.PreserveAspectCrop imageFillMode: Image.PreserveAspectCrop
anchors.centerIn: parent anchors.centerIn: parent
anchors.margins: 0 anchors.margins: 0
@ -211,8 +211,8 @@ Kirigami.Page {
ListView { ListView {
id: chapterList id: chapterList
model: ChapterModel { model: ChapterModel {
enclosureId: AudioManager.entry.id id: chapterModel
enclosurePath: AudioManager.entry.enclosure.path entry: AudioManager.entry ? AudioManager.entry : undefined
} }
clip: true clip: true
visible: chapterList.count !== 0 visible: chapterList.count !== 0