diff --git a/src/core/fullscreen_ui.cpp b/src/core/fullscreen_ui.cpp index 50d5b9fac..a91a5c6ab 100644 --- a/src/core/fullscreen_ui.cpp +++ b/src/core/fullscreen_ui.cpp @@ -464,6 +464,7 @@ static void DrawGameList(const ImVec2& heading_size); static void DrawGameGrid(const ImVec2& heading_size); static void HandleGameListActivate(const GameList::Entry* entry); static void HandleGameListOptions(const GameList::Entry* entry); +static void HandleSelectDiscForDiscSet(std::string_view disc_set_name); static void DrawGameListSettingsWindow(); static void SwitchToGameList(); static void PopulateGameListEntryList(); @@ -5919,7 +5920,7 @@ bool FullscreenUI::OpenLoadStateSelectorForGameResume(const GameList::Entry* ent void FullscreenUI::DrawResumeStateSelector() { - ImGui::SetNextWindowSize(LayoutScale(800.0f, 600.0f)); + ImGui::SetNextWindowSize(LayoutScale(800.0f, 602.0f)); ImGui::SetNextWindowPos(ImGui::GetIO().DisplaySize * 0.5f, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); ImGui::OpenPopup(FSUI_CSTR("Load Resume State")); @@ -6048,11 +6049,27 @@ void FullscreenUI::PopulateGameListEntryList() { const s32 sort = Host::GetBaseIntSettingValue("Main", "FullscreenUIGameSort", 0); const bool reverse = Host::GetBaseBoolSettingValue("Main", "FullscreenUIGameSortReverse", false); + const bool merge_disc_sets = Host::GetBaseBoolSettingValue("Main", "FullscreenUIMergeDiscSets", true); const u32 count = GameList::GetEntryCount(); - s_game_list_sorted_entries.resize(count); + s_game_list_sorted_entries.clear(); + s_game_list_sorted_entries.reserve(count); for (u32 i = 0; i < count; i++) - s_game_list_sorted_entries[i] = GameList::GetEntryByIndex(i); + { + const GameList::Entry* entry = GameList::GetEntryByIndex(i); + if (merge_disc_sets) + { + if (entry->disc_set_member) + continue; + } + else + { + if (entry->IsDiscSet()) + continue; + } + + s_game_list_sorted_entries.push_back(entry); + } std::sort(s_game_list_sorted_entries.begin(), s_game_list_sorted_entries.end(), [sort, reverse](const GameList::Entry* lhs, const GameList::Entry* rhs) { @@ -6539,6 +6556,12 @@ void FullscreenUI::DrawGameGrid(const ImVec2& heading_size) void FullscreenUI::HandleGameListActivate(const GameList::Entry* entry) { + if (entry->IsDiscSet()) + { + HandleSelectDiscForDiscSet(entry->path); + return; + } + // launch game if (!OpenLoadStateSelectorForGameResume(entry)) DoStartPath(entry->path); @@ -6546,53 +6569,121 @@ void FullscreenUI::HandleGameListActivate(const GameList::Entry* entry) void FullscreenUI::HandleGameListOptions(const GameList::Entry* entry) { - ImGuiFullscreen::ChoiceDialogOptions options = { - {FSUI_ICONSTR(ICON_FA_WRENCH, "Game Properties"), false}, - {FSUI_ICONSTR(ICON_FA_FOLDER_OPEN, "Open Containing Directory"), false}, - {FSUI_ICONSTR(ICON_FA_PLAY, "Resume Game"), false}, - {FSUI_ICONSTR(ICON_FA_UNDO, "Load State"), false}, - {FSUI_ICONSTR(ICON_FA_COMPACT_DISC, "Default Boot"), false}, - {FSUI_ICONSTR(ICON_FA_LIGHTBULB, "Fast Boot"), false}, - {FSUI_ICONSTR(ICON_FA_MAGIC, "Slow Boot"), false}, - {FSUI_ICONSTR(ICON_FA_FOLDER_MINUS, "Reset Play Time"), false}, - {FSUI_ICONSTR(ICON_FA_WINDOW_CLOSE, "Close Menu"), false}, - }; + if (!entry->IsDiscSet()) + { + ImGuiFullscreen::ChoiceDialogOptions options = { + {FSUI_ICONSTR(ICON_FA_WRENCH, "Game Properties"), false}, + {FSUI_ICONSTR(ICON_FA_FOLDER_OPEN, "Open Containing Directory"), false}, + {FSUI_ICONSTR(ICON_FA_PLAY, "Resume Game"), false}, + {FSUI_ICONSTR(ICON_FA_UNDO, "Load State"), false}, + {FSUI_ICONSTR(ICON_FA_COMPACT_DISC, "Default Boot"), false}, + {FSUI_ICONSTR(ICON_FA_LIGHTBULB, "Fast Boot"), false}, + {FSUI_ICONSTR(ICON_FA_MAGIC, "Slow Boot"), false}, + {FSUI_ICONSTR(ICON_FA_FOLDER_MINUS, "Reset Play Time"), false}, + {FSUI_ICONSTR(ICON_FA_WINDOW_CLOSE, "Close Menu"), false}, + }; - OpenChoiceDialog( - entry->title.c_str(), false, std::move(options), - [entry_path = entry->path, entry_serial = entry->serial](s32 index, const std::string& title, bool checked) { - switch (index) - { - case 0: // Open Game Properties - SwitchToGameSettingsForPath(entry_path); - break; - case 1: // Open Containing Directory - ExitFullscreenAndOpenURL(Path::CreateFileURL(Path::GetDirectory(entry_path))); - break; - case 2: // Resume Game - DoStartPath(entry_path, System::GetGameSaveStateFileName(entry_serial, -1)); - break; - case 3: // Load State - OpenLoadStateSelectorForGame(entry_path); - break; - case 4: // Default Boot - DoStartPath(entry_path); - break; - case 5: // Fast Boot - DoStartPath(entry_path, {}, true); - break; - case 6: // Slow Boot - DoStartPath(entry_path, {}, false); - break; - case 7: // Reset Play Time - GameList::ClearPlayedTimeForSerial(entry_serial); - break; - default: - break; - } + OpenChoiceDialog( + entry->title.c_str(), false, std::move(options), + [entry_path = entry->path, entry_serial = entry->serial](s32 index, const std::string& title, bool checked) { + switch (index) + { + case 0: // Open Game Properties + SwitchToGameSettingsForPath(entry_path); + break; + case 1: // Open Containing Directory + ExitFullscreenAndOpenURL(Path::CreateFileURL(Path::GetDirectory(entry_path))); + break; + case 2: // Resume Game + DoStartPath(entry_path, System::GetGameSaveStateFileName(entry_serial, -1)); + break; + case 3: // Load State + OpenLoadStateSelectorForGame(entry_path); + break; + case 4: // Default Boot + DoStartPath(entry_path); + break; + case 5: // Fast Boot + DoStartPath(entry_path, {}, true); + break; + case 6: // Slow Boot + DoStartPath(entry_path, {}, false); + break; + case 7: // Reset Play Time + GameList::ClearPlayedTimeForSerial(entry_serial); + break; + default: + break; + } - CloseChoiceDialog(); - }); + CloseChoiceDialog(); + }); + } + else + { + // shouldn't fail + const GameList::Entry* first_disc_entry = GameList::GetFirstDiscSetMember(entry->path); + if (!first_disc_entry) + return; + + ImGuiFullscreen::ChoiceDialogOptions options = { + {FSUI_ICONSTR(ICON_FA_WRENCH, "Game Properties"), false}, + {FSUI_ICONSTR(ICON_FA_COMPACT_DISC, "Select Disc"), false}, + {FSUI_ICONSTR(ICON_FA_WINDOW_CLOSE, "Close Menu"), false}, + }; + + OpenChoiceDialog(entry->title.c_str(), false, std::move(options), + [entry_path = first_disc_entry->path, + disc_set_name = entry->path](s32 index, const std::string& title, bool checked) { + switch (index) + { + case 0: // Open Game Properties + SwitchToGameSettingsForPath(entry_path); + break; + case 1: // Select Disc + HandleSelectDiscForDiscSet(disc_set_name); + break; + default: + break; + } + + CloseChoiceDialog(); + }); + } +} + +void FullscreenUI::HandleSelectDiscForDiscSet(std::string_view disc_set_name) +{ + auto lock = GameList::GetLock(); + const std::vector entries = GameList::GetDiscSetMembers(disc_set_name, true); + if (entries.empty()) + return; + + ImGuiFullscreen::ChoiceDialogOptions options; + std::vector paths; + paths.reserve(entries.size()); + + for (const GameList::Entry* entry : entries) + { + std::string title = fmt::format(fmt::runtime(FSUI_ICONSTR(ICON_FA_COMPACT_DISC, "Disc {} | {}")), + entry->disc_set_index + 1, Path::GetFileName(entry->path)); + options.emplace_back(std::move(title), false); + paths.push_back(entry->path); + } + options.emplace_back(FSUI_ICONSTR(ICON_FA_WINDOW_CLOSE, "Close Menu"), false); + + OpenChoiceDialog(SmallString::from_format("Select Disc for {}", disc_set_name), false, std::move(options), + [paths = std::move(paths)](s32 index, const std::string& title, bool checked) { + if (static_cast(index) < paths.size()) + { + auto lock = GameList::GetLock(); + const GameList::Entry* entry = GameList::GetEntryForPath(paths[index]); + if (entry) + HandleGameListActivate(entry); + } + + CloseChoiceDialog(); + }); } void FullscreenUI::DrawGameListSettingsWindow() @@ -6742,6 +6833,9 @@ void FullscreenUI::DrawGameListSettingsWindow() bsi, FSUI_ICONSTR(ICON_FA_SORT_ALPHA_DOWN, "Sort Reversed"), FSUI_CSTR("Reverses the game list sort order from the default (usually ascending to descending)."), "Main", "FullscreenUIGameSortReverse", false); + DrawToggleSetting(bsi, FSUI_ICONSTR(ICON_FA_LIST, "Merge Multi-Disc Games"), + FSUI_CSTR("Merges multi-disc games into one item in the game list."), "Main", + "FullscreenUIMergeDiscSets", true); } MenuHeading(FSUI_CSTR("Cover Settings")); diff --git a/src/core/game_list.cpp b/src/core/game_list.cpp index f5df28d42..f8e5f956c 100644 --- a/src/core/game_list.cpp +++ b/src/core/game_list.cpp @@ -82,6 +82,7 @@ static bool OpenCacheForWriting(); static bool WriteEntryToCache(const Entry* entry); static void CloseCacheFileStream(); static void DeleteCacheFile(); +static void CreateDiscSetEntries(const PlayedTimeMap& played_time_map); static std::string GetPlayedTimeFile(); static bool ParsePlayedTimeLine(char* line, std::string& serial, PlayedTimeEntry& entry); @@ -100,15 +101,16 @@ static bool s_game_list_loaded = false; const char* GameList::GetEntryTypeName(EntryType type) { - static std::array(EntryType::Count)> names = {{"Disc", "PSExe", "Playlist", "PSF"}}; + static std::array(EntryType::Count)> names = { + {"Disc", "DiscSet", "PSExe", "Playlist", "PSF"}}; return names[static_cast(type)]; } const char* GameList::GetEntryTypeDisplayName(EntryType type) { static std::array(EntryType::Count)> names = { - {TRANSLATE_NOOP("GameList", "Disc"), TRANSLATE_NOOP("GameList", "PS-EXE"), TRANSLATE_NOOP("GameList", "Playlist"), - TRANSLATE_NOOP("GameList", "PSF")}}; + {TRANSLATE_NOOP("GameList", "Disc"), TRANSLATE_NOOP("GameList", "Disc Set"), TRANSLATE_NOOP("GameList", "PS-EXE"), + TRANSLATE_NOOP("GameList", "Playlist"), TRANSLATE_NOOP("GameList", "PSF")}}; return Host::TranslateToCString("GameList", names[static_cast(type)]); } @@ -515,8 +517,8 @@ void GameList::ScanDirectory(const char* path, bool recursive, bool only_cache, } std::unique_lock lock(s_mutex); - if (GetEntryForPath(ffd.FileName) || - AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map) || only_cache) + if (GetEntryForPath(ffd.FileName) || AddFileFromCache(ffd.FileName, ffd.ModificationTime, played_time_map) || + only_cache) { continue; } @@ -611,7 +613,7 @@ const GameList::Entry* GameList::GetEntryBySerial(std::string_view serial) { for (const Entry& entry : s_entries) { - if (entry.serial == serial) + if (entry.serial == serial) return &entry; } @@ -629,19 +631,51 @@ const GameList::Entry* GameList::GetEntryBySerialAndHash(std::string_view serial return nullptr; } -std::vector GameList::GetDiscSetMembers(std::string_view disc_set_name) +std::vector GameList::GetDiscSetMembers(std::string_view disc_set_name, + bool sort_by_most_recent) { std::vector ret; for (const Entry& entry : s_entries) { - if (/*!entry.disc_set_member || */ disc_set_name != entry.disc_set_name) + if (!entry.disc_set_member || disc_set_name != entry.disc_set_name) continue; ret.push_back(&entry); } + + if (sort_by_most_recent) + { + std::sort(ret.begin(), ret.end(), [](const Entry* lhs, const Entry* rhs) { + if (lhs->last_played_time == rhs->last_played_time) + return (lhs->disc_set_index < rhs->disc_set_index); + else + return (lhs->last_played_time > rhs->last_played_time); + }); + } + else + { + std::sort(ret.begin(), ret.end(), + [](const Entry* lhs, const Entry* rhs) { return (lhs->disc_set_index < rhs->disc_set_index); }); + } + return ret; } +const GameList::Entry* GameList::GetFirstDiscSetMember(std::string_view disc_set_name) +{ + for (const Entry& entry : s_entries) + { + if (!entry.disc_set_member || disc_set_name != entry.disc_set_name) + continue; + + // Disc set should not have been created without the first disc being present. + if (entry.disc_set_index == 0) + return &entry; + } + + return nullptr; +} + u32 GameList::GetEntryCount() { return static_cast(s_entries.size()); @@ -703,6 +737,112 @@ void GameList::Refresh(bool invalidate_cache, bool only_cache, ProgressCallback* // don't need unused cache entries CloseCacheFileStream(); s_cache_map.clear(); + + // merge multi-disc games + CreateDiscSetEntries(played_time); +} + +void GameList::CreateDiscSetEntries(const PlayedTimeMap& played_time_map) +{ + std::unique_lock lock(s_mutex); + + for (size_t i = 0; i < s_entries.size(); i++) + { + const Entry& entry = s_entries[i]; + + // only first discs can create sets + if (entry.type != EntryType::Disc || entry.disc_set_member || entry.disc_set_index != 0) + continue; + + // already have a disc set by this name? + const std::string& disc_set_name = entry.disc_set_name; + if (GetEntryForPath(disc_set_name.c_str())) + continue; + + const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(entry.serial); + if (!dbentry) + continue; + + // need at least two discs for a set + bool found_another_disc = false; + for (const Entry& other_entry : s_entries) + { + if (other_entry.type != EntryType::Disc || other_entry.disc_set_member || + other_entry.disc_set_name != disc_set_name || other_entry.disc_set_index == entry.disc_set_index) + { + continue; + } + found_another_disc = true; + break; + } + if (!found_another_disc) + { + Log_DevFmt("Not creating disc set {}, only one disc found", disc_set_name); + continue; + } + + Entry set_entry; + set_entry.type = EntryType::DiscSet; + set_entry.region = entry.region; + set_entry.path = disc_set_name; + set_entry.serial = entry.serial; + set_entry.title = entry.disc_set_name; + set_entry.genre = entry.developer; + set_entry.publisher = entry.publisher; + set_entry.developer = entry.developer; + set_entry.hash = entry.hash; + set_entry.file_size = 0; + set_entry.uncompressed_size = 0; + set_entry.last_modified_time = entry.last_modified_time; + set_entry.last_played_time = 0; + set_entry.total_played_time = 0; + set_entry.release_date = entry.release_date; + set_entry.supported_controllers = entry.supported_controllers; + set_entry.min_players = entry.min_players; + set_entry.max_players = entry.max_players; + set_entry.min_blocks = entry.min_blocks; + set_entry.max_blocks = entry.max_blocks; + set_entry.compatibility = entry.compatibility; + + // figure out play time for all discs, and sum it + // we do this via lookups, rather than the other entries, because of duplicates + for (const std::string& set_serial : dbentry->disc_set_serials) + { + const auto it = played_time_map.find(set_serial); + if (it == played_time_map.end()) + continue; + + set_entry.last_played_time = + (set_entry.last_played_time == 0) ? + it->second.last_played_time : + ((it->second.last_played_time != 0) ? std::min(set_entry.last_played_time, it->second.last_played_time) : + set_entry.last_played_time); + set_entry.total_played_time += it->second.total_played_time; + } + + // mark all discs for this set as part of it, so we don't try to add them again, and for filtering + u32 num_parts = 0; + for (Entry& other_entry : s_entries) + { + if (other_entry.type != EntryType::Disc || other_entry.disc_set_member || + other_entry.disc_set_name != disc_set_name) + { + continue; + } + + Log_InfoFmt("Adding {} to disc set {}", other_entry.path, disc_set_name); + other_entry.disc_set_member = true; + set_entry.last_modified_time = std::min(set_entry.last_modified_time, other_entry.last_modified_time); + set_entry.file_size += other_entry.file_size; + set_entry.uncompressed_size += other_entry.uncompressed_size; + num_parts++; + } + + Log_InfoFmt("Created disc set {} from {} entries", disc_set_name, num_parts); + + // entry is done :) + s_entries.push_back(std::move(set_entry)); + } } std::string GameList::GetCoverImagePathForEntry(const Entry* entry) @@ -959,9 +1099,23 @@ void GameList::AddPlayedTimeForSerial(const std::string& serial, std::time_t las Log_VerbosePrintf("Add %u seconds play time to %s -> now %u", static_cast(add_time), serial.c_str(), static_cast(pt.total_played_time)); + const GameDatabase::Entry* dbentry = GameDatabase::GetEntryForSerial(serial); + std::unique_lock lock(s_mutex); for (GameList::Entry& entry : s_entries) { + // add it to the disc set, if any + if (entry.type == EntryType::DiscSet) + { + if (dbentry && dbentry->disc_set_name == entry.path) + { + entry.last_played_time = pt.last_played_time; + entry.total_played_time = pt.total_played_time; + } + + continue; + } + if (entry.serial != serial) continue; diff --git a/src/core/game_list.h b/src/core/game_list.h index a6b8fe45b..da885b5a4 100644 --- a/src/core/game_list.h +++ b/src/core/game_list.h @@ -25,6 +25,7 @@ namespace GameList { enum class EntryType { Disc, + DiscSet, PSExe, Playlist, PSF, @@ -57,12 +58,14 @@ struct Entry u8 min_blocks = 0; u8 max_blocks = 0; s8 disc_set_index = -1; + bool disc_set_member = false; GameDatabase::CompatibilityRating compatibility = GameDatabase::CompatibilityRating::Unknown; size_t GetReleaseDateString(char* buffer, size_t buffer_size) const; ALWAYS_INLINE bool IsDisc() const { return (type == EntryType::Disc); } + ALWAYS_INLINE bool IsDiscSet() const { return (type == EntryType::DiscSet); } }; const char* GetEntryTypeName(EntryType type); @@ -80,7 +83,8 @@ const Entry* GetEntryByIndex(u32 index); const Entry* GetEntryForPath(std::string_view path); const Entry* GetEntryBySerial(std::string_view serial); const Entry* GetEntryBySerialAndHash(std::string_view serial, u64 hash); -std::vector GetDiscSetMembers(std::string_view disc_set_name); +std::vector GetDiscSetMembers(std::string_view disc_set_name, bool sort_by_most_recent = false); +const Entry* GetFirstDiscSetMember(std::string_view disc_set_name); u32 GetEntryCount(); bool IsGameListLoaded(); diff --git a/src/duckstation-qt/CMakeLists.txt b/src/duckstation-qt/CMakeLists.txt index 8b86c5328..a3299895c 100644 --- a/src/duckstation-qt/CMakeLists.txt +++ b/src/duckstation-qt/CMakeLists.txt @@ -140,6 +140,9 @@ set(SRCS qtutils.cpp qtutils.h resource.h + selectdiscdialog.cpp + selectdiscdialog.h + selectdiscdialog.ui settingswindow.cpp settingswindow.h settingswindow.ui diff --git a/src/duckstation-qt/duckstation-qt.vcxproj b/src/duckstation-qt/duckstation-qt.vcxproj index a21b7a00b..e1937c8ce 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj +++ b/src/duckstation-qt/duckstation-qt.vcxproj @@ -50,6 +50,7 @@ + @@ -88,6 +89,7 @@ + @@ -257,6 +259,7 @@ + @@ -344,6 +347,9 @@ Document + + Document + diff --git a/src/duckstation-qt/duckstation-qt.vcxproj.filters b/src/duckstation-qt/duckstation-qt.vcxproj.filters index 3de628b98..eb3b670ce 100644 --- a/src/duckstation-qt/duckstation-qt.vcxproj.filters +++ b/src/duckstation-qt/duckstation-qt.vcxproj.filters @@ -182,6 +182,10 @@ moc + + + moc + @@ -246,6 +250,7 @@ + @@ -292,6 +297,7 @@ + diff --git a/src/duckstation-qt/gamelistwidget.cpp b/src/duckstation-qt/gamelistwidget.cpp index 9987fe265..9fdaf485d 100644 --- a/src/duckstation-qt/gamelistwidget.cpp +++ b/src/duckstation-qt/gamelistwidget.cpp @@ -38,6 +38,14 @@ class GameListSortModel final : public QSortFilterProxyModel public: explicit GameListSortModel(GameListModel* parent) : QSortFilterProxyModel(parent), m_model(parent) {} + bool getMergeDiscSets() const { return m_merge_disc_sets; } + + void setMergeDiscSets(bool enabled) + { + m_merge_disc_sets = enabled; + invalidateRowsFilter(); + } + void setFilterType(GameList::EntryType type) { m_filter_type = type; @@ -56,18 +64,28 @@ public: bool filterAcceptsRow(int source_row, const QModelIndex& source_parent) const override { - if (m_filter_type != GameList::EntryType::Count || m_filter_region != DiscRegion::Count || !m_filter_name.isEmpty()) + const auto lock = GameList::GetLock(); + const GameList::Entry* entry = GameList::GetEntryByIndex(source_row); + + if (m_merge_disc_sets) { - const auto lock = GameList::GetLock(); - const GameList::Entry* entry = GameList::GetEntryByIndex(source_row); - if (m_filter_type != GameList::EntryType::Count && entry->type != m_filter_type) - return false; - if (m_filter_region != DiscRegion::Count && entry->region != m_filter_region) - return false; - if (!m_filter_name.isEmpty() && - !QString::fromStdString(entry->title).contains(m_filter_name, Qt::CaseInsensitive)) + if (entry->disc_set_member) return false; } + else + { + if (entry->IsDiscSet()) + return false; + } + + if (m_filter_type != GameList::EntryType::Count && entry->type != m_filter_type) + return false; + + if (m_filter_region != DiscRegion::Count && entry->region != m_filter_region) + return false; + + if (!m_filter_name.isEmpty() && !QString::fromStdString(entry->title).contains(m_filter_name, Qt::CaseInsensitive)) + return false; return QSortFilterProxyModel::filterAcceptsRow(source_row, source_parent); } @@ -82,6 +100,7 @@ private: GameList::EntryType m_filter_type = GameList::EntryType::Count; DiscRegion m_filter_region = DiscRegion::Count; QString m_filter_name; + bool m_merge_disc_sets = true; }; GameListWidget::GameListWidget(QWidget* parent /* = nullptr */) : QWidget(parent) @@ -94,11 +113,13 @@ void GameListWidget::initialize() { const float cover_scale = Host::GetBaseFloatSettingValue("UI", "GameListCoverArtScale", 0.45f); const bool show_cover_titles = Host::GetBaseBoolSettingValue("UI", "GameListShowCoverTitles", true); + const bool merge_disc_sets = Host::GetBaseBoolSettingValue("UI", "GameListMergeDiscSets", true); m_model = new GameListModel(cover_scale, show_cover_titles, this); m_model->updateCacheSize(width(), height()); m_sort_model = new GameListSortModel(m_model); m_sort_model->setSourceModel(m_model); + m_sort_model->setMergeDiscSets(merge_disc_sets); m_ui.setupUi(this); for (u32 type = 0; type < static_cast(GameList::EntryType::Count); type++) @@ -117,6 +138,7 @@ void GameListWidget::initialize() connect(m_ui.viewGameGrid, &QPushButton::clicked, this, &GameListWidget::showGameGrid); connect(m_ui.gridScale, &QSlider::valueChanged, this, &GameListWidget::gridIntScale); connect(m_ui.viewGridTitles, &QPushButton::toggled, this, &GameListWidget::setShowCoverTitles); + connect(m_ui.viewMergeDiscSets, &QPushButton::toggled, this, &GameListWidget::setMergeDiscSets); connect(m_ui.filterType, &QComboBox::currentIndexChanged, this, [this](int index) { m_sort_model->setFilterType((index == 0) ? GameList::EntryType::Count : static_cast(index - 1)); @@ -429,6 +451,21 @@ void GameListWidget::setShowCoverTitles(bool enabled) emit layoutChange(); } +void GameListWidget::setMergeDiscSets(bool enabled) +{ + if (m_sort_model->getMergeDiscSets() == enabled) + { + updateToolbar(); + return; + } + + Host::SetBaseBoolSettingValue("UI", "GameListMergeDiscSets", enabled); + Host::CommitBaseSettingChanges(); + m_sort_model->setMergeDiscSets(enabled); + updateToolbar(); + emit layoutChange(); +} + void GameListWidget::updateToolbar() { const bool grid_view = isShowingGameGrid(); @@ -444,6 +481,10 @@ void GameListWidget::updateToolbar() QSignalBlocker sb(m_ui.viewGridTitles); m_ui.viewGridTitles->setChecked(m_model->getShowCoverTitles()); } + { + QSignalBlocker sb(m_ui.viewMergeDiscSets); + m_ui.viewMergeDiscSets->setChecked(m_sort_model->getMergeDiscSets()); + } { QSignalBlocker sb(m_ui.gridScale); m_ui.gridScale->setValue(static_cast(m_model->getCoverScale() * 100.0f)); diff --git a/src/duckstation-qt/gamelistwidget.h b/src/duckstation-qt/gamelistwidget.h index 979331ee7..ef1a9c2ea 100644 --- a/src/duckstation-qt/gamelistwidget.h +++ b/src/duckstation-qt/gamelistwidget.h @@ -82,6 +82,7 @@ public Q_SLOTS: void showGameList(); void showGameGrid(); void setShowCoverTitles(bool enabled); + void setMergeDiscSets(bool enabled); void gridZoomIn(); void gridZoomOut(); void gridIntScale(int int_scale); diff --git a/src/duckstation-qt/gamelistwidget.ui b/src/duckstation-qt/gamelistwidget.ui index 67e00bb28..05915151c 100644 --- a/src/duckstation-qt/gamelistwidget.ui +++ b/src/duckstation-qt/gamelistwidget.ui @@ -91,6 +91,29 @@ + + + + + 32 + 0 + + + + Merge Multi-Disc Games + + + + .. + + + true + + + true + + + diff --git a/src/duckstation-qt/mainwindow.cpp b/src/duckstation-qt/mainwindow.cpp index 24d6c3b30..fd7696ee4 100644 --- a/src/duckstation-qt/mainwindow.cpp +++ b/src/duckstation-qt/mainwindow.cpp @@ -17,6 +17,7 @@ #include "memoryscannerwindow.h" #include "qthost.h" #include "qtutils.h" +#include "selectdiscdialog.h" #include "settingswindow.h" #include "settingwidgetbinder.h" @@ -1077,6 +1078,22 @@ void MainWindow::populateCheatsMenu(QMenu* menu) } } +const GameList::Entry* MainWindow::resolveDiscSetEntry(const GameList::Entry* entry, + std::unique_lock& lock) +{ + if (!entry || entry->type != GameList::EntryType::DiscSet) + return entry; + + // disc set... need to figure out the disc we want + SelectDiscDialog dlg(entry->path, this); + + lock.unlock(); + const int res = dlg.exec(); + lock.lock(); + + return res ? GameList::GetEntryForPath(dlg.getSelectedDiscPath()) : nullptr; +} + std::shared_ptr MainWindow::getSystemBootParameters(std::string file) { std::shared_ptr ret = std::make_shared(std::move(file)); @@ -1376,7 +1393,7 @@ void MainWindow::onGameListSelectionChanged() void MainWindow::onGameListEntryActivated() { auto lock = GameList::GetLock(); - const GameList::Entry* entry = m_game_list_widget->getSelectedEntry(); + const GameList::Entry* entry = resolveDiscSetEntry(m_game_list_widget->getSelectedEntry(), lock); if (!entry) return; @@ -1421,67 +1438,83 @@ void MainWindow::onGameListEntryContextMenuRequested(const QPoint& point) // Hopefully this pointer doesn't disappear... it shouldn't. if (entry) { - QAction* action = menu.addAction(tr("Properties...")); - connect(action, &QAction::triggered, - [entry]() { SettingsWindow::openGamePropertiesDialog(entry->path, entry->serial, entry->region); }); - - connect(menu.addAction(tr("Open Containing Directory...")), &QAction::triggered, [this, entry]() { - const QFileInfo fi(QString::fromStdString(entry->path)); - QtUtils::OpenURL(this, QUrl::fromLocalFile(fi.absolutePath())); - }); - - connect(menu.addAction(tr("Set Cover Image...")), &QAction::triggered, - [this, entry]() { setGameListEntryCoverImage(entry); }); - - menu.addSeparator(); - - if (!s_system_valid) + if (!entry->IsDiscSet()) { - populateGameListContextMenu(entry, this, &menu); + connect(menu.addAction(tr("Properties...")), &QAction::triggered, + [entry]() { SettingsWindow::openGamePropertiesDialog(entry->path, entry->serial, entry->region); }); + + connect(menu.addAction(tr("Open Containing Directory...")), &QAction::triggered, [this, entry]() { + const QFileInfo fi(QString::fromStdString(entry->path)); + QtUtils::OpenURL(this, QUrl::fromLocalFile(fi.absolutePath())); + }); + + connect(menu.addAction(tr("Set Cover Image...")), &QAction::triggered, + [this, entry]() { setGameListEntryCoverImage(entry); }); + menu.addSeparator(); - connect(menu.addAction(tr("Default Boot")), &QAction::triggered, - [this, entry]() { g_emu_thread->bootSystem(getSystemBootParameters(entry->path)); }); - - connect(menu.addAction(tr("Fast Boot")), &QAction::triggered, [this, entry]() { - std::shared_ptr boot_params = getSystemBootParameters(entry->path); - boot_params->override_fast_boot = true; - g_emu_thread->bootSystem(std::move(boot_params)); - }); - - connect(menu.addAction(tr("Full Boot")), &QAction::triggered, [this, entry]() { - std::shared_ptr boot_params = getSystemBootParameters(entry->path); - boot_params->override_fast_boot = false; - g_emu_thread->bootSystem(std::move(boot_params)); - }); - - if (m_ui.menuDebug->menuAction()->isVisible() && !Achievements::IsHardcoreModeActive()) + if (!s_system_valid) { - connect(menu.addAction(tr("Boot and Debug")), &QAction::triggered, [this, entry]() { - m_open_debugger_on_start = true; + populateGameListContextMenu(entry, this, &menu); + menu.addSeparator(); + connect(menu.addAction(tr("Default Boot")), &QAction::triggered, + [this, entry]() { g_emu_thread->bootSystem(getSystemBootParameters(entry->path)); }); + + connect(menu.addAction(tr("Fast Boot")), &QAction::triggered, [this, entry]() { std::shared_ptr boot_params = getSystemBootParameters(entry->path); - boot_params->override_start_paused = true; + boot_params->override_fast_boot = true; g_emu_thread->bootSystem(std::move(boot_params)); }); + + connect(menu.addAction(tr("Full Boot")), &QAction::triggered, [this, entry]() { + std::shared_ptr boot_params = getSystemBootParameters(entry->path); + boot_params->override_fast_boot = false; + g_emu_thread->bootSystem(std::move(boot_params)); + }); + + if (m_ui.menuDebug->menuAction()->isVisible() && !Achievements::IsHardcoreModeActive()) + { + connect(menu.addAction(tr("Boot and Debug")), &QAction::triggered, [this, entry]() { + m_open_debugger_on_start = true; + + std::shared_ptr boot_params = getSystemBootParameters(entry->path); + boot_params->override_start_paused = true; + g_emu_thread->bootSystem(std::move(boot_params)); + }); + } } + else + { + connect(menu.addAction(tr("Change Disc")), &QAction::triggered, [this, entry]() { + g_emu_thread->changeDisc(QString::fromStdString(entry->path), false, true); + g_emu_thread->setSystemPaused(false); + switchToEmulationView(); + }); + } + + menu.addSeparator(); + + connect(menu.addAction(tr("Exclude From List")), &QAction::triggered, + [this, entry]() { getSettingsDialog()->getGameListSettingsWidget()->addExcludedPath(entry->path); }); + + connect(menu.addAction(tr("Reset Play Time")), &QAction::triggered, + [this, entry]() { clearGameListEntryPlayTime(entry); }); } else { - connect(menu.addAction(tr("Change Disc")), &QAction::triggered, [this, entry]() { - g_emu_thread->changeDisc(QString::fromStdString(entry->path), false, true); - g_emu_thread->setSystemPaused(false); - switchToEmulationView(); + connect(menu.addAction(tr("Properties...")), &QAction::triggered, [disc_set_name = entry->path]() { + // resolve path first + auto lock = GameList::GetLock(); + const GameList::Entry* first_disc = GameList::GetFirstDiscSetMember(disc_set_name); + if (first_disc) + SettingsWindow::openGamePropertiesDialog(first_disc->path, first_disc->serial, first_disc->region); }); + + menu.addSeparator(); + + connect(menu.addAction(tr("Select Disc")), &QAction::triggered, this, &MainWindow::onGameListEntryActivated); } - - menu.addSeparator(); - - connect(menu.addAction(tr("Exclude From List")), &QAction::triggered, - [this, entry]() { getSettingsDialog()->getGameListSettingsWidget()->addExcludedPath(entry->path); }); - - connect(menu.addAction(tr("Reset Play Time")), &QAction::triggered, - [this, entry]() { clearGameListEntryPlayTime(entry); }); } connect(menu.addAction(tr("Add Search Directory...")), &QAction::triggered, @@ -2037,6 +2070,7 @@ void MainWindow::connectSignals() connect(m_ui.actionCPUDebugger, &QAction::triggered, this, &MainWindow::openCPUDebugger); SettingWidgetBinder::BindWidgetToBoolSetting(nullptr, m_ui.actionEnableGDBServer, "Debug", "EnableGDBServer", false); connect(m_ui.actionOpenDataDirectory, &QAction::triggered, this, &MainWindow::onToolsOpenDataDirectoryTriggered); + connect(m_ui.actionMergeDiscSets, &QAction::triggered, m_game_list_widget, &GameListWidget::setMergeDiscSets); connect(m_ui.actionGridViewShowTitles, &QAction::triggered, m_game_list_widget, &GameListWidget::setShowCoverTitles); connect(m_ui.actionGridViewZoomIn, &QAction::triggered, m_game_list_widget, [this]() { if (isShowingGameList()) diff --git a/src/duckstation-qt/mainwindow.h b/src/duckstation-qt/mainwindow.h index f81fad40e..cd5b43eda 100644 --- a/src/duckstation-qt/mainwindow.h +++ b/src/duckstation-qt/mainwindow.h @@ -270,6 +270,8 @@ private: /// Fills menu with the current cheat options. void populateCheatsMenu(QMenu* menu); + const GameList::Entry* resolveDiscSetEntry(const GameList::Entry* entry, + std::unique_lock& lock); std::shared_ptr getSystemBootParameters(std::string file); std::optional promptForResumeState(const std::string& save_state_path); void startFile(std::string path, std::optional save_path, std::optional fast_boot); diff --git a/src/duckstation-qt/mainwindow.ui b/src/duckstation-qt/mainwindow.ui index b50aa88d8..9c7f436ae 100644 --- a/src/duckstation-qt/mainwindow.ui +++ b/src/duckstation-qt/mainwindow.ui @@ -220,6 +220,7 @@ + @@ -863,6 +864,17 @@ Game &Grid + + + true + + + true + + + Merge Multi-Disc Games + + true diff --git a/src/duckstation-qt/qtutils.cpp b/src/duckstation-qt/qtutils.cpp index e1028365d..c908fd56f 100644 --- a/src/duckstation-qt/qtutils.cpp +++ b/src/duckstation-qt/qtutils.cpp @@ -304,6 +304,7 @@ QIcon GetIconForEntryType(GameList::EntryType type) case GameList::EntryType::Disc: return QIcon::fromTheme(QStringLiteral("disc-line")); case GameList::EntryType::Playlist: + case GameList::EntryType::DiscSet: return QIcon::fromTheme(QStringLiteral("play-list-2-line")); case GameList::EntryType::PSF: return QIcon::fromTheme(QStringLiteral("file-music-line")); diff --git a/src/duckstation-qt/selectdiscdialog.cpp b/src/duckstation-qt/selectdiscdialog.cpp new file mode 100644 index 000000000..61276303e --- /dev/null +++ b/src/duckstation-qt/selectdiscdialog.cpp @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#include "selectdiscdialog.h" +#include "qtutils.h" + +#include "core/game_list.h" + +#include "common/assert.h" +#include "common/path.h" + +#include + +SelectDiscDialog::SelectDiscDialog(const std::string& disc_set_name, QWidget* parent /* = nullptr */) : QDialog(parent) +{ + m_ui.setupUi(this); + populateList(disc_set_name); + updateStartEnabled(); + + connect(m_ui.select, &QPushButton::clicked, this, &SelectDiscDialog::onSelectClicked); + connect(m_ui.cancel, &QPushButton::clicked, this, &SelectDiscDialog::onCancelClicked); + connect(m_ui.discList, &QTreeWidget::itemActivated, this, &SelectDiscDialog::onListItemActivated); + connect(m_ui.discList, &QTreeWidget::itemSelectionChanged, this, &SelectDiscDialog::updateStartEnabled); +} + +SelectDiscDialog::~SelectDiscDialog() = default; + +void SelectDiscDialog::resizeEvent(QResizeEvent* ev) +{ + QDialog::resizeEvent(ev); + + QtUtils::ResizeColumnsForTreeView(m_ui.discList, {50, -1, 100}); +} + +void SelectDiscDialog::onListItemActivated(const QTreeWidgetItem* item) +{ + if (!item) + return; + + m_selected_path = item->data(0, Qt::UserRole).toString().toStdString(); + done(1); +} + +void SelectDiscDialog::updateStartEnabled() +{ + const QList items = m_ui.discList->selectedItems(); + m_ui.select->setEnabled(!items.isEmpty()); + if (!items.isEmpty()) + m_selected_path = items.first()->data(0, Qt::UserRole).toString().toStdString(); + else + m_selected_path = {}; +} + +void SelectDiscDialog::onSelectClicked() +{ + done(1); +} + +void SelectDiscDialog::onCancelClicked() +{ + done(0); +} + +void SelectDiscDialog::populateList(const std::string& disc_set_name) +{ + const auto lock = GameList::GetLock(); + const std::vector entries = GameList::GetDiscSetMembers(disc_set_name); + const GameList::Entry* last_played_entry = nullptr; + + for (const GameList::Entry* entry : entries) + { + QTreeWidgetItem* item = new QTreeWidgetItem(); + item->setData(0, Qt::UserRole, QString::fromStdString(entry->path)); + item->setIcon(0, QtUtils::GetIconForEntryType(GameList::EntryType::Disc)); + item->setText(0, QString::number(entry->disc_set_index + 1)); + item->setText(1, QtUtils::StringViewToQString(Path::GetFileName(entry->path))); + item->setText(2, QtUtils::StringViewToQString(GameList::FormatTimestamp(entry->last_played_time))); + m_ui.discList->addTopLevelItem(item); + + if (!last_played_entry || + (entry->last_played_time > 0 && entry->last_played_time > last_played_entry->last_played_time)) + { + last_played_entry = entry; + m_ui.discList->setCurrentItem(item); + } + } + + setWindowTitle(tr("Select Disc for %1").arg(QString::fromStdString(disc_set_name))); +} diff --git a/src/duckstation-qt/selectdiscdialog.h b/src/duckstation-qt/selectdiscdialog.h new file mode 100644 index 000000000..36dc88317 --- /dev/null +++ b/src/duckstation-qt/selectdiscdialog.h @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: 2019-2023 Connor McLaughlin +// SPDX-License-Identifier: (GPL-3.0 OR CC-BY-NC-ND-4.0) + +#pragma once +#include "common/timer.h" +#include "common/types.h" +#include "qtprogresscallback.h" +#include "ui_selectdiscdialog.h" +#include +#include +#include +#include + +class SelectDiscDialog final : public QDialog +{ + Q_OBJECT + +public: + SelectDiscDialog(const std::string& disc_set_name, QWidget* parent = nullptr); + ~SelectDiscDialog(); + + ALWAYS_INLINE const std::string& getSelectedDiscPath() { return m_selected_path; } + +protected: + void resizeEvent(QResizeEvent* ev); + +private Q_SLOTS: + void onListItemActivated(const QTreeWidgetItem* item); + void updateStartEnabled(); + void onSelectClicked(); + void onCancelClicked(); + +private: + void populateList(const std::string& disc_set_name); + + Ui::SelectDiscDialog m_ui; + std::string m_selected_path; +}; diff --git a/src/duckstation-qt/selectdiscdialog.ui b/src/duckstation-qt/selectdiscdialog.ui new file mode 100644 index 000000000..84e616d6e --- /dev/null +++ b/src/duckstation-qt/selectdiscdialog.ui @@ -0,0 +1,84 @@ + + + SelectDiscDialog + + + + 0 + 0 + 553 + 206 + + + + Dialog + + + + + + Select the disc that you want to boot. + + + + + + + false + + + + Disc + + + + + File Name + + + + + Last Played + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Select + + + true + + + + + + + Cancel + + + + + + + + + +