mirror of
https://github.com/KDE/kasts.git
synced 2025-02-05 11:48:27 +01:00
Load chapter images from tags and show in user interface
This commit is contained in:
parent
b273d5ffa9
commit
6a6962e2d3
@ -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)) {
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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;
|
||||||
};
|
};
|
||||||
|
@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user