Enable multi-selection and context menus on all lists of entries

This commit adds keyboard navigation to entry lists.
Selection of items can be done through keyboard (shift+up/down), mouse
(left, left+shift, left+ctrl) or touch (long press).
When items are selected, contextual actions will show up on
the page (useful for touch screens), or, alternatively, a context menu
with the same actions can be opened through right mouse click (useful
for desktop).
If a single entry is selected, then only the relevant actions will be
shown (e.g. only "Mark as Played" if the entry has not been played yet).

Additionally, (database) transactions for the actions have been
optimized.  This was necessary to make sure that actions on large
selections of entries finish within an acceptable time.  E.g. actions on
a list of 1000 items should finish within a few seconds (on all but
underpowered hardware).

BUG: 441764
This commit is contained in:
Bart De Vries 2021-09-06 16:54:59 +02:00
parent 6121f6622f
commit 4b2da3533c
25 changed files with 891 additions and 285 deletions

View File

@ -149,6 +149,8 @@ if(ANDROID)
document-open-folder document-open-folder
edit-delete-remove edit-delete-remove
edit-clear-all edit-clear-all
edit-select-all
edit-select-none
download download
media-playlist-append media-playlist-append
media-seek-backward media-seek-backward

View File

@ -76,7 +76,7 @@ DataManager::DataManager()
Database::instance().execute(query); Database::instance().execute(query);
while (query.next()) { while (query.next()) {
QString id = query.value(QStringLiteral("id")).toString(); QString id = query.value(QStringLiteral("id")).toString();
addToQueue(feedurl, id); getEntry(id)->setQueueStatusInternal(true);
if (SettingsManager::self()->autoDownload()) { if (SettingsManager::self()->autoDownload()) {
if (getEntry(id) && getEntry(id)->hasEnclosure() && getEntry(id)->enclosure()) { if (getEntry(id) && getEntry(id)->hasEnclosure() && getEntry(id)->enclosure()) {
qCDebug(kastsDataManager) << "Start downloading" << getEntry(id)->title(); qCDebug(kastsDataManager) << "Start downloading" << getEntry(id)->title();
@ -152,6 +152,11 @@ int DataManager::feedCount() const
return m_feedmap.count(); return m_feedmap.count();
} }
QStringList DataManager::getIdList(const Feed *feed) const
{
return m_entrymap[feed->url()];
}
int DataManager::entryCount(const int feed_index) const int DataManager::entryCount(const int feed_index) const
{ {
return m_entrymap[m_feedmap[feed_index]].count(); return m_entrymap[m_feedmap[feed_index]].count();
@ -199,14 +204,16 @@ void DataManager::removeFeed(const int index)
// Delete the object instances and mappings // Delete the object instances and mappings
// First delete entries in Queue // First delete entries in Queue
qCDebug(kastsDataManager) << "delete queueentries of" << feedurl; qCDebug(kastsDataManager) << "delete queueentries of" << feedurl;
QStringList removeFromQueueList;
for (auto &id : m_queuemap) { for (auto &id : m_queuemap) {
if (getEntry(id)->feed()->url() == feedurl) { if (getEntry(id)->feed()->url() == feedurl) {
if (AudioManager::instance().entry() == getEntry(id)) { if (AudioManager::instance().entry() == getEntry(id)) {
AudioManager::instance().next(); AudioManager::instance().next();
} }
removeQueueItem(id); removeFromQueueList += id;
} }
} }
bulkQueueStatus(false, removeFromQueueList);
// Delete entries themselves // Delete entries themselves
qCDebug(kastsDataManager) << "delete entries of" << feedurl; qCDebug(kastsDataManager) << "delete entries of" << feedurl;
@ -326,23 +333,27 @@ QStringList DataManager::queue() const
bool DataManager::entryInQueue(const Entry *entry) bool DataManager::entryInQueue(const Entry *entry)
{ {
return entryInQueue(entry->feed()->url(), entry->id()); return entryInQueue(entry->id());
} }
bool DataManager::entryInQueue(const QString &feedurl, const QString &id) const bool DataManager::entryInQueue(const QString &id) const
{ {
Q_UNUSED(feedurl);
return m_queuemap.contains(id); return m_queuemap.contains(id);
} }
void DataManager::addToQueue(const Entry *entry) void DataManager::moveQueueItem(const int from, const int to)
{ {
if (entry != nullptr) { // First move the items in the internal data structure
return addToQueue(entry->feed()->url(), entry->id()); m_queuemap.move(from, to);
}
// Then make sure that the database Queue table reflects these changes
updateQueueListnrs();
// Make sure that the QueueModel is aware of the changes so it can update
Q_EMIT queueEntryMoved(from, to);
} }
void DataManager::addToQueue(const QString &feedurl, const QString &id) void DataManager::addToQueue(const QString &id)
{ {
// If item is already in queue, then stop here // If item is already in queue, then stop here
if (m_queuemap.contains(id)) if (m_queuemap.contains(id))
@ -359,43 +370,28 @@ void DataManager::addToQueue(const QString &feedurl, const QString &id)
QSqlQuery query; QSqlQuery query;
query.prepare(QStringLiteral("INSERT INTO Queue VALUES (:index, :feedurl, :id, :playing);")); query.prepare(QStringLiteral("INSERT INTO Queue VALUES (:index, :feedurl, :id, :playing);"));
query.bindValue(QStringLiteral(":index"), index); query.bindValue(QStringLiteral(":index"), index);
query.bindValue(QStringLiteral(":feedurl"), feedurl); query.bindValue(QStringLiteral(":feedurl"), getEntry(id)->feed()->url());
query.bindValue(QStringLiteral(":id"), id); query.bindValue(QStringLiteral(":id"), id);
query.bindValue(QStringLiteral(":playing"), false); query.bindValue(QStringLiteral(":playing"), false);
Database::instance().execute(query); Database::instance().execute(query);
// Set status to unplayed/unread when adding item to the queue
if (getEntry(id)) {
getEntry(id)->setRead(false);
}
// Make sure that the QueueModel is aware of the changes // Make sure that the QueueModel is aware of the changes
Q_EMIT queueEntryAdded(index, id); Q_EMIT queueEntryAdded(index, id);
} }
void DataManager::moveQueueItem(const int from, const int to) void DataManager::removeFromQueue(const QString &id)
{ {
// First move the items in the internal data structure if (!entryInQueue(id)) {
m_queuemap.move(from, to); return;
}
// Then make sure that the database Queue table reflects these changes const int index = m_queuemap.indexOf(id);
updateQueueListnrs();
// Make sure that the QueueModel is aware of the changes so it can update
Q_EMIT queueEntryMoved(from, to);
}
void DataManager::removeQueueItem(const int index)
{
qCDebug(kastsDataManager) << "Queuemap is now:" << m_queuemap; qCDebug(kastsDataManager) << "Queuemap is now:" << m_queuemap;
// Unset "new" state qCDebug(kastsDataManager) << "Queue index of item to be removed" << index;
getEntry(m_queuemap[index])->setNew(false);
const QString id = m_queuemap[index]; // Move to next track if it's currently playing
// Unload track from AudioManager if it's currently playing
if (AudioManager::instance().entry() == getEntry(id)) { if (AudioManager::instance().entry() == getEntry(id)) {
AudioManager::instance().setEntry(nullptr); AudioManager::instance().next();
} }
// Remove the item from the internal data structure // Remove the item from the internal data structure
@ -403,26 +399,14 @@ void DataManager::removeQueueItem(const int index)
// Then make sure that the database Queue table reflects these changes // Then make sure that the database Queue table reflects these changes
QSqlQuery query; QSqlQuery query;
query.prepare(QStringLiteral("DELETE FROM Queue WHERE listnr=:listnr;")); query.prepare(QStringLiteral("DELETE FROM Queue WHERE id=:id;"));
query.bindValue(QStringLiteral(":listnr"), index); query.bindValue(QStringLiteral(":id"), id);
Database::instance().execute(query); Database::instance().execute(query);
// ... and update all other listnrs in Queue table
updateQueueListnrs();
// Make sure that the QueueModel is aware of the change so it can update // Make sure that the QueueModel is aware of the change so it can update
Q_EMIT queueEntryRemoved(index, id); Q_EMIT queueEntryRemoved(index, id);
} }
void DataManager::removeQueueItem(const QString id)
{
removeQueueItem(m_queuemap.indexOf(id));
}
void DataManager::removeQueueItem(Entry *entry)
{
removeQueueItem(m_queuemap.indexOf(entry->id()));
}
QString DataManager::lastPlayingEntry() QString DataManager::lastPlayingEntry()
{ {
QSqlQuery query; QSqlQuery query;
@ -541,8 +525,8 @@ bool DataManager::feedExists(const QString &url)
void DataManager::updateQueueListnrs() const void DataManager::updateQueueListnrs() const
{ {
QSqlQuery query; QSqlQuery query;
for (int i = 0; i < m_queuemap.count(); i++) {
query.prepare(QStringLiteral("UPDATE Queue SET listnr=:i WHERE id=:id;")); query.prepare(QStringLiteral("UPDATE Queue SET listnr=:i WHERE id=:id;"));
for (int i = 0; i < m_queuemap.count(); i++) {
query.bindValue(QStringLiteral(":i"), i); query.bindValue(QStringLiteral(":i"), i);
query.bindValue(QStringLiteral(":id"), m_queuemap[i]); query.bindValue(QStringLiteral(":id"), m_queuemap[i]);
Database::instance().execute(query); Database::instance().execute(query);
@ -553,3 +537,109 @@ bool DataManager::isFeedExists(const QString &url)
{ {
return m_feeds.contains(url); return m_feeds.contains(url);
} }
void DataManager::bulkMarkReadByIndex(bool state, QModelIndexList list)
{
bulkMarkRead(state, getIdsFromModelIndexList(list));
}
void DataManager::bulkMarkRead(bool state, QStringList list)
{
Database::instance().execute(QStringLiteral("BEGIN TRANSACTION;"));
if (state) { // Mark as read
// This needs special attention as the DB operations are very intensive.
// Reversing the loop is much faster
for (int i = list.count() - 1; i >= 0; i--) {
getEntry(list[i])->setReadInternal(state);
}
updateQueueListnrs(); // update queue after modification
} else { // Mark as unread
for (QString id : list) {
getEntry(id)->setReadInternal(state);
}
}
Database::instance().execute(QStringLiteral("COMMIT;"));
Q_EMIT bulkReadStatusActionFinished();
}
void DataManager::bulkMarkNewByIndex(bool state, QModelIndexList list)
{
bulkMarkNew(state, getIdsFromModelIndexList(list));
}
void DataManager::bulkMarkNew(bool state, QStringList list)
{
Database::instance().execute(QStringLiteral("BEGIN TRANSACTION;"));
for (QString id : list) {
getEntry(id)->setNewInternal(state);
}
Database::instance().execute(QStringLiteral("COMMIT;"));
Q_EMIT bulkNewStatusActionFinished();
}
void DataManager::bulkQueueStatusByIndex(bool state, QModelIndexList list)
{
bulkQueueStatus(state, getIdsFromModelIndexList(list));
}
void DataManager::bulkQueueStatus(bool state, QStringList list)
{
Database::instance().execute(QStringLiteral("BEGIN TRANSACTION;"));
if (state) { // i.e. add to queue
for (QString id : list) {
getEntry(id)->setQueueStatusInternal(state);
}
} else { // i.e. remove from queue
// This needs special attention as the DB operations are very intensive.
// Reversing the loop is much faster.
for (int i = list.count() - 1; i >= 0; i--) {
qCDebug(kastsDataManager) << "getting entry" << getEntry(list[i])->id();
getEntry(list[i])->setQueueStatusInternal(state);
}
updateQueueListnrs();
}
Database::instance().execute(QStringLiteral("COMMIT;"));
Q_EMIT bulkReadStatusActionFinished();
Q_EMIT bulkNewStatusActionFinished();
}
void DataManager::bulkDownloadEnclosuresByIndex(QModelIndexList list)
{
bulkDownloadEnclosures(getIdsFromModelIndexList(list));
}
void DataManager::bulkDownloadEnclosures(QStringList list)
{
bulkQueueStatus(true, list);
for (QString id : list) {
getEntry(id)->enclosure()->download();
}
}
void DataManager::bulkDeleteEnclosuresByIndex(QModelIndexList list)
{
bulkDeleteEnclosures(getIdsFromModelIndexList(list));
}
void DataManager::bulkDeleteEnclosures(QStringList list)
{
Database::instance().execute(QStringLiteral("BEGIN TRANSACTION;"));
for (QString id : list) {
getEntry(id)->enclosure()->deleteFile();
}
Database::instance().execute(QStringLiteral("COMMIT;"));
}
QStringList DataManager::getIdsFromModelIndexList(const QModelIndexList &list) const
{
QStringList ids;
for (QModelIndex index : list) {
ids += index.data(EpisodeModel::Roles::IdRole).value<QString>();
}
qCDebug(kastsDataManager) << "Ids of selection:" << ids;
return ids;
}

View File

@ -33,6 +33,7 @@ public:
Entry *getEntry(const Feed *feed, const int entry_index) const; Entry *getEntry(const Feed *feed, const int entry_index) const;
Q_INVOKABLE Entry *getEntry(const QString &id) const; Q_INVOKABLE Entry *getEntry(const QString &id) const;
int feedCount() const; int feedCount() const;
QStringList getIdList(const Feed *feed) const;
int entryCount(const int feed_index) const; int entryCount(const int feed_index) const;
int entryCount(const Feed *feed) const; int entryCount(const Feed *feed) const;
int unreadEntryCount(const Feed *feed) const; int unreadEntryCount(const Feed *feed) const;
@ -46,14 +47,11 @@ public:
Entry *getQueueEntry(int index) const; Entry *getQueueEntry(int index) const;
int queueCount() const; int queueCount() const;
QStringList queue() const; QStringList queue() const;
Q_INVOKABLE bool entryInQueue(const Entry *entry); bool entryInQueue(const Entry *entry);
Q_INVOKABLE bool entryInQueue(const QString &feedurl, const QString &id) const; bool entryInQueue(const QString &id) const;
Q_INVOKABLE void addToQueue(const Entry *entry);
Q_INVOKABLE void addToQueue(const QString &feedurl, const QString &id);
Q_INVOKABLE void moveQueueItem(const int from, const int to); Q_INVOKABLE void moveQueueItem(const int from, const int to);
Q_INVOKABLE void removeQueueItem(const int index); void addToQueue(const QString &id);
Q_INVOKABLE void removeQueueItem(const QString id); void removeFromQueue(const QString &id);
Q_INVOKABLE void removeQueueItem(Entry *entry);
Q_INVOKABLE QString lastPlayingEntry(); Q_INVOKABLE QString lastPlayingEntry();
Q_INVOKABLE void setLastPlayingEntry(const QString &id); Q_INVOKABLE void setLastPlayingEntry(const QString &id);
@ -64,6 +62,18 @@ public:
Q_INVOKABLE void exportFeeds(const QString &path); Q_INVOKABLE void exportFeeds(const QString &path);
Q_INVOKABLE bool isFeedExists(const QString &url); Q_INVOKABLE bool isFeedExists(const QString &url);
Q_INVOKABLE void bulkMarkRead(bool state, QStringList list);
Q_INVOKABLE void bulkMarkNew(bool state, QStringList list);
Q_INVOKABLE void bulkQueueStatus(bool state, QStringList list);
Q_INVOKABLE void bulkDownloadEnclosures(QStringList list);
Q_INVOKABLE void bulkDeleteEnclosures(QStringList list);
Q_INVOKABLE void bulkMarkReadByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkMarkNewByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkQueueStatusByIndex(bool state, QModelIndexList list);
Q_INVOKABLE void bulkDownloadEnclosuresByIndex(QModelIndexList list);
Q_INVOKABLE void bulkDeleteEnclosuresByIndex(QModelIndexList list);
Q_SIGNALS: Q_SIGNALS:
void feedAdded(const QString &url); void feedAdded(const QString &url);
void feedRemoved(const int &index); void feedRemoved(const int &index);
@ -77,6 +87,9 @@ Q_SIGNALS:
void unreadEntryCountChanged(const QString &url); void unreadEntryCountChanged(const QString &url);
void newEntryCountChanged(const QString &url); void newEntryCountChanged(const QString &url);
void bulkReadStatusActionFinished();
void bulkNewStatusActionFinished();
private: private:
DataManager(); DataManager();
void loadFeed(QString feedurl) const; void loadFeed(QString feedurl) const;
@ -84,6 +97,8 @@ private:
bool feedExists(const QString &url); bool feedExists(const QString &url);
void updateQueueListnrs() const; void updateQueueListnrs() const;
QStringList getIdsFromModelIndexList(const QModelIndexList &list) const;
mutable QHash<QString, Feed *> m_feeds; // hash of pointers to all feeds in db, key = url (lazy loading) mutable QHash<QString, Feed *> m_feeds; // hash of pointers to all feeds in db, key = url (lazy loading)
mutable QHash<QString, Entry *> m_entries; // hash of pointers to all entries in db, key = id (lazy loading) mutable QHash<QString, Entry *> m_entries; // hash of pointers to all entries in db, key = id (lazy loading)

View File

@ -87,6 +87,10 @@ Enclosure::Status Enclosure::dbToStatus(int value)
void Enclosure::download() void Enclosure::download()
{ {
if (m_status == Downloaded) {
return;
}
if (Fetcher::instance().isMeteredConnection() && !SettingsManager::self()->allowMeteredEpisodeDownloads()) { if (Fetcher::instance().isMeteredConnection() && !SettingsManager::self()->allowMeteredEpisodeDownloads()) {
Q_EMIT downloadError(Error::Type::MeteredNotAllowed, Q_EMIT downloadError(Error::Type::MeteredNotAllowed,
m_entry->feed()->url(), m_entry->feed()->url(),

View File

@ -18,7 +18,7 @@
#include "settingsmanager.h" #include "settingsmanager.h"
Entry::Entry(Feed *feed, const QString &id) Entry::Entry(Feed *feed, const QString &id)
: QObject(nullptr) : QObject(&DataManager::instance())
, m_feed(feed) , m_feed(feed)
{ {
connect(&Fetcher::instance(), &Fetcher::downloadFinished, this, [this](QString url) { connect(&Fetcher::instance(), &Fetcher::downloadFinished, this, [this](QString url) {
@ -30,16 +30,7 @@ Entry::Entry(Feed *feed, const QString &id)
Q_EMIT cachedImageChanged(cachedImage()); Q_EMIT cachedImageChanged(cachedImage());
} }
}); });
connect(&DataManager::instance(), &DataManager::queueEntryAdded, this, [this](const int &index, const QString &id) {
Q_UNUSED(index)
if (id == m_id)
Q_EMIT queueStatusChanged(queueStatus());
});
connect(&DataManager::instance(), &DataManager::queueEntryRemoved, this, [this](const int &index, const QString &id) {
Q_UNUSED(index)
if (id == m_id)
Q_EMIT queueStatusChanged(queueStatus());
});
QSqlQuery entryQuery; QSqlQuery entryQuery;
entryQuery.prepare(QStringLiteral("SELECT * FROM Entries WHERE feed=:feed AND id=:id;")); entryQuery.prepare(QStringLiteral("SELECT * FROM Entries WHERE feed=:feed AND id=:id;"));
entryQuery.bindValue(QStringLiteral(":feed"), m_feed->url()); entryQuery.bindValue(QStringLiteral(":feed"), m_feed->url());
@ -134,50 +125,83 @@ QString Entry::baseUrl() const
void Entry::setRead(bool read) void Entry::setRead(bool read)
{ {
if (read != m_read) {
// Making a detour through DataManager to make bulk operations more
// performant. DataManager will call setReadInternal on every item to
// be marked read/unread. So implement features there.
DataManager::instance().bulkMarkRead(read, QStringList(m_id));
}
}
void Entry::setReadInternal(bool read)
{
if (read != m_read) {
// Make sure that operations done here can be wrapped inside an sqlite
// transaction. I.e. no calls that trigger a SELECT operation.
m_read = read; m_read = read;
Q_EMIT readChanged(m_read); Q_EMIT readChanged(m_read);
QSqlQuery query; QSqlQuery query;
query.prepare(QStringLiteral("UPDATE Entries SET read=:read WHERE id=:id AND feed=:feed")); query.prepare(QStringLiteral("UPDATE Entries SET read=:read WHERE id=:id AND feed=:feed"));
query.bindValue(QStringLiteral(":id"), m_id); query.bindValue(QStringLiteral(":id"), m_id);
query.bindValue(QStringLiteral(":feed"), m_feed->url()); query.bindValue(QStringLiteral(":feed"), m_feed->url());
query.bindValue(QStringLiteral(":read"), m_read); query.bindValue(QStringLiteral(":read"), m_read);
Database::instance().execute(query); Database::instance().execute(query);
Q_EMIT m_feed->unreadEntryCountChanged(); Q_EMIT m_feed->unreadEntryCountChanged();
Q_EMIT DataManager::instance().unreadEntryCountChanged(m_feed->url()); Q_EMIT DataManager::instance().unreadEntryCountChanged(m_feed->url());
// TODO: can one of the two slots be removed?? // TODO: can one of the two slots be removed??
// Follow up actions // Follow up actions
if (read && hasEnclosure()) { if (read) {
// 1) Reset play position // 1) Remove item from queue
setQueueStatusInternal(false);
// 2) Remove "new" label
setNewInternal(false);
if (hasEnclosure()) {
// 3) Reset play position
if (SettingsManager::self()->resetPositionOnPlayed()) { if (SettingsManager::self()->resetPositionOnPlayed()) {
m_enclosure->setPlayPosition(0); m_enclosure->setPlayPosition(0);
} }
// 2) Remove item from queue
setQueueStatus(false);
// 3) Remove "new" label
setNew(false);
// 4) Delete episode if that setting is set // 4) Delete episode if that setting is set
if (SettingsManager::self()->autoDeleteOnPlayed() == 1) { if (SettingsManager::self()->autoDeleteOnPlayed() == 1) {
m_enclosure->deleteFile(); m_enclosure->deleteFile();
} }
} }
}
}
} }
void Entry::setNew(bool state) void Entry::setNew(bool state)
{ {
if (state != m_new) {
// Making a detour through DataManager to make bulk operations more
// performant. DataManager will call setNewInternal on every item to
// be marked new/not new. So implement features there.
DataManager::instance().bulkMarkNew(state, QStringList(m_id));
}
}
void Entry::setNewInternal(bool state)
{
if (state != m_new) {
// Make sure that operations done here can be wrapped inside an sqlite
// transaction. I.e. no calls that trigger a SELECT operation.
m_new = state; m_new = state;
Q_EMIT newChanged(m_new); Q_EMIT newChanged(m_new);
QSqlQuery query; QSqlQuery query;
query.prepare(QStringLiteral("UPDATE Entries SET new=:new WHERE id=:id AND feed=:feed")); query.prepare(QStringLiteral("UPDATE Entries SET new=:new WHERE id=:id;"));
query.bindValue(QStringLiteral(":id"), m_id); query.bindValue(QStringLiteral(":id"), m_id);
query.bindValue(QStringLiteral(":feed"), m_feed->url());
query.bindValue(QStringLiteral(":new"), m_new); query.bindValue(QStringLiteral(":new"), m_new);
Database::instance().execute(query); Database::instance().execute(query);
// Q_EMIT m_feed->newEntryCountChanged(); // TODO: signal and slots to be implemented // Q_EMIT m_feed->newEntryCountChanged(); // TODO: signal and slots to be implemented
Q_EMIT DataManager::instance().newEntryCountChanged(m_feed->url()); Q_EMIT DataManager::instance().newEntryCountChanged(m_feed->url());
}
} }
QString Entry::adjustedContent(int width, int fontSize) QString Entry::adjustedContent(int width, int fontSize)
@ -248,14 +272,30 @@ bool Entry::queueStatus() const
void Entry::setQueueStatus(bool state) void Entry::setQueueStatus(bool state)
{ {
if (state != DataManager::instance().entryInQueue(this)) { if (state != DataManager::instance().entryInQueue(this)) {
if (state) // Making a detour through DataManager to make bulk operations more
DataManager::instance().addToQueue(this); // performant. DataManager will call setQueueStatusInternal on every
else // item to be processed. So implement features there.
DataManager::instance().removeQueueItem(this); DataManager::instance().bulkQueueStatus(state, QStringList(m_id));
Q_EMIT queueStatusChanged(state);
} }
} }
void Entry::setQueueStatusInternal(bool state)
{
// Make sure that operations done here can be wrapped inside an sqlite
// transaction. I.e. no calls that trigger a SELECT operation.
if (state) {
DataManager::instance().addToQueue(m_id);
// Set status to unplayed/unread when adding item to the queue
setReadInternal(false);
} else {
DataManager::instance().removeFromQueue(m_id);
// Unset "new" state
setNewInternal(false);
}
Q_EMIT queueStatusChanged(state);
}
void Entry::setImage(const QString &image) void Entry::setImage(const QString &image)
{ {
m_image = image; m_image = image;

View File

@ -69,6 +69,10 @@ public:
Q_INVOKABLE QString adjustedContent(int width, int fontSize); Q_INVOKABLE QString adjustedContent(int width, int fontSize);
void setNewInternal(bool state);
void setReadInternal(bool read);
void setQueueStatusInternal(bool state);
Q_SIGNALS: Q_SIGNALS:
void readChanged(bool read); void readChanged(bool read);
void newChanged(bool state); void newChanged(bool state);

View File

@ -41,6 +41,7 @@
#include "kasts-version.h" #include "kasts-version.h"
#include "models/downloadmodel.h" #include "models/downloadmodel.h"
#include "models/entriesmodel.h" #include "models/entriesmodel.h"
#include "models/episodemodel.h"
#include "models/episodeproxymodel.h" #include "models/episodeproxymodel.h"
#include "models/errorlogmodel.h" #include "models/errorlogmodel.h"
#include "models/feedsmodel.h" #include "models/feedsmodel.h"
@ -126,6 +127,7 @@ int main(int argc, char *argv[])
qmlRegisterUncreatableType<EntriesModel>("org.kde.kasts", 1, 0, "EntriesModel", QStringLiteral("Get from Feed")); qmlRegisterUncreatableType<EntriesModel>("org.kde.kasts", 1, 0, "EntriesModel", QStringLiteral("Get from Feed"));
qmlRegisterUncreatableType<Enclosure>("org.kde.kasts", 1, 0, "Enclosure", QStringLiteral("Only for enums")); qmlRegisterUncreatableType<Enclosure>("org.kde.kasts", 1, 0, "Enclosure", QStringLiteral("Only for enums"));
qmlRegisterUncreatableType<EpisodeModel>("org.kde.kasts", 1, 0, "EpisodeModel", QStringLiteral("Only for enums"));
qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "Fetcher", &Fetcher::instance()); qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "Fetcher", &Fetcher::instance());
qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "Database", &Database::instance()); qmlRegisterSingletonInstance("org.kde.kasts", 1, 0, "Database", &Database::instance());

View File

@ -11,6 +11,7 @@
#include "database.h" #include "database.h"
#include "datamanager.h" #include "datamanager.h"
#include "models/episodemodel.h"
DownloadModel::DownloadModel() DownloadModel::DownloadModel()
: QAbstractListModel(nullptr) : QAbstractListModel(nullptr)
@ -20,32 +21,30 @@ DownloadModel::DownloadModel()
QVariant DownloadModel::data(const QModelIndex &index, int role) const QVariant DownloadModel::data(const QModelIndex &index, int role) const
{ {
if (role != 0) switch (role) {
return QVariant(); case EpisodeModel::Roles::EntryRole:
if (index.row() < m_downloadingCount) { return QVariant::fromValue(DataManager::instance().getEntry(m_entryIds[index.row()]));
return QVariant::fromValue(DataManager::instance().getEntry(m_downloadingIds[index.row()])); case EpisodeModel::Roles::IdRole:
} else if (index.row() < m_downloadingCount + m_partiallyDownloadedCount) { return QVariant::fromValue(m_entryIds[index.row()]);
return QVariant::fromValue(DataManager::instance().getEntry(m_partiallyDownloadedIds[index.row() - m_downloadingCount])); default:
} else if (index.row() < m_downloadingCount + m_partiallyDownloadedCount + m_downloadedCount) {
return QVariant::fromValue(DataManager::instance().getEntry(m_downloadedIds[index.row() - m_downloadingCount - m_partiallyDownloadedCount]));
} else {
qWarning() << "Trying to fetch DownloadModel item outside of valid range; this should never happen";
return QVariant(); return QVariant();
} }
} }
QHash<int, QByteArray> DownloadModel::roleNames() const QHash<int, QByteArray> DownloadModel::roleNames() const
{ {
QHash<int, QByteArray> roleNames; return {
roleNames[0] = "entry"; {EpisodeModel::Roles::EntryRole, "entry"},
return roleNames; {EpisodeModel::Roles::IdRole, "id"},
{EpisodeModel::Roles::ReadRole, "read"},
{EpisodeModel::Roles::NewRole, "new"},
};
} }
int DownloadModel::rowCount(const QModelIndex &parent) const int DownloadModel::rowCount(const QModelIndex &parent) const
{ {
Q_UNUSED(parent) Q_UNUSED(parent)
return m_entryIds.count();
return m_downloadingCount + m_partiallyDownloadedCount + m_downloadedCount;
} }
void DownloadModel::monitorDownloadStatus() void DownloadModel::monitorDownloadStatus()
@ -57,9 +56,7 @@ void DownloadModel::monitorDownloadStatus()
void DownloadModel::updateInternalState() void DownloadModel::updateInternalState()
{ {
m_downloadingIds.clear(); m_entryIds.clear();
m_partiallyDownloadedIds.clear();
m_downloadedIds.clear();
QSqlQuery query; QSqlQuery query;
query.prepare( query.prepare(
@ -68,22 +65,24 @@ void DownloadModel::updateInternalState()
query.bindValue(QStringLiteral(":downloaded"), Enclosure::statusToDb(Enclosure::Downloading)); query.bindValue(QStringLiteral(":downloaded"), Enclosure::statusToDb(Enclosure::Downloading));
Database::instance().execute(query); Database::instance().execute(query);
while (query.next()) { while (query.next()) {
m_downloadingIds += query.value(QStringLiteral("id")).toString(); m_entryIds += query.value(QStringLiteral("id")).toString();
} }
query.bindValue(QStringLiteral(":downloaded"), Enclosure::statusToDb(Enclosure::PartiallyDownloaded)); query.bindValue(QStringLiteral(":downloaded"), Enclosure::statusToDb(Enclosure::PartiallyDownloaded));
Database::instance().execute(query); Database::instance().execute(query);
while (query.next()) { while (query.next()) {
m_partiallyDownloadedIds += query.value(QStringLiteral("id")).toString(); m_entryIds += query.value(QStringLiteral("id")).toString();
} }
query.bindValue(QStringLiteral(":downloaded"), Enclosure::statusToDb(Enclosure::Downloaded)); query.bindValue(QStringLiteral(":downloaded"), Enclosure::statusToDb(Enclosure::Downloaded));
Database::instance().execute(query); Database::instance().execute(query);
while (query.next()) { while (query.next()) {
m_downloadedIds += query.value(QStringLiteral("id")).toString(); m_entryIds += query.value(QStringLiteral("id")).toString();
} }
}
m_downloadingCount = m_downloadingIds.count();
m_partiallyDownloadedCount = m_partiallyDownloadedIds.count(); // Hack to get a QItemSelection in QML
m_downloadedCount = m_downloadedIds.count(); QItemSelection DownloadModel::createSelection(int rowa, int rowb)
{
return QItemSelection(index(rowa, 0), index(rowb, 0));
} }

View File

@ -8,6 +8,7 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QHash> #include <QHash>
#include <QItemSelection>
#include <QObject> #include <QObject>
#include <QVariant> #include <QVariant>
@ -29,6 +30,8 @@ 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;
Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb);
public Q_SLOTS: public Q_SLOTS:
void monitorDownloadStatus(); void monitorDownloadStatus();
@ -40,8 +43,10 @@ private:
QStringList m_downloadingIds; QStringList m_downloadingIds;
QStringList m_partiallyDownloadedIds; QStringList m_partiallyDownloadedIds;
QStringList m_downloadedIds; QStringList m_downloadedIds;
QStringList m_entryIds;
int m_downloadingCount = 0; int m_downloadingCount = 0;
int m_partiallyDownloadedCount = 0; int m_partiallyDownloadedCount = 0;
int m_downloadedCount = 0; int m_downloadedCount = 0;
int m_entryCount = 0;
}; };

View File

@ -12,6 +12,7 @@
#include "datamanager.h" #include "datamanager.h"
#include "entry.h" #include "entry.h"
#include "feed.h" #include "feed.h"
#include "models/episodemodel.h"
EntriesModel::EntriesModel(Feed *feed) EntriesModel::EntriesModel(Feed *feed)
: QAbstractListModel(feed) : QAbstractListModel(feed)
@ -30,17 +31,24 @@ EntriesModel::EntriesModel(Feed *feed)
QVariant EntriesModel::data(const QModelIndex &index, int role) const QVariant EntriesModel::data(const QModelIndex &index, int role) const
{ {
if (role != 0) switch (role) {
return QVariant(); case EpisodeModel::Roles::EntryRole:
// qDebug() << "fetching item" << index.row();
return QVariant::fromValue(DataManager::instance().getEntry(m_feed, index.row())); return QVariant::fromValue(DataManager::instance().getEntry(m_feed, index.row()));
case EpisodeModel::Roles::IdRole:
return QVariant::fromValue(DataManager::instance().getIdList(m_feed)[index.row()]);
default:
return QVariant();
}
} }
QHash<int, QByteArray> EntriesModel::roleNames() const QHash<int, QByteArray> EntriesModel::roleNames() const
{ {
QHash<int, QByteArray> roleNames; return {
roleNames[0] = "entry"; {EpisodeModel::Roles::EntryRole, "entry"},
return roleNames; {EpisodeModel::Roles::IdRole, "id"},
{EpisodeModel::Roles::ReadRole, "read"},
{EpisodeModel::Roles::NewRole, "new"},
};
} }
int EntriesModel::rowCount(const QModelIndex &parent) const int EntriesModel::rowCount(const QModelIndex &parent) const
@ -53,3 +61,9 @@ Feed *EntriesModel::feed() const
{ {
return m_feed; return m_feed;
} }
// Hack to get a QItemSelection in QML
QItemSelection EntriesModel::createSelection(int rowa, int rowb)
{
return QItemSelection(index(rowa, 0), index(rowb, 0));
}

View File

@ -9,6 +9,7 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QHash> #include <QHash>
#include <QItemSelection>
#include <QObject> #include <QObject>
#include <QVariant> #include <QVariant>
@ -28,6 +29,8 @@ public:
Feed *feed() const; Feed *feed() const;
Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb);
private: private:
Feed *m_feed; Feed *m_feed;
}; };

View File

@ -33,6 +33,8 @@ QVariant EpisodeModel::data(const QModelIndex &index, int role) const
switch (role) { switch (role) {
case EntryRole: case EntryRole:
return QVariant::fromValue(DataManager::instance().getEntry(m_entryIds[index.row()])); return QVariant::fromValue(DataManager::instance().getEntry(m_entryIds[index.row()]));
case IdRole:
return QVariant::fromValue(m_entryIds[index.row()]);
case ReadRole: case ReadRole:
return QVariant::fromValue(m_read[index.row()]); return QVariant::fromValue(m_read[index.row()]);
case NewRole: case NewRole:
@ -46,6 +48,7 @@ QHash<int, QByteArray> EpisodeModel::roleNames() const
{ {
return { return {
{EntryRole, "entry"}, {EntryRole, "entry"},
{IdRole, "id"},
{ReadRole, "read"}, {ReadRole, "read"},
{NewRole, "new"}, {NewRole, "new"},
}; };

View File

@ -19,9 +19,11 @@ class EpisodeModel : public QAbstractListModel
public: public:
enum Roles { enum Roles {
EntryRole = Qt::UserRole, EntryRole = Qt::UserRole,
IdRole,
ReadRole, ReadRole,
NewRole, NewRole,
}; };
Q_ENUM(Roles)
explicit EpisodeModel(); explicit EpisodeModel();
QVariant data(const QModelIndex &index, int role = Qt::UserRole) const override; QVariant data(const QModelIndex &index, int role = Qt::UserRole) const override;

View File

@ -8,6 +8,9 @@
#include <KLocalizedString> #include <KLocalizedString>
#include "datamanager.h"
#include "entry.h"
EpisodeProxyModel::EpisodeProxyModel() EpisodeProxyModel::EpisodeProxyModel()
: QSortFilterProxyModel(nullptr) : QSortFilterProxyModel(nullptr)
{ {
@ -49,13 +52,29 @@ EpisodeProxyModel::FilterType EpisodeProxyModel::filterType() const
void EpisodeProxyModel::setFilterType(FilterType type) void EpisodeProxyModel::setFilterType(FilterType type)
{ {
if (type != m_currentFilter) { if (type != m_currentFilter) {
disconnect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, nullptr);
disconnect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, nullptr);
beginResetModel(); beginResetModel();
// TODO: Connect to signals to capture new and read updates in case those
// filters are active. Also disconnect from signals if the filters are
// removed
m_currentFilter = type; m_currentFilter = type;
m_episodeModel->updateInternalState(); m_episodeModel->updateInternalState();
endResetModel(); endResetModel();
// connect to signals which indicate that read/new statuses have been updated
if (type == ReadFilter || type == NotReadFilter) {
connect(&DataManager::instance(), &DataManager::bulkReadStatusActionFinished, this, [this]() {
beginResetModel();
m_episodeModel->updateInternalState();
endResetModel();
});
} else if (type == NewFilter || type == NotNewFilter) {
connect(&DataManager::instance(), &DataManager::bulkNewStatusActionFinished, this, [this]() {
beginResetModel();
m_episodeModel->updateInternalState();
endResetModel();
});
}
Q_EMIT filterTypeChanged(); Q_EMIT filterTypeChanged();
} }
} }
@ -82,3 +101,9 @@ QString EpisodeProxyModel::getFilterName(FilterType type) const
return QString(); return QString();
} }
} }
// Hack to get a QItemSelection in QML
QItemSelection EpisodeProxyModel::createSelection(int rowa, int rowb)
{
return QItemSelection(index(rowa, 0), index(rowb, 0));
}

View File

@ -6,10 +6,13 @@
#pragma once #pragma once
#include <QItemSelection>
#include <QSortFilterProxyModel> #include <QSortFilterProxyModel>
#include "models/episodemodel.h" #include "models/episodemodel.h"
class Entry;
class EpisodeProxyModel : public QSortFilterProxyModel class EpisodeProxyModel : public QSortFilterProxyModel
{ {
Q_OBJECT Q_OBJECT
@ -38,6 +41,8 @@ public:
Q_INVOKABLE QString getFilterName(FilterType type) const; Q_INVOKABLE QString getFilterName(FilterType type) const;
Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb);
Q_SIGNALS: Q_SIGNALS:
void filterTypeChanged(); void filterTypeChanged();

View File

@ -13,6 +13,7 @@
#include "audiomanager.h" #include "audiomanager.h"
#include "datamanager.h" #include "datamanager.h"
#include "entry.h" #include "entry.h"
#include "models/episodemodel.h"
QueueModel::QueueModel(QObject *parent) QueueModel::QueueModel(QObject *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
@ -47,17 +48,24 @@ QueueModel::QueueModel(QObject *parent)
QVariant QueueModel::data(const QModelIndex &index, int role) const QVariant QueueModel::data(const QModelIndex &index, int role) const
{ {
if (role != 0) switch (role) {
return QVariant(); case EpisodeModel::Roles::EntryRole:
qCDebug(kastsQueueModel) << "return entry" << DataManager::instance().getQueueEntry(index.row());
return QVariant::fromValue(DataManager::instance().getQueueEntry(index.row())); return QVariant::fromValue(DataManager::instance().getQueueEntry(index.row()));
case EpisodeModel::Roles::IdRole:
return QVariant::fromValue(DataManager::instance().queue()[index.row()]);
default:
return QVariant();
}
} }
QHash<int, QByteArray> QueueModel::roleNames() const QHash<int, QByteArray> QueueModel::roleNames() const
{ {
QHash<int, QByteArray> roleNames; return {
roleNames[0] = "entry"; {EpisodeModel::Roles::EntryRole, "entry"},
return roleNames; {EpisodeModel::Roles::IdRole, "id"},
{EpisodeModel::Roles::ReadRole, "read"},
{EpisodeModel::Roles::NewRole, "new"},
};
} }
int QueueModel::rowCount(const QModelIndex &parent) const int QueueModel::rowCount(const QModelIndex &parent) const
@ -86,3 +94,9 @@ QString QueueModel::formattedTimeLeft() const
static KFormat format; static KFormat format;
return format.formatDuration(timeLeft()); return format.formatDuration(timeLeft());
} }
// Hack to get a QItemSelection in QML
QItemSelection QueueModel::createSelection(int rowa, int rowb)
{
return QItemSelection(index(rowa, 0), index(rowb, 0));
}

View File

@ -8,6 +8,7 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QHash> #include <QHash>
#include <QItemSelection>
#include <QObject> #include <QObject>
#include <QVariant> #include <QVariant>
@ -36,6 +37,8 @@ public:
int timeLeft() const; int timeLeft() const;
QString formattedTimeLeft() const; QString formattedTimeLeft() const;
Q_INVOKABLE QItemSelection createSelection(int rowa, int rowb);
Q_SIGNALS: Q_SIGNALS:
void timeLeftChanged(); void timeLeftChanged();
}; };

View File

@ -33,6 +33,8 @@ Kirigami.ScrollablePage {
visible: !Kirigami.Settings.isMobile visible: !Kirigami.Settings.isMobile
} }
contextualActions: episodeList.defaultActionList
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
visible: episodeList.count === 0 visible: episodeList.count === 0
@ -50,10 +52,14 @@ Kirigami.ScrollablePage {
} }
} }
ListView { GenericEntryListView {
id: episodeList id: episodeList
visible: count !== 0 visible: count !== 0
isDownloads: true
reuseItems: true
model: DownloadModel model: DownloadModel
delegate: episodeListDelegate
section { section {
delegate: Kirigami.ListSectionHeader { delegate: Kirigami.ListSectionHeader {
@ -65,10 +71,5 @@ Kirigami.ScrollablePage {
} }
property: "entry.enclosure.status" property: "entry.enclosure.status"
} }
delegate: Kirigami.DelegateRecycler {
width: episodeList.width
sourceComponent: episodeListDelegate
}
} }
} }

View File

@ -5,7 +5,7 @@
* 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
*/ */
import QtQuick 2.14 import QtQuick 2.15
import QtQuick.Controls 2.14 as Controls import QtQuick.Controls 2.14 as Controls
import QtQuick.Layouts 1.14 import QtQuick.Layouts 1.14
import QtGraphicalEffects 1.15 import QtGraphicalEffects 1.15
@ -49,6 +49,13 @@ Kirigami.ScrollablePage {
} }
} }
actions.main: Kirigami.Action {
iconName: "view-refresh"
text: i18n("Refresh Podcast")
onTriggered: page.refreshing = true
visible: !Kirigami.Settings.isMobile || entryList.count === 0
}
contextualActions: [ contextualActions: [
Kirigami.Action { Kirigami.Action {
iconName: "help-about-symbolic" iconName: "help-about-symbolic"
@ -70,11 +77,12 @@ Kirigami.ScrollablePage {
} }
] ]
actions.main: Kirigami.Action { // add the default actions through onCompleted to add them to the ones
iconName: "view-refresh" // defined above
text: i18n("Refresh Podcast") Component.onCompleted: {
onTriggered: page.refreshing = true for (var i in entryList.defaultActionList) {
visible: !Kirigami.Settings.isMobile || entryList.count === 0 contextualActions.push(entryList.defaultActionList[i]);
}
} }
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
@ -94,15 +102,13 @@ Kirigami.ScrollablePage {
} }
} }
ListView { GenericEntryListView {
id: entryList id: entryList
visible: count !== 0 visible: count !== 0
model: page.feed.entries reuseItems: true
delegate: Kirigami.DelegateRecycler { model: page.feed.entries
width: entryList.width delegate: entryListDelegate
sourceComponent: entryListDelegate
}
// OverlayHeader looks nicer, but seems completely broken when flicking the list // OverlayHeader looks nicer, but seems completely broken when flicking the list
// headerPositioning: ListView.OverlayHeader // headerPositioning: ListView.OverlayHeader

View File

@ -47,7 +47,7 @@ Kirigami.ScrollablePage {
ListView { ListView {
// TODO: fix automatic width // TODO: fix automatic width
implicitWidth: Kirigami.Units.gridUnit * 12 implicitWidth: Kirigami.Units.gridUnit * 15
clip: true clip: true
model: ListModel { model: ListModel {
@ -105,6 +105,8 @@ Kirigami.ScrollablePage {
] ]
} }
contextualActions: episodeList.defaultActionList
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
visible: episodeList.count === 0 visible: episodeList.count === 0
@ -125,12 +127,14 @@ Kirigami.ScrollablePage {
id: episodeProxyModel id: episodeProxyModel
} }
ListView { GenericEntryListView {
id: episodeList id: episodeList
anchors.fill: parent anchors.fill: parent
visible: count !== 0 visible: count !== 0
model: episodeProxyModel model: episodeProxyModel
// TODO: seems like reuseItems is way too slow; using DelegateRecycler
// for now; still have to find out why...
delegate: Kirigami.DelegateRecycler { delegate: Kirigami.DelegateRecycler {
width: episodeList.width width: episodeList.width
sourceComponent: episodeListDelegate sourceComponent: episodeListDelegate

View File

@ -4,11 +4,12 @@
* 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
*/ */
import QtQuick 2.14 import QtQuick 2.15
import QtQuick.Controls 2.14 as Controls import QtQuick.Controls 2.14 as Controls
import QtQuick.Layouts 1.14 import QtQuick.Layouts 1.14
import QtMultimedia 5.15 import QtMultimedia 5.15
import QtGraphicalEffects 1.15 import QtGraphicalEffects 1.15
import QtQml.Models 2.15
import org.kde.kirigami 2.14 as Kirigami import org.kde.kirigami 2.14 as Kirigami
@ -20,7 +21,12 @@ Kirigami.SwipeListItem {
property bool isQueue: false property bool isQueue: false
property bool isDownloads: false property bool isDownloads: false
property var listView: "" property QtObject listView: undefined
property bool selected: false
property int row: model ? model.row : -1
highlighted: selected
activeBackgroundColor: Qt.lighter(Kirigami.Theme.highlightColor, 1.3)
Accessible.role: Accessible.Button Accessible.role: Accessible.Button
Accessible.name: entry.title Accessible.name: entry.title
@ -30,7 +36,73 @@ Kirigami.SwipeListItem {
Keys.onReturnPressed: clicked() Keys.onReturnPressed: clicked()
contentItem: RowLayout { // We need to update the "selected" status:
// - if the selected indexes changes
// - if our delegate moves
// - if the model moves and the delegate stays in the same place
function updateIsSelected() {
selected = listView.selectionModel.rowIntersectsSelection(row);
}
onRowChanged: {
updateIsSelected();
}
Component.onCompleted: {
updateIsSelected();
}
contentItem: MouseArea {
id: mouseArea
implicitHeight: rowLayout.implicitHeight
implicitWidth: rowLayout.implicitWidth
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: {
// Keep track of (currently) selected items
var modelIndex = listItem.listView.model.index(index, 0);
if (listView.selectionModel.isSelected(modelIndex) && mouse.button == Qt.RightButton) {
listView.contextMenu.popup(null, mouse.x+1, mouse.y+1);
} else if (mouse.modifiers & Qt.ShiftModifier) {
// Have to take a detour through c++ since selecting large sets
// in QML is extremely slow
listView.selectionModel.select(listView.model.createSelection(modelIndex.row, listView.selectionModel.currentIndex.row), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows);
} else if (mouse.modifiers & Qt.ControlModifier) {
listView.selectionModel.select(modelIndex, ItemSelectionModel.Toggle | ItemSelectionModel.Rows);
} else if (mouse.button == Qt.LeftButton) {
listView.currentIndex = index;
listView.selectionModel.setCurrentIndex(modelIndex, ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows);
listItem.clicked();
} else if (mouse.button == Qt.RightButton) {
// This item is right-clicked, but isn't selected
listView.selectionForContextMenu = [modelIndex];
listView.contextMenu.popup(null, mouse.x+1, mouse.y+1);
}
}
onPressAndHold: {
var modelIndex = listItem.listView.model.index(index, 0);
listView.selectionModel.select(modelIndex, ItemSelectionModel.Toggle | ItemSelectionModel.Rows);
}
Connections {
target: listView.selectionModel
function onSelectionChanged() {
updateIsSelected();
}
}
Connections {
target: listView.model
function onLayoutChanged() {
updateIsSelected();
}
}
RowLayout {
id: rowLayout
anchors.fill: parent
Loader { Loader {
property var loaderListView: listView property var loaderListView: listView
@ -44,7 +116,13 @@ Kirigami.SwipeListItem {
Kirigami.ListItemDragHandle { Kirigami.ListItemDragHandle {
listItem: loaderListItem listItem: loaderListItem
listView: loaderListView listView: loaderListView
onMoveRequested: DataManager.moveQueueItem(oldIndex, newIndex) onMoveRequested: {
DataManager.moveQueueItem(oldIndex, newIndex);
// reset current selection when moving items
var modelIndex = listItem.listView.model.index(newIndex, 0);
listView.currentIndex = newIndex;
listView.selectionModel.setCurrentIndex(modelIndex, ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows);
}
} }
} }
@ -54,7 +132,7 @@ Kirigami.SwipeListItem {
property int size: Kirigami.Units.gridUnit * 3 property int size: Kirigami.Units.gridUnit * 3
Layout.preferredHeight: size Layout.preferredHeight: size
Layout.preferredWidth: size Layout.preferredWidth: size
Layout.rightMargin:Kirigami.Units.smallSpacing Layout.rightMargin: Kirigami.Units.smallSpacing
fractionalRadius: 1.0 / 8.0 fractionalRadius: 1.0 / 8.0
} }
@ -170,6 +248,14 @@ Kirigami.SwipeListItem {
} }
} }
} }
Controls.Menu {
id: contextMenu
Controls.MenuItem {
action: listView.addToQueueAction
}
}
}
onClicked: { onClicked: {
// only mark pure rss feeds as read + not new; // only mark pure rss feeds as read + not new;

View File

@ -0,0 +1,267 @@
/**
* SPDX-FileCopyrightText: 2021 Bart De Vries <bart@mogwai.be>
*
* SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
*/
import QtQuick 2.15
import QtQuick.Controls 2.14 as Controls
import QtQuick.Layouts 1.2
import QtQml.Models 2.15
import org.kde.kirigami 2.13 as Kirigami
import org.kde.kasts 1.0
ListView {
id: listView
visible: count !== 0
property bool isQueue: false
property bool isDownloads: false
property var selectionForContextMenu: []
property var singleSelectedEntry: undefined
property ItemSelectionModel selectionModel: ItemSelectionModel {
id: selectionModel
model: listView.model
onSelectionChanged: {
selectionForContextMenu = selectedIndexes;
}
}
onSelectionForContextMenuChanged: {
if (selectionForContextMenu.length === 1) {
singleSelectedEntry = selectionForContextMenu[0].model.data(selectionForContextMenu[0], EpisodeModel.EntryRole);
} else {
singleSelectedEntry = undefined;
}
}
// The selection is not updated when the model is reset, so we have to take
// this into account manually.
// TODO: Fix the fact that the current item is not highlighted after reset
Connections {
target: listView.model
function onModelAboutToBeReset() {
startOfSelection = -1;
selectionForContextMenu = [];
listView.selectionModel.clear();
listView.selectionModel.setCurrentIndex(model.index(0, 0), ItemSelectionModel.Current); // Only set current item; don't select it
currentIndex = 0;
}
}
Keys.onPressed: {
if (event.matches(StandardKey.SelectAll)) {
listView.selectionModel.select(model.index(0, 0), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Columns);
return;
}
switch (event.key) {
case Qt.Key_Up:
selectRelative(-1, event.modifiers == Qt.ShiftModifier);
return;
case Qt.Key_Down:
selectRelative(1, event.modifiers == Qt.ShiftModifier);
return;
case Qt.Key_PageUp:
if (!atYBeginning) {
if ((contentY - listView.height) < 0) {
contentY = 0
} else {
contentY -= listView.height
}
returnToBounds()
}
return;
case Qt.Key_PageDown:
if (!atYEnd) {
if ((contentY + listView.height) > contentHeight - height) {
contentY = contentHeight - height
} else {
contentY += listView.height
}
returnToBounds()
}
return;
case Qt.Key_Home:
if (!atYBeginning) {
contentY = 0
returnToBounds()
}
return;
case Qt.Key_End:
if (!atYEnd) {
contentY = contentHeight - height
returnToBounds()
}
return;
default:
break;
}
}
onActiveFocusChanged: {
if (activeFocus && !selectionModel.hasSelection) {
selectionModel.clear();
selectionModel.setCurrentIndex(model.index(0, 0), ItemSelectionModel.Current); // Only set current item; don't select it
}
}
function selectRelative(delta, append) {
var nextRow = listView.currentIndex + delta;
if (nextRow < 0) {
nextRow = 0;
}
if (nextRow >= listView.count) {
nextRow = listView.count - 1;
}
if (append) {
listView.selectionModel.select(listView.model.createSelection(nextRow, listView.selectionModel.currentIndex.row), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows);
} else {
listView.selectionModel.setCurrentIndex(model.index(nextRow, 0), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Rows);
}
}
// For lack of a better place, we put generic entry list actions here so
// they can be re-used across the different ListViews.
property var selectAllAction: Kirigami.Action {
iconName: "edit-select-all"
text: i18n("Select All")
visible: true
onTriggered: {
listView.selectionModel.select(model.index(0, 0), ItemSelectionModel.ClearAndSelect | ItemSelectionModel.Columns);
}
}
property var selectNoneAction: Kirigami.Action {
iconName: "edit-select-none"
text: i18n("Deselect All")
visible: listView.selectionModel.hasSelection
onTriggered: {
listView.selectionModel.clearSelection();
}
}
property var addToQueueAction: Kirigami.Action {
text: i18n("Add to Queue")
icon.name: "media-playlist-append"
visible: listView.selectionModel.hasSelection && !listView.isQueue && (singleSelectedEntry ? !singleSelectedEntry.queueStatus : true)
//visible: listView.selectionModel.hasSelection && !listView.isQueue
onTriggered: {
DataManager.bulkQueueStatusByIndex(true, selectionForContextMenu);
}
}
property var removeFromQueueAction: Kirigami.Action {
text: i18n("Remove from Queue")
icon.name: "list-remove"
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? singleSelectedEntry.queueStatus : true)
//visible: listView.selectionModel.hasSelection
onTriggered: {
DataManager.bulkQueueStatusByIndex(false, selectionForContextMenu);
}
}
property var markPlayedAction: Kirigami.Action {
text: i18n("Mark as Played")
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? !singleSelectedEntry.read : true)
onTriggered: {
DataManager.bulkMarkReadByIndex(true, selectionForContextMenu);
}
}
property var markNotPlayedAction: Kirigami.Action {
text: i18n("Mark as Unplayed")
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? singleSelectedEntry.read : true)
onTriggered: {
DataManager.bulkMarkReadByIndex(false, selectionForContextMenu);
}
}
property var markNewAction: Kirigami.Action {
text: i18n("Label as \"New\"")
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? !singleSelectedEntry.new : true)
onTriggered: {
DataManager.bulkMarkNewByIndex(true, selectionForContextMenu);
}
}
property var markNotNewAction: Kirigami.Action {
text: i18n("Remove \"New\" Label")
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? singleSelectedEntry.new : true)
onTriggered: {
DataManager.bulkMarkNewByIndex(false, selectionForContextMenu);
}
}
property var downloadEnclosureAction: Kirigami.Action {
text: i18n("Download")
icon.name: "download"
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? (singleSelectedEntry.hasEnclosure ? singleSelectedEntry.enclosure.status !== Enclosure.Downloaded : false) : true)
onTriggered: {
downloadOverlay.selection = selectionForContextMenu;
downloadOverlay.run();
}
}
property var deleteEnclosureAction: Kirigami.Action {
text: i18n("Delete Download")
icon.name: "delete"
visible: listView.selectionModel.hasSelection && (singleSelectedEntry ? (singleSelectedEntry.hasEnclosure ? singleSelectedEntry.enclosure.status !== Enclosure.Downloadable : false) : true)
onTriggered: {
DataManager.bulkDeleteEnclosuresByIndex(selectionForContextMenu);
}
}
property var defaultActionList: [addToQueueAction,
removeFromQueueAction,
markPlayedAction,
markNotPlayedAction,
markNewAction,
markNotNewAction,
downloadEnclosureAction,
deleteEnclosureAction,
selectAllAction,
selectNoneAction]
property Controls.Menu contextMenu: Controls.Menu {
id: contextMenu
Controls.MenuItem {
action: listView.addToQueueAction
visible: !listView.isQueue && (singleSelectedEntry ? !singleSelectedEntry.queueStatus : true)
}
Controls.MenuItem {
action: listView.removeFromQueueAction
visible: singleSelectedEntry ? singleSelectedEntry.queueStatus : true
}
Controls.MenuItem {
action: listView.markPlayedAction
visible: singleSelectedEntry ? !singleSelectedEntry.read : true
}
Controls.MenuItem {
action: listView.markNotPlayedAction
visible: singleSelectedEntry ? singleSelectedEntry.read : true
}
Controls.MenuItem {
action: listView.markNewAction
visible: singleSelectedEntry ? !singleSelectedEntry.new : true
}
Controls.MenuItem {
action: listView.markNotNewAction
visible: singleSelectedEntry ? singleSelectedEntry.new : true
}
Controls.MenuItem {
action: listView.downloadEnclosureAction
visible: singleSelectedEntry ? (singleSelectedEntry.hasEnclosure ? singleSelectedEntry.enclosure.status !== Enclosure.Downloaded : false) : true
}
Controls.MenuItem {
action: listView.deleteEnclosureAction
visible: singleSelectedEntry ? (singleSelectedEntry.hasEnclosure ? singleSelectedEntry.enclosure.status !== Enclosure.Downloadable : false) : true
}
onClosed: {
// reset to normal selection if this context menu is closed
listView.selectionForContextMenu = listView.selectionModel.selectedIndexes;
}
}
}

View File

@ -4,9 +4,10 @@
* 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
*/ */
import QtQuick 2.14 import QtQuick 2.15
import QtQuick.Controls 2.14 as Controls import QtQuick.Controls 2.14 as Controls
import QtQuick.Layouts 1.2 import QtQuick.Layouts 1.2
import QtQml.Models 2.15
import org.kde.kirigami 2.13 as Kirigami import org.kde.kirigami 2.13 as Kirigami
@ -34,6 +35,8 @@ Kirigami.ScrollablePage {
visible: !Kirigami.Settings.isMobile || queueList.count === 0 visible: !Kirigami.Settings.isMobile || queueList.count === 0
} }
contextualActions: queueList.defaultActionList
Kirigami.PlaceholderMessage { Kirigami.PlaceholderMessage {
visible: queueList.count === 0 visible: queueList.count === 0
@ -51,8 +54,9 @@ Kirigami.ScrollablePage {
} }
} }
ListView { GenericEntryListView {
id: queueList id: queueList
isQueue: true
visible: count !== 0 visible: count !== 0
anchors.fill: parent anchors.fill: parent

View File

@ -294,11 +294,18 @@ Kirigami.ApplicationWindow {
headingText: i18n("Podcast downloads are currently not allowed on metered connections") headingText: i18n("Podcast downloads are currently not allowed on metered connections")
condition: SettingsManager.allowMeteredEpisodeDownloads condition: SettingsManager.allowMeteredEpisodeDownloads
property var entry: undefined property var entry: undefined
property var selection: undefined
function action() { function action() {
if (selection) {
DataManager.bulkDownloadEnclosuresByIndex(selection);
} else if (entry) {
entry.queueStatus = true; entry.queueStatus = true;
entry.enclosure.download(); entry.enclosure.download();
} }
selection = undefined;
entry = undefined;
}
function allowOnceAction() { function allowOnceAction() {
SettingsManager.allowMeteredEpisodeDownloads = true; SettingsManager.allowMeteredEpisodeDownloads = true;

View File

@ -20,6 +20,7 @@
<file alias="ErrorListOverlay.qml">qml/ErrorListOverlay.qml</file> <file alias="ErrorListOverlay.qml">qml/ErrorListOverlay.qml</file>
<file alias="GenericHeader.qml">qml/GenericHeader.qml</file> <file alias="GenericHeader.qml">qml/GenericHeader.qml</file>
<file alias="GenericEntryDelegate.qml">qml/GenericEntryDelegate.qml</file> <file alias="GenericEntryDelegate.qml">qml/GenericEntryDelegate.qml</file>
<file alias="GenericEntryListView.qml">qml/GenericEntryListView.qml</file>
<file alias="DiscoverPage.qml">qml/DiscoverPage.qml</file> <file alias="DiscoverPage.qml">qml/DiscoverPage.qml</file>
<file alias="ImageWithFallback.qml">qml/ImageWithFallback.qml</file> <file alias="ImageWithFallback.qml">qml/ImageWithFallback.qml</file>
<file alias="UpdateNotification.qml">qml/UpdateNotification.qml</file> <file alias="UpdateNotification.qml">qml/UpdateNotification.qml</file>