[core, android] Initial playtime implementation #2535

Open
inix wants to merge 10 commits from inix/eden:playtime-android into master
24 changed files with 590 additions and 44 deletions

View file

@ -206,6 +206,17 @@ object NativeLibrary {
ErrorUnknown
}
/**
* playtime tracking
*/
external fun playTimeManagerInit()
external fun playTimeManagerStart()
external fun playTimeManagerStop()
external fun playTimeManagerGetPlayTime(programId: String): Long
external fun playTimeManagerGetCurrentTitleId(): Long
external fun playTimeManagerResetProgramPlayTime(programId: String)
external fun playTimeManagerSetPlayTime(programId: String, playTimeSeconds: Long)
var coreErrorAlertResult = false
val coreErrorAlertLock = Object()

View file

@ -53,6 +53,7 @@ class YuzuApplication : Application() {
application = this
documentsTree = DocumentsTree()
DirectoryInitialization.start()
NativeLibrary.playTimeManagerInit()
GpuDriverHelper.initializeDriverParameters()
NativeInput.reloadInputDevices()
NativeLibrary.logDeviceInfo()

View file

@ -61,6 +61,7 @@ import org.yuzu.yuzu_emu.utils.ThemeHelper
import java.text.NumberFormat
import kotlin.math.roundToInt
import org.yuzu.yuzu_emu.utils.ForegroundService
import androidx.core.os.BundleCompat
class EmulationActivity : AppCompatActivity(), SensorEventListener {
private lateinit var binding: ActivityEmulationBinding
@ -322,6 +323,11 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
override fun onAccuracyChanged(sensor: Sensor, i: Int) {}
override fun onDestroy() {
super.onDestroy()
NativeLibrary.playTimeManagerStop()
}
private fun enableFullscreenImmersive() {
WindowCompat.setDecorFitsSystemWindows(window, false)
@ -526,6 +532,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
fun onEmulationStarted() {
emulationViewModel.setEmulationStarted(true)
NativeLibrary.playTimeManagerStart()
}
fun onEmulationStopped(status: Int) {

View file

@ -1635,6 +1635,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
Log.debug("[EmulationFragment] Pausing emulation.")
NativeLibrary.pauseEmulation()
NativeLibrary.playTimeManagerStop()
state = State.PAUSED
} else {
@ -1725,6 +1726,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
State.PAUSED -> {
Log.debug("[EmulationFragment] Resuming emulation.")
NativeLibrary.unpauseEmulation()
NativeLibrary.playTimeManagerStart()
}
else -> Log.debug("[EmulationFragment] Bug, run called while already running.")

View file

@ -1,4 +1,4 @@
// SPDX-FileCopyrightText: 2025 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
package org.yuzu.yuzu_emu.fragments
@ -25,6 +25,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.yuzu.yuzu_emu.HomeNavigationDirections
import org.yuzu.yuzu_emu.NativeLibrary
import org.yuzu.yuzu_emu.R
import org.yuzu.yuzu_emu.YuzuApplication
import org.yuzu.yuzu_emu.adapters.GamePropertiesAdapter
@ -104,6 +105,8 @@ class GamePropertiesFragment : Fragment() {
binding.title.text = args.game.title
binding.title.marquee()
getPlayTime()
binding.buttonStart.setOnClickListener {
LaunchGameDialogFragment.newInstance(args.game)
.show(childFragmentManager, LaunchGameDialogFragment.TAG)
@ -128,6 +131,109 @@ class GamePropertiesFragment : Fragment() {
gamesViewModel.reloadGames(true)
}
private fun getPlayTime() {
binding.playtime.text = buildString {
val playTimeSeconds = NativeLibrary.playTimeManagerGetPlayTime(args.game.programId)
val hours = playTimeSeconds / 3600
val minutes = (playTimeSeconds % 3600) / 60
val seconds = playTimeSeconds % 60
val readablePlayTime = when {
hours > 0 -> "${hours}h ${minutes}m ${seconds}s"
minutes > 0 -> "${minutes}m ${seconds}s"
else -> "${seconds}s"
}
append(getString(R.string.playtime))
append(readablePlayTime)
}
binding.playtime.setOnClickListener {
showEditPlaytimeDialog()
}
}
private fun showEditPlaytimeDialog() {
val dialogView = layoutInflater.inflate(R.layout.dialog_edit_playtime, null)
val hoursLayout =
dialogView.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.layout_hours)
val minutesLayout =
dialogView.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.layout_minutes)
val secondsLayout =
dialogView.findViewById<com.google.android.material.textfield.TextInputLayout>(R.id.layout_seconds)
val hoursInput =
dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.input_hours)
val minutesInput =
dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.input_minutes)
val secondsInput =
dialogView.findViewById<com.google.android.material.textfield.TextInputEditText>(R.id.input_seconds)
val playTimeSeconds = NativeLibrary.playTimeManagerGetPlayTime(args.game.programId)
val hours = playTimeSeconds / 3600
val minutes = (playTimeSeconds % 3600) / 60
val seconds = playTimeSeconds % 60
hoursInput.setText(hours.toString())
minutesInput.setText(minutes.toString())
secondsInput.setText(seconds.toString())
val dialog = com.google.android.material.dialog.MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.edit_playtime)
.setView(dialogView)
.setPositiveButton(android.R.string.ok, null)
.setNegativeButton(android.R.string.cancel, null)
.create()
dialog.setOnShowListener {
val positiveButton = dialog.getButton(android.app.AlertDialog.BUTTON_POSITIVE)
positiveButton.setOnClickListener {
hoursLayout.error = null
minutesLayout.error = null
secondsLayout.error = null
val hoursText = hoursInput.text.toString()
val minutesText = minutesInput.text.toString()
val secondsText = secondsInput.text.toString()
val hoursValue = hoursText.toLongOrNull() ?: 0
val minutesValue = minutesText.toLongOrNull() ?: 0
val secondsValue = secondsText.toLongOrNull() ?: 0
var hasError = false
// normally cant be above 9999
if (hoursValue < 0 || hoursValue > 9999) {
hoursLayout.error = getString(R.string.hours_must_be_between_0_and_9999)
hasError = true
}
if (minutesValue < 0 || minutesValue > 59) {
minutesLayout.error = getString(R.string.minutes_must_be_between_0_and_59)
hasError = true
}
if (secondsValue < 0 || secondsValue > 59) {
secondsLayout.error = getString(R.string.seconds_must_be_between_0_and_59)
hasError = true
}
if (!hasError) {
val totalSeconds = hoursValue * 3600 + minutesValue * 60 + secondsValue
NativeLibrary.playTimeManagerSetPlayTime(args.game.programId, totalSeconds)
getPlayTime()
Toast.makeText(
requireContext(),
R.string.playtime_updated_successfully,
Toast.LENGTH_SHORT
).show()
dialog.dismiss()
}
}
}
dialog.show()
}
private fun reloadList() {
_binding ?: return
@ -272,6 +378,31 @@ class GamePropertiesFragment : Fragment() {
}
)
}
if (NativeLibrary.playTimeManagerGetPlayTime(args.game.programId) > 0) {
add(
SubmenuProperty(
R.string.reset_playtime,
R.string.reset_playtime_description,
R.drawable.ic_delete
) {
MessageDialogFragment.newInstance(
requireActivity(),
titleId = R.string.reset_playtime,
descriptionId = R.string.reset_playtime_warning_description,
positiveAction = {
NativeLibrary.playTimeManagerResetProgramPlayTime( args.game.programId)
Toast.makeText(
YuzuApplication.appContext,
R.string.playtime_reset_successfully,
Toast.LENGTH_SHORT
).show()
getPlayTime()
homeViewModel.reloadPropertiesList(true)
}
).show(parentFragmentManager, MessageDialogFragment.TAG)
}
)
}
}
}
binding.listProperties.apply {
@ -284,6 +415,7 @@ class GamePropertiesFragment : Fragment() {
override fun onResume() {
super.onResume()
driverViewModel.updateDriverNameForGame(args.game)
getPlayTime()
}
private fun setInsets() =

View file

@ -36,6 +36,7 @@
#include "common/scope_exit.h"
#include "common/settings.h"
#include "common/string_util.h"
#include "frontend_common/play_time_manager.h"
#include "core/core.h"
#include "core/cpu_manager.h"
#include "core/crypto/key_manager.h"
@ -85,6 +86,9 @@ std::atomic<int> g_battery_percentage = {100};
std::atomic<bool> g_is_charging = {false};
std::atomic<bool> g_has_battery = {true};
// playtime
std::unique_ptr<PlayTime::PlayTimeManager> play_time_manager;
EmulationSession::EmulationSession() {
m_vfs = std::make_shared<FileSys::RealVfsFilesystem>();
}
@ -733,6 +737,56 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_initializeEmptyUserDirectory(JNIEnv*
}
}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerInit(JNIEnv* env, jobject obj) {
// for some reason the full user directory isnt initialized in Android, so we need to create it
const auto play_time_dir = Common::FS::GetEdenPath(Common::FS::EdenPath::PlayTimeDir);
if (!Common::FS::IsDir(play_time_dir)) {
if (!Common::FS::CreateDir(play_time_dir)) {
LOG_WARNING(Frontend, "Failed to create play time directory");
}
}
play_time_manager = std::make_unique<PlayTime::PlayTimeManager>();
}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerStart(JNIEnv* env, jobject obj) {
if (play_time_manager) {
play_time_manager->SetProgramId(EmulationSession::GetInstance().System().GetApplicationProcessProgramID());
play_time_manager->Start();
}
}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerStop(JNIEnv* env, jobject obj) {
play_time_manager->Stop();
}
jlong Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerGetPlayTime(JNIEnv* env, jobject obj,
jstring jprogramId) {
u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
return play_time_manager->GetPlayTime(program_id);
}
jlong Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerGetCurrentTitleId(JNIEnv* env,
jobject obj) {
return EmulationSession::GetInstance().System().GetApplicationProcessProgramID();
}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerResetProgramPlayTime(JNIEnv* env, jobject obj,
jstring jprogramId) {
u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
if (play_time_manager) {
play_time_manager->ResetProgramPlayTime(program_id);
}
}
void Java_org_yuzu_yuzu_1emu_NativeLibrary_playTimeManagerSetPlayTime(JNIEnv* env, jobject obj,
jstring jprogramId, jlong playTimeSeconds) {
u64 program_id = EmulationSession::GetProgramId(env, jprogramId);
if (play_time_manager) {
play_time_manager->SetPlayTime(program_id, static_cast<u64>(playTimeSeconds));
}
}
jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_getAppletLaunchPath(JNIEnv* env, jclass clazz,
jlong jid) {
auto bis_system =

View file

@ -105,6 +105,16 @@
android:textAlignment="center"
tools:text="deko_basic" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/playtime"
style="?attr/textAppearanceBodyMedium"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="8dp"
android:textAlignment="center"
tools:text="Game Playtime" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton

View file

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp">
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_hours"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:hint="@string/hours"
app:boxBackgroundMode="outline">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_hours"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="4" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_minutes"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:hint="@string/minutes"
app:boxBackgroundMode="outline">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_minutes"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="2" />
</com.google.android.material.textfield.TextInputLayout>
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/layout_seconds"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:hint="@string/seconds"
app:boxBackgroundMode="outline">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/input_seconds"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="number"
android:maxLength="2" />
</com.google.android.material.textfield.TextInputLayout>
</LinearLayout>

View file

@ -74,12 +74,22 @@
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:layout_marginBottom="12dp"
android:layout_marginBottom="2dp"
android:layout_marginHorizontal="16dp"
android:requiresFadingEdge="horizontal"
android:textAlignment="center"
tools:text="deko_basic" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/playtime"
style="?attr/textAppearanceBodyMedium"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginBottom="8dp"
android:textAlignment="center"
tools:text="Game Playtime" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_properties"
android:layout_width="match_parent"

View file

@ -757,6 +757,18 @@
<string name="copy_details">Copy details</string>
<string name="add_ons">Add-ons</string>
<string name="add_ons_description">Toggle mods, updates and DLC</string>
<string name="playtime">Playtime:</string>
<string name="reset_playtime">Clear Playtime</string>
<string name="reset_playtime_description">Reset the current game\'s playtime back to 0 seconds</string>
<string name="reset_playtime_warning_description">This will clear the current game\'s playtime data. Are you sure?</string>
<string name="playtime_reset_successfully">Playtime has been reset</string>
<string name="edit_playtime">Edit Playtime</string>
<string name="hours">Hours</string>
<string name="minutes">Minutes</string>
<string name="hours_must_be_between_0_and_9999">Hours must be between 0 and 9999</string>
<string name="minutes_must_be_between_0_and_59">Minutes must be between 0 and 59</string>
<string name="seconds_must_be_between_0_and_59">Seconds must be between 0 and 59</string>
<string name="playtime_updated_successfully">Playtime updated successfully</string>
<string name="clear_shader_cache">Clear shader cache</string>
<string name="clear_shader_cache_description">Removes all shaders built while playing this game</string>
<string name="clear_shader_cache_warning_description">You will experience more stuttering as the shader cache regenerates</string>

View file

@ -1,3 +1,6 @@
# SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
# SPDX-License-Identifier: GPL-3.0-or-later
# SPDX-FileCopyrightText: 2023 yuzu Emulator Project
# SPDX-License-Identifier: GPL-2.0-or-later
@ -7,6 +10,8 @@ add_library(frontend_common STATIC
content_manager.h
firmware_manager.h
firmware_manager.cpp
play_time_manager.cpp
play_time_manager.h
)
create_target_directory_groups(frontend_common)

View file

@ -11,7 +11,10 @@
#include "common/settings.h"
#include "common/thread.h"
#include "core/hle/service/acc/profile_manager.h"
#include "yuzu/play_time_manager.h"
#include "play_time_manager.h"
#include <fmt/format.h>
#include <algorithm>
namespace PlayTime {
@ -22,19 +25,13 @@ struct PlayTimeElement {
PlayTime play_time;
};
std::optional<std::filesystem::path> GetCurrentUserPlayTimePath(
const Service::Account::ProfileManager& manager) {
const auto uuid = manager.GetUser(static_cast<s32>(Settings::values.current_user));
if (!uuid.has_value()) {
return std::nullopt;
}
std::optional<std::filesystem::path> GetCurrentUserPlayTimePath() {
return Common::FS::GetEdenPath(Common::FS::EdenPath::PlayTimeDir) /
uuid->RawString().append(".bin");
"playtime.bin";
}
[[nodiscard]] bool ReadPlayTimeFile(PlayTimeDatabase& out_play_time_db,
const Service::Account::ProfileManager& manager) {
const auto filename = GetCurrentUserPlayTimePath(manager);
[[nodiscard]] bool ReadPlayTimeFile(PlayTimeDatabase& out_play_time_db) {
const auto filename = GetCurrentUserPlayTimePath();
if (!filename.has_value()) {
LOG_ERROR(Frontend, "Failed to get current user path");
@ -69,9 +66,8 @@ std::optional<std::filesystem::path> GetCurrentUserPlayTimePath(
return true;
}
[[nodiscard]] bool WritePlayTimeFile(const PlayTimeDatabase& play_time_db,
const Service::Account::ProfileManager& manager) {
const auto filename = GetCurrentUserPlayTimePath(manager);
[[nodiscard]] bool WritePlayTimeFile(const PlayTimeDatabase& play_time_db) {
const auto filename = GetCurrentUserPlayTimePath();
if (!filename.has_value()) {
LOG_ERROR(Frontend, "Failed to get current user path");
@ -100,9 +96,9 @@ std::optional<std::filesystem::path> GetCurrentUserPlayTimePath(
} // namespace
PlayTimeManager::PlayTimeManager(Service::Account::ProfileManager& profile_manager)
: manager{profile_manager} {
if (!ReadPlayTimeFile(database, manager)) {
PlayTimeManager::PlayTimeManager()
: running_program_id() {
if (!ReadPlayTimeFile(database)) {
LOG_ERROR(Frontend, "Failed to read play time database! Resetting to default.");
}
}
@ -147,7 +143,7 @@ void PlayTimeManager::AutoTimestamp(std::stop_token stop_token) {
}
void PlayTimeManager::Save() {
if (!WritePlayTimeFile(database, manager)) {
if (!WritePlayTimeFile(database)) {
LOG_ERROR(Frontend, "Failed to update play time database!");
}
}
@ -161,24 +157,47 @@ u64 PlayTimeManager::GetPlayTime(u64 program_id) const {
}
}
void PlayTimeManager::SetPlayTime(u64 program_id, u64 play_time) {
database[program_id] = play_time;
Save();
}
void PlayTimeManager::ResetProgramPlayTime(u64 program_id) {
database.erase(program_id);
Save();
}
QString ReadablePlayTime(qulonglong time_seconds) {
std::string PlayTimeManager::GetReadablePlayTime(u64 time_seconds) {
if (time_seconds == 0) {
return {};
}
const auto time_minutes = (std::max)(static_cast<double>(time_seconds) / 60, 1.0);
const auto time_hours = static_cast<double>(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));
const auto time_minutes = std::max(static_cast<double>(time_seconds) / 60.0, 1.0);
const auto time_hours = static_cast<double>(time_seconds) / 3600.0;
const bool is_minutes = time_minutes < 60.0;
if (is_minutes) {
return fmt::format("{:.0f} m", time_minutes);
} else {
const bool has_remainder = time_seconds % 60 != 0;
if (has_remainder) {
return fmt::format("{:.1f} h", time_hours);
} else {
return fmt::format("{:.0f} h", time_hours);
}
}
}
std::string PlayTimeManager::GetPlayTimeHours(u64 time_seconds) {
return fmt::format("{}", time_seconds / 3600);
}
std::string PlayTimeManager::GetPlayTimeMinutes(u64 time_seconds) {
return fmt::format("{}", (time_seconds % 3600) / 60);
}
std::string PlayTimeManager::GetPlayTimeSeconds(u64 time_seconds) {
return fmt::format("{}", time_seconds % 60);
}
} // namespace PlayTime

View file

@ -6,8 +6,6 @@
#pragma once
#include <QString>
#include <map>
#include "common/common_funcs.h"
@ -27,7 +25,7 @@ using PlayTimeDatabase = std::map<ProgramId, PlayTime>;
class PlayTimeManager {
public:
explicit PlayTimeManager(Service::Account::ProfileManager& profile_manager);
explicit PlayTimeManager();
~PlayTimeManager();
YUZU_NON_COPYABLE(PlayTimeManager);
@ -36,9 +34,15 @@ public:
u64 GetPlayTime(u64 program_id) const;
void ResetProgramPlayTime(u64 program_id);
void SetProgramId(u64 program_id);
void SetPlayTime(u64 program_id, u64 play_time);
void Start();
void Stop();
static std::string GetReadablePlayTime(u64 time_seconds);
static std::string GetPlayTimeHours(u64 time_seconds);
static std::string GetPlayTimeMinutes(u64 time_seconds);
static std::string GetPlayTimeSeconds(u64 time_seconds);
private:
void AutoTimestamp(std::stop_token stop_token);
void Save();
@ -46,9 +50,7 @@ private:
PlayTimeDatabase database;
u64 running_program_id;
std::jthread play_time_thread;
Service::Account::ProfileManager& manager;
};
QString ReadablePlayTime(qulonglong time_seconds);
} // namespace PlayTime

View file

@ -27,7 +27,6 @@ add_library(qt_common STATIC
qt_rom_util.h qt_rom_util.cpp
qt_applet_util.h qt_applet_util.cpp
qt_progress_dialog.h qt_progress_dialog.cpp
)
create_target_directory_groups(qt_common)

View file

@ -198,11 +198,11 @@ add_executable(yuzu
multiplayer/state.cpp
multiplayer/state.h
multiplayer/validation.h
play_time_manager.cpp
play_time_manager.h
precompiled_headers.h
startup_checks.cpp
startup_checks.h
set_play_time_dialog.cpp
set_play_time_dialog.h
util/clickable_label.cpp
util/clickable_label.h
util/controller_navigation.cpp

View file

@ -557,13 +557,15 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
QAction* remove_update = remove_menu->addAction(tr("Remove Installed Update"));
QAction* remove_dlc = remove_menu->addAction(tr("Remove All Installed DLC"));
QAction* remove_custom_config = remove_menu->addAction(tr("Remove Custom Configuration"));
QAction* remove_play_time_data = remove_menu->addAction(tr("Remove Play Time Data"));
QAction* remove_cache_storage = remove_menu->addAction(tr("Remove Cache Storage"));
QAction* remove_gl_shader_cache = remove_menu->addAction(tr("Remove OpenGL Pipeline Cache"));
QAction* remove_vk_shader_cache = remove_menu->addAction(tr("Remove Vulkan Pipeline Cache"));
remove_menu->addSeparator();
QAction* remove_shader_cache = remove_menu->addAction(tr("Remove All Pipeline Caches"));
QAction* remove_all_content = remove_menu->addAction(tr("Remove All Installed Contents"));
QMenu* play_time_menu = context_menu.addMenu(tr("Manage Play Time"));
QAction* set_play_time = play_time_menu->addAction(tr("Edit Play Time Data"));
crueter marked this conversation as resolved

"Edit" would work better here

"Edit" would work better here
QAction* remove_play_time_data = play_time_menu->addAction(tr("Remove Play Time Data"));
QMenu* dump_romfs_menu = context_menu.addMenu(tr("Dump RomFS"));
QAction* dump_romfs = dump_romfs_menu->addAction(tr("Dump RomFS"));
QAction* dump_romfs_sdmc = dump_romfs_menu->addAction(tr("Dump RomFS to SDMC"));
@ -629,6 +631,8 @@ void GameList::AddGamePopup(QMenu& context_menu, u64 program_id, const std::stri
connect(remove_custom_config, &QAction::triggered, [this, program_id, path]() {
emit RemoveFileRequested(program_id, QtCommon::Game::GameListRemoveTarget::CustomConfiguration, path);
});
connect(set_play_time, &QAction::triggered,
[this, program_id]() { emit SetPlayTimeRequested(program_id); });
connect(remove_play_time_data, &QAction::triggered,
[this, program_id]() { emit RemovePlayTimeRequested(program_id); });
connect(remove_cache_storage, &QAction::triggered, [this, program_id, path] {

View file

@ -23,7 +23,7 @@
#include "qt_common/uisettings.h"
#include "qt_common/qt_game_util.h"
#include "yuzu/compatibility_list.h"
#include "yuzu/play_time_manager.h"
#include "frontend_common/play_time_manager.h"
namespace Core {
class System;
@ -104,6 +104,7 @@ signals:
void RemoveFileRequested(u64 program_id, QtCommon::Game::GameListRemoveTarget target,
const std::string& game_path);
void RemovePlayTimeRequested(u64 program_id);
void SetPlayTimeRequested(u64 program_id);
void DumpRomFSRequested(u64 program_id, const std::string& game_path, DumpRomFSTarget target);
void VerifyIntegrityRequested(const std::string& game_path);
void CopyTIDRequested(u64 program_id);

View file

@ -21,7 +21,7 @@
#include "common/common_types.h"
#include "common/logging/log.h"
#include "common/string_util.h"
#include "yuzu/play_time_manager.h"
#include "frontend_common/play_time_manager.h"
#include "qt_common/uisettings.h"
#include "yuzu/util/util.h"
@ -241,7 +241,7 @@ public:
void setData(const QVariant& value, int role) override {
qulonglong time_seconds = value.toULongLong();
GameListItem::setData(PlayTime::ReadablePlayTime(time_seconds), Qt::DisplayRole);
GameListItem::setData(QString::fromStdString(PlayTime::PlayTimeManager::GetReadablePlayTime(time_seconds)), Qt::DisplayRole);
GameListItem::setData(value, PlayTimeRole);
}

View file

@ -20,7 +20,7 @@
#include "core/file_sys/registered_cache.h"
#include "qt_common/uisettings.h"
#include "yuzu/compatibility_list.h"
#include "yuzu/play_time_manager.h"
#include "frontend_common/play_time_manager.h"
namespace Core {
class System;

View file

@ -15,6 +15,8 @@
#include <memory>
#include <thread>
#include "set_play_time_dialog.h"
#ifdef __APPLE__
#include <unistd.h> // for chdir
#endif
@ -163,7 +165,7 @@ static FileSys::VirtualFile VfsDirectoryCreateFileWrapper(const FileSys::Virtual
#include "yuzu/install_dialog.h"
#include "yuzu/loading_screen.h"
#include "yuzu/main.h"
#include "yuzu/play_time_manager.h"
#include "frontend_common/play_time_manager.h"
#include "yuzu/startup_checks.h"
#include "qt_common/uisettings.h"
#include "yuzu/util/clickable_label.h"
@ -447,7 +449,7 @@ GMainWindow::GMainWindow(bool has_broken_vulkan)
SetDiscordEnabled(UISettings::values.enable_discord_presence.GetValue());
discord_rpc->Update();
play_time_manager = std::make_unique<PlayTime::PlayTimeManager>(QtCommon::system->GetProfileManager());
play_time_manager = std::make_unique<PlayTime::PlayTimeManager>();
Network::Init();
@ -1574,6 +1576,8 @@ void GMainWindow::ConnectWidgetEvents() {
connect(game_list, &GameList::RemoveFileRequested, this, &GMainWindow::OnGameListRemoveFile);
connect(game_list, &GameList::RemovePlayTimeRequested, this,
&GMainWindow::OnGameListRemovePlayTimeData);
connect(game_list, &GameList::SetPlayTimeRequested, this,
&GMainWindow::OnGameListSetPlayTime);
connect(game_list, &GameList::DumpRomFSRequested, this, &GMainWindow::OnGameListDumpRomFS);
connect(game_list, &GameList::VerifyIntegrityRequested, this,
&GMainWindow::OnGameListVerifyIntegrity);
@ -2634,6 +2638,19 @@ void GMainWindow::OnGameListRemoveFile(u64 program_id, QtCommon::Game::GameListR
}
}
void GMainWindow::OnGameListSetPlayTime(u64 program_id) {
const u64 current_play_time = play_time_manager->GetPlayTime(program_id);
crueter marked this conversation as resolved

We can probably make this a .ui file

We can probably make this a .ui file
SetPlayTimeDialog dialog(this, current_play_time);
if (dialog.exec() == QDialog::Accepted) {
const u64 total_seconds = dialog.GetTotalSeconds();
play_time_manager->SetPlayTime(program_id, total_seconds);
crueter marked this conversation as resolved

These can be spin boxes

These can be spin boxes
game_list->PopulateAsync(UISettings::values.game_dirs);
}
}
crueter marked this conversation as resolved

And if we do go with QSpinBox you can just set the range and this won't be necessary

And if we do go with QSpinBox you can just set the range and this won't be necessary
void GMainWindow::OnGameListRemovePlayTimeData(u64 program_id) {
if (QMessageBox::question(this, tr("Remove Play Time Data"), tr("Reset play time?"),
QMessageBox::Yes | QMessageBox::No,

View file

@ -345,6 +345,7 @@ private slots:
void OnGameListRemoveFile(u64 program_id, QtCommon::Game::GameListRemoveTarget target,
const std::string& game_path);
void OnGameListRemovePlayTimeData(u64 program_id);
void OnGameListSetPlayTime(u64 program_id);
void OnGameListDumpRomFS(u64 program_id, const std::string& game_path, DumpRomFSTarget target);
void OnGameListVerifyIntegrity(const std::string& game_path);
void OnGameListCopyTID(u64 program_id);

View file

@ -0,0 +1,49 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#include "yuzu/set_play_time_dialog.h"
#include "frontend_common/play_time_manager.h"
#include "ui_set_play_time_dialog.h"
SetPlayTimeDialog::SetPlayTimeDialog(QWidget* parent, u64 current_play_time)
: QDialog(parent), ui{std::make_unique<Ui::SetPlayTimeDialog>()} {
ui->setupUi(this);
ui->hoursSpinBox->setValue(
QString::fromStdString(PlayTime::PlayTimeManager::GetPlayTimeHours(current_play_time)).toInt());
ui->minutesSpinBox->setValue(
QString::fromStdString(PlayTime::PlayTimeManager::GetPlayTimeMinutes(current_play_time)).toInt());
ui->secondsSpinBox->setValue(
QString::fromStdString(PlayTime::PlayTimeManager::GetPlayTimeSeconds(current_play_time)).toInt());
connect(ui->hoursSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), this,
&SetPlayTimeDialog::OnValueChanged);
connect(ui->minutesSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), this,
&SetPlayTimeDialog::OnValueChanged);
connect(ui->secondsSpinBox, QOverload<int>::of(&QSpinBox::valueChanged), this,
&SetPlayTimeDialog::OnValueChanged);
}
SetPlayTimeDialog::~SetPlayTimeDialog() = default;
u64 SetPlayTimeDialog::GetTotalSeconds() const {
const u64 hours = static_cast<u64>(ui->hoursSpinBox->value());
const u64 minutes = static_cast<u64>(ui->minutesSpinBox->value());
const u64 seconds = static_cast<u64>(ui->secondsSpinBox->value());
return hours * 3600 + minutes * 60 + seconds;
}
void SetPlayTimeDialog::OnValueChanged() {
if (ui->errorLabel->isVisible()) {
ui->errorLabel->setVisible(false);
}
const u64 total_seconds = GetTotalSeconds();
constexpr u64 max_reasonable_time = 9999ULL * 3600;
if (total_seconds > max_reasonable_time) {
ui->errorLabel->setText(tr("Total play time reached maximum."));
ui->errorLabel->setVisible(true);
}
}

View file

@ -0,0 +1,27 @@
// SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QDialog>
#include <memory>
#include "common/common_types.h"
namespace Ui {
class SetPlayTimeDialog;
}
class SetPlayTimeDialog : public QDialog {
Q_OBJECT
public:
explicit SetPlayTimeDialog(QWidget* parent, u64 current_play_time);
~SetPlayTimeDialog() override;
u64 GetTotalSeconds() const;
private:
void OnValueChanged();
std::unique_ptr<Ui::SetPlayTimeDialog> ui;
};

View file

@ -0,0 +1,123 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>SetPlayTimeDialog</class>
<widget class="QDialog" name="SetPlayTimeDialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>400</width>
<height>150</height>
</rect>
</property>
<property name="windowTitle">
<string>Set Play Time Data</string>
</property>
<property name="modal">
<bool>true</bool>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<layout class="QHBoxLayout" name="inputLayout">
<item>
<widget class="QLabel" name="labelHours">
<property name="text">
<string>Hours:</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="hoursSpinBox">
<property name="maximum">
<number>9999</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelMinutes">
<property name="text">
<string>Minutes:</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="minutesSpinBox">
<property name="maximum">
<number>59</number>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="labelSeconds">
<property name="text">
<string>Seconds:</string>
</property>
</widget>
</item>
<item>
<widget class="QSpinBox" name="secondsSpinBox">
<property name="maximum">
<number>59</number>
</property>
</widget>
</item>
</layout>
</item>
<item>
<widget class="QLabel" name="errorLabel">
<property name="styleSheet">
<string notr="true">QLabel { color : red; }</string>
</property>
<property name="text">
<string/>
</property>
<property name="visible">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QDialogButtonBox" name="buttonBox">
<property name="standardButtons">
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections>
<connection>
<sender>buttonBox</sender>
<signal>accepted()</signal>
<receiver>SetPlayTimeDialog</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>199</x>
<y>129</y>
</hint>
<hint type="destinationlabel">
<x>199</x>
<y>74</y>
</hint>
</hints>
</connection>
<connection>
<sender>buttonBox</sender>
<signal>rejected()</signal>
<receiver>SetPlayTimeDialog</receiver>
<slot>reject()</slot>
<hints>
<hint type="sourcelabel">
<x>199</x>
<y>129</y>
</hint>
<hint type="destinationlabel">
<x>199</x>
<y>74</y>
</hint>
</hints>
</connection>
</connections>
</ui>