diff options
| author | inix <Nixy01@proton.me> | 2025-10-17 22:47:43 +0200 |
|---|---|---|
| committer | crueter <crueter@eden-emu.dev> | 2025-10-17 22:47:43 +0200 |
| commit | 6bdf479488dd87a35bf5ccf9ec334f49bf43ffc7 (patch) | |
| tree | 166376be36272e3177cc2fd474cc24c7eb9d1360 /src | |
| parent | 9c7ed0f59dd41ec0bbbfbc81678209ae60a36016 (diff) | |
[core, android] Initial playtime implementation (#2535)
So firstly, playtime code is moved to src/common and qt specific code to yuzu/utils.cpp.
The dependency on ProfileManager was removed because it was working properly on Android, and I think a shared playtime is better behavior.
Now, playtime is stored in a file called "playtime.bin".
JNI code is from Azahar although modified by me, as well as that I added code to reset the game's playtime which was missing for some reason on there.
Before this gets merged, I plan to add the ability to manually edit the database as well.
Note: Code still needs a bit of cleanup.
Reviewed-on: https://git.eden-emu.dev/eden-emu/eden/pulls/2535
Reviewed-by: CamilleLaVey <camillelavey99@gmail.com>
Reviewed-by: Lizzie <lizzie@eden-emu.dev>
Reviewed-by: crueter <crueter@eden-emu.dev>
Reviewed-by: MaranBr <maranbr@eden-emu.dev>
Co-authored-by: inix <Nixy01@proton.me>
Co-committed-by: inix <Nixy01@proton.me>
Diffstat (limited to 'src')
23 files changed, 587 insertions, 46 deletions
diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt index ba50bcad34..ef8e2bafe9 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/NativeLibrary.kt @@ -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() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt index e9a0d5a75c..daea2b0370 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/YuzuApplication.kt @@ -53,6 +53,7 @@ class YuzuApplication : Application() { application = this documentsTree = DocumentsTree() DirectoryInitialization.start() + NativeLibrary.playTimeManagerInit() GpuDriverHelper.initializeDriverParameters() NativeInput.reloadInputDevices() NativeLibrary.logDeviceInfo() diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt index ba60aeace2..cee68689c3 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/activities/EmulationActivity.kt @@ -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 @@ -326,6 +327,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) @@ -530,6 +536,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener { fun onEmulationStarted() { emulationViewModel.setEmulationStarted(true) + NativeLibrary.playTimeManagerStart() + } fun onEmulationStopped(status: Int) { diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt index b2d6135372..5f49399406 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/EmulationFragment.kt @@ -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.") diff --git a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt index 6979409cad..bc6272b857 100644 --- a/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt +++ b/src/android/app/src/main/java/org/yuzu/yuzu_emu/fragments/GamePropertiesFragment.kt @@ -1,9 +1,6 @@ // SPDX-FileCopyrightText: Copyright 2025 Eden Emulator Project // SPDX-License-Identifier: GPL-3.0-or-later -// SPDX-FileCopyrightText: 2025 Eden Emulator Project -// SPDX-License-Identifier: GPL-3.0-or-later - package org.yuzu.yuzu_emu.fragments import android.content.Intent @@ -31,6 +28,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 @@ -112,6 +110,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) @@ -136,6 +136,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 @@ -324,6 +427,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 { @@ -336,6 +464,7 @@ class GamePropertiesFragment : Fragment() { override fun onResume() { super.onResume() driverViewModel.updateDriverNameForGame(args.game) + getPlayTime() reloadList() } diff --git a/src/android/app/src/main/jni/native.cpp b/src/android/app/src/main/jni/native.cpp index 306b7e2a4c..78f0ec1521 100644 --- a/src/android/app/src/main/jni/native.cpp +++ b/src/android/app/src/main/jni/native.cpp @@ -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 = diff --git a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml index 7cdef569f6..0fa2fa2a29 100644 --- a/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml +++ b/src/android/app/src/main/res/layout-w600dp/fragment_game_properties.xml @@ -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 diff --git a/src/android/app/src/main/res/layout/dialog_edit_playtime.xml b/src/android/app/src/main/res/layout/dialog_edit_playtime.xml new file mode 100644 index 0000000000..e20508125f --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_edit_playtime.xml @@ -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> diff --git a/src/android/app/src/main/res/layout/fragment_game_properties.xml b/src/android/app/src/main/res/layout/fragment_game_properties.xml index 5e3f3cf280..9a3437404f 100644 --- a/src/android/app/src/main/res/layout/fragment_game_properties.xml +++ b/src/android/app/src/main/res/layout/fragment_game_properties.xml @@ -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" diff --git a/src/android/app/src/main/res/values/strings.xml b/src/android/app/src/main/res/values/strings.xml index fd1e7d42fc..37f215e871 100644 --- a/src/android/app/src/main/res/values/strings.xml +++ b/src/android/app/src/main/res/values/strings.xml @@ -756,6 +756,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> diff --git a/src/frontend_common/CMakeLists.txt b/src/frontend_common/CMakeLists.txt index aa44740596..c9ce6f7ad4 100644 --- a/src/frontend_common/CMakeLists.txt +++ b/src/frontend_common/CMakeLists.txt @@ -11,6 +11,8 @@ add_library(frontend_common STATIC firmware_manager.h firmware_manager.cpp data_manager.h data_manager.cpp + play_time_manager.cpp + play_time_manager.h ) create_target_directory_groups(frontend_common) diff --git a/src/yuzu/play_time_manager.cpp b/src/frontend_common/play_time_manager.cpp index 6d06bc7614..782433392f 100644 --- a/src/yuzu/play_time_manager.cpp +++ b/src/frontend_common/play_time_manager.cpp @@ -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 diff --git a/src/yuzu/play_time_manager.h b/src/frontend_common/play_time_manager.h index cd81bdb061..a99ccebb1e 100644 --- a/src/yuzu/play_time_manager.h +++ b/src/frontend_common/play_time_manager.h @@ -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 diff --git a/src/yuzu/CMakeLists.txt b/src/yuzu/CMakeLists.txt index 9d584f1ac9..ee45b2a79a 100644 --- a/src/yuzu/CMakeLists.txt +++ b/src/yuzu/CMakeLists.txt @@ -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 diff --git a/src/yuzu/game_list.cpp b/src/yuzu/game_list.cpp index 523071eef5..875e794181 100644 --- a/src/yuzu/game_list.cpp +++ b/src/yuzu/game_list.cpp @@ -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")); + 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] { diff --git a/src/yuzu/game_list.h b/src/yuzu/game_list.h index 802c26f971..b4cdf61f0d 100644 --- a/src/yuzu/game_list.h +++ b/src/yuzu/game_list.h @@ -23,7 +23,7 @@ #include "qt_common/config/uisettings.h" #include "qt_common/util/game.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); diff --git a/src/yuzu/game_list_p.h b/src/yuzu/game_list_p.h index 21bb9238a9..ea11d34865 100644 --- a/src/yuzu/game_list_p.h +++ b/src/yuzu/game_list_p.h @@ -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/config/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); } diff --git a/src/yuzu/game_list_worker.h b/src/yuzu/game_list_worker.h index edcb3e531c..1bbb024df3 100644 --- a/src/yuzu/game_list_worker.h +++ b/src/yuzu/game_list_worker.h @@ -20,7 +20,7 @@ #include "core/file_sys/registered_cache.h" #include "qt_common/config/uisettings.h" #include "yuzu/compatibility_list.h" -#include "yuzu/play_time_manager.h" +#include "frontend_common/play_time_manager.h" namespace Core { class System; diff --git a/src/yuzu/main.cpp b/src/yuzu/main.cpp index 98915c48ef..4f9dd4f9cd 100644 --- a/src/yuzu/main.cpp +++ b/src/yuzu/main.cpp @@ -15,6 +15,8 @@ #include <memory> #include <thread> +#include "set_play_time_dialog.h" + #ifdef __APPLE__ #include <unistd.h> // for chdir #endif @@ -164,7 +166,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/config/uisettings.h" #include "yuzu/util/clickable_label.h" @@ -448,7 +450,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(); @@ -1576,6 +1578,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); @@ -2636,6 +2640,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); + + 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); + game_list->PopulateAsync(UISettings::values.game_dirs); + } +} + + void GMainWindow::OnGameListRemovePlayTimeData(u64 program_id) { if (QMessageBox::question(this, tr("Remove Play Time Data"), tr("Reset play time?"), QMessageBox::Yes | QMessageBox::No, diff --git a/src/yuzu/main.h b/src/yuzu/main.h index d1f47f42e2..1c2035fbab 100644 --- a/src/yuzu/main.h +++ b/src/yuzu/main.h @@ -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); diff --git a/src/yuzu/set_play_time_dialog.cpp b/src/yuzu/set_play_time_dialog.cpp new file mode 100644 index 0000000000..c0f1f0be22 --- /dev/null +++ b/src/yuzu/set_play_time_dialog.cpp @@ -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); + } +} diff --git a/src/yuzu/set_play_time_dialog.h b/src/yuzu/set_play_time_dialog.h new file mode 100644 index 0000000000..75513539e5 --- /dev/null +++ b/src/yuzu/set_play_time_dialog.h @@ -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; +}; diff --git a/src/yuzu/set_play_time_dialog.ui b/src/yuzu/set_play_time_dialog.ui new file mode 100644 index 0000000000..dca1c7f1a7 --- /dev/null +++ b/src/yuzu/set_play_time_dialog.ui @@ -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> |
