Prepare visible image and video previews through the shared background cache
This commit is contained in:
1 parent
468af7abbe
commit
60d2ba0fac
2 files changed
+76
-61
No files matched your search
@@ -1,5 +1,6 @@
|
||||
#include <QCoreApplication>
|
||||
#include "messagelist.h"
|
||||
#include "media_preview_cache.h"
|
||||
#include "../transport/stream_bridge.h"
|
||||
#include "debug_ui.h"
|
||||
#include "chatview.h"
|
||||
@@ -30,7 +31,8 @@
|
||||
#include <QDebug>
|
||||
#include <QFileInfo>
|
||||
#include <QFile>
|
||||
#include <QImageReader>
|
||||
#include <QStandardPaths>
|
||||
#include <QDir>
|
||||
#include <QMenu>
|
||||
#include <QFileDialog>
|
||||
#include <QToolTip>
|
||||
@@ -358,12 +360,27 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent
|
||||
, m_recorder(recorder)
|
||||
{
|
||||
auto *layout = new QVBoxLayout(this);
|
||||
m_imagePreviewTimer.setSingleShot(true);
|
||||
connect(&m_imagePreviewTimer, &QTimer::timeout, this, &MessageList::prepareImagePreviews);
|
||||
connect(m_model, &QAbstractItemModel::rowsInserted, this, [this]() { m_imagePreviewTimer.start(0); });
|
||||
const QString cacheBase = m_db && !m_db->dbPath().isEmpty() ? m_db->dbPath() : QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||
m_previewCache = std::make_shared<MediaPreviewCache::Cache>(QDir(cacheBase).filePath("media_previews/v1"));
|
||||
m_previewPool.setMaxThreadCount(2); m_previewTimer.setSingleShot(true);
|
||||
auto cleanup = [this] { auto cache = m_previewCache; m_previewPool.start([cache] { cache->prune(); }, -1); };
|
||||
m_cacheCleanupTimer.setInterval(60 * 60 * 1000); connect(&m_cacheCleanupTimer, &QTimer::timeout, this, cleanup);
|
||||
m_cacheCleanupTimer.start(); cleanup();
|
||||
m_view->viewport()->installEventFilter(this);
|
||||
connect(&m_previewTimer, &QTimer::timeout, this, &MessageList::prepareMediaPreviews);
|
||||
connect(m_view->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { m_previewTimer.start(0); });
|
||||
connect(m_model, &QAbstractItemModel::rowsInserted, this, [this] { m_previewTimer.start(0); });
|
||||
connect(m_model, &QAbstractItemModel::modelReset, this, [this] { m_previewTimer.start(0); });
|
||||
connect(m_model, &QAbstractItemModel::dataChanged, this,
|
||||
[this](const QModelIndex&, const QModelIndex&, const QVector<int>& roles) {
|
||||
if (roles.isEmpty() || roles.contains(MsgVoiceFileRole) || roles.contains(MsgContentTypeRole)) m_imagePreviewTimer.start(0);
|
||||
[this](const QModelIndex& first, const QModelIndex& last, const QVector<int>& roles) {
|
||||
const bool sourceChanged = roles.isEmpty() || roles.contains(MsgVoiceFileRole) || roles.contains(MsgContentTypeRole);
|
||||
const bool downloadChanged = roles.contains(MsgFileDownloadStateRole);
|
||||
if (!sourceChanged && !downloadChanged) return;
|
||||
for (int row = first.row(); row <= last.row(); ++row) if (auto* item = m_model->item(row)) {
|
||||
if (sourceChanged) item->setData(QVariant(), MsgImagePreviewRole);
|
||||
if (sourceChanged || item->data(MsgFileDownloadStateRole).toInt() == 2) item->setData(QVariant(), MsgImageErrorRole);
|
||||
}
|
||||
m_previewTimer.start(0);
|
||||
});
|
||||
if (auto* manager = AvatarManager::instance()) connect(manager, &AvatarManager::changed, this,
|
||||
[this, manager](const QString& channel, quint64 owner) {
|
||||
@@ -1233,66 +1250,64 @@ void MessageList::onAttachmentDownloaded(const QString& chId, int64_t msgId) {
|
||||
}
|
||||
}
|
||||
|
||||
if (m.contentType == "video/mp4") prepareVideoThumbnail(item->data(MsgVoiceFileRole).toString());
|
||||
}
|
||||
|
||||
/* Общие превью для каналов/PM; не более двух decoder workers, без запуска из paint. */
|
||||
void MessageList::prepareImagePreviews() {
|
||||
bool MessageList::eventFilter(QObject* object, QEvent* event) {
|
||||
if (object == m_view->viewport() && (event->type() == QEvent::Resize || event->type() == QEvent::Show)) m_previewTimer.start(0);
|
||||
return QWidget::eventFilter(object, event);
|
||||
}
|
||||
|
||||
/* Общая очередь каналов/PM: сначала видимые сообщения, затем один экран запаса; не читаем файлы в GUI. */
|
||||
void MessageList::prepareMediaPreviews() {
|
||||
struct Candidate { QString path, job; MediaPreviewCache::Kind kind; int priority; };
|
||||
QList<Candidate> candidates; QSet<QString> seen;
|
||||
const QRect visible = m_view->viewport()->rect();
|
||||
const QRect nearby = visible.adjusted(0, -visible.height(), 0, visible.height());
|
||||
for (int row = 0; row < m_model->rowCount(); ++row) {
|
||||
auto* item = m_model->item(row);
|
||||
if (!item->data(MsgContentTypeRole).toString().startsWith("image/")) continue;
|
||||
const QString type = item->data(MsgContentTypeRole).toString();
|
||||
if (!type.startsWith("image/") && type != "video/mp4") continue;
|
||||
const QRect rectangle = m_view->visualRect(item->index());
|
||||
if (!rectangle.intersects(nearby)) {
|
||||
if (item->data(MsgImagePreviewRole).isValid()) item->setData(QVariant(), MsgImagePreviewRole);
|
||||
continue;
|
||||
}
|
||||
const QString path = item->data(MsgVoiceFileRole).toString();
|
||||
if (path.isEmpty() || item->data(MsgImagePreviewRole).isValid() || item->data(MsgImageErrorRole).toBool()) continue;
|
||||
if (const auto* image = m_imagePreviews.object(path)) {
|
||||
item->setData(*image, MsgImagePreviewRole); item->setData(image->isNull(), MsgImageErrorRole); continue;
|
||||
}
|
||||
if (m_imageJobs.contains(path)) continue;
|
||||
m_imageJobs.insert(path); m_thumbnailPool.setMaxThreadCount(2);
|
||||
QPointer<MessageList> self(this);
|
||||
const auto kind = type == "video/mp4" ? MediaPreviewCache::Kind::Video : MediaPreviewCache::Kind::Image;
|
||||
const QString job = path + QChar(0) + QString::number(int(kind));
|
||||
if (m_previewJobs.contains(job) || seen.contains(job)) continue;
|
||||
seen.insert(job);
|
||||
int priority = qAbs(rectangle.center().y() - visible.center().y()) + (rectangle.intersects(visible) ? 0 : visible.height() * 3);
|
||||
candidates.append({path, job, kind, priority});
|
||||
}
|
||||
std::stable_sort(candidates.begin(), candidates.end(), [](const auto& left, const auto& right) { return left.priority < right.priority; });
|
||||
for (const auto& candidate : candidates) {
|
||||
if (m_previewJobs.size() >= 2) break;
|
||||
m_previewJobs.insert(candidate.job); QPointer<MessageList> self(this); auto cache = m_previewCache;
|
||||
QObject* dispatcher = QCoreApplication::instance();
|
||||
m_thumbnailPool.start([self, dispatcher, path]() {
|
||||
QImageReader reader(path); reader.setAutoTransform(true);
|
||||
const QSize size = reader.size();
|
||||
QImage image;
|
||||
if (size.isValid() && size.width() <= 16384 && size.height() <= 16384) {
|
||||
reader.setScaledSize(size.scaled(600, 600, Qt::KeepAspectRatio)); image = reader.read();
|
||||
}
|
||||
if (image.isNull()) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "image preview failed path=%s dimensions=%dx%d error=%s",
|
||||
qPrintable(path), size.width(), size.height(), qPrintable(reader.errorString()));
|
||||
else DEBUG_DEBUG(DEBUG_CATEGORY_MEDIA, "image preview ready path=%s dimensions=%dx%d", qPrintable(path), image.width(), image.height());
|
||||
QMetaObject::invokeMethod(dispatcher, [self, path, image]() {
|
||||
m_previewPool.start([self, dispatcher, cache, candidate] {
|
||||
const auto result = cache->load(candidate.path, candidate.kind);
|
||||
if (!QMetaObject::invokeMethod(dispatcher, [self, candidate, result] {
|
||||
if (!self) return;
|
||||
self->m_imageJobs.remove(path);
|
||||
self->m_imagePreviews.insert(path, new QImage(image), qMax(1, (int)image.sizeInBytes()));
|
||||
self->m_previewJobs.remove(candidate.job);
|
||||
const QRect nearby = self->m_view->viewport()->rect().adjusted(0, -self->m_view->viewport()->height(), 0, self->m_view->viewport()->height());
|
||||
for (int r = 0; r < self->m_model->rowCount(); ++r) {
|
||||
auto* current = self->m_model->item(r);
|
||||
if (current->data(MsgVoiceFileRole).toString() != path) continue;
|
||||
current->setData(image, MsgImagePreviewRole); current->setData(image.isNull(), MsgImageErrorRole);
|
||||
if (current->data(MsgVoiceFileRole).toString() != candidate.path) continue;
|
||||
const QString type = current->data(MsgContentTypeRole).toString();
|
||||
if ((candidate.kind == MediaPreviewCache::Kind::Video && type != "video/mp4")
|
||||
|| (candidate.kind == MediaPreviewCache::Kind::Image && !type.startsWith("image/"))) continue;
|
||||
if (!result.retryable && self->m_view->visualRect(current->index()).intersects(nearby)) {
|
||||
current->setData(result.image, MsgImagePreviewRole); current->setData(result.image.isNull(), MsgImageErrorRole);
|
||||
}
|
||||
}
|
||||
}, Qt::QueuedConnection);
|
||||
});
|
||||
self->m_previewTimer.start(0);
|
||||
}, Qt::QueuedConnection)) DEBUG_WARN(DEBUG_CATEGORY_MEDIA, "preview completion could not be queued source=%s", qPrintable(candidate.path));
|
||||
}, 1);
|
||||
}
|
||||
}
|
||||
|
||||
void MessageList::prepareVideoThumbnail(const QString& path) {
|
||||
if (path.isEmpty() || m_thumbnailJobs.contains(path) || !QFile::exists(path) || QFile::exists(path + ".jpg")) return;
|
||||
m_thumbnailPool.setMaxThreadCount(2);
|
||||
m_thumbnailJobs.insert(path);
|
||||
QPointer<MessageList> self(this);
|
||||
QObject* dispatcher = QCoreApplication::instance();
|
||||
m_thumbnailPool.start([self, dispatcher, path]() {
|
||||
QString output = path + ".jpg";
|
||||
int rc = video_extract_thumbnail(path.toUtf8().constData(), output.toUtf8().constData(), 320);
|
||||
if (rc) DEBUG_WARN(DEBUG_CATEGORY_VIDEO, "thumbnail failed path=%s error=%d", qPrintable(path), rc);
|
||||
else DEBUG_TRACE(DEBUG_CATEGORY_VIDEO, "thumbnail ready path=%s", qPrintable(output));
|
||||
QMetaObject::invokeMethod(dispatcher, [self, path]() {
|
||||
if (!self) return;
|
||||
self->m_thumbnailJobs.remove(path);
|
||||
self->m_view->viewport()->update();
|
||||
}, Qt::QueuedConnection);
|
||||
});
|
||||
}
|
||||
|
||||
/* Цитаты разрешаются независимо от окна истории; поздняя синхронизация обновляет уже видимые ответы. */
|
||||
void MessageList::updateReply(QStandardItem* item, const QByteArray& original) {
|
||||
item->setData(QString::fromLatin1(original.toHex()), MsgReplyToRole);
|
||||
@@ -1640,7 +1655,6 @@ void MessageList::onDmMessages(const uint8_t* data, int len) {
|
||||
item->setData(duration, MsgVideoDurationMsRole);
|
||||
item->setData(message.value("width").toInt(), MsgVideoWidthRole);
|
||||
item->setData(message.value("height").toInt(), MsgVideoHeightRole);
|
||||
prepareVideoThumbnail(path);
|
||||
} else if (type.startsWith("image/")) {
|
||||
item->setData(message.value("width").toInt(), MsgImageWidthRole);
|
||||
item->setData(message.value("height").toInt(), MsgImageHeightRole);
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
#include <QThreadPool>
|
||||
#include <QJsonObject>
|
||||
#include <QPointer>
|
||||
#include <QCache>
|
||||
#include <QImage>
|
||||
#include <memory>
|
||||
#include "stream_sources.h"
|
||||
|
||||
class ChatView;
|
||||
@@ -31,6 +31,7 @@ class QDialog;
|
||||
class QMenu;
|
||||
class QTreeWidget;
|
||||
struct MessageRow;
|
||||
namespace MediaPreviewCache { class Cache; }
|
||||
|
||||
class MessageList : public QWidget {
|
||||
Q_OBJECT
|
||||
@@ -103,6 +104,8 @@ signals:
|
||||
void channelSettingsRequested(const QString& channelId);
|
||||
void linkActivated(const QString& link);
|
||||
|
||||
protected:
|
||||
bool eventFilter(QObject* object, QEvent* event) override; // Resize/Show viewport планируют превью, paint ничего не загружает.
|
||||
private:
|
||||
// Qt children: view/model/composer/panel. Внешний db не уничтожается этим виджетом.
|
||||
ChatView *m_view;
|
||||
@@ -140,12 +143,11 @@ private:
|
||||
QList<LiveStreamSource> m_streamSources; // Проверенный полный снимок выбранной группы.
|
||||
RadioPanel* m_radioPanel = nullptr; /* верхняя панель устройств, VAD и PTT */
|
||||
bool m_radioOn = false; // Локальный audio engine успешно включён для текущей группы.
|
||||
QThreadPool m_thumbnailPool; // До двух media workers; деструктор pool дожидается jobs.
|
||||
QSet<QString> m_thumbnailJobs; // Пути незавершённых jobs, без дублей.
|
||||
QCache<QString, QImage> m_imagePreviews{32 * 1024 * 1024}; // Ограниченный кеш decoded-превью по неизменному пути.
|
||||
QSet<QString> m_imageJobs; // Незавершённые image jobs; GUI изменяет, workers получают копию пути.
|
||||
QTimer m_imagePreviewTimer; // Объединяет изменения модели до одного обхода видимой истории.
|
||||
void prepareImagePreviews(); // Асинхронное декодирование локальных изображений и обновление актуальных строк модели.
|
||||
std::shared_ptr<MediaPreviewCache::Cache> m_previewCache; // Общий кеш картинок/первых кадров; workers удерживают собственную ссылку.
|
||||
QThreadPool m_previewPool; // Не более двух workers, включая очистку; деструктор дожидается jobs.
|
||||
QSet<QString> m_previewJobs; // Неповторяющиеся path+kind в работе; очередь видимых сообщений ограничена двумя заданиями.
|
||||
QTimer m_previewTimer, m_cacheCleanupTimer; // Объединение обновлений viewport и часовая очистка по созданию.
|
||||
void prepareMediaPreviews(); // Видимые и соседние сообщения: worker читает кеш/исходник, результат идёт в актуальные роли.
|
||||
QHash<QString, QJsonObject> m_feedback; // message_id hex → последний полный снимок реакций/опроса.
|
||||
QPointer<QDialog> m_pollDialog; // Один диалог голосования; уничтожение owner безопасно для exec.
|
||||
QString m_pollDialogMessage, m_pendingPoll; // Открытый message_id и ожидаемый запрос состояния.
|
||||
@@ -175,7 +177,6 @@ private:
|
||||
// Запросить выбранное поколение PM; ожидающее удаление блокирует запрос.
|
||||
void requestDmMessages(const QString& channelId);
|
||||
// Не более двух FFmpeg jobs; GUI callback защищён QPointer.
|
||||
void prepareVideoThumbnail(const QString& path);
|
||||
// Owned-подписка в uasync и local audio; ошибка восстанавливает кнопку.
|
||||
void onRadioToggled(bool on);
|
||||
void updateRadioUi();
|
||||
|
||||
Reference in new issue
Block a user