diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt
index 73e3e4686..cb3d3c3de 100644
--- a/src/duckstation-qt/CMakeLists.txt
+++ b/src/duckstation-qt/CMakeLists.txt
@@ -80,8 +80,6 @@ set(SRCS
gamepatchsettingswidget.cpp
gamepatchsettingswidget.h
gamepatchsettingswidget.ui
- gamelistmodel.cpp
- gamelistmodel.h
gamelistrefreshthread.cpp
gamelistrefreshthread.h
gamelistsettingswidget.cpp
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj
index 9a4e242a0..2c153359b 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj
+++ b/src/duckstation-qt/duckstation-qt.vcxproj
@@ -20,7 +20,6 @@
-
@@ -69,7 +68,6 @@
-
@@ -231,7 +229,6 @@
-
diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters
index 8fac7166a..2e836a206 100644
--- a/src/duckstation-qt/duckstation-qt.vcxproj.filters
+++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters
@@ -18,7 +18,6 @@
-
@@ -96,9 +95,6 @@
moc
-
- moc
-
moc
@@ -215,7 +211,6 @@
-
diff --git a/src/duckstation-qt/gamelistmodel.cpp b/src/duckstation-qt/gamelistmodel.cpp
deleted file mode 100644
index 96fcfef36..000000000
--- a/src/duckstation-qt/gamelistmodel.cpp
+++ /dev/null
@@ -1,896 +0,0 @@
-// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin
-// SPDX-License-Identifier: CC-BY-NC-ND-4.0
-
-#include "gamelistmodel.h"
-#include "qthost.h"
-#include "qtutils.h"
-
-#include "core/system.h"
-
-#include "common/assert.h"
-#include "common/file_system.h"
-#include "common/path.h"
-#include "common/string_util.h"
-
-#include
-#include
-#include
-#include
-#include
-
-static constexpr std::array s_column_names = {
- {"Icon", "Serial", "Title", "File Title", "Developer", "Publisher", "Genre", "Year", "Players", "Time Played",
- "Last Played", "Size", "File Size", "Region", "Achievements", "Compatibility", "Cover"}};
-
-static constexpr int COVER_ART_WIDTH = 512;
-static constexpr int COVER_ART_HEIGHT = 512;
-static constexpr int COVER_ART_SPACING = 32;
-static constexpr int MIN_COVER_CACHE_SIZE = 256;
-
-static void resizeAndPadImage(QImage* image, int expected_width, int expected_height)
-{
- const qreal dpr = image->devicePixelRatio();
- const int dpr_expected_width = static_cast(static_cast(expected_width) * dpr);
- const int dpr_expected_height = static_cast(static_cast(expected_height) * dpr);
- if (image->width() == dpr_expected_width && image->height() == dpr_expected_height)
- return;
-
- if (image->width() > image->height())
- *image = image->scaledToWidth(dpr_expected_width, Qt::SmoothTransformation);
- else
- *image = image->scaledToHeight(dpr_expected_height, Qt::SmoothTransformation);
-
- if (image->width() == dpr_expected_width && image->height() == dpr_expected_height)
- return;
-
- // QPainter works in unscaled coordinates.
- int xoffs = 0;
- int yoffs = 0;
- if (image->width() < dpr_expected_width)
- xoffs = static_cast(static_cast((dpr_expected_width - image->width()) / 2) / dpr);
- if (image->height() < dpr_expected_height)
- yoffs = static_cast(static_cast((dpr_expected_height - image->height()) / 2) / dpr);
-
- QImage padded_image(dpr_expected_width, dpr_expected_height, QImage::Format_ARGB32);
- padded_image.setDevicePixelRatio(dpr);
- padded_image.fill(Qt::transparent);
- QPainter painter;
- if (painter.begin(&padded_image))
- {
- painter.setCompositionMode(QPainter::CompositionMode_Source);
- painter.drawImage(xoffs, yoffs, *image);
- painter.end();
- }
-
- *image = std::move(padded_image);
-}
-
-GameListCoverLoader::GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width,
- int height, float scale)
- : QObject(nullptr), m_path(ge->path), m_serial(ge->serial), m_title(ge->title),
- m_placeholder_image(placeholder_image), m_width(width), m_height(height), m_scale(scale),
- m_dpr(qApp->devicePixelRatio())
-{
-}
-
-GameListCoverLoader::~GameListCoverLoader() = default;
-
-void GameListCoverLoader::loadOrGenerateCover()
-{
- QPixmap image;
- const std::string cover_path(GameList::GetCoverImagePath(m_path, m_serial, m_title));
- if (!cover_path.empty())
- {
- m_image.load(QString::fromStdString(cover_path));
- if (!m_image.isNull())
- {
- m_image.setDevicePixelRatio(m_dpr);
- resizeAndPadImage(&m_image, m_width, m_height);
- }
- }
-
- if (m_image.isNull())
- createPlaceholderImage();
-
- // Have to pass through the UI thread, because the thread pool isn't a QThread...
- // Can't create pixmaps on the worker thread, have to create it on the UI thread.
- QtHost::RunOnUIThread([this]() {
- if (!m_image.isNull())
- emit coverLoaded(m_path, m_image, m_scale);
- else
- emit coverLoaded(m_path, m_image, m_scale);
- delete this;
- });
-}
-
-void GameListCoverLoader::createPlaceholderImage()
-{
- m_image = m_placeholder_image.copy();
- if (m_image.isNull())
- return;
-
- resizeAndPadImage(&m_image, m_width, m_height);
-
- QPainter painter;
- if (painter.begin(&m_image))
- {
- QFont font;
- font.setPointSize(std::max(static_cast(32.0f * m_scale), 1));
- painter.setFont(font);
- painter.setPen(Qt::white);
-
- const QRect text_rc(0, 0, static_cast(static_cast(m_width)),
- static_cast(static_cast(m_height)));
- painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(m_title));
- painter.end();
- }
-}
-
-std::optional GameListModel::getColumnIdForName(std::string_view name)
-{
- for (int column = 0; column < Column_Count; column++)
- {
- if (name == s_column_names[column])
- return static_cast(column);
- }
-
- return std::nullopt;
-}
-
-const char* GameListModel::getColumnName(Column col)
-{
- return s_column_names[static_cast(col)];
-}
-
-GameListModel::GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons,
- QObject* parent /* = nullptr */)
- : QAbstractTableModel(parent), m_show_titles_for_covers(show_cover_titles), m_show_game_icons(show_game_icons),
- m_memcard_pixmap_cache(MIN_COVER_CACHE_SIZE)
-{
- loadCommonImages();
- setCoverScale(cover_scale);
- setColumnDisplayNames();
-
- if (m_show_game_icons)
- GameList::ReloadMemcardTimestampCache();
-
- connect(g_emu_thread, &EmuThread::gameListRowsChanged, this, &GameListModel::rowsChanged);
-}
-
-GameListModel::~GameListModel() = default;
-
-void GameListModel::setShowGameIcons(bool enabled)
-{
- m_show_game_icons = enabled;
-
- beginResetModel();
- m_memcard_pixmap_cache.Clear();
- if (enabled)
- GameList::ReloadMemcardTimestampCache();
- endResetModel();
-}
-
-void GameListModel::setCoverScale(float scale)
-{
- if (m_cover_scale == scale)
- return;
-
- m_cover_pixmap_cache.Clear();
- m_cover_scale = scale;
-
- const qreal dpr = qApp->devicePixelRatio();
-
- QImage loading_image;
- if (loading_image.load(QStringLiteral("%1/images/placeholder.png").arg(QtHost::GetResourcesBasePath())))
- {
- loading_image.setDevicePixelRatio(dpr);
- resizeAndPadImage(&loading_image, getCoverArtWidth(), getCoverArtHeight());
- }
- else
- {
- loading_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32);
- loading_image.setDevicePixelRatio(dpr);
- loading_image.fill(QColor(0, 0, 0, 0));
- }
- m_loading_pixmap = QPixmap::fromImage(loading_image);
-
- m_placeholder_image = QImage();
- if (m_placeholder_image.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath())))
- {
- m_placeholder_image.setDevicePixelRatio(dpr);
- resizeAndPadImage(&m_placeholder_image, getCoverArtWidth(), getCoverArtHeight());
- }
- else
- {
- m_placeholder_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32);
- m_placeholder_image.setDevicePixelRatio(dpr);
- m_placeholder_image.fill(QColor(0, 0, 0, 0));
- }
-
- emit coverScaleChanged();
-}
-
-void GameListModel::refreshCovers()
-{
- m_cover_pixmap_cache.Clear();
- refresh();
-}
-
-void GameListModel::updateCacheSize(int width, int height)
-{
- // This is a bit conversative, since it doesn't consider padding, but better to be over than under.
- const int cover_width = getCoverArtWidth();
- const int cover_height = getCoverArtHeight();
- const int num_columns = ((width + (cover_width - 1)) / cover_width);
- const int num_rows = ((height + (cover_height - 1)) / cover_height);
- m_cover_pixmap_cache.SetMaxCapacity(static_cast(std::max(num_columns * num_rows, MIN_COVER_CACHE_SIZE)));
-}
-
-void GameListModel::reloadThemeSpecificImages()
-{
- loadThemeSpecificImages();
- refresh();
-}
-
-void GameListModel::loadOrGenerateCover(const GameList::Entry* ge)
-{
- // NOTE: Must get connected before queuing, because otherwise you risk a race.
- GameListCoverLoader* loader =
- new GameListCoverLoader(ge, m_placeholder_image, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale);
- connect(loader, &GameListCoverLoader::coverLoaded, this, &GameListModel::coverLoaded);
- System::QueueAsyncTask([loader]() { loader->loadOrGenerateCover(); });
-}
-
-void GameListModel::coverLoaded(const std::string& path, const QImage& image, float scale)
-{
- // old request before cover scale change?
- if (m_cover_scale != scale)
- return;
-
- if (!image.isNull())
- m_cover_pixmap_cache.Insert(path, QPixmap::fromImage(image));
- else
- m_cover_pixmap_cache.Insert(path, QPixmap());
-
- invalidateCoverForPath(path);
-}
-
-void GameListModel::rowsChanged(const QList& rows)
-{
- const QList roles_changed = {Qt::DisplayRole};
-
- // try to collapse multiples
- size_t start = 0;
- size_t idx = 0;
- const size_t size = rows.size();
- for (; idx < size;)
- {
- if ((idx + 1) < size && rows[idx + 1] == (rows[idx] + 1))
- {
- idx++;
- }
- else
- {
- emit dataChanged(createIndex(rows[start], 0), createIndex(rows[idx], Column_Count - 1), roles_changed);
- start = ++idx;
- }
- }
-}
-
-void GameListModel::invalidateCoverForPath(const std::string& path)
-{
- std::optional row;
- if (hasTakenGameList())
- {
- for (u32 i = 0; i < static_cast(m_taken_entries->size()); i++)
- {
- if (path == m_taken_entries.value()[i].path)
- {
- row = i;
- break;
- }
- }
- }
- else
- {
- // This isn't ideal, but not sure how else we can get the row, when it might change while scanning...
- auto lock = GameList::GetLock();
- const u32 count = GameList::GetEntryCount();
- for (u32 i = 0; i < count; i++)
- {
- if (GameList::GetEntryByIndex(i)->path == path)
- {
- row = i;
- break;
- }
- }
- }
-
- if (!row.has_value())
- {
- // Game removed?
- return;
- }
-
- const QModelIndex mi(index(static_cast(row.value()), Column_Cover));
- emit dataChanged(mi, mi, {Qt::DecorationRole});
-}
-
-QString GameListModel::formatTimespan(time_t timespan)
-{
- // avoid an extra string conversion
- const u32 hours = static_cast(timespan / 3600);
- const u32 minutes = static_cast((timespan % 3600) / 60);
- if (hours > 0)
- return qApp->translate("GameList", "%n hours", "", hours);
- else
- return qApp->translate("GameList", "%n minutes", "", minutes);
-}
-
-const QPixmap& GameListModel::getIconPixmapForEntry(const GameList::Entry* ge) const
-{
- // We only do this for discs/disc sets for now.
- if (m_show_game_icons && (!ge->serial.empty() && (ge->IsDisc() || ge->IsDiscSet())))
- {
- QPixmap* item = m_memcard_pixmap_cache.Lookup(ge->serial);
- if (item)
- return *item;
-
- // Assumes game list lock is held.
- const std::string path = GameList::GetGameIconPath(ge->serial, ge->path);
- QPixmap pm;
- if (!path.empty() && pm.load(QString::fromStdString(path)))
- {
- fixIconPixmapSize(pm);
- return *m_memcard_pixmap_cache.Insert(ge->serial, std::move(pm));
- }
-
- return *m_memcard_pixmap_cache.Insert(ge->serial, m_type_pixmaps[static_cast(ge->type)]);
- }
-
- return m_type_pixmaps[static_cast(ge->type)];
-}
-
-const QPixmap& GameListModel::getFlagPixmapForEntry(const GameList::Entry* ge) const
-{
- static constexpr u32 FLAG_PIXMAP_WIDTH = 30;
- static constexpr u32 FLAG_PIXMAP_HEIGHT = 20;
-
- const std::string_view name = ge->GetLanguageIcon();
- auto it = m_flag_pixmap_cache.find(name);
- if (it != m_flag_pixmap_cache.end())
- return it->second;
-
- const QIcon icon(QString::fromStdString(QtHost::GetResourcePath(ge->GetLanguageIconName(), true)));
- it = m_flag_pixmap_cache.emplace(name, icon.pixmap(FLAG_PIXMAP_WIDTH, FLAG_PIXMAP_HEIGHT)).first;
- return it->second;
-}
-
-QIcon GameListModel::getIconForGame(const QString& path)
-{
- QIcon ret;
-
- if (m_show_game_icons)
- {
- const auto lock = GameList::GetLock();
- const GameList::Entry* entry = GameList::GetEntryForPath(path.toStdString());
-
- // See above.
- if (entry && !entry->serial.empty() && (entry->IsDisc() || entry->IsDiscSet()))
- {
- const std::string icon_path = GameList::GetGameIconPath(entry->serial, entry->path);
- if (!icon_path.empty())
- {
- QPixmap newpm;
- if (!icon_path.empty() && newpm.load(QString::fromStdString(icon_path)))
- {
- fixIconPixmapSize(newpm);
- ret = QIcon(*m_memcard_pixmap_cache.Insert(entry->serial, std::move(newpm)));
- }
- }
- }
- }
-
- return ret;
-}
-
-void GameListModel::fixIconPixmapSize(QPixmap& pm)
-{
- const qreal dpr = pm.devicePixelRatio();
- const int width = static_cast(static_cast(pm.width()) * dpr);
- const int height = static_cast(static_cast(pm.height()) * dpr);
- const int max_dim = std::max(width, height);
- if (max_dim == 16)
- return;
-
- const float wanted_dpr = qApp->devicePixelRatio();
- pm.setDevicePixelRatio(wanted_dpr);
-
- const float scale = static_cast(max_dim) / 16.0f / wanted_dpr;
- const int new_width = static_cast(static_cast(width) / scale);
- const int new_height = static_cast(static_cast(height) / scale);
- pm = pm.scaled(new_width, new_height);
-}
-
-int GameListModel::getCoverArtWidth() const
-{
- return std::max(static_cast(static_cast(COVER_ART_WIDTH) * m_cover_scale), 1);
-}
-
-int GameListModel::getCoverArtHeight() const
-{
- return std::max(static_cast(static_cast(COVER_ART_HEIGHT) * m_cover_scale), 1);
-}
-
-int GameListModel::getCoverArtSpacing() const
-{
- return std::max(static_cast(static_cast(COVER_ART_SPACING) * m_cover_scale), 1);
-}
-
-int GameListModel::rowCount(const QModelIndex& parent) const
-{
- if (parent.isValid()) [[unlikely]]
- return 0;
-
- if (m_taken_entries.has_value())
- return static_cast(m_taken_entries->size());
-
- const auto lock = GameList::GetLock();
- return static_cast(GameList::GetEntryCount());
-}
-
-int GameListModel::columnCount(const QModelIndex& parent) const
-{
- if (parent.isValid())
- return 0;
-
- return Column_Count;
-}
-
-QVariant GameListModel::data(const QModelIndex& index, int role) const
-{
- if (!index.isValid()) [[unlikely]]
- return {};
-
- const int row = index.row();
- DebugAssert(row >= 0);
-
- if (m_taken_entries.has_value()) [[unlikely]]
- {
- if (static_cast(row) >= m_taken_entries->size())
- return {};
-
- return data(index, role, &m_taken_entries.value()[row]);
- }
- else
- {
- const auto lock = GameList::GetLock();
- const GameList::Entry* ge = GameList::GetEntryByIndex(static_cast(row));
- if (!ge)
- return {};
-
- return data(index, role, ge);
- }
-}
-
-QVariant GameListModel::data(const QModelIndex& index, int role, const GameList::Entry* ge) const
-{
- switch (role)
- {
- case Qt::DisplayRole:
- {
- switch (index.column())
- {
- case Column_Serial:
- return QtUtils::StringViewToQString(ge->serial);
-
- case Column_Title:
- return QtUtils::StringViewToQString(ge->title);
-
- case Column_FileTitle:
- return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path));
-
- case Column_Developer:
- return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->developer) : QString();
-
- case Column_Publisher:
- return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->publisher) : QString();
-
- case Column_Genre:
- return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->genre) : QString();
-
- case Column_Year:
- {
- if (ge->dbentry && ge->dbentry->release_date != 0)
- {
- return QStringLiteral("%1").arg(
- QDateTime::fromSecsSinceEpoch(static_cast(ge->dbentry->release_date), QTimeZone::utc())
- .date()
- .year());
- }
- else
- {
- return QString();
- }
- }
-
- case Column_Players:
- {
- if (!ge->dbentry || ge->dbentry->min_players == 0)
- return QString();
- else if (ge->dbentry->min_players == ge->dbentry->max_players)
- return QStringLiteral("%1").arg(ge->dbentry->min_players);
- else
- return QStringLiteral("%1-%2").arg(ge->dbentry->min_players).arg(ge->dbentry->max_players);
- }
-
- case Column_FileSize:
- return (ge->file_size >= 0) ?
- QStringLiteral("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) :
- tr("Unknown");
-
- case Column_UncompressedSize:
- return QStringLiteral("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2);
-
- case Column_Achievements:
- return {};
-
- case Column_TimePlayed:
- {
- if (ge->total_played_time == 0)
- return {};
- else
- return formatTimespan(ge->total_played_time);
- }
-
- case Column_LastPlayed:
- return QtUtils::StringViewToQString(GameList::FormatTimestamp(ge->last_played_time));
-
- case Column_Cover:
- {
- if (m_show_titles_for_covers)
- return QString::fromStdString(ge->title);
- else
- return {};
- }
-
- default:
- return {};
- }
- }
-
- case Qt::InitialSortOrderRole:
- {
- const int column = index.column();
- if (column == Column_TimePlayed || column == Column_LastPlayed)
- return Qt::DescendingOrder;
- else
- return Qt::AscendingOrder;
- }
-
- case Qt::DecorationRole:
- {
- switch (index.column())
- {
- case Column_Icon:
- {
- return getIconPixmapForEntry(ge);
- }
-
- case Column_Region:
- {
- return getFlagPixmapForEntry(ge);
- }
-
- case Column_Compatibility:
- {
- return m_compatibility_pixmaps[static_cast(ge->dbentry ? ge->dbentry->compatibility :
- GameDatabase::CompatibilityRating::Unknown)];
- }
-
- case Column_Cover:
- {
- QPixmap* pm = m_cover_pixmap_cache.Lookup(ge->path);
- if (pm)
- return *pm;
-
- // We insert the placeholder into the cache, so that we don't repeatedly
- // queue loading jobs for this game.
- const_cast(this)->loadOrGenerateCover(ge);
- return *m_cover_pixmap_cache.Insert(ge->path, m_loading_pixmap);
- }
- break;
-
- default:
- return {};
- }
-
- default:
- return {};
- }
- }
-}
-
-QVariant GameListModel::headerData(int section, Qt::Orientation orientation, int role) const
-{
- if (orientation != Qt::Horizontal || role != Qt::DisplayRole || section < 0 || section >= Column_Count)
- return {};
-
- return m_column_display_names[section];
-}
-
-const GameList::Entry* GameListModel::getTakenGameListEntry(u32 index) const
-{
- return (m_taken_entries.has_value() && index < m_taken_entries->size()) ? &m_taken_entries.value()[index] : nullptr;
-}
-
-bool GameListModel::hasTakenGameList() const
-{
- return m_taken_entries.has_value();
-}
-
-void GameListModel::takeGameList()
-{
- const auto lock = GameList::GetLock();
- m_taken_entries = GameList::TakeEntryList();
-
- // If it's empty (e.g. first boot), don't use it.
- if (m_taken_entries->empty())
- m_taken_entries.reset();
-}
-
-void GameListModel::refresh()
-{
- beginResetModel();
-
- m_taken_entries.reset();
-
- // Invalidate memcard LRU cache, forcing a re-query of the memcard timestamps.
- m_memcard_pixmap_cache.Clear();
-
- endResetModel();
-}
-
-bool GameListModel::titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const
-{
- return (StringUtil::Strcasecmp(left->title.c_str(), right->title.c_str()) < 0);
-}
-
-bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const
-{
- if (!left_index.isValid() || !right_index.isValid())
- return false;
-
- const int left_row = left_index.row();
- const int right_row = right_index.row();
-
- if (m_taken_entries.has_value()) [[unlikely]]
- {
- const GameList::Entry* left =
- (static_cast(left_row) < m_taken_entries->size()) ? &m_taken_entries.value()[left_row] : nullptr;
- const GameList::Entry* right =
- (static_cast(right_row) < m_taken_entries->size()) ? &m_taken_entries.value()[right_row] : nullptr;
- if (!left || !right)
- return false;
-
- return lessThan(left, right, column);
- }
- else
- {
- const auto lock = GameList::GetLock();
- const GameList::Entry* left = GameList::GetEntryByIndex(left_row);
- const GameList::Entry* right = GameList::GetEntryByIndex(right_row);
- if (!left || !right)
- return false;
-
- return lessThan(left, right, column);
- }
-}
-
-bool GameListModel::lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const
-{
- switch (column)
- {
- case Column_Icon:
- {
- const GameList::EntryType lst = left->GetSortType();
- const GameList::EntryType rst = right->GetSortType();
- if (lst == rst)
- return titlesLessThan(left, right);
-
- return (static_cast(lst) < static_cast(rst));
- }
-
- case Column_Serial:
- {
- if (left->serial == right->serial)
- return titlesLessThan(left, right);
- return (StringUtil::Strcasecmp(left->serial.c_str(), right->serial.c_str()) < 0);
- }
-
- case Column_Title:
- {
- return titlesLessThan(left, right);
- }
-
- case Column_FileTitle:
- {
- const std::string_view file_title_left = Path::GetFileTitle(left->path);
- const std::string_view file_title_right = Path::GetFileTitle(right->path);
- if (file_title_left == file_title_right)
- return titlesLessThan(left, right);
-
- const std::size_t smallest = std::min(file_title_left.size(), file_title_right.size());
- return (StringUtil::Strncasecmp(file_title_left.data(), file_title_right.data(), smallest) < 0);
- }
-
- case Column_Region:
- {
- if (left->region == right->region)
- return titlesLessThan(left, right);
- return (static_cast(left->region) < static_cast(right->region));
- }
-
- case Column_Compatibility:
- {
- const GameDatabase::CompatibilityRating left_compatibility =
- left->dbentry ? left->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown;
- const GameDatabase::CompatibilityRating right_compatibility =
- right->dbentry ? right->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown;
- if (left_compatibility == right_compatibility)
- return titlesLessThan(left, right);
-
- return (static_cast(left_compatibility) < static_cast(right_compatibility));
- }
-
- case Column_FileSize:
- {
- if (left->file_size == right->file_size)
- return titlesLessThan(left, right);
-
- return (left->file_size < right->file_size);
- }
-
- case Column_UncompressedSize:
- {
- if (left->uncompressed_size == right->uncompressed_size)
- return titlesLessThan(left, right);
-
- return (left->uncompressed_size < right->uncompressed_size);
- }
-
- case Column_Genre:
- {
- const int compres =
- StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->genre) : std::string_view(),
- right->dbentry ? std::string_view(right->dbentry->genre) : std::string_view());
- return (compres == 0) ? titlesLessThan(left, right) : (compres < 0);
- }
-
- case Column_Developer:
- {
- const int compres =
- StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->developer) : std::string_view(),
- right->dbentry ? std::string_view(right->dbentry->developer) : std::string_view());
- return (compres == 0) ? titlesLessThan(left, right) : (compres < 0);
- }
-
- case Column_Publisher:
- {
- const int compres =
- StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->publisher) : std::string_view(),
- right->dbentry ? std::string_view(right->dbentry->publisher) : std::string_view());
- return (compres == 0) ? titlesLessThan(left, right) : (compres < 0);
- }
-
- case Column_Year:
- {
- const u64 ldate = left->dbentry ? left->dbentry->release_date : 0;
- const u64 rdate = right->dbentry ? right->dbentry->release_date : 0;
- if (ldate == rdate)
- return titlesLessThan(left, right);
-
- return (ldate < rdate);
- }
-
- case Column_TimePlayed:
- {
- if (left->total_played_time == right->total_played_time)
- return titlesLessThan(left, right);
-
- return (left->total_played_time < right->total_played_time);
- }
-
- case Column_LastPlayed:
- {
- if (left->last_played_time == right->last_played_time)
- return titlesLessThan(left, right);
-
- return (left->last_played_time < right->last_played_time);
- }
-
- case Column_Players:
- {
- const u8 left_players = left->dbentry ? ((left->dbentry->min_players << 4) + left->dbentry->max_players) : 0;
- const u8 right_players = right->dbentry ? ((right->dbentry->min_players << 4) + right->dbentry->max_players) : 0;
- if (left_players == right_players)
- return titlesLessThan(left, right);
-
- return (left_players < right_players);
- }
-
- case Column_Achievements:
- {
- // sort by unlock percentage
- const float unlock_left =
- (left->num_achievements > 0) ?
- (static_cast(std::max(left->unlocked_achievements, left->unlocked_achievements_hc)) /
- static_cast(left->num_achievements)) :
- 0;
- const float unlock_right =
- (right->num_achievements > 0) ?
- (static_cast(std::max(right->unlocked_achievements, right->unlocked_achievements_hc)) /
- static_cast(right->num_achievements)) :
- 0;
- if (std::abs(unlock_left - unlock_right) < 0.0001f)
- {
- // order by achievement count
- if (left->num_achievements == right->num_achievements)
- return titlesLessThan(left, right);
-
- return (left->num_achievements < right->num_achievements);
- }
-
- return (unlock_left < unlock_right);
- }
-
- default:
- return false;
- }
-}
-
-void GameListModel::loadThemeSpecificImages()
-{
- for (u32 i = 0; i < static_cast(GameList::EntryType::Count); i++)
- m_type_pixmaps[i] = QtUtils::GetIconForEntryType(static_cast(i)).pixmap(QSize(24, 24));
-}
-
-void GameListModel::loadCommonImages()
-{
- loadThemeSpecificImages();
-
- for (int i = 0; i < static_cast(GameDatabase::CompatibilityRating::Count); i++)
- {
- m_compatibility_pixmaps[i] =
- QtUtils::GetIconForCompatibility(static_cast(i)).pixmap(96, 24);
- }
-
- constexpr int ACHIEVEMENT_ICON_SIZE = 16;
- m_no_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-gray.svg", true)))
- .pixmap(ACHIEVEMENT_ICON_SIZE);
- m_has_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon.svg", true)))
- .pixmap(ACHIEVEMENT_ICON_SIZE);
- m_mastered_achievements_pixmap =
- QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-star.svg", true)))
- .pixmap(ACHIEVEMENT_ICON_SIZE);
-}
-
-void GameListModel::setColumnDisplayNames()
-{
- m_column_display_names[Column_Icon] = tr("Icon");
- m_column_display_names[Column_Serial] = tr("Serial");
- m_column_display_names[Column_Title] = tr("Title");
- m_column_display_names[Column_FileTitle] = tr("File Title");
- m_column_display_names[Column_Developer] = tr("Developer");
- m_column_display_names[Column_Publisher] = tr("Publisher");
- m_column_display_names[Column_Genre] = tr("Genre");
- m_column_display_names[Column_Year] = tr("Year");
- m_column_display_names[Column_Players] = tr("Players");
- m_column_display_names[Column_Achievements] = tr("Achievements");
- m_column_display_names[Column_TimePlayed] = tr("Time Played");
- m_column_display_names[Column_LastPlayed] = tr("Last Played");
- m_column_display_names[Column_FileSize] = tr("Size");
- m_column_display_names[Column_UncompressedSize] = tr("Raw Size");
- m_column_display_names[Column_Region] = tr("Region");
- m_column_display_names[Column_Compatibility] = tr("Compatibility");
-}
diff --git a/src/duckstation-qt/gamelistmodel.h b/src/duckstation-qt/gamelistmodel.h
deleted file mode 100644
index 52d8bb591..000000000
--- a/src/duckstation-qt/gamelistmodel.h
+++ /dev/null
@@ -1,165 +0,0 @@
-// SPDX-FileCopyrightText: 2019-2025 Connor McLaughlin
-// SPDX-License-Identifier: CC-BY-NC-ND-4.0
-
-#pragma once
-
-#include "core/game_database.h"
-#include "core/game_list.h"
-#include "core/types.h"
-
-#include "common/heterogeneous_containers.h"
-#include "common/lru_cache.h"
-
-#include
-#include
-#include
-
-#include
-#include
-#include
-
-class GameListModel final : public QAbstractTableModel
-{
- Q_OBJECT
-
-public:
- enum Column : int
- {
- Column_Icon,
- Column_Serial,
- Column_Title,
- Column_FileTitle,
- Column_Developer,
- Column_Publisher,
- Column_Genre,
- Column_Year,
- Column_Players,
- Column_TimePlayed,
- Column_LastPlayed,
- Column_FileSize,
- Column_UncompressedSize,
- Column_Region,
- Column_Achievements,
- Column_Compatibility,
- Column_Cover,
-
- Column_Count
- };
-
- static std::optional getColumnIdForName(std::string_view name);
- static const char* getColumnName(Column col);
-
- GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons, QObject* parent = nullptr);
- ~GameListModel();
-
- int rowCount(const QModelIndex& parent = QModelIndex()) const override;
- int columnCount(const QModelIndex& parent = QModelIndex()) const override;
- QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
- QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
-
- ALWAYS_INLINE const QString& getColumnDisplayName(int column) const { return m_column_display_names[column]; }
- ALWAYS_INLINE const QPixmap& getNoAchievementsPixmap() const { return m_no_achievements_pixmap; }
- ALWAYS_INLINE const QPixmap& getHasAchievementsPixmap() const { return m_has_achievements_pixmap; }
- ALWAYS_INLINE const QPixmap& getMasteredAchievementsPixmap() const { return m_mastered_achievements_pixmap; }
-
- const GameList::Entry* getTakenGameListEntry(u32 index) const;
- bool hasTakenGameList() const;
- void takeGameList();
-
- void refresh();
- void reloadThemeSpecificImages();
-
- bool titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const;
- bool lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const;
-
- bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const;
-
- bool getShowCoverTitles() const { return m_show_titles_for_covers; }
- void setShowCoverTitles(bool enabled) { m_show_titles_for_covers = enabled; }
-
- bool getShowGameIcons() const { return m_show_game_icons; }
- void setShowGameIcons(bool enabled);
- QIcon getIconForGame(const QString& path);
-
- float getCoverScale() const { return m_cover_scale; }
- void setCoverScale(float scale);
- int getCoverArtWidth() const;
- int getCoverArtHeight() const;
- int getCoverArtSpacing() const;
- void refreshCovers();
- void updateCacheSize(int width, int height);
-
-Q_SIGNALS:
- void coverScaleChanged();
-
-private Q_SLOTS:
- void coverLoaded(const std::string& path, const QImage& image, float scale);
- void rowsChanged(const QList& rows);
-
-private:
- QVariant data(const QModelIndex& index, int role, const GameList::Entry* ge) const;
-
- void loadCommonImages();
- void loadThemeSpecificImages();
- void setColumnDisplayNames();
- void loadOrGenerateCover(const GameList::Entry* ge);
- void invalidateCoverForPath(const std::string& path);
-
- const QPixmap& getIconPixmapForEntry(const GameList::Entry* ge) const;
- const QPixmap& getFlagPixmapForEntry(const GameList::Entry* ge) const;
- static void fixIconPixmapSize(QPixmap& pm);
-
- static QString formatTimespan(time_t timespan);
-
- std::optional m_taken_entries;
-
- float m_cover_scale = 0.0f;
- bool m_show_titles_for_covers = false;
- bool m_show_game_icons = false;
-
- std::array m_column_display_names;
- std::array(GameList::EntryType::Count)> m_type_pixmaps;
- std::array(GameDatabase::CompatibilityRating::Count)> m_compatibility_pixmaps;
-
- QImage m_placeholder_image;
- QPixmap m_loading_pixmap;
-
- QPixmap m_no_achievements_pixmap;
- QPixmap m_has_achievements_pixmap;
- QPixmap m_mastered_achievements_pixmap;
-
- mutable PreferUnorderedStringMap m_flag_pixmap_cache;
-
- mutable LRUCache m_cover_pixmap_cache;
-
- mutable LRUCache m_memcard_pixmap_cache;
-};
-
-class GameListCoverLoader : public QObject
-{
- Q_OBJECT
-
-public:
- GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width, int height, float scale);
- ~GameListCoverLoader();
-
-public:
- void loadOrGenerateCover();
-
-Q_SIGNALS:
- void coverLoaded(const std::string& path, const QImage& image, float scale);
-
-private:
- void createPlaceholderImage();
-
- std::string m_path;
- std::string m_serial;
- std::string m_title;
- QImage m_placeholder_image;
- int m_width;
- int m_height;
- float m_scale;
- float m_dpr;
-
- QImage m_image;
-};
diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp
index 9680342b3..52f23cab2 100644
--- a/src/duckstation-qt/gamelistwidget.cpp
+++ b/src/duckstation-qt/gamelistwidget.cpp
@@ -2,7 +2,6 @@
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#include "gamelistwidget.h"
-#include "gamelistmodel.h"
#include "gamelistrefreshthread.h"
#include "qthost.h"
#include "qtutils.h"
@@ -12,12 +11,18 @@
#include "core/game_list.h"
#include "core/host.h"
#include "core/settings.h"
+#include "core/system.h"
#include "common/assert.h"
+#include "common/file_system.h"
+#include "common/path.h"
#include "common/string_util.h"
+#include
+#include
#include
#include
+#include
#include
#include
#include
@@ -37,6 +42,883 @@ static const char* SUPPORTED_FORMATS_STRING =
".chd (Compressed Hunks of Data)\n"
".pbp (PlayStation Portable, Only Decrypted)");
+static constexpr std::array s_column_names = {
+ {"Icon", "Serial", "Title", "File Title", "Developer", "Publisher", "Genre", "Year", "Players", "Time Played",
+ "Last Played", "Size", "File Size", "Region", "Achievements", "Compatibility", "Cover"}};
+
+static constexpr int COVER_ART_WIDTH = 512;
+static constexpr int COVER_ART_HEIGHT = 512;
+static constexpr int COVER_ART_SPACING = 32;
+static constexpr int MIN_COVER_CACHE_SIZE = 256;
+
+static void resizeAndPadImage(QImage* image, int expected_width, int expected_height)
+{
+ const qreal dpr = image->devicePixelRatio();
+ const int dpr_expected_width = static_cast(static_cast(expected_width) * dpr);
+ const int dpr_expected_height = static_cast(static_cast(expected_height) * dpr);
+ if (image->width() == dpr_expected_width && image->height() == dpr_expected_height)
+ return;
+
+ if (image->width() > image->height())
+ *image = image->scaledToWidth(dpr_expected_width, Qt::SmoothTransformation);
+ else
+ *image = image->scaledToHeight(dpr_expected_height, Qt::SmoothTransformation);
+
+ if (image->width() == dpr_expected_width && image->height() == dpr_expected_height)
+ return;
+
+ // QPainter works in unscaled coordinates.
+ int xoffs = 0;
+ int yoffs = 0;
+ if (image->width() < dpr_expected_width)
+ xoffs = static_cast(static_cast((dpr_expected_width - image->width()) / 2) / dpr);
+ if (image->height() < dpr_expected_height)
+ yoffs = static_cast(static_cast((dpr_expected_height - image->height()) / 2) / dpr);
+
+ QImage padded_image(dpr_expected_width, dpr_expected_height, QImage::Format_ARGB32);
+ padded_image.setDevicePixelRatio(dpr);
+ padded_image.fill(Qt::transparent);
+ QPainter painter;
+ if (painter.begin(&padded_image))
+ {
+ painter.setCompositionMode(QPainter::CompositionMode_Source);
+ painter.drawImage(xoffs, yoffs, *image);
+ painter.end();
+ }
+
+ *image = std::move(padded_image);
+}
+
+GameListCoverLoader::GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width,
+ int height, float scale)
+ : QObject(nullptr), m_path(ge->path), m_serial(ge->serial), m_title(ge->title),
+ m_placeholder_image(placeholder_image), m_width(width), m_height(height), m_scale(scale),
+ m_dpr(qApp->devicePixelRatio())
+{
+}
+
+GameListCoverLoader::~GameListCoverLoader() = default;
+
+void GameListCoverLoader::loadOrGenerateCover()
+{
+ QPixmap image;
+ const std::string cover_path(GameList::GetCoverImagePath(m_path, m_serial, m_title));
+ if (!cover_path.empty())
+ {
+ m_image.load(QString::fromStdString(cover_path));
+ if (!m_image.isNull())
+ {
+ m_image.setDevicePixelRatio(m_dpr);
+ resizeAndPadImage(&m_image, m_width, m_height);
+ }
+ }
+
+ if (m_image.isNull())
+ createPlaceholderImage();
+
+ // Have to pass through the UI thread, because the thread pool isn't a QThread...
+ // Can't create pixmaps on the worker thread, have to create it on the UI thread.
+ QtHost::RunOnUIThread([this]() {
+ if (!m_image.isNull())
+ emit coverLoaded(m_path, m_image, m_scale);
+ else
+ emit coverLoaded(m_path, m_image, m_scale);
+ delete this;
+ });
+}
+
+void GameListCoverLoader::createPlaceholderImage()
+{
+ m_image = m_placeholder_image.copy();
+ if (m_image.isNull())
+ return;
+
+ resizeAndPadImage(&m_image, m_width, m_height);
+
+ QPainter painter;
+ if (painter.begin(&m_image))
+ {
+ QFont font;
+ font.setPointSize(std::max(static_cast(32.0f * m_scale), 1));
+ painter.setFont(font);
+ painter.setPen(Qt::white);
+
+ const QRect text_rc(0, 0, static_cast(static_cast(m_width)),
+ static_cast(static_cast(m_height)));
+ painter.drawText(text_rc, Qt::AlignCenter | Qt::TextWordWrap, QString::fromStdString(m_title));
+ painter.end();
+ }
+}
+
+std::optional GameListModel::getColumnIdForName(std::string_view name)
+{
+ for (int column = 0; column < Column_Count; column++)
+ {
+ if (name == s_column_names[column])
+ return static_cast(column);
+ }
+
+ return std::nullopt;
+}
+
+const char* GameListModel::getColumnName(Column col)
+{
+ return s_column_names[static_cast(col)];
+}
+
+GameListModel::GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons,
+ QObject* parent /* = nullptr */)
+ : QAbstractTableModel(parent), m_show_titles_for_covers(show_cover_titles), m_show_game_icons(show_game_icons),
+ m_memcard_pixmap_cache(MIN_COVER_CACHE_SIZE)
+{
+ loadCommonImages();
+ setCoverScale(cover_scale);
+ setColumnDisplayNames();
+
+ if (m_show_game_icons)
+ GameList::ReloadMemcardTimestampCache();
+
+ connect(g_emu_thread, &EmuThread::gameListRowsChanged, this, &GameListModel::rowsChanged);
+}
+
+GameListModel::~GameListModel() = default;
+
+void GameListModel::setShowGameIcons(bool enabled)
+{
+ m_show_game_icons = enabled;
+
+ beginResetModel();
+ m_memcard_pixmap_cache.Clear();
+ if (enabled)
+ GameList::ReloadMemcardTimestampCache();
+ endResetModel();
+}
+
+void GameListModel::setCoverScale(float scale)
+{
+ if (m_cover_scale == scale)
+ return;
+
+ m_cover_pixmap_cache.Clear();
+ m_cover_scale = scale;
+
+ const qreal dpr = qApp->devicePixelRatio();
+
+ QImage loading_image;
+ if (loading_image.load(QStringLiteral("%1/images/placeholder.png").arg(QtHost::GetResourcesBasePath())))
+ {
+ loading_image.setDevicePixelRatio(dpr);
+ resizeAndPadImage(&loading_image, getCoverArtWidth(), getCoverArtHeight());
+ }
+ else
+ {
+ loading_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32);
+ loading_image.setDevicePixelRatio(dpr);
+ loading_image.fill(QColor(0, 0, 0, 0));
+ }
+ m_loading_pixmap = QPixmap::fromImage(loading_image);
+
+ m_placeholder_image = QImage();
+ if (m_placeholder_image.load(QStringLiteral("%1/images/cover-placeholder.png").arg(QtHost::GetResourcesBasePath())))
+ {
+ m_placeholder_image.setDevicePixelRatio(dpr);
+ resizeAndPadImage(&m_placeholder_image, getCoverArtWidth(), getCoverArtHeight());
+ }
+ else
+ {
+ m_placeholder_image = QImage(getCoverArtWidth(), getCoverArtHeight(), QImage::Format_RGB32);
+ m_placeholder_image.setDevicePixelRatio(dpr);
+ m_placeholder_image.fill(QColor(0, 0, 0, 0));
+ }
+
+ emit coverScaleChanged();
+}
+
+void GameListModel::refreshCovers()
+{
+ m_cover_pixmap_cache.Clear();
+ refresh();
+}
+
+void GameListModel::updateCacheSize(int width, int height)
+{
+ // This is a bit conversative, since it doesn't consider padding, but better to be over than under.
+ const int cover_width = getCoverArtWidth();
+ const int cover_height = getCoverArtHeight();
+ const int num_columns = ((width + (cover_width - 1)) / cover_width);
+ const int num_rows = ((height + (cover_height - 1)) / cover_height);
+ m_cover_pixmap_cache.SetMaxCapacity(static_cast(std::max(num_columns * num_rows, MIN_COVER_CACHE_SIZE)));
+}
+
+void GameListModel::reloadThemeSpecificImages()
+{
+ loadThemeSpecificImages();
+ refresh();
+}
+
+void GameListModel::loadOrGenerateCover(const GameList::Entry* ge)
+{
+ // NOTE: Must get connected before queuing, because otherwise you risk a race.
+ GameListCoverLoader* loader =
+ new GameListCoverLoader(ge, m_placeholder_image, getCoverArtWidth(), getCoverArtHeight(), m_cover_scale);
+ connect(loader, &GameListCoverLoader::coverLoaded, this, &GameListModel::coverLoaded);
+ System::QueueAsyncTask([loader]() { loader->loadOrGenerateCover(); });
+}
+
+void GameListModel::coverLoaded(const std::string& path, const QImage& image, float scale)
+{
+ // old request before cover scale change?
+ if (m_cover_scale != scale)
+ return;
+
+ if (!image.isNull())
+ m_cover_pixmap_cache.Insert(path, QPixmap::fromImage(image));
+ else
+ m_cover_pixmap_cache.Insert(path, QPixmap());
+
+ invalidateCoverForPath(path);
+}
+
+void GameListModel::rowsChanged(const QList& rows)
+{
+ const QList roles_changed = {Qt::DisplayRole};
+
+ // try to collapse multiples
+ size_t start = 0;
+ size_t idx = 0;
+ const size_t size = rows.size();
+ for (; idx < size;)
+ {
+ if ((idx + 1) < size && rows[idx + 1] == (rows[idx] + 1))
+ {
+ idx++;
+ }
+ else
+ {
+ emit dataChanged(createIndex(rows[start], 0), createIndex(rows[idx], Column_Count - 1), roles_changed);
+ start = ++idx;
+ }
+ }
+}
+
+void GameListModel::invalidateCoverForPath(const std::string& path)
+{
+ std::optional row;
+ if (hasTakenGameList())
+ {
+ for (u32 i = 0; i < static_cast(m_taken_entries->size()); i++)
+ {
+ if (path == m_taken_entries.value()[i].path)
+ {
+ row = i;
+ break;
+ }
+ }
+ }
+ else
+ {
+ // This isn't ideal, but not sure how else we can get the row, when it might change while scanning...
+ auto lock = GameList::GetLock();
+ const u32 count = GameList::GetEntryCount();
+ for (u32 i = 0; i < count; i++)
+ {
+ if (GameList::GetEntryByIndex(i)->path == path)
+ {
+ row = i;
+ break;
+ }
+ }
+ }
+
+ if (!row.has_value())
+ {
+ // Game removed?
+ return;
+ }
+
+ const QModelIndex mi(index(static_cast(row.value()), Column_Cover));
+ emit dataChanged(mi, mi, {Qt::DecorationRole});
+}
+
+QString GameListModel::formatTimespan(time_t timespan)
+{
+ // avoid an extra string conversion
+ const u32 hours = static_cast(timespan / 3600);
+ const u32 minutes = static_cast((timespan % 3600) / 60);
+ if (hours > 0)
+ return qApp->translate("GameList", "%n hours", "", hours);
+ else
+ return qApp->translate("GameList", "%n minutes", "", minutes);
+}
+
+const QPixmap& GameListModel::getIconPixmapForEntry(const GameList::Entry* ge) const
+{
+ // We only do this for discs/disc sets for now.
+ if (m_show_game_icons && (!ge->serial.empty() && (ge->IsDisc() || ge->IsDiscSet())))
+ {
+ QPixmap* item = m_memcard_pixmap_cache.Lookup(ge->serial);
+ if (item)
+ return *item;
+
+ // Assumes game list lock is held.
+ const std::string path = GameList::GetGameIconPath(ge->serial, ge->path);
+ QPixmap pm;
+ if (!path.empty() && pm.load(QString::fromStdString(path)))
+ {
+ fixIconPixmapSize(pm);
+ return *m_memcard_pixmap_cache.Insert(ge->serial, std::move(pm));
+ }
+
+ return *m_memcard_pixmap_cache.Insert(ge->serial, m_type_pixmaps[static_cast(ge->type)]);
+ }
+
+ return m_type_pixmaps[static_cast(ge->type)];
+}
+
+const QPixmap& GameListModel::getFlagPixmapForEntry(const GameList::Entry* ge) const
+{
+ static constexpr u32 FLAG_PIXMAP_WIDTH = 30;
+ static constexpr u32 FLAG_PIXMAP_HEIGHT = 20;
+
+ const std::string_view name = ge->GetLanguageIcon();
+ auto it = m_flag_pixmap_cache.find(name);
+ if (it != m_flag_pixmap_cache.end())
+ return it->second;
+
+ const QIcon icon(QString::fromStdString(QtHost::GetResourcePath(ge->GetLanguageIconName(), true)));
+ it = m_flag_pixmap_cache.emplace(name, icon.pixmap(FLAG_PIXMAP_WIDTH, FLAG_PIXMAP_HEIGHT)).first;
+ return it->second;
+}
+
+QIcon GameListModel::getIconForGame(const QString& path)
+{
+ QIcon ret;
+
+ if (m_show_game_icons)
+ {
+ const auto lock = GameList::GetLock();
+ const GameList::Entry* entry = GameList::GetEntryForPath(path.toStdString());
+
+ // See above.
+ if (entry && !entry->serial.empty() && (entry->IsDisc() || entry->IsDiscSet()))
+ {
+ const std::string icon_path = GameList::GetGameIconPath(entry->serial, entry->path);
+ if (!icon_path.empty())
+ {
+ QPixmap newpm;
+ if (!icon_path.empty() && newpm.load(QString::fromStdString(icon_path)))
+ {
+ fixIconPixmapSize(newpm);
+ ret = QIcon(*m_memcard_pixmap_cache.Insert(entry->serial, std::move(newpm)));
+ }
+ }
+ }
+ }
+
+ return ret;
+}
+
+void GameListModel::fixIconPixmapSize(QPixmap& pm)
+{
+ const qreal dpr = pm.devicePixelRatio();
+ const int width = static_cast(static_cast(pm.width()) * dpr);
+ const int height = static_cast(static_cast(pm.height()) * dpr);
+ const int max_dim = std::max(width, height);
+ if (max_dim == 16)
+ return;
+
+ const float wanted_dpr = qApp->devicePixelRatio();
+ pm.setDevicePixelRatio(wanted_dpr);
+
+ const float scale = static_cast(max_dim) / 16.0f / wanted_dpr;
+ const int new_width = static_cast(static_cast(width) / scale);
+ const int new_height = static_cast(static_cast(height) / scale);
+ pm = pm.scaled(new_width, new_height);
+}
+
+int GameListModel::getCoverArtWidth() const
+{
+ return std::max(static_cast(static_cast(COVER_ART_WIDTH) * m_cover_scale), 1);
+}
+
+int GameListModel::getCoverArtHeight() const
+{
+ return std::max(static_cast(static_cast(COVER_ART_HEIGHT) * m_cover_scale), 1);
+}
+
+int GameListModel::getCoverArtSpacing() const
+{
+ return std::max(static_cast(static_cast(COVER_ART_SPACING) * m_cover_scale), 1);
+}
+
+int GameListModel::rowCount(const QModelIndex& parent) const
+{
+ if (parent.isValid()) [[unlikely]]
+ return 0;
+
+ if (m_taken_entries.has_value())
+ return static_cast(m_taken_entries->size());
+
+ const auto lock = GameList::GetLock();
+ return static_cast(GameList::GetEntryCount());
+}
+
+int GameListModel::columnCount(const QModelIndex& parent) const
+{
+ if (parent.isValid())
+ return 0;
+
+ return Column_Count;
+}
+
+QVariant GameListModel::data(const QModelIndex& index, int role) const
+{
+ if (!index.isValid()) [[unlikely]]
+ return {};
+
+ const int row = index.row();
+ DebugAssert(row >= 0);
+
+ if (m_taken_entries.has_value()) [[unlikely]]
+ {
+ if (static_cast(row) >= m_taken_entries->size())
+ return {};
+
+ return data(index, role, &m_taken_entries.value()[row]);
+ }
+ else
+ {
+ const auto lock = GameList::GetLock();
+ const GameList::Entry* ge = GameList::GetEntryByIndex(static_cast(row));
+ if (!ge)
+ return {};
+
+ return data(index, role, ge);
+ }
+}
+
+QVariant GameListModel::data(const QModelIndex& index, int role, const GameList::Entry* ge) const
+{
+ switch (role)
+ {
+ case Qt::DisplayRole:
+ {
+ switch (index.column())
+ {
+ case Column_Serial:
+ return QtUtils::StringViewToQString(ge->serial);
+
+ case Column_Title:
+ return QtUtils::StringViewToQString(ge->title);
+
+ case Column_FileTitle:
+ return QtUtils::StringViewToQString(Path::GetFileTitle(ge->path));
+
+ case Column_Developer:
+ return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->developer) : QString();
+
+ case Column_Publisher:
+ return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->publisher) : QString();
+
+ case Column_Genre:
+ return ge->dbentry ? QtUtils::StringViewToQString(ge->dbentry->genre) : QString();
+
+ case Column_Year:
+ {
+ if (ge->dbentry && ge->dbentry->release_date != 0)
+ {
+ return QStringLiteral("%1").arg(
+ QDateTime::fromSecsSinceEpoch(static_cast(ge->dbentry->release_date), QTimeZone::utc())
+ .date()
+ .year());
+ }
+ else
+ {
+ return QString();
+ }
+ }
+
+ case Column_Players:
+ {
+ if (!ge->dbentry || ge->dbentry->min_players == 0)
+ return QString();
+ else if (ge->dbentry->min_players == ge->dbentry->max_players)
+ return QStringLiteral("%1").arg(ge->dbentry->min_players);
+ else
+ return QStringLiteral("%1-%2").arg(ge->dbentry->min_players).arg(ge->dbentry->max_players);
+ }
+
+ case Column_FileSize:
+ return (ge->file_size >= 0) ?
+ QStringLiteral("%1 MB").arg(static_cast(ge->file_size) / 1048576.0, 0, 'f', 2) :
+ tr("Unknown");
+
+ case Column_UncompressedSize:
+ return QStringLiteral("%1 MB").arg(static_cast(ge->uncompressed_size) / 1048576.0, 0, 'f', 2);
+
+ case Column_Achievements:
+ return {};
+
+ case Column_TimePlayed:
+ {
+ if (ge->total_played_time == 0)
+ return {};
+ else
+ return formatTimespan(ge->total_played_time);
+ }
+
+ case Column_LastPlayed:
+ return QtUtils::StringViewToQString(GameList::FormatTimestamp(ge->last_played_time));
+
+ case Column_Cover:
+ {
+ if (m_show_titles_for_covers)
+ return QString::fromStdString(ge->title);
+ else
+ return {};
+ }
+
+ default:
+ return {};
+ }
+ }
+
+ case Qt::InitialSortOrderRole:
+ {
+ const int column = index.column();
+ if (column == Column_TimePlayed || column == Column_LastPlayed)
+ return Qt::DescendingOrder;
+ else
+ return Qt::AscendingOrder;
+ }
+
+ case Qt::DecorationRole:
+ {
+ switch (index.column())
+ {
+ case Column_Icon:
+ {
+ return getIconPixmapForEntry(ge);
+ }
+
+ case Column_Region:
+ {
+ return getFlagPixmapForEntry(ge);
+ }
+
+ case Column_Compatibility:
+ {
+ return m_compatibility_pixmaps[static_cast(ge->dbentry ? ge->dbentry->compatibility :
+ GameDatabase::CompatibilityRating::Unknown)];
+ }
+
+ case Column_Cover:
+ {
+ QPixmap* pm = m_cover_pixmap_cache.Lookup(ge->path);
+ if (pm)
+ return *pm;
+
+ // We insert the placeholder into the cache, so that we don't repeatedly
+ // queue loading jobs for this game.
+ const_cast(this)->loadOrGenerateCover(ge);
+ return *m_cover_pixmap_cache.Insert(ge->path, m_loading_pixmap);
+ }
+ break;
+
+ default:
+ return {};
+ }
+
+ default:
+ return {};
+ }
+ }
+}
+
+QVariant GameListModel::headerData(int section, Qt::Orientation orientation, int role) const
+{
+ if (orientation != Qt::Horizontal || role != Qt::DisplayRole || section < 0 || section >= Column_Count)
+ return {};
+
+ return m_column_display_names[section];
+}
+
+const GameList::Entry* GameListModel::getTakenGameListEntry(u32 index) const
+{
+ return (m_taken_entries.has_value() && index < m_taken_entries->size()) ? &m_taken_entries.value()[index] : nullptr;
+}
+
+bool GameListModel::hasTakenGameList() const
+{
+ return m_taken_entries.has_value();
+}
+
+void GameListModel::takeGameList()
+{
+ const auto lock = GameList::GetLock();
+ m_taken_entries = GameList::TakeEntryList();
+
+ // If it's empty (e.g. first boot), don't use it.
+ if (m_taken_entries->empty())
+ m_taken_entries.reset();
+}
+
+void GameListModel::refresh()
+{
+ beginResetModel();
+
+ m_taken_entries.reset();
+
+ // Invalidate memcard LRU cache, forcing a re-query of the memcard timestamps.
+ m_memcard_pixmap_cache.Clear();
+
+ endResetModel();
+}
+
+bool GameListModel::titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const
+{
+ return (StringUtil::Strcasecmp(left->title.c_str(), right->title.c_str()) < 0);
+}
+
+bool GameListModel::lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const
+{
+ if (!left_index.isValid() || !right_index.isValid())
+ return false;
+
+ const int left_row = left_index.row();
+ const int right_row = right_index.row();
+
+ if (m_taken_entries.has_value()) [[unlikely]]
+ {
+ const GameList::Entry* left =
+ (static_cast(left_row) < m_taken_entries->size()) ? &m_taken_entries.value()[left_row] : nullptr;
+ const GameList::Entry* right =
+ (static_cast(right_row) < m_taken_entries->size()) ? &m_taken_entries.value()[right_row] : nullptr;
+ if (!left || !right)
+ return false;
+
+ return lessThan(left, right, column);
+ }
+ else
+ {
+ const auto lock = GameList::GetLock();
+ const GameList::Entry* left = GameList::GetEntryByIndex(left_row);
+ const GameList::Entry* right = GameList::GetEntryByIndex(right_row);
+ if (!left || !right)
+ return false;
+
+ return lessThan(left, right, column);
+ }
+}
+
+bool GameListModel::lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const
+{
+ switch (column)
+ {
+ case Column_Icon:
+ {
+ const GameList::EntryType lst = left->GetSortType();
+ const GameList::EntryType rst = right->GetSortType();
+ if (lst == rst)
+ return titlesLessThan(left, right);
+
+ return (static_cast(lst) < static_cast(rst));
+ }
+
+ case Column_Serial:
+ {
+ if (left->serial == right->serial)
+ return titlesLessThan(left, right);
+ return (StringUtil::Strcasecmp(left->serial.c_str(), right->serial.c_str()) < 0);
+ }
+
+ case Column_Title:
+ {
+ return titlesLessThan(left, right);
+ }
+
+ case Column_FileTitle:
+ {
+ const std::string_view file_title_left = Path::GetFileTitle(left->path);
+ const std::string_view file_title_right = Path::GetFileTitle(right->path);
+ if (file_title_left == file_title_right)
+ return titlesLessThan(left, right);
+
+ const std::size_t smallest = std::min(file_title_left.size(), file_title_right.size());
+ return (StringUtil::Strncasecmp(file_title_left.data(), file_title_right.data(), smallest) < 0);
+ }
+
+ case Column_Region:
+ {
+ if (left->region == right->region)
+ return titlesLessThan(left, right);
+ return (static_cast(left->region) < static_cast(right->region));
+ }
+
+ case Column_Compatibility:
+ {
+ const GameDatabase::CompatibilityRating left_compatibility =
+ left->dbentry ? left->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown;
+ const GameDatabase::CompatibilityRating right_compatibility =
+ right->dbentry ? right->dbentry->compatibility : GameDatabase::CompatibilityRating::Unknown;
+ if (left_compatibility == right_compatibility)
+ return titlesLessThan(left, right);
+
+ return (static_cast(left_compatibility) < static_cast(right_compatibility));
+ }
+
+ case Column_FileSize:
+ {
+ if (left->file_size == right->file_size)
+ return titlesLessThan(left, right);
+
+ return (left->file_size < right->file_size);
+ }
+
+ case Column_UncompressedSize:
+ {
+ if (left->uncompressed_size == right->uncompressed_size)
+ return titlesLessThan(left, right);
+
+ return (left->uncompressed_size < right->uncompressed_size);
+ }
+
+ case Column_Genre:
+ {
+ const int compres =
+ StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->genre) : std::string_view(),
+ right->dbentry ? std::string_view(right->dbentry->genre) : std::string_view());
+ return (compres == 0) ? titlesLessThan(left, right) : (compres < 0);
+ }
+
+ case Column_Developer:
+ {
+ const int compres =
+ StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->developer) : std::string_view(),
+ right->dbentry ? std::string_view(right->dbentry->developer) : std::string_view());
+ return (compres == 0) ? titlesLessThan(left, right) : (compres < 0);
+ }
+
+ case Column_Publisher:
+ {
+ const int compres =
+ StringUtil::CompareNoCase(left->dbentry ? std::string_view(left->dbentry->publisher) : std::string_view(),
+ right->dbentry ? std::string_view(right->dbentry->publisher) : std::string_view());
+ return (compres == 0) ? titlesLessThan(left, right) : (compres < 0);
+ }
+
+ case Column_Year:
+ {
+ const u64 ldate = left->dbentry ? left->dbentry->release_date : 0;
+ const u64 rdate = right->dbentry ? right->dbentry->release_date : 0;
+ if (ldate == rdate)
+ return titlesLessThan(left, right);
+
+ return (ldate < rdate);
+ }
+
+ case Column_TimePlayed:
+ {
+ if (left->total_played_time == right->total_played_time)
+ return titlesLessThan(left, right);
+
+ return (left->total_played_time < right->total_played_time);
+ }
+
+ case Column_LastPlayed:
+ {
+ if (left->last_played_time == right->last_played_time)
+ return titlesLessThan(left, right);
+
+ return (left->last_played_time < right->last_played_time);
+ }
+
+ case Column_Players:
+ {
+ const u8 left_players = left->dbentry ? ((left->dbentry->min_players << 4) + left->dbentry->max_players) : 0;
+ const u8 right_players = right->dbentry ? ((right->dbentry->min_players << 4) + right->dbentry->max_players) : 0;
+ if (left_players == right_players)
+ return titlesLessThan(left, right);
+
+ return (left_players < right_players);
+ }
+
+ case Column_Achievements:
+ {
+ // sort by unlock percentage
+ const float unlock_left =
+ (left->num_achievements > 0) ?
+ (static_cast(std::max(left->unlocked_achievements, left->unlocked_achievements_hc)) /
+ static_cast(left->num_achievements)) :
+ 0;
+ const float unlock_right =
+ (right->num_achievements > 0) ?
+ (static_cast(std::max(right->unlocked_achievements, right->unlocked_achievements_hc)) /
+ static_cast(right->num_achievements)) :
+ 0;
+ if (std::abs(unlock_left - unlock_right) < 0.0001f)
+ {
+ // order by achievement count
+ if (left->num_achievements == right->num_achievements)
+ return titlesLessThan(left, right);
+
+ return (left->num_achievements < right->num_achievements);
+ }
+
+ return (unlock_left < unlock_right);
+ }
+
+ default:
+ return false;
+ }
+}
+
+void GameListModel::loadThemeSpecificImages()
+{
+ for (u32 i = 0; i < static_cast(GameList::EntryType::Count); i++)
+ m_type_pixmaps[i] = QtUtils::GetIconForEntryType(static_cast(i)).pixmap(QSize(24, 24));
+}
+
+void GameListModel::loadCommonImages()
+{
+ loadThemeSpecificImages();
+
+ for (int i = 0; i < static_cast(GameDatabase::CompatibilityRating::Count); i++)
+ {
+ m_compatibility_pixmaps[i] =
+ QtUtils::GetIconForCompatibility(static_cast(i)).pixmap(96, 24);
+ }
+
+ constexpr int ACHIEVEMENT_ICON_SIZE = 16;
+ m_no_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-gray.svg", true)))
+ .pixmap(ACHIEVEMENT_ICON_SIZE);
+ m_has_achievements_pixmap = QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon.svg", true)))
+ .pixmap(ACHIEVEMENT_ICON_SIZE);
+ m_mastered_achievements_pixmap =
+ QIcon(QString::fromStdString(QtHost::GetResourcePath("images/trophy-icon-star.svg", true)))
+ .pixmap(ACHIEVEMENT_ICON_SIZE);
+}
+
+void GameListModel::setColumnDisplayNames()
+{
+ m_column_display_names[Column_Icon] = tr("Icon");
+ m_column_display_names[Column_Serial] = tr("Serial");
+ m_column_display_names[Column_Title] = tr("Title");
+ m_column_display_names[Column_FileTitle] = tr("File Title");
+ m_column_display_names[Column_Developer] = tr("Developer");
+ m_column_display_names[Column_Publisher] = tr("Publisher");
+ m_column_display_names[Column_Genre] = tr("Genre");
+ m_column_display_names[Column_Year] = tr("Year");
+ m_column_display_names[Column_Players] = tr("Players");
+ m_column_display_names[Column_Achievements] = tr("Achievements");
+ m_column_display_names[Column_TimePlayed] = tr("Time Played");
+ m_column_display_names[Column_LastPlayed] = tr("Last Played");
+ m_column_display_names[Column_FileSize] = tr("Size");
+ m_column_display_names[Column_UncompressedSize] = tr("Raw Size");
+ m_column_display_names[Column_Region] = tr("Region");
+ m_column_display_names[Column_Compatibility] = tr("Compatibility");
+}
+
class GameListSortModel final : public QSortFilterProxyModel
{
public:
diff --git a/src/duckstation-qt/gamelistwidget.h b/src/duckstation-qt/gamelistwidget.h
index 70992a498..045ddfb3d 100644
--- a/src/duckstation-qt/gamelistwidget.h
+++ b/src/duckstation-qt/gamelistwidget.h
@@ -2,20 +2,179 @@
// SPDX-License-Identifier: CC-BY-NC-ND-4.0
#pragma once
+
#include "ui_emptygamelistwidget.h"
#include "ui_gamelistwidget.h"
+#include "core/game_database.h"
#include "core/game_list.h"
+#include "core/types.h"
+#include "common/heterogeneous_containers.h"
+#include "common/lru_cache.h"
+
+#include
+#include
+#include
#include
#include
+#include
+#include
+#include
+
Q_DECLARE_METATYPE(const GameList::Entry*);
class GameListModel;
class GameListSortModel;
class GameListRefreshThread;
+class GameListModel final : public QAbstractTableModel
+{
+ Q_OBJECT
+
+public:
+ enum Column : int
+ {
+ Column_Icon,
+ Column_Serial,
+ Column_Title,
+ Column_FileTitle,
+ Column_Developer,
+ Column_Publisher,
+ Column_Genre,
+ Column_Year,
+ Column_Players,
+ Column_TimePlayed,
+ Column_LastPlayed,
+ Column_FileSize,
+ Column_UncompressedSize,
+ Column_Region,
+ Column_Achievements,
+ Column_Compatibility,
+ Column_Cover,
+
+ Column_Count
+ };
+
+ static std::optional getColumnIdForName(std::string_view name);
+ static const char* getColumnName(Column col);
+
+ GameListModel(float cover_scale, bool show_cover_titles, bool show_game_icons, QObject* parent = nullptr);
+ ~GameListModel();
+
+ int rowCount(const QModelIndex& parent = QModelIndex()) const override;
+ int columnCount(const QModelIndex& parent = QModelIndex()) const override;
+ QVariant data(const QModelIndex& index, int role = Qt::DisplayRole) const override;
+ QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override;
+
+ ALWAYS_INLINE const QString& getColumnDisplayName(int column) const { return m_column_display_names[column]; }
+ ALWAYS_INLINE const QPixmap& getNoAchievementsPixmap() const { return m_no_achievements_pixmap; }
+ ALWAYS_INLINE const QPixmap& getHasAchievementsPixmap() const { return m_has_achievements_pixmap; }
+ ALWAYS_INLINE const QPixmap& getMasteredAchievementsPixmap() const { return m_mastered_achievements_pixmap; }
+
+ const GameList::Entry* getTakenGameListEntry(u32 index) const;
+ bool hasTakenGameList() const;
+ void takeGameList();
+
+ void refresh();
+ void reloadThemeSpecificImages();
+
+ bool titlesLessThan(const GameList::Entry* left, const GameList::Entry* right) const;
+ bool lessThan(const GameList::Entry* left, const GameList::Entry* right, int column) const;
+
+ bool lessThan(const QModelIndex& left_index, const QModelIndex& right_index, int column) const;
+
+ bool getShowCoverTitles() const { return m_show_titles_for_covers; }
+ void setShowCoverTitles(bool enabled) { m_show_titles_for_covers = enabled; }
+
+ bool getShowGameIcons() const { return m_show_game_icons; }
+ void setShowGameIcons(bool enabled);
+ QIcon getIconForGame(const QString& path);
+
+ float getCoverScale() const { return m_cover_scale; }
+ void setCoverScale(float scale);
+ int getCoverArtWidth() const;
+ int getCoverArtHeight() const;
+ int getCoverArtSpacing() const;
+ void refreshCovers();
+ void updateCacheSize(int width, int height);
+
+Q_SIGNALS:
+ void coverScaleChanged();
+
+private Q_SLOTS:
+ void coverLoaded(const std::string& path, const QImage& image, float scale);
+ void rowsChanged(const QList& rows);
+
+private:
+ QVariant data(const QModelIndex& index, int role, const GameList::Entry* ge) const;
+
+ void loadCommonImages();
+ void loadThemeSpecificImages();
+ void setColumnDisplayNames();
+ void loadOrGenerateCover(const GameList::Entry* ge);
+ void invalidateCoverForPath(const std::string& path);
+
+ const QPixmap& getIconPixmapForEntry(const GameList::Entry* ge) const;
+ const QPixmap& getFlagPixmapForEntry(const GameList::Entry* ge) const;
+ static void fixIconPixmapSize(QPixmap& pm);
+
+ static QString formatTimespan(time_t timespan);
+
+ std::optional m_taken_entries;
+
+ float m_cover_scale = 0.0f;
+ bool m_show_titles_for_covers = false;
+ bool m_show_game_icons = false;
+
+ std::array m_column_display_names;
+ std::array(GameList::EntryType::Count)> m_type_pixmaps;
+ std::array(GameDatabase::CompatibilityRating::Count)> m_compatibility_pixmaps;
+
+ QImage m_placeholder_image;
+ QPixmap m_loading_pixmap;
+
+ QPixmap m_no_achievements_pixmap;
+ QPixmap m_has_achievements_pixmap;
+ QPixmap m_mastered_achievements_pixmap;
+
+ mutable PreferUnorderedStringMap m_flag_pixmap_cache;
+
+ mutable LRUCache m_cover_pixmap_cache;
+
+ mutable LRUCache m_memcard_pixmap_cache;
+};
+
+class GameListCoverLoader : public QObject
+{
+ Q_OBJECT
+
+public:
+ GameListCoverLoader(const GameList::Entry* ge, const QImage& placeholder_image, int width, int height, float scale);
+ ~GameListCoverLoader();
+
+public:
+ void loadOrGenerateCover();
+
+Q_SIGNALS:
+ void coverLoaded(const std::string& path, const QImage& image, float scale);
+
+private:
+ void createPlaceholderImage();
+
+ std::string m_path;
+ std::string m_serial;
+ std::string m_title;
+ QImage m_placeholder_image;
+ int m_width;
+ int m_height;
+ float m_scale;
+ float m_dpr;
+
+ QImage m_image;
+};
+
class GameListGridListView : public QListView
{
Q_OBJECT
diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp
index bd0025cab..a0d9d551e 100644
--- a/src/duckstation-qt/mainwindow.cpp
+++ b/src/duckstation-qt/mainwindow.cpp
@@ -8,7 +8,6 @@
#include "coverdownloaddialog.h"
#include "debuggerwindow.h"
#include "displaywidget.h"
-#include "gamelistmodel.h"
#include "gamelistsettingswidget.h"
#include "gamelistwidget.h"
#include "interfacesettingswidget.h"