Compare commits

...

1 commit

Author SHA1 Message Date
nyx
d71814a68b WIP: [core, android] Initial playtime implementation
Some checks failed
eden-license / license-header (pull_request) Failing after 17s
JNI code is from Azahar although modified
2025-09-20 08:01:08 +02:00
19 changed files with 178 additions and 47 deletions

View file

@ -206,6 +206,16 @@ 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)
var coreErrorAlertResult = false
val coreErrorAlertLock = Object()

View file

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

View file

@ -59,6 +59,7 @@ import org.yuzu.yuzu_emu.utils.ParamPackage
import org.yuzu.yuzu_emu.utils.ThemeHelper
import java.text.NumberFormat
import kotlin.math.roundToInt
import androidx.core.os.BundleCompat
class EmulationActivity : AppCompatActivity(), SensorEventListener {
private lateinit var binding: ActivityEmulationBinding
@ -306,6 +307,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)
@ -478,6 +484,8 @@ class EmulationActivity : AppCompatActivity(), SensorEventListener {
fun onEmulationStarted() {
emulationViewModel.setEmulationStarted(true)
NativeLibrary.playTimeManagerStart()
}
fun onEmulationStopped(status: Int) {

View file

@ -1616,6 +1616,7 @@ class EmulationFragment : Fragment(), SurfaceHolder.Callback {
Log.debug("[EmulationFragment] Pausing emulation.")
NativeLibrary.pauseEmulation()
NativeLibrary.playTimeManagerStop()
state = State.PAUSED
} else {
@ -1706,6 +1707,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

@ -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,25 @@ 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("Playtime: ")
append(readablePlayTime)
}
}
private fun reloadList() {
_binding ?: return
@ -272,6 +294,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 {

View file

@ -36,6 +36,7 @@
#include "common/scope_exit.h"
#include "common/settings.h"
#include "common/string_util.h"
#include "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,48 @@ 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);
}
}
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"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@+id/about_game_filename"
android:ellipsize="none"
tools:text="Game Playtime" />
</LinearLayout>
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton

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"
app:layout_constraintStart_toStartOf="@id/title"
app:layout_constraintTop_toBottomOf="@+id/about_game_filename"
tools:text="Game Playtime" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list_properties"
android:layout_width="match_parent"

View file

@ -1603,4 +1603,9 @@ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</string>
<string name="playtime_format">Playtime: %1$d h, %2$d m</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>
</resources>

View file

@ -107,6 +107,8 @@ add_library(
parent_of_member.h
point.h
precompiled_headers.h
play_time_manager.cpp
play_time_manager.h
quaternion.h
range_map.h
range_mutex.h

View file

@ -11,7 +11,7 @@
#include "common/settings.h"
#include "common/thread.h"
#include "core/hle/service/acc/profile_manager.h"
#include "yuzu/play_time_manager.h"
#include "common/play_time_manager.h"
namespace PlayTime {
@ -22,19 +22,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 +63,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 +93,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 +140,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!");
}
}
@ -166,19 +159,4 @@ void PlayTimeManager::ResetProgramPlayTime(u64 program_id) {
Save();
}
QString ReadablePlayTime(qulonglong 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));
}
} // 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);
@ -46,9 +44,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

@ -198,8 +198,6 @@ 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

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 "common/play_time_manager.h"
namespace Core {
class System;

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 "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(ReadablePlayTime(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 "common/play_time_manager.h""
namespace Core {
class System;

View file

@ -163,7 +163,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 "common/play_time_manager.h"
#include "yuzu/startup_checks.h"
#include "qt_common/uisettings.h"
#include "yuzu/util/clickable_label.h"
@ -447,7 +447,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();

View file

@ -48,6 +48,21 @@ QPixmap CreateCirclePixmapFromColor(const QColor& color) {
return circle_pixmap;
}
QString ReadableDuration(qulonglong 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));
}
bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image) {
#if defined(WIN32)
#pragma pack(push, 2)

View file

@ -27,3 +27,6 @@
* @return bool If the operation succeeded
*/
[[nodiscard]] bool SaveIconToFile(const std::filesystem::path& icon_path, const QImage& image);
// Converts a length of time in seconds into a readable format
QString ReadableDuration(qulonglong time_seconds);