From 41eee6a6f3f0a014184b93f8686e9f343d8a3754 Mon Sep 17 00:00:00 2001 From: FearlessTobi Date: Fri, 26 Jul 2024 03:45:26 +0200 Subject: [PATCH] citra_qt: Add support for favorites, desktop shortcuts, and play time tracking Co-Authored-By: Reg Tiangha --- dist/license.md | 3 + dist/qt_themes/colorful/icons/48x48/star.png | Bin 0 -> 1108 bytes dist/qt_themes/colorful/style.qrc | 1 + dist/qt_themes/colorful_dark/style.qrc | 1 + dist/qt_themes/default/default.qrc | 1 + dist/qt_themes/default/icons/48x48/star.png | Bin 0 -> 1029 bytes .../qt_themes/qdarkstyle/icons/48x48/star.png | Bin 0 -> 1055 bytes dist/qt_themes/qdarkstyle/style.qrc | 1 + .../icons/48x48/star.png | Bin 0 -> 725 bytes .../qdarkstyle_midnight_blue/style.qrc | 1 + license.txt | 1 + src/citra_qt/CMakeLists.txt | 2 + src/citra_qt/configuration/config.cpp | 18 ++ .../configuration/configure_per_game.cpp | 12 - src/citra_qt/debugger/registers.h | 1 + src/citra_qt/game_list.cpp | 163 +++++++++- src/citra_qt/game_list.h | 19 +- src/citra_qt/game_list_p.h | 52 +++- src/citra_qt/game_list_worker.cpp | 7 +- src/citra_qt/game_list_worker.h | 5 +- src/citra_qt/loading_screen.cpp | 13 +- src/citra_qt/main.cpp | 286 +++++++++++++++++- src/citra_qt/main.h | 31 ++ src/citra_qt/play_time_manager.cpp | 158 ++++++++++ src/citra_qt/play_time_manager.h | 45 +++ src/citra_qt/uisettings.h | 4 + src/citra_qt/util/util.cpp | 124 ++++++++ src/citra_qt/util/util.h | 17 ++ src/common/common_paths.h | 1 + src/common/file_util.cpp | 4 +- src/common/file_util.h | 58 ++++ src/common/polyfill_thread.h | 39 +++ 32 files changed, 1021 insertions(+), 47 deletions(-) create mode 100644 dist/qt_themes/colorful/icons/48x48/star.png create mode 100644 dist/qt_themes/default/icons/48x48/star.png create mode 100644 dist/qt_themes/qdarkstyle/icons/48x48/star.png create mode 100644 dist/qt_themes/qdarkstyle_midnight_blue/icons/48x48/star.png create mode 100644 src/citra_qt/play_time_manager.cpp create mode 100644 src/citra_qt/play_time_manager.h diff --git a/dist/license.md b/dist/license.md index b3e8d05a48..207fc638e4 100644 --- a/dist/license.md +++ b/dist/license.md @@ -15,6 +15,7 @@ qt_themes/default/icons/48x48/folder.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/48x48/no_avatar.png | CC BY-ND 3.0 | https://icons8.com qt_themes/default/icons/48x48/plus.png | CC0 1.0 | Designed by BreadFish64 from the Citra team qt_themes/default/icons/48x48/sd_card.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/default/icons/48x48/star.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/16x16/connected.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/16x16/connected_notification.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/16x16/disconnected.png | CC BY-ND 3.0 | https://icons8.com @@ -26,6 +27,7 @@ qt_themes/qdarkstyle/icons/48x48/folder.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/48x48/no_avatar.png | CC BY-ND 3.0 | https://icons8.com qt_themes/qdarkstyle/icons/48x48/plus.png | CC0 1.0 | Designed by BreadFish64 from the Citra team qt_themes/qdarkstyle/icons/48x48/sd_card.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/qdarkstyle/icons/48x48/star.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/16x16/connected.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/16x16/connected_notification.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/16x16/disconnected.png | CC BY-ND 3.0 | https://icons8.com @@ -36,6 +38,7 @@ qt_themes/colorful/icons/48x48/chip.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/48x48/folder.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/48x48/plus.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful/icons/48x48/sd_card.png | CC BY-ND 3.0 | https://icons8.com +qt_themes/colorful/icons/48x48/star.png | CC BY-ND 3.0 | https://icons8.com qt_themes/colorful_dark/icons/16x16/connected.png | CC BY-ND 3.0 | https://icons8.com diff --git a/dist/qt_themes/colorful/icons/48x48/star.png b/dist/qt_themes/colorful/icons/48x48/star.png new file mode 100644 index 0000000000000000000000000000000000000000..19d55a0a8065cf4168752568bc67d4ebc3b8ec9f GIT binary patch literal 1108 zcmV-a1grarP)Px#1ZP1_K>z@;j|==^1poj532;bRa{vGff&c&uf&sqz;-vrp1L;XbK~!i%?U~PS zOi>ia&v{dXiliH?Ea>m5YV=J@_#cF(BsMmFOT@16TZoX5NLUE55E8}4qVW$P%nb1( zn$EO{AYwr)%!F#a<9pxSl&Kl>?zm68m``)xJLk-u`|dgC{+d@RQ>ILKNUVOj#2Qy= zH543u4+o~h}$Yl66AKfZVTN z1_u%lf3?qEJG%pMtyv?S3^;TM*WBQU6Utn$o&iZTwvu=cA^zF6h|BG%2U@d&{S0_= z5ZC;HacHlWtYI|+602FVnIO`WGQ!~RNa5ZVg^L5`$5`c)UviOSjm9gt7)J}8Ennd_IIhAYjbzV_$ z-R3U(L9hBZLcElHUSGJ0}_t za#K6u#u;t;A6y91c|}nN6uuK}$z90DX>AG*=5ypGw*=|DB9H?LhAYw#E3y`AjA6H(U1-B9gn0&roO)bscV1CJA0u^I4ve`2%!{>TpAy09=b-!WT>Oda@r{HvE!H;BCkj^gxa@IHsz20kcfvGmtMh4a|N#xYyvOee95 zjyfs!k|(V=Idicons/48x48/folder.png icons/48x48/plus.png icons/48x48/sd_card.png + icons/48x48/star.png icons/256x256/plus_folder.png diff --git a/dist/qt_themes/colorful_dark/style.qrc b/dist/qt_themes/colorful_dark/style.qrc index 9c531fe1ba..ec328117d9 100644 --- a/dist/qt_themes/colorful_dark/style.qrc +++ b/dist/qt_themes/colorful_dark/style.qrc @@ -11,6 +11,7 @@ ../qdarkstyle/icons/48x48/no_avatar.png ../colorful/icons/48x48/plus.png ../colorful/icons/48x48/sd_card.png + ../colorful/icons/48x48/star.png ../colorful/icons/256x256/plus_folder.png diff --git a/dist/qt_themes/default/default.qrc b/dist/qt_themes/default/default.qrc index 6da475316c..9c8e8f13e6 100644 --- a/dist/qt_themes/default/default.qrc +++ b/dist/qt_themes/default/default.qrc @@ -14,6 +14,7 @@ icons/48x48/plus.png icons/48x48/sd_card.png icons/256x256/citra.png + icons/48x48/star.png icons/256x256/plus_folder.png diff --git a/dist/qt_themes/default/icons/48x48/star.png b/dist/qt_themes/default/icons/48x48/star.png new file mode 100644 index 0000000000000000000000000000000000000000..c2b78f0c3e543913b18d4f95956109c7d2646ba8 GIT binary patch literal 1029 zcmV+g1p51lP)X)lQZo@-L=cu)ZHg3&%A{3AU{olP z1-a8`)uIOkK?H#db72rfkkLY-$D*Q0B3jg{0v8dMp}mlZe3E70Z83B4od0>=d**0_ z9}GA%|M|Xej&tVxXD-ZPc9DpYdjqf?NCHoQS21cCMHX-wW!zdW0rO_jS7;o=X+Bks z&Z4hSNC5qKPoST8mu+NJV>djmi?yxVq@d|Fxlh#Z0}n*oUc;J3@gDort_K=K+lN_m z7l8HHjZOghz(U}s=v!w>)Z`M9%jXd=hI$7c2gLppc!T{ZAF}iv7JYAL++6_HU^h4k z6j}Ng0l!7eY7@eymXKT$-N2BgK8#8y{KjO2Y>)N@Kn<`gB+o%nzwHup3)o?)Hv->6 z@(%#dP#Z%awgI2<{+b4rKR}5KZ;9^9l=l;`HOTxDU^IeA61d{Se>Eca2vF)bfin>W zo}%`CFBG7bLKKbDRzmp>tP=HJZ7K*qfakzHE#ieypcdE-YyuXC;I0MAeLUK50sCyH zfQvwG2qDwR)!I|S(|Nx^<+#6SuYrmf%}P{gP3dD2SQ#|h?r?fl42qN*&`iR&v&woIo+~%R@L- zt`Y7dl&@p`xF!g9;7<$ZJj6y&w`}R6QIB=wJR9Av6A9oXY8y>Un14sh237!t2005+lkvK%ox>dF@W0|O*YW8UHg=Uy00000NkvXXu0mjfk8a2c literal 0 HcmV?d00001 diff --git a/dist/qt_themes/qdarkstyle/icons/48x48/star.png b/dist/qt_themes/qdarkstyle/icons/48x48/star.png new file mode 100644 index 0000000000000000000000000000000000000000..546779e2a810e73169f65a79850aa07dffd70267 GIT binary patch literal 1055 zcmV+)1mOFLP)6O$xyr@ zPaa%KDJkUv50sKZ9we6sL?n6O3FSd@YsM`}W;l~^srh+0Yd71s*WUY_v)6V?^RNA| z*80}}|Nqumd+oK>5$Qv9Ve%pdW{JpR5kW-m7-JsgrR5bs4RC@ejsyLw?JpI;c8B=@ z+p6s^5r6^O5)xPZUG@p1(Wj>GIk{SuuZhTYRkzBsrau5L)o8Z>YgOIr zYWs8on5m3*19iX<;Ja#@;Y-)&6w=e@rZJ{qjOh}QTPd;C2k=yl@`g~`M%DJLnqDIS zrYVEJfDxhg;lNK7Gu4N-uPLOb#BF0tM^G@vbc)EGls*-qzpD}$EFw)JGOk3P^{RZy z5pz*QmW0Z!z^4-V+ePG_F{V2;5?BPhqjLNPMmcy#*|tjY7Fd{Oehly}g9yOM1pZSQ zxxWIVlM>jQQQ$66m%u+1xR+65Pnc7`1Cv$xxrp42&A1m|MC87RTs6iJ7i5h21~iMv zauJy)A|p%SZWfV-1dqJ{4ytT>fFnR{2|`{0)LQKx<>^3Drv}_F+GAkKKQuqZYHIuf zCZ%;=X9G35fX)BF{AQwC@WA}qQc30jFB~KQ7YTi3JsKYhoQu(H1Ll`YSO=VqA#4X0 zcr-tU=vF+yg-inmYyt`n;@`j)59YVve(wsvrmXydNCWWDA<_dZNoR=VT}E`3!D=<6 zGn6M*3mkR`>b-tBoVOhVa+o}qZK6lSHyOC}o~yJ~CWK11(*sobLj@cG-luS>^0E-oQ z-1W$FV0Hq}Y#ZZ(=4|$vKt1pyMmWG}O85v-JZGFv;pzrTIq;X2#QqquuRv=Be66;x z3;Vp9w}J-PMR6H~;@0;vFd+ly1mFt2Tl7v~Cwcyl2J{Ce0>eCV4ztNP?`r$dhd%tr Z_yd6az_icons/48x48/no_avatar.png icons/48x48/plus.png icons/48x48/sd_card.png + icons/48x48/star.png icons/256x256/plus_folder.png diff --git a/dist/qt_themes/qdarkstyle_midnight_blue/icons/48x48/star.png b/dist/qt_themes/qdarkstyle_midnight_blue/icons/48x48/star.png new file mode 100644 index 0000000000000000000000000000000000000000..90d423a1d4c1e05ccec0a01fa34abca9fe99676d GIT binary patch literal 725 zcmV;`0xJE9P)_t6%-62 zVr82q2v*vNjbd-7sEw6YRw`;*Au;C>A_fF=QIp5w_Hek|?d?ovvLg4u7RTQAeeXLn zyE7jvb?MS28bBE+=dHsFpda|2T>Y`?tcsOb(ukyDa$SyCCp)c4X8n|W{fGxmW%IG(!0i{rNL(aezNm<_Y1^sIq8_-96v&zaeuqH(9)t-X;C2fn8;Z;z&BvnNxWrZ0C<<9_zG~)OT1-b1Mn&b@#DbGWr?>;6o5x7DE>9D zDTsK>L=SLp1;jrF)&>>dWaLIf@ecv7V=iHZ#CAz!8~vaYn?A6 zZwYt+ELi!6LTGL-v=Mkiy|LL7A^&0Oo!icons/48x48/no_avatar.png icons/48x48/plus.png icons/48x48/sd_card.png + icons/48x48/star.png icons/256x256/plus_folder.png diff --git a/license.txt b/license.txt index 09f9ad2d5c..f94c73f1af 100644 --- a/license.txt +++ b/license.txt @@ -356,3 +356,4 @@ folder.png | CC BY-ND 3.0 | https://icons8.com plus.png (Default, Dark) | CC0 1.0 | Designed by BreadFish64 from the Citra team plus.png (Colorful, Colorful Dark) | CC BY-ND 3.0 | https://icons8.com sd_card.png | CC BY-ND 3.0 | https://icons8.com +star.png | CC BY-ND 3.0 | https://icons8.com diff --git a/src/citra_qt/CMakeLists.txt b/src/citra_qt/CMakeLists.txt index a6c389b555..2ba3488b1b 100644 --- a/src/citra_qt/CMakeLists.txt +++ b/src/citra_qt/CMakeLists.txt @@ -170,6 +170,8 @@ add_executable(citra-qt multiplayer/state.cpp multiplayer/state.h multiplayer/validation.h + play_time_manager.cpp + play_time_manager.h precompiled_headers.h uisettings.cpp uisettings.h diff --git a/src/citra_qt/configuration/config.cpp b/src/citra_qt/configuration/config.cpp index db142d6277..5882061f09 100644 --- a/src/citra_qt/configuration/config.cpp +++ b/src/citra_qt/configuration/config.cpp @@ -822,6 +822,15 @@ void Config::ReadUIGameListValues() { ReadBasicSetting(UISettings::values.show_region_column); ReadBasicSetting(UISettings::values.show_type_column); ReadBasicSetting(UISettings::values.show_size_column); + ReadBasicSetting(UISettings::values.show_play_time_column); + + const int favorites_size = qt_config->beginReadArray(QStringLiteral("favorites")); + for (int i = 0; i < favorites_size; i++) { + qt_config->setArrayIndex(i); + UISettings::values.favorited_ids.append( + ReadSetting(QStringLiteral("program_id")).toULongLong()); + } + qt_config->endArray(); qt_config->endGroup(); } @@ -1318,6 +1327,15 @@ void Config::SaveUIGameListValues() { WriteBasicSetting(UISettings::values.show_region_column); WriteBasicSetting(UISettings::values.show_type_column); WriteBasicSetting(UISettings::values.show_size_column); + WriteBasicSetting(UISettings::values.show_play_time_column); + + qt_config->beginWriteArray(QStringLiteral("favorites")); + for (int i = 0; i < UISettings::values.favorited_ids.size(); i++) { + qt_config->setArrayIndex(i); + WriteSetting(QStringLiteral("program_id"), + QVariant::fromValue(UISettings::values.favorited_ids[i])); + } + qt_config->endArray(); qt_config->endGroup(); } diff --git a/src/citra_qt/configuration/configure_per_game.cpp b/src/citra_qt/configuration/configure_per_game.cpp index 1dd5f713fa..3defae9458 100644 --- a/src/citra_qt/configuration/configure_per_game.cpp +++ b/src/citra_qt/configuration/configure_per_game.cpp @@ -141,18 +141,6 @@ void ConfigurePerGame::HandleApplyButtonClicked() { } } -static QPixmap GetQPixmapFromSMDH(std::vector& smdh_data) { - Loader::SMDH smdh; - std::memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); - - bool large = true; - std::vector icon_data = smdh.GetIcon(large); - const uchar* data = reinterpret_cast(icon_data.data()); - int size = large ? 48 : 24; - QImage icon(data, size, size, QImage::Format::Format_RGB16); - return QPixmap::fromImage(icon); -} - void ConfigurePerGame::LoadConfiguration() { if (filename.empty()) { return; diff --git a/src/citra_qt/debugger/registers.h b/src/citra_qt/debugger/registers.h index 19ed374eaa..0e38a87005 100644 --- a/src/citra_qt/debugger/registers.h +++ b/src/citra_qt/debugger/registers.h @@ -6,6 +6,7 @@ #include #include +#include "common/common_types.h" class QTreeWidget; class QTreeWidgetItem; diff --git a/src/citra_qt/game_list.cpp b/src/citra_qt/game_list.cpp index 43fcbcee69..cbe66ec5d9 100644 --- a/src/citra_qt/game_list.cpp +++ b/src/citra_qt/game_list.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -105,6 +106,10 @@ void GameListSearchField::setFilterResult(int visible, int total) { QStringLiteral("%1 %2 %3 %4").arg(visible).arg(result_of_text).arg(total).arg(result_text)); } +bool GameListSearchField::IsEmpty() const { + return edit_filter->text().isEmpty(); +} + QString GameList::GetLastFilterResultItem() const { QString file_path; const int folderCount = item_model->rowCount(); @@ -206,7 +211,9 @@ void GameList::OnTextChanged(const QString& new_text) { // If the searchfield is empty every item is visible // Otherwise the filter gets applied if (edit_filter_text.isEmpty()) { - for (int i = 0; i < folder_count; ++i) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + UISettings::values.favorited_ids.size() == 0); + for (int i = 1; i < folder_count; ++i) { folder = item_model->item(i, 0); const QModelIndex folder_index = folder->index(); const int children_count = folder->rowCount(); @@ -217,8 +224,9 @@ void GameList::OnTextChanged(const QString& new_text) { } search_field->setFilterResult(children_total, children_total); } else { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); int result_count = 0; - for (int i = 0; i < folder_count; ++i) { + for (int i = 1; i < folder_count; ++i) { folder = item_model->item(i, 0); const QModelIndex folder_index = folder->index(); const int children_count = folder->rowCount(); @@ -281,6 +289,13 @@ void GameList::OnUpdateThemedIcons() { child->setData(QIcon::fromTheme(QStringLiteral("plus")).pixmap(icon_size), Qt::DecorationRole); break; + case GameListItemType::Favorites: + child->setData( + QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + break; default: break; } @@ -291,7 +306,8 @@ void GameList::OnFilterCloseClicked() { main_window->filterBarSetChecked(false); } -GameList::GameList(GMainWindow* parent) : QWidget{parent} { +GameList::GameList(PlayTime::PlayTimeManager& play_time_manager_, GMainWindow* parent) + : QWidget{parent}, play_time_manager{play_time_manager_} { watcher = new QFileSystemWatcher(this); connect(watcher, &QFileSystemWatcher::directoryChanged, this, &GameList::RefreshGameDirectory, Qt::UniqueConnection); @@ -422,6 +438,15 @@ void GameList::DonePopulating(const QStringList& watch_list) { item_model->invisibleRootItem()->appendRow(new GameListAddDir()); + // Add favorites row + item_model->invisibleRootItem()->insertRow(0, new GameListFavorites()); + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + UISettings::values.favorited_ids.size() == 0); + tree_view->expand(item_model->invisibleRootItem()->child(0)->index()); + for (const auto id : UISettings::values.favorited_ids) { + AddFavorite(id); + } + // Clear out the old directories to watch for changes and add the new ones auto watch_dirs = watcher->directories(); if (!watch_dirs.isEmpty()) { @@ -440,7 +465,7 @@ void GameList::DonePopulating(const QStringList& watch_list) { tree_view->setEnabled(true); const int folderCount = tree_view->model()->rowCount(); int children_total = 0; - for (int i = 0; i < folderCount; ++i) { + for (int i = 1; i < folderCount; ++i) { children_total += item_model->item(i, 0)->rowCount(); } search_field->setFilterResult(children_total, children_total); @@ -450,6 +475,12 @@ void GameList::DonePopulating(const QStringList& watch_list) { item_model->sort(tree_view->header()->sortIndicatorSection(), tree_view->header()->sortIndicatorOrder()); + // resize all columns except for Name to fit their contents + for (int i = 1; i < COLUMN_COUNT; i++) { + tree_view->resizeColumnToContents(i); + } + tree_view->header()->setStretchLastSection(true); + emit PopulatingCompleted(); } @@ -477,6 +508,9 @@ void GameList::PopupContextMenu(const QPoint& menu_location) { case GameListItemType::SystemDir: AddPermDirPopup(context_menu, selected); break; + case GameListItemType::Favorites: + AddFavoritesPopup(context_menu); + break; default: break; } @@ -495,7 +529,8 @@ void GameList::PopupHeaderContextMenu(const QPoint& menu_location) { {tr("Compatibility"), &UISettings::values.show_compat_column}, {tr("Region"), &UISettings::values.show_region_column}, {tr("File type"), &UISettings::values.show_type_column}, - {tr("Size"), &UISettings::values.show_size_column}}; + {tr("Size"), &UISettings::values.show_size_column}, + {tr("Play time"), &UISettings::values.show_play_time_column}}; QActionGroup* column_group = new QActionGroup(this); column_group->setExclusive(false); @@ -517,6 +552,7 @@ void GameList::UpdateColumnVisibility() { tree_view->setColumnHidden(COLUMN_REGION, !UISettings::values.show_region_column); tree_view->setColumnHidden(COLUMN_FILE_TYPE, !UISettings::values.show_type_column); tree_view->setColumnHidden(COLUMN_SIZE, !UISettings::values.show_size_column); + tree_view->setColumnHidden(COLUMN_PLAY_TIME, !UISettings::values.show_play_time_column); } #ifdef ENABLE_OPENGL @@ -533,15 +569,20 @@ void ForEachOpenGLCacheFile(u64 program_id, auto func) { void GameList::AddGamePopup(QMenu& context_menu, const QString& path, const QString& name, u64 program_id, u64 extdata_id, Service::FS::MediaType media_type) { - QAction* open_save_location = context_menu.addAction(tr("Open Save Data Location")); - QAction* open_extdata_location = context_menu.addAction(tr("Open Extra Data Location")); - QAction* open_application_location = context_menu.addAction(tr("Open Application Location")); - QAction* open_update_location = context_menu.addAction(tr("Open Update Data Location")); - QAction* open_dlc_location = context_menu.addAction(tr("Open DLC Data Location")); - QAction* open_texture_dump_location = context_menu.addAction(tr("Open Texture Dump Location")); - QAction* open_texture_load_location = - context_menu.addAction(tr("Open Custom Texture Location")); - QAction* open_mods_location = context_menu.addAction(tr("Open Mods Location")); + QAction* favorite = context_menu.addAction(tr("Favorite")); + context_menu.addSeparator(); + QMenu* open_menu = context_menu.addMenu(tr("Open")); + QAction* open_application_location = open_menu->addAction(tr("Application Location")); + open_menu->addSeparator(); + QAction* open_save_location = open_menu->addAction(tr("Save Data Location")); + QAction* open_extdata_location = open_menu->addAction(tr("Extra Data Location")); + QAction* open_update_location = open_menu->addAction(tr("Update Data Location")); + QAction* open_dlc_location = open_menu->addAction(tr("DLC Data Location")); + open_menu->addSeparator(); + QAction* open_texture_dump_location = open_menu->addAction(tr("Texture Dump Location")); + QAction* open_texture_load_location = open_menu->addAction(tr("Custom Texture Location")); + QAction* open_mods_location = open_menu->addAction(tr("Mods Location")); + QAction* dump_romfs = context_menu.addAction(tr("Dump RomFS")); QMenu* shader_menu = context_menu.addMenu(tr("Disk Shader Cache")); @@ -559,7 +600,16 @@ void GameList::AddGamePopup(QMenu& context_menu, const QString& path, const QStr QAction* uninstall_update = uninstall_menu->addAction(tr("Update")); QAction* uninstall_dlc = uninstall_menu->addAction(tr("DLC")); + QAction* remove_play_time_data = context_menu.addAction(tr("Remove Play Time Data")); QAction* navigate_to_gamedb_entry = context_menu.addAction(tr("Navigate to GameDB entry")); + +#if !defined(__APPLE__) + QMenu* shortcut_menu = context_menu.addMenu(tr("Create Shortcut")); + QAction* create_desktop_shortcut = shortcut_menu->addAction(tr("Add to Desktop")); + QAction* create_applications_menu_shortcut = + shortcut_menu->addAction(tr("Add to Applications Menu")); +#endif + context_menu.addSeparator(); QAction* properties = context_menu.addAction(tr("Properties")); @@ -574,6 +624,10 @@ void GameList::AddGamePopup(QMenu& context_menu, const QString& path, const QStr program_id, [&opengl_cache_exists](QFile& file) { opengl_cache_exists |= file.exists(); }); #endif + favorite->setVisible(program_id != 0); + favorite->setCheckable(true); + favorite->setChecked(UISettings::values.favorited_ids.contains(program_id)); + std::string sdmc_dir = FileUtil::GetUserPath(FileUtil::UserPath::SDMCDir); open_save_location->setEnabled( is_application && FileUtil::Exists(FileSys::ArchiveSource_SDSaveData::GetSaveDataPathFor( @@ -621,6 +675,7 @@ void GameList::AddGamePopup(QMenu& context_menu, const QString& path, const QStr auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); navigate_to_gamedb_entry->setVisible(it != compatibility_list.end()); + connect(favorite, &QAction::triggered, [this, program_id]() { ToggleFavorite(program_id); }); connect(open_save_location, &QAction::triggered, this, [this, program_id] { emit OpenFolderRequested(program_id, GameListOpenTarget::SAVE_DATA); }); @@ -667,6 +722,8 @@ void GameList::AddGamePopup(QMenu& context_menu, const QString& path, const QStr }); connect(dump_romfs, &QAction::triggered, this, [this, path, program_id] { emit DumpRomFSRequested(path, program_id); }); + connect(remove_play_time_data, &QAction::triggered, + [this, program_id]() { emit RemovePlayTimeRequested(program_id); }); connect(navigate_to_gamedb_entry, &QAction::triggered, this, [this, program_id]() { emit NavigateToGamedbEntryRequested(program_id, compatibility_list); }); @@ -738,6 +795,15 @@ void GameList::AddGamePopup(QMenu& context_menu, const QString& path, const QStr main_window->UninstallTitles(titles); } }); + // TODO: Implement shortcut creation for macOS +#if !defined(__APPLE__) + connect(create_desktop_shortcut, &QAction::triggered, [this, program_id, path]() { + emit CreateShortcut(program_id, path.toStdString(), GameListShortcutTarget::Desktop); + }); + connect(create_applications_menu_shortcut, &QAction::triggered, [this, program_id, path]() { + emit CreateShortcut(program_id, path.toStdString(), GameListShortcutTarget::Applications); + }); +#endif } void GameList::AddCustomDirPopup(QMenu& context_menu, QModelIndex selected) { @@ -771,7 +837,7 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) { const int row = selected.row(); - move_up->setEnabled(row > 0); + move_up->setEnabled(row > 1); move_down->setEnabled(row < item_model->rowCount() - 2); connect(move_up, &QAction::triggered, this, [this, selected, row, game_dir_index] { @@ -809,6 +875,18 @@ void GameList::AddPermDirPopup(QMenu& context_menu, QModelIndex selected) { }); } +void GameList::AddFavoritesPopup(QMenu& context_menu) { + QAction* clear = context_menu.addAction(tr("Clear")); + + connect(clear, &QAction::triggered, [this] { + for (const auto id : UISettings::values.favorited_ids) { + RemoveFavorite(id); + } + UISettings::values.favorited_ids.clear(); + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); + }); +} + void GameList::LoadCompatibilityList() { QFile compat_list{QStringLiteral(":compatibility_list/compatibility_list.json")}; @@ -867,6 +945,7 @@ void GameList::RetranslateUI() { item_model->setHeaderData(COLUMN_REGION, Qt::Horizontal, tr("Region")); item_model->setHeaderData(COLUMN_FILE_TYPE, Qt::Horizontal, tr("File type")); item_model->setHeaderData(COLUMN_SIZE, Qt::Horizontal, tr("Size")); + item_model->setHeaderData(COLUMN_PLAY_TIME, Qt::Horizontal, tr("Play time")); } void GameListSearchField::changeEvent(QEvent* event) { @@ -898,7 +977,7 @@ void GameList::PopulateAsync(QVector& game_dirs) { emit ShouldCancelWorker(); - GameListWorker* worker = new GameListWorker(game_dirs, compatibility_list); + GameListWorker* worker = new GameListWorker(game_dirs, compatibility_list, play_time_manager); connect(worker, &GameListWorker::EntryReady, this, &GameList::AddEntry, Qt::QueuedConnection); connect(worker, &GameListWorker::DirEntryReady, this, &GameList::AddDirEntry, @@ -941,6 +1020,58 @@ void GameList::RefreshGameDirectory() { } } +void GameList::ToggleFavorite(u64 program_id) { + if (!UISettings::values.favorited_ids.contains(program_id)) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), + !search_field->IsEmpty()); + UISettings::values.favorited_ids.append(program_id); + AddFavorite(program_id); + item_model->sort(tree_view->header()->sortIndicatorSection(), + tree_view->header()->sortIndicatorOrder()); + } else { + UISettings::values.favorited_ids.removeOne(program_id); + RemoveFavorite(program_id); + if (UISettings::values.favorited_ids.size() == 0) { + tree_view->setRowHidden(0, item_model->invisibleRootItem()->index(), true); + } + } +} + +void GameList::AddFavorite(u64 program_id) { + auto* favorites_row = item_model->item(0); + + for (int i = 1; i < item_model->rowCount() - 1; i++) { + const auto* folder = item_model->item(i); + for (int j = 0; j < folder->rowCount(); j++) { + if (folder->child(j)->data(GameListItemPath::ProgramIdRole).toULongLong() == + program_id) { + QList list; + for (int k = 0; k < COLUMN_COUNT; k++) { + list.append(folder->child(j, k)->clone()); + } + list[0]->setData(folder->child(j)->data(GameListItem::SortRole), + GameListItem::SortRole); + list[0]->setText(folder->child(j)->data(Qt::DisplayRole).toString()); + + favorites_row->appendRow(list); + return; + } + } + } +} + +void GameList::RemoveFavorite(u64 program_id) { + auto* favorites_row = item_model->item(0); + + for (int i = 0; i < favorites_row->rowCount(); i++) { + const auto* game = favorites_row->child(i); + if (game->data(GameListItemPath::ProgramIdRole).toULongLong() == program_id) { + favorites_row->removeRow(i); + return; + } + } +} + QString GameList::FindGameByProgramID(u64 program_id, int role) { return FindGameByProgramID(item_model->invisibleRootItem(), program_id, role); } diff --git a/src/citra_qt/game_list.h b/src/citra_qt/game_list.h index 9b9ccb05aa..61670c2013 100644 --- a/src/citra_qt/game_list.h +++ b/src/citra_qt/game_list.h @@ -9,6 +9,7 @@ #include #include #include "citra_qt/compatibility_list.h" +#include "citra_qt/play_time_manager.h" #include "common/common_types.h" #include "uisettings.h" @@ -45,6 +46,11 @@ enum class GameListOpenTarget { SHADER_CACHE = 8 }; +enum class GameListShortcutTarget { + Desktop, + Applications, +}; + class GameList : public QWidget { Q_OBJECT @@ -55,10 +61,11 @@ class GameList : public QWidget { COLUMN_REGION, COLUMN_FILE_TYPE, COLUMN_SIZE, + COLUMN_PLAY_TIME, COLUMN_COUNT, // Number of columns }; - explicit GameList(GMainWindow* parent = nullptr); + explicit GameList(PlayTime::PlayTimeManager& play_time_manager_, GMainWindow* parent = nullptr); ~GameList() override; QString GetLastFilterResultItem() const; @@ -80,12 +87,19 @@ class GameList : public QWidget { void RefreshGameDirectory(); + void ToggleFavorite(u64 program_id); + void AddFavorite(u64 program_id); + void RemoveFavorite(u64 program_id); + static const QStringList supported_file_extensions; signals: void GameChosen(const QString& game_path); void ShouldCancelWorker(); void OpenFolderRequested(u64 program_id, GameListOpenTarget target); + void CreateShortcut(u64 program_id, const std::string& game_path, + GameListShortcutTarget target); + void RemovePlayTimeRequested(u64 program_id); void NavigateToGamedbEntryRequested(u64 program_id, const CompatibilityList& compatibility_list); void OpenPerGameGeneralRequested(const QString file); @@ -113,6 +127,7 @@ private slots: u64 extdata_id, Service::FS::MediaType media_type); void AddCustomDirPopup(QMenu& context_menu, QModelIndex selected); void AddPermDirPopup(QMenu& context_menu, QModelIndex selected); + void AddFavoritesPopup(QMenu& context_menu); void UpdateColumnVisibility(); QString FindGameByProgramID(QStandardItem* current_item, u64 program_id, int role); @@ -130,6 +145,8 @@ private slots: CompatibilityList compatibility_list; friend class GameListSearchField; + + const PlayTime::PlayTimeManager& play_time_manager; }; Q_DECLARE_METATYPE(GameListOpenTarget); diff --git a/src/citra_qt/game_list_p.h b/src/citra_qt/game_list_p.h index 338d8cee79..c41e43df47 100644 --- a/src/citra_qt/game_list_p.h +++ b/src/citra_qt/game_list_p.h @@ -18,6 +18,7 @@ #include #include #include +#include "citra_qt/play_time_manager.h" #include "citra_qt/uisettings.h" #include "citra_qt/util/util.h" #include "common/file_util.h" @@ -34,7 +35,8 @@ enum class GameListItemType { CustomDir = QStandardItem::UserType + 2, InstalledDir = QStandardItem::UserType + 3, SystemDir = QStandardItem::UserType + 4, - AddDir = QStandardItem::UserType + 5 + AddDir = QStandardItem::UserType + 5, + Favorites = QStandardItem::UserType + 6, }; Q_DECLARE_METATYPE(GameListItemType); @@ -361,6 +363,31 @@ class GameListItemSize : public GameListItem { } }; +/** + * GameListItem for Play Time values. + * This object stores the play time of a game in seconds, and its readable + * representation in minutes/hours + */ +class GameListItemPlayTime : public GameListItem { +public: + static constexpr int PlayTimeRole = SortRole; + + GameListItemPlayTime() = default; + explicit GameListItemPlayTime(const qulonglong time_seconds) { + setData(time_seconds, PlayTimeRole); + } + + void setData(const QVariant& value, int role) override { + qulonglong time_seconds = value.toULongLong(); + GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole); + GameListItem::setData(value, PlayTimeRole); + } + + bool operator<(const QStandardItem& other) const override { + return data(PlayTimeRole).toULongLong() < other.data(PlayTimeRole).toULongLong(); + } +}; + class GameListDir : public GameListItem { public: static constexpr int GameDirRole = Qt::UserRole + 2; @@ -430,6 +457,28 @@ class GameListAddDir : public GameListItem { } }; +class GameListFavorites : public GameListItem { +public: + explicit GameListFavorites() { + setData(type(), TypeRole); + + const int icon_size = IconSizes.at(UISettings::values.game_list_icon_size.GetValue()); + setData(QIcon::fromTheme(QStringLiteral("star")) + .pixmap(icon_size) + .scaled(icon_size, icon_size, Qt::IgnoreAspectRatio, Qt::SmoothTransformation), + Qt::DecorationRole); + setData(QObject::tr("Favorites"), Qt::DisplayRole); + } + + int type() const override { + return static_cast(GameListItemType::Favorites); + } + + bool operator<(const QStandardItem& other) const override { + return false; + } +}; + class GameList; class QHBoxLayout; class QTreeView; @@ -444,6 +493,7 @@ class GameListSearchField : public QWidget { explicit GameListSearchField(GameList* parent = nullptr); void setFilterResult(int visible, int total); + bool IsEmpty() const; void clear(); void setFocus(); diff --git a/src/citra_qt/game_list_worker.cpp b/src/citra_qt/game_list_worker.cpp index e4471b6ae7..6755d121d0 100644 --- a/src/citra_qt/game_list_worker.cpp +++ b/src/citra_qt/game_list_worker.cpp @@ -27,8 +27,10 @@ bool HasSupportedFileExtension(const std::string& file_name) { } // Anonymous namespace GameListWorker::GameListWorker(QVector& game_dirs, - const CompatibilityList& compatibility_list) - : game_dirs(game_dirs), compatibility_list(compatibility_list) {} + const CompatibilityList& compatibility_list, + const PlayTime::PlayTimeManager& play_time_manager_) + : game_dirs(game_dirs), + compatibility_list(compatibility_list), play_time_manager{play_time_manager_} {} GameListWorker::~GameListWorker() = default; @@ -112,6 +114,7 @@ void GameListWorker::AddFstEntriesToGameList(const std::string& dir_path, unsign new GameListItem( QString::fromStdString(Loader::GetFileTypeString(loader->GetFileType()))), new GameListItemSize(FileUtil::GetSize(physical_name)), + new GameListItemPlayTime(play_time_manager.GetPlayTime(program_id)), }, parent_dir); diff --git a/src/citra_qt/game_list_worker.h b/src/citra_qt/game_list_worker.h index 60012e62a6..09da74f4fd 100644 --- a/src/citra_qt/game_list_worker.h +++ b/src/citra_qt/game_list_worker.h @@ -13,6 +13,7 @@ #include #include #include "citra_qt/compatibility_list.h" +#include "citra_qt/play_time_manager.h" #include "common/common_types.h" namespace Service::FS { @@ -30,7 +31,8 @@ class GameListWorker : public QObject, public QRunnable { public: GameListWorker(QVector& game_dirs, - const CompatibilityList& compatibility_list); + const CompatibilityList& compatibility_list, + const PlayTime::PlayTimeManager& play_time_manager_); ~GameListWorker() override; /// Starts the processing of directory tree information. @@ -60,6 +62,7 @@ class GameListWorker : public QObject, public QRunnable { QVector& game_dirs; const CompatibilityList& compatibility_list; + const PlayTime::PlayTimeManager& play_time_manager; QStringList watch_list; std::atomic_bool stop_processing; diff --git a/src/citra_qt/loading_screen.cpp b/src/citra_qt/loading_screen.cpp index 23d15b9d43..2135b7db1b 100644 --- a/src/citra_qt/loading_screen.cpp +++ b/src/citra_qt/loading_screen.cpp @@ -15,6 +15,7 @@ #include #include #include "citra_qt/loading_screen.h" +#include "citra_qt/util/util.h" #include "common/logging/log.h" #include "core/loader/loader.h" #include "core/loader/smdh.h" @@ -79,18 +80,6 @@ const static std::unordered_map progr {VideoCore::LoadCallbackStage::Complete, PROGRESSBAR_STYLE_COMPLETE}, }; -static QPixmap GetQPixmapFromSMDH(std::vector& smdh_data) { - Loader::SMDH smdh; - std::memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); - - bool large = true; - std::vector icon_data = smdh.GetIcon(large); - const uchar* data = reinterpret_cast(icon_data.data()); - int size = large ? 48 : 24; - QImage icon(data, size, size, QImage::Format::Format_RGB16); - return QPixmap::fromImage(icon); -} - LoadingScreen::LoadingScreen(QWidget* parent) : QWidget(parent), ui(std::make_unique()), previous_stage(VideoCore::LoadCallbackStage::Complete) { diff --git a/src/citra_qt/main.cpp b/src/citra_qt/main.cpp index 0788be479d..0f4a5b34ac 100644 --- a/src/citra_qt/main.cpp +++ b/src/citra_qt/main.cpp @@ -15,12 +15,14 @@ #include #include #include +#include #ifdef __APPLE__ #include // for chdir #endif #ifdef _WIN32 #define _WIN32_WINNT 0x0A00 #include +#include #include #ifdef _MSC_VER #pragma comment(lib, "dwmapi.lib") @@ -63,12 +65,14 @@ #include "citra_qt/movie/movie_play_dialog.h" #include "citra_qt/movie/movie_record_dialog.h" #include "citra_qt/multiplayer/state.h" +#include "citra_qt/play_time_manager.h" #include "citra_qt/qt_image_interface.h" #include "citra_qt/uisettings.h" #include "citra_qt/updater/updater.h" #include "citra_qt/util/clickable_label.h" #include "citra_qt/util/graphics_device_info.h" #include "citra_qt/util/mica.h" +#include "citra_qt/util/util.h" #include "common/arch.h" #include "common/common_paths.h" #include "common/detached_tasks.h" @@ -84,6 +88,7 @@ #include "common/x64/cpu_detect.h" #endif #include "common/settings.h" +#include "common/string_util.h" #include "core/core.h" #include "core/dumping/backend.h" #include "core/file_sys/archive_extsavedata.h" @@ -197,6 +202,8 @@ GMainWindow::GMainWindow(Core::System& system_) SetDiscordEnabled(UISettings::values.enable_discord_presence.GetValue()); discord_rpc->Update(); + play_time_manager = std::make_unique(); + Network::Init(); movie.SetPlaybackCompletionCallback([this] { @@ -348,7 +355,7 @@ void GMainWindow::InitializeWidgets() { secondary_window->hide(); secondary_window->setParent(nullptr); - game_list = new GameList(this); + game_list = new GameList(*play_time_manager, this); ui->horizontalLayout->addWidget(game_list); game_list_placeholder = new GameListPlaceholder(this); @@ -834,8 +841,11 @@ void GMainWindow::ConnectWidgetEvents() { connect(game_list, &GameList::GameChosen, this, &GMainWindow::OnGameListLoadFile); connect(game_list, &GameList::OpenDirectory, this, &GMainWindow::OnGameListOpenDirectory); connect(game_list, &GameList::OpenFolderRequested, this, &GMainWindow::OnGameListOpenFolder); + connect(game_list, &GameList::RemovePlayTimeRequested, this, + &GMainWindow::OnGameListRemovePlayTimeData); connect(game_list, &GameList::NavigateToGamedbEntryRequested, this, &GMainWindow::OnGameListNavigateToGamedbEntry); + connect(game_list, &GameList::CreateShortcut, this, &GMainWindow::OnGameListCreateShortcut); connect(game_list, &GameList::DumpRomFSRequested, this, &GMainWindow::OnGameListDumpRomFS); connect(game_list, &GameList::AddDirectory, this, &GMainWindow::OnGameListAddDirectory); connect(game_list_placeholder, &GameListPlaceholder::AddDirectory, this, @@ -1241,7 +1251,11 @@ bool GMainWindow::LoadROM(const QString& filename) { game_title = QString::fromStdString(title); UpdateWindowTitle(); + u64 title_id; + system.GetAppLoader().ReadProgramId(title_id); + game_path = filename; + game_title_id = title_id; return true; } @@ -1487,6 +1501,7 @@ void GMainWindow::ShutdownGame() { UpdateWindowTitle(); game_path.clear(); + game_title_id = 0; // Update the GUI UpdateMenuState(); @@ -1674,6 +1689,17 @@ void GMainWindow::OnGameListOpenFolder(u64 data_id, GameListOpenTarget target) { QDesktopServices::openUrl(QUrl::fromLocalFile(qpath)); } +void GMainWindow::OnGameListRemovePlayTimeData(u64 program_id) { + if (QMessageBox::question(this, tr("Remove Play Time Data"), tr("Reset play time?"), + QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) != QMessageBox::Yes) { + return; + } + + play_time_manager->ResetProgramPlayTime(program_id); + game_list->PopulateAsync(UISettings::values.game_dirs); +} + void GMainWindow::OnGameListNavigateToGamedbEntry(u64 program_id, const CompatibilityList& compatibility_list) { auto it = FindMatchingCompatibilityEntry(compatibility_list, program_id); @@ -1685,6 +1711,255 @@ void GMainWindow::OnGameListNavigateToGamedbEntry(u64 program_id, QDesktopServices::openUrl(QUrl(QStringLiteral("https://citra-emu.org/game/") + directory)); } +bool GMainWindow::CreateShortcutLink(const std::filesystem::path& shortcut_path, + const std::string& comment, + const std::filesystem::path& icon_path, + const std::filesystem::path& command, + const std::string& arguments, const std::string& categories, + const std::string& keywords, const std::string& name) try { +#if defined(__linux__) || defined(__FreeBSD__) // Linux and FreeBSD + std::filesystem::path shortcut_path_full = shortcut_path / (name + ".desktop"); + std::ofstream shortcut_stream(shortcut_path_full, std::ios::binary | std::ios::trunc); + if (!shortcut_stream.is_open()) { + LOG_ERROR(Frontend, "Failed to create shortcut"); + return false; + } + // TODO: Migrate fmt::print to std::print in futures STD C++ 23. + fmt::print(shortcut_stream, "[Desktop Entry]\n"); + fmt::print(shortcut_stream, "Type=Application\n"); + fmt::print(shortcut_stream, "Version=1.0\n"); + fmt::print(shortcut_stream, "Name={}\n", name); + if (!comment.empty()) { + fmt::print(shortcut_stream, "Comment={}\n", comment); + } + if (std::filesystem::is_regular_file(icon_path)) { + fmt::print(shortcut_stream, "Icon={}\n", icon_path.string()); + } + fmt::print(shortcut_stream, "TryExec={}\n", command.string()); + fmt::print(shortcut_stream, "Exec={} {}\n", command.string(), arguments); + if (!categories.empty()) { + fmt::print(shortcut_stream, "Categories={}\n", categories); + } + if (!keywords.empty()) { + fmt::print(shortcut_stream, "Keywords={}\n", keywords); + } + return true; +#elif defined(_WIN32) // Windows + HRESULT hr = CoInitialize(nullptr); + if (FAILED(hr)) { + LOG_ERROR(Frontend, "CoInitialize failed"); + return false; + } + SCOPE_EXIT({ CoUninitialize(); }); + IShellLinkW* ps1 = nullptr; + IPersistFile* persist_file = nullptr; + SCOPE_EXIT({ + if (persist_file != nullptr) { + persist_file->Release(); + } + if (ps1 != nullptr) { + ps1->Release(); + } + }); + HRESULT hres = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_IShellLinkW, + reinterpret_cast(&ps1)); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to create IShellLinkW instance"); + return false; + } + hres = ps1->SetPath(command.c_str()); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set path"); + return false; + } + if (!arguments.empty()) { + hres = ps1->SetArguments(Common::UTF8ToUTF16W(arguments).data()); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set arguments"); + return false; + } + } + if (!comment.empty()) { + hres = ps1->SetDescription(Common::UTF8ToUTF16W(comment).data()); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set description"); + return false; + } + } + if (std::filesystem::is_regular_file(icon_path)) { + hres = ps1->SetIconLocation(icon_path.c_str(), 0); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to set icon location"); + return false; + } + } + hres = ps1->QueryInterface(IID_IPersistFile, reinterpret_cast(&persist_file)); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to get IPersistFile interface"); + return false; + } + hres = persist_file->Save(std::filesystem::path{shortcut_path / (name + ".lnk")}.c_str(), TRUE); + if (FAILED(hres)) { + LOG_ERROR(Frontend, "Failed to save shortcut"); + return false; + } + return true; +#else // Unsupported platform + return false; +#endif +} catch (const std::exception& e) { + LOG_ERROR(Frontend, "Failed to create shortcut: {}", e.what()); + return false; +} + +// Messages in pre-defined message boxes for less code spaghetti +bool GMainWindow::CreateShortcutMessagesGUI(QWidget* parent, int message, + const QString& game_title) { + int result = 0; + QMessageBox::StandardButtons buttons; + switch (message) { + case GMainWindow::CREATE_SHORTCUT_MSGBOX_FULLSCREEN_PROMPT: + buttons = QMessageBox::Yes | QMessageBox::No; + result = + QMessageBox::information(parent, tr("Create Shortcut"), + tr("Do you want to launch the game in fullscreen?"), buttons); + return result == QMessageBox::Yes; + case GMainWindow::CREATE_SHORTCUT_MSGBOX_SUCCESS: + QMessageBox::information(parent, tr("Create Shortcut"), + tr("Successfully created a shortcut to %1").arg(game_title)); + return false; + case GMainWindow::CREATE_SHORTCUT_MSGBOX_APPIMAGE_VOLATILE_WARNING: + buttons = QMessageBox::StandardButton::Ok | QMessageBox::StandardButton::Cancel; + result = + QMessageBox::warning(this, tr("Create Shortcut"), + tr("This will create a shortcut to the current AppImage. This may " + "not work well if you update. Continue?"), + buttons); + return result == QMessageBox::Ok; + default: + buttons = QMessageBox::Ok; + QMessageBox::critical(parent, tr("Create Shortcut"), + tr("Failed to create a shortcut to %1").arg(game_title), buttons); + return false; + } +} + +bool GMainWindow::MakeShortcutIcoPath(const u64 program_id, const std::string_view game_file_name, + std::filesystem::path& out_icon_path) { + // Get path to Citra icons directory & icon extension + std::string ico_extension = "png"; +#if defined(_WIN32) + out_icon_path = FileUtil::GetUserPath(FileUtil::UserPath::IconsDir); + ico_extension = "ico"; +#elif defined(__linux__) || defined(__FreeBSD__) + out_icon_path = FileUtil::GetUserDirectory("XDG_DATA_HOME") + "/icons/hicolor/256x256"; +#endif + // Create icons directory if it doesn't exist + if (!FileUtil::CreateDir(out_icon_path.string())) { + QMessageBox::critical( + this, tr("Create Icon"), + tr("Cannot create icon file. Path \"%1\" does not exist and cannot be created.") + .arg(QString::fromStdString(out_icon_path.string())), + QMessageBox::StandardButton::Ok); + out_icon_path.clear(); + return false; + } + + // Create icon file path + out_icon_path /= (program_id == 0 ? fmt::format("citra-{}.{}", game_file_name, ico_extension) + : fmt::format("citra-{:016X}.{}", program_id, ico_extension)); + return true; +} + +void GMainWindow::OnGameListCreateShortcut(u64 program_id, const std::string& game_path, + GameListShortcutTarget target) { + // Get path to citra executable + const QStringList args = QApplication::arguments(); + std::filesystem::path citra_command = args[0].toStdString(); + // If relative path, make it an absolute path + if (citra_command.c_str()[0] == '.') { + citra_command = FileUtil::GetCurrentDir().value_or("") + DIR_SEP + citra_command.string(); + } + + // Shortcut path + std::filesystem::path shortcut_path{}; + if (target == GameListShortcutTarget::Desktop) { + shortcut_path = + QStandardPaths::writableLocation(QStandardPaths::DesktopLocation).toStdString(); + } else if (target == GameListShortcutTarget::Applications) { + shortcut_path = + QStandardPaths::writableLocation(QStandardPaths::ApplicationsLocation).toStdString(); + } + + // Icon path and title + if (!std::filesystem::exists(shortcut_path)) { + CreateShortcutMessagesGUI(this, CREATE_SHORTCUT_MSGBOX_ERROR, {}); + LOG_ERROR(Frontend, "Invalid shortcut target"); + return; + } + + // Get title from game file + const auto loader = Loader::GetLoader(game_path); + std::string game_title = fmt::format("{:016X}", program_id); + if (loader->ReadTitle(game_title) != Loader::ResultStatus::Success) { + game_title = fmt::format("{:016x}", program_id); + } + + // Delete illegal characters from title + const std::string illegal_chars = "<>:\"/\\|?*."; + for (auto it = game_title.rbegin(); it != game_title.rend(); ++it) { + if (illegal_chars.find(*it) != std::string::npos) { + game_title.erase(it.base() - 1); + } + } + + // Get icon from game file + std::vector icon_image_file; + if (loader->ReadIcon(icon_image_file) != Loader::ResultStatus::Success) { + LOG_WARNING(Frontend, "Could not read icon from {:s}", game_path); + } + + const QPixmap pixmap = GetQPixmapFromSMDH(icon_image_file); + const QImage icon_data = pixmap.toImage(); + std::filesystem::path out_icon_path; + if (MakeShortcutIcoPath(program_id, game_title, out_icon_path)) { + if (!SaveIconToFile(out_icon_path, icon_data)) { + LOG_ERROR(Frontend, "Could not write icon to file"); + } + } + + const auto qt_game_title = QString::fromStdString(game_title); +#if defined(__linux__) + // Special case for AppImages + // Warn once if we are making a shortcut to a volatile AppImage + const std::string appimage_ending = + std::string(Common::g_scm_rev).substr(0, 9).append(".AppImage"); + if (citra_command.string().ends_with(appimage_ending) && + !UISettings::values.shortcut_already_warned) { + if (CreateShortcutMessagesGUI(this, CREATE_SHORTCUT_MSGBOX_APPIMAGE_VOLATILE_WARNING, + qt_game_title)) { + return; + } + UISettings::values.shortcut_already_warned = true; + } +#endif // __linux__ + // Create shortcut + std::string arguments = fmt::format("-g \"{:s}\"", game_path); + if (CreateShortcutMessagesGUI(this, CREATE_SHORTCUT_MSGBOX_FULLSCREEN_PROMPT, qt_game_title)) { + arguments = "-f " + arguments; + } + const std::string comment = fmt::format("Start {:s} with the Citra Emulator", game_title); + const std::string categories = "Game;Emulator;Qt;"; + const std::string keywords = "3ds;Nintendo;"; + + if (CreateShortcutLink(shortcut_path, comment, out_icon_path, citra_command, arguments, + categories, keywords, game_title)) { + CreateShortcutMessagesGUI(this, CREATE_SHORTCUT_MSGBOX_SUCCESS, qt_game_title); + return; + } + CreateShortcutMessagesGUI(this, CREATE_SHORTCUT_MSGBOX_ERROR, qt_game_title); +} + void GMainWindow::OnGameListDumpRomFS(QString game_path, u64 program_id) { auto* dialog = new QProgressDialog(tr("Dumping..."), tr("Cancel"), 0, 0, this); dialog->setWindowModality(Qt::WindowModal); @@ -1968,6 +2243,9 @@ void GMainWindow::OnStartGame() { UpdateMenuState(); + play_time_manager->SetProgramId(game_title_id); + play_time_manager->Start(); + discord_rpc->Update(); #ifdef __unix__ @@ -1990,6 +2268,8 @@ void GMainWindow::OnPauseGame() { emu_thread->SetRunning(false); qt_cameras->PauseCameras(); + play_time_manager->Stop(); + UpdateMenuState(); AllowOSSleep(); @@ -2009,6 +2289,10 @@ void GMainWindow::OnPauseContinueGame() { } void GMainWindow::OnStopGame() { + play_time_manager->Stop(); + // Update game list to show new play time + game_list->PopulateAsync(UISettings::values.game_dirs); + ShutdownGame(); graphics_api_button->setEnabled(true); Settings::RestoreGlobalState(false); diff --git a/src/citra_qt/main.h b/src/citra_qt/main.h index 8f3c8328dd..3e70ecc7d6 100644 --- a/src/citra_qt/main.h +++ b/src/citra_qt/main.h @@ -21,6 +21,9 @@ #include #endif +// Needs to be included at the end due to https://bugreports.qt.io/browse/QTBUG-73263 +#include + class AboutDialog; class Config; class ClickableLabel; @@ -28,6 +31,7 @@ class EmuThread; class GameList; enum class GameListOpenTarget; class GameListPlaceholder; +enum class GameListShortcutTarget; class GImageInfo; class GPUCommandListWidget; class GPUCommandStreamWidget; @@ -61,6 +65,10 @@ namespace DiscordRPC { class DiscordInterface; } +namespace PlayTime { +class PlayTimeManager; +} + namespace Core { class Movie; } @@ -91,6 +99,7 @@ class GMainWindow : public QMainWindow { ~GMainWindow(); GameList* game_list; + std::unique_ptr play_time_manager; std::unique_ptr discord_rpc; bool DropAction(QDropEvent* event); @@ -193,6 +202,22 @@ public slots: bool ConfirmChangeGame(); void closeEvent(QCloseEvent* event) override; + enum { + CREATE_SHORTCUT_MSGBOX_FULLSCREEN_PROMPT, + CREATE_SHORTCUT_MSGBOX_SUCCESS, + CREATE_SHORTCUT_MSGBOX_ERROR, + CREATE_SHORTCUT_MSGBOX_APPIMAGE_VOLATILE_WARNING, + }; + + bool CreateShortcutMessagesGUI(QWidget* parent, int message, const QString& game_title); + bool MakeShortcutIcoPath(const u64 program_id, const std::string_view game_file_name, + std::filesystem::path& out_icon_path); + bool CreateShortcutLink(const std::filesystem::path& shortcut_path, const std::string& comment, + const std::filesystem::path& icon_path, + const std::filesystem::path& command, const std::string& arguments, + const std::string& categories, const std::string& keywords, + const std::string& name); + private slots: void OnStartGame(); void OnRestartGame(); @@ -205,8 +230,11 @@ private slots: /// Called whenever a user selects a game in the game list widget. void OnGameListLoadFile(QString game_path); void OnGameListOpenFolder(u64 program_id, GameListOpenTarget target); + void OnGameListRemovePlayTimeData(u64 program_id); void OnGameListNavigateToGamedbEntry(u64 program_id, const CompatibilityList& compatibility_list); + void OnGameListCreateShortcut(u64 program_id, const std::string& game_path, + GameListShortcutTarget target); void OnGameListDumpRomFS(QString game_path, u64 program_id); void OnGameListOpenDirectory(const QString& directory); void OnGameListAddDirectory(); @@ -278,6 +306,7 @@ private slots: void UpdateWindowTitle(); void UpdateUISettings(); void RetranslateStatusBar(); + void RemovePlayTimeData(u64 program_id); void InstallCIA(QStringList filepaths); void HideMouseCursor(); void ShowMouseCursor(); @@ -324,6 +353,8 @@ private slots: QString game_title; // The path to the game currently running QString game_path; + // The title id of the game currently running + u64 game_title_id; bool auto_paused = false; bool auto_muted = false; diff --git a/src/citra_qt/play_time_manager.cpp b/src/citra_qt/play_time_manager.cpp new file mode 100644 index 0000000000..03c2e1b16b --- /dev/null +++ b/src/citra_qt/play_time_manager.cpp @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2024 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#include +#include "citra_qt/play_time_manager.h" +#include "common/alignment.h" +#include "common/common_paths.h" +#include "common/file_util.h" +#include "common/logging/log.h" +#include "common/settings.h" +#include "common/thread.h" + +namespace PlayTime { + +namespace { + +struct PlayTimeElement { + ProgramId program_id; + PlayTime play_time; +}; + +std::string GetCurrentUserPlayTimePath() { + return FileUtil::GetUserPath(FileUtil::UserPath::PlayTimeDir) + DIR_SEP + "play_time.bin"; +} + +[[nodiscard]] bool ReadPlayTimeFile(PlayTimeDatabase& out_play_time_db) { + const auto filename = GetCurrentUserPlayTimePath(); + + out_play_time_db.clear(); + + if (FileUtil::Exists(filename)) { + FileUtil::IOFile file{filename, "rb"}; + if (!file.IsOpen()) { + LOG_ERROR(Frontend, "Failed to open play time file: {}", filename); + return false; + } + + const size_t num_elements = file.GetSize() / sizeof(PlayTimeElement); + std::vector elements(num_elements); + + if (file.ReadSpan(elements) != num_elements) { + return false; + } + + for (const auto& [program_id, play_time] : elements) { + if (program_id != 0) { + out_play_time_db[program_id] = play_time; + } + } + } + + return true; +} + +[[nodiscard]] bool WritePlayTimeFile(const PlayTimeDatabase& play_time_db) { + const auto filename = GetCurrentUserPlayTimePath(); + + FileUtil::IOFile file{filename, "wb"}; + if (!file.IsOpen()) { + LOG_ERROR(Frontend, "Failed to open play time file: {}", filename); + return false; + } + + std::vector elements; + elements.reserve(play_time_db.size()); + + for (auto& [program_id, play_time] : play_time_db) { + if (program_id != 0) { + elements.push_back(PlayTimeElement{program_id, play_time}); + } + } + + return file.WriteSpan(elements) == elements.size(); +} + +} // namespace + +PlayTimeManager::PlayTimeManager() { + if (!ReadPlayTimeFile(database)) { + LOG_ERROR(Frontend, "Failed to read play time database! Resetting to default."); + } +} + +PlayTimeManager::~PlayTimeManager() { + Save(); +} + +void PlayTimeManager::SetProgramId(u64 program_id) { + running_program_id = program_id; +} + +void PlayTimeManager::Start() { + play_time_thread = std::jthread([&](std::stop_token stop_token) { AutoTimestamp(stop_token); }); +} + +void PlayTimeManager::Stop() { + play_time_thread = {}; +} + +void PlayTimeManager::AutoTimestamp(std::stop_token stop_token) { + Common::SetCurrentThreadName("PlayTimeReport"); + + using namespace std::literals::chrono_literals; + using std::chrono::seconds; + using std::chrono::steady_clock; + + auto timestamp = steady_clock::now(); + + const auto GetDuration = [&]() -> u64 { + const auto last_timestamp = std::exchange(timestamp, steady_clock::now()); + const auto duration = std::chrono::duration_cast(timestamp - last_timestamp); + return static_cast(duration.count()); + }; + + while (!stop_token.stop_requested()) { + Common::StoppableTimedWait(stop_token, 30s); + + database[running_program_id] += GetDuration(); + Save(); + } +} + +void PlayTimeManager::Save() { + if (!WritePlayTimeFile(database)) { + LOG_ERROR(Frontend, "Failed to update play time database!"); + } +} + +u64 PlayTimeManager::GetPlayTime(u64 program_id) const { + auto it = database.find(program_id); + if (it != database.end()) { + return it->second; + } else { + return 0; + } +} + +void PlayTimeManager::ResetProgramPlayTime(u64 program_id) { + database.erase(program_id); + Save(); +} + +QString ReadablePlayTime(qulonglong time_seconds) { + if (time_seconds == 0) { + return {}; + } + const auto time_minutes = std::max(static_cast(time_seconds) / 60, 1.0); + const auto time_hours = static_cast(time_seconds) / 3600; + const bool is_minutes = time_minutes < 60; + const char* unit = is_minutes ? "m" : "h"; + const auto value = is_minutes ? time_minutes : time_hours; + + return QStringLiteral("%L1 %2") + .arg(value, 0, 'f', !is_minutes && time_seconds % 60 != 0) + .arg(QString::fromUtf8(unit)); +} + +} // namespace PlayTime diff --git a/src/citra_qt/play_time_manager.h b/src/citra_qt/play_time_manager.h new file mode 100644 index 0000000000..c8ba48db78 --- /dev/null +++ b/src/citra_qt/play_time_manager.h @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: 2024 Citra Emulator Project +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +#include + +#include "common/common_funcs.h" +#include "common/common_types.h" +#include "common/polyfill_thread.h" + +namespace PlayTime { + +using ProgramId = u64; +using PlayTime = u64; +using PlayTimeDatabase = std::map; + +class PlayTimeManager { +public: + explicit PlayTimeManager(); + ~PlayTimeManager(); + + PlayTimeManager(const PlayTimeManager&) = delete; + PlayTimeManager& operator=(const PlayTimeManager&) = delete; + + u64 GetPlayTime(u64 program_id) const; + void ResetProgramPlayTime(u64 program_id); + void SetProgramId(u64 program_id); + void Start(); + void Stop(); + +private: + void AutoTimestamp(std::stop_token stop_token); + void Save(); + + PlayTimeDatabase database; + u64 running_program_id; + std::jthread play_time_thread; +}; + +QString ReadablePlayTime(qulonglong time_seconds); + +} // namespace PlayTime diff --git a/src/citra_qt/uisettings.h b/src/citra_qt/uisettings.h index be4ea533b9..3b4224f496 100644 --- a/src/citra_qt/uisettings.h +++ b/src/citra_qt/uisettings.h @@ -100,6 +100,7 @@ struct Values { Settings::Setting show_region_column{true, "show_region_column"}; Settings::Setting show_type_column{true, "show_type_column"}; Settings::Setting show_size_column{true, "show_size_column"}; + Settings::Setting show_play_time_column{true, "show_play_time_column"}; Settings::Setting screenshot_resolution_factor{0, "screenshot_resolution_factor"}; Settings::SwitchableSetting screenshot_path{"", "screenshotPath"}; @@ -113,6 +114,7 @@ struct Values { bool game_dir_deprecated_deepscan; QVector game_dirs; QStringList recent_files; + QVector favorited_ids; QString last_artic_base_addr; QString language; @@ -143,6 +145,8 @@ struct Values { // logging Settings::Setting show_console{false, "showConsole"}; + + bool shortcut_already_warned = false; }; extern Values values; diff --git a/src/citra_qt/util/util.cpp b/src/citra_qt/util/util.cpp index ec10fe4716..280ea0929c 100644 --- a/src/citra_qt/util/util.cpp +++ b/src/citra_qt/util/util.cpp @@ -6,6 +6,13 @@ #include #include #include "citra_qt/util/util.h" +#include "common/logging/log.h" +#include "core/loader/smdh.h" + +#ifdef _WIN32 +#include +#include "common/file_util.h" +#endif QFont GetMonospaceFont() { QFont font(QStringLiteral("monospace")); @@ -36,3 +43,120 @@ QPixmap CreateCirclePixmapFromColor(const QColor& color) { painter.drawEllipse({circle_pixmap.width() / 2.0, circle_pixmap.height() / 2.0}, 7.0, 7.0); return circle_pixmap; } + +QPixmap GetQPixmapFromSMDH(const std::vector& smdh_data) { + Loader::SMDH smdh; + std::memcpy(&smdh, smdh_data.data(), sizeof(Loader::SMDH)); + + bool large = true; + std::vector icon_data = smdh.GetIcon(large); + const uchar* data = reinterpret_cast(icon_data.data()); + int size = large ? 48 : 24; + QImage icon(data, size, size, QImage::Format::Format_RGB16); + return QPixmap::fromImage(icon); +} + +bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image) { +#if defined(WIN32) +#pragma pack(push, 2) + struct IconDir { + WORD id_reserved; + WORD id_type; + WORD id_count; + }; + + struct IconDirEntry { + BYTE width; + BYTE height; + BYTE color_count; + BYTE reserved; + WORD planes; + WORD bit_count; + DWORD bytes_in_res; + DWORD image_offset; + }; +#pragma pack(pop) + + const QImage source_image = image.convertToFormat(QImage::Format_RGB32); + constexpr std::array scale_sizes{256, 128, 64, 48, 32, 24, 16}; + constexpr int bytes_per_pixel = 4; + + const IconDir icon_dir{ + .id_reserved = 0, + .id_type = 1, + .id_count = static_cast(scale_sizes.size()), + }; + + FileUtil::IOFile icon_file(icon_path.string(), "wb"); + if (!icon_file.IsOpen()) { + return false; + } + + if (!icon_file.WriteBytes(&icon_dir, sizeof(IconDir))) { + return false; + } + + std::size_t image_offset = sizeof(IconDir) + (sizeof(IconDirEntry) * scale_sizes.size()); + for (std::size_t i = 0; i < scale_sizes.size(); i++) { + const int image_size = scale_sizes[i] * scale_sizes[i] * bytes_per_pixel; + const IconDirEntry icon_entry{ + .width = static_cast(scale_sizes[i]), + .height = static_cast(scale_sizes[i]), + .color_count = 0, + .reserved = 0, + .planes = 1, + .bit_count = bytes_per_pixel * 8, + .bytes_in_res = static_cast(sizeof(BITMAPINFOHEADER) + image_size), + .image_offset = static_cast(image_offset), + }; + image_offset += icon_entry.bytes_in_res; + if (!icon_file.WriteBytes(&icon_entry, sizeof(icon_entry))) { + return false; + } + } + + for (std::size_t i = 0; i < scale_sizes.size(); i++) { + const QImage scaled_image = source_image.scaled( + scale_sizes[i], scale_sizes[i], Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + const BITMAPINFOHEADER info_header{ + .biSize = sizeof(BITMAPINFOHEADER), + .biWidth = scaled_image.width(), + .biHeight = scaled_image.height() * 2, + .biPlanes = 1, + .biBitCount = bytes_per_pixel * 8, + .biCompression = BI_RGB, + .biSizeImage{}, + .biXPelsPerMeter{}, + .biYPelsPerMeter{}, + .biClrUsed{}, + .biClrImportant{}, + }; + + if (!icon_file.WriteBytes(&info_header, sizeof(info_header))) { + return false; + } + + for (int y = 0; y < scaled_image.height(); y++) { + const auto* line = scaled_image.scanLine(scaled_image.height() - 1 - y); + std::vector line_data(scaled_image.width() * bytes_per_pixel); + std::memcpy(line_data.data(), line, line_data.size()); + if (!icon_file.WriteBytes(line_data.data(), line_data.size())) { + return false; + } + } + } + icon_file.Close(); + + return true; +#elif defined(__linux__) || defined(__FreeBSD__) + // Convert and write the icon as a PNG + if (!image.save(QString::fromStdString(icon_path.string()))) { + LOG_ERROR(Frontend, "Could not write icon as PNG to file"); + } else { + LOG_INFO(Frontend, "Wrote an icon to {}", icon_path.string()); + } + return true; +#else + return false; +#endif +} diff --git a/src/citra_qt/util/util.h b/src/citra_qt/util/util.h index e6790f2606..67bd51a901 100644 --- a/src/citra_qt/util/util.h +++ b/src/citra_qt/util/util.h @@ -4,8 +4,10 @@ #pragma once +#include #include #include +#include "common/common_types.h" /// Returns a QFont object appropriate to use as a monospace font for debugging widgets, etc. QFont GetMonospaceFont(); @@ -19,3 +21,18 @@ QString ReadableByteSize(qulonglong size); * @return QPixmap circle pixmap */ QPixmap CreateCirclePixmapFromColor(const QColor& color); + +/** + * Gets the game icon from SMDH data. + * @param smdh_data SMDH data + * @return QPixmap game icon + */ +QPixmap GetQPixmapFromSMDH(const std::vector& smdh_data); + +/** + * Saves a windows icon to a file + * @param path The icons path + * @param image The image to save + * @return bool If the operation succeeded + */ +[[nodiscard]] bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image); diff --git a/src/common/common_paths.h b/src/common/common_paths.h index f12eedb0c3..f748a89e38 100644 --- a/src/common/common_paths.h +++ b/src/common/common_paths.h @@ -52,6 +52,7 @@ #define LOAD_DIR "load" #define SHADER_DIR "shaders" #define STATES_DIR "states" +#define ICONS_DIR "icons" // Filenames // Files in the directory returned by GetUserPath(UserPath::LogDir) diff --git a/src/common/file_util.cpp b/src/common/file_util.cpp index cda752e5f0..88fb2e3249 100644 --- a/src/common/file_util.cpp +++ b/src/common/file_util.cpp @@ -729,7 +729,7 @@ static const std::string& GetHomeDirectory() { * @return The directory path * @sa http://standards.freedesktop.org/basedir-spec/basedir-spec-latest.html */ -[[maybe_unused]] static const std::string GetUserDirectory(const std::string& envvar) { +[[maybe_unused]] const std::string GetUserDirectory(const std::string& envvar) { const char* directory = getenv(envvar.c_str()); std::string user_dir; @@ -826,6 +826,8 @@ void SetUserPath(const std::string& path) { g_paths.emplace(UserPath::DumpDir, user_path + DUMP_DIR DIR_SEP); g_paths.emplace(UserPath::LoadDir, user_path + LOAD_DIR DIR_SEP); g_paths.emplace(UserPath::StatesDir, user_path + STATES_DIR DIR_SEP); + g_paths.emplace(UserPath::IconsDir, user_path + ICONS_DIR DIR_SEP); + g_paths.emplace(UserPath::PlayTimeDir, user_path + LOG_DIR DIR_SEP); g_default_paths = g_paths; } diff --git a/src/common/file_util.h b/src/common/file_util.h index 6595fead90..fdacd7a423 100644 --- a/src/common/file_util.h +++ b/src/common/file_util.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -40,6 +41,8 @@ enum class UserPath { StatesDir, SysDataDir, UserDir, + IconsDir, + PlayTimeDir, }; // Replaces install-specific paths with standard placeholders, and back again @@ -200,6 +203,8 @@ void UpdateUserPath(UserPath path, const std::string& filename); #ifdef _WIN32 [[nodiscard]] const std::string& GetExeDirectory(); [[nodiscard]] std::string AppDataRoamingDirectory(); +#else +[[nodiscard]] const std::string GetUserDirectory(const std::string& envvar); #endif std::size_t WriteStringToFile(bool text_file, const std::string& filename, std::string_view str); @@ -343,6 +348,59 @@ class IOFile : public NonCopyable { return WriteArray(str.data(), str.length()); } + /** + * Reads a span of T data from a file sequentially. + * This function reads from the current position of the file pointer and + * advances it by the (count of T * sizeof(T)) bytes successfully read. + * + * Failures occur when: + * - The file is not open + * - The opened file lacks read permissions + * - Attempting to read beyond the end-of-file + * + * @tparam T Data type + * + * @param data Span of T data + * + * @returns Count of T data successfully read. + */ + template + [[nodiscard]] size_t ReadSpan(std::span data) const { + static_assert(std::is_trivially_copyable_v, "Data type must be trivially copyable."); + + if (!IsOpen()) { + return 0; + } + + return std::fread(data.data(), sizeof(T), data.size(), m_file); + } + + /** + * Writes a span of T data to a file sequentially. + * This function writes from the current position of the file pointer and + * advances it by the (count of T * sizeof(T)) bytes successfully written. + * + * Failures occur when: + * - The file is not open + * - The opened file lacks write permissions + * + * @tparam T Data type + * + * @param data Span of T data + * + * @returns Count of T data successfully written. + */ + template + [[nodiscard]] size_t WriteSpan(std::span data) const { + static_assert(std::is_trivially_copyable_v, "Data type must be trivially copyable."); + + if (!IsOpen()) { + return 0; + } + + return std::fwrite(data.data(), sizeof(T), data.size(), m_file); + } + [[nodiscard]] bool IsOpen() const { return nullptr != m_file; } diff --git a/src/common/polyfill_thread.h b/src/common/polyfill_thread.h index 3146075f3d..bf8cb4ecb4 100644 --- a/src/common/polyfill_thread.h +++ b/src/common/polyfill_thread.h @@ -12,8 +12,11 @@ #ifdef __cpp_lib_jthread +#include +#include #include #include +#include namespace Common { @@ -22,11 +25,23 @@ void CondvarWait(Condvar& cv, Lock& lock, std::stop_token token, Pred&& pred) { cv.wait(lock, token, std::move(pred)); } +template +bool StoppableTimedWait(std::stop_token token, const std::chrono::duration& rel_time) { + std::condition_variable_any cv; + std::mutex m; + + // Perform the timed wait. + std::unique_lock lk{m}; + return !cv.wait_for(lk, token, rel_time, [&] { return token.stop_requested(); }); +} + } // namespace Common #else #include +#include +#include #include #include #include @@ -333,6 +348,30 @@ void CondvarWait(Condvar& cv, Lock& lock, std::stop_token token, Pred pred) { cv.wait(lock, [&] { return pred() || token.stop_requested(); }); } +template +bool StoppableTimedWait(std::stop_token token, const std::chrono::duration& rel_time) { + if (token.stop_requested()) { + return false; + } + + bool stop_requested = false; + std::condition_variable cv; + std::mutex m; + + std::stop_callback cb(token, [&] { + // Wake up the waiting thread. + { + std::scoped_lock lk{m}; + stop_requested = true; + } + cv.notify_one(); + }); + + // Perform the timed wait. + std::unique_lock lk{m}; + return !cv.wait_for(lk, rel_time, [&] { return stop_requested; }); +} + } // namespace Common #endif // __cpp_lib_jthread