custom covers option update gh pages refresh button

This commit is contained in:
izzy2lost
2025-11-16 21:27:53 -05:00
parent 9ff98c8b4c
commit d7cdd49f7f
18 changed files with 1083 additions and 180 deletions
+5 -4
View File
@@ -1,10 +1,11 @@
# PSX2 - PlayStation 2 Emulator for Android
# 🎮 PSX2 - PlayStation 2 Emulator for Android
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://izzy2lost.github.io/PSX2/license.html)
[![Android](https://img.shields.io/badge/Platform-Android-green.svg)](https://developer.android.com/)
[![ARM64](https://img.shields.io/badge/Architecture-ARM64-orange.svg)](https://developer.arm.com/)
[![GitHub](https://img.shields.io/badge/GitHub-izzy2lost%2FPSX2-black.svg)](https://github.com/izzy2lost/PSX2)
PSX2 is a high-performance PlayStation 2 emulator for Android devices, bringing your favorite PS2 games to mobile platforms with enhanced graphics and modern features.
**PSX2** is a high-performance PlayStation 2 emulator for Android devices, bringing your favorite PS2 games to mobile platforms with enhanced graphics and modern features. Experience authentic retro gaming on the go.
## 🎮 About
@@ -154,7 +155,7 @@ PSX2 aims for high compatibility with the PlayStation 2 library. Performance var
### License
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details.
This project is licensed under the **GNU General Public License v3.0** - see the [LICENSE](LICENSE) file for details or view it on our [GitHub Pages](https://izzy2lost.github.io/PSX2/license.html).
### Third-Party Licenses
+1
View File
@@ -115,6 +115,7 @@ dependencies {
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
implementation 'androidx.documentfile:documentfile:1.1.0'
implementation 'androidx.recyclerview:recyclerview:1.4.0'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.activity:activity:1.10.1'
implementation 'androidx.core:core-ktx:1.15.0'
implementation 'com.github.bumptech.glide:glide:4.16.0'
+5
View File
@@ -279,4 +279,9 @@ namespace AchievementsJNI
env->DeleteLocalRef(j_message);
}
JavaVM* GetJavaVM()
{
return s_jvm;
}
} // namespace AchievementsJNI
+3
View File
@@ -41,4 +41,7 @@ namespace AchievementsJNI
/// Show a generic notification
void ShowNotification(const char* message, int duration);
/// Get the JavaVM instance (for internal use)
JavaVM* GetJavaVM();
} // namespace AchievementsJNI
@@ -7,6 +7,7 @@
#include "rc_client.h"
#include "pcsx2/Achievements.h"
#include "pcsx2/Config.h"
#include "pcsx2/Host.h"
#include "common/Console.h"
#include "common/Error.h"
@@ -241,4 +242,28 @@ Java_com_izzy2lost_psx2_NativeApp_achievementsSetHardcoreMode(JNIEnv* env, jclas
__android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Hardcore mode will apply on next game load");
}
JNIEXPORT void JNICALL
Java_com_izzy2lost_psx2_NativeApp_achievementsLoginWithToken(JNIEnv* env, jclass clazz,
jstring username, jstring token)
{
if (!username || !token)
{
__android_log_print(ANDROID_LOG_ERROR, "PCSX2Achievements", "Login with token called with null username or token");
return;
}
const char* username_str = env->GetStringUTFChars(username, nullptr);
const char* token_str = env->GetStringUTFChars(token, nullptr);
__android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Attempting token login for user: %s", username_str);
// Store credentials using Host functions
Host::SetBaseStringSettingValue("Achievements", "Username", username_str);
Host::SetBaseStringSettingValue("Achievements", "Token", token_str);
__android_log_print(ANDROID_LOG_INFO, "PCSX2Achievements", "Token login credentials set");
env->ReleaseStringUTFChars(username, username_str);
env->ReleaseStringUTFChars(token, token_str);
}
} // extern "C"
+89 -1
View File
@@ -1378,7 +1378,95 @@ Java_com_izzy2lost_psx2_NativeApp_getImageSlot(JNIEnv *env, jclass clazz, jint p
void Host::CommitBaseSettingChanges()
{
// nothing to save, we're all in memory
// Save achievements settings to Android SharedPreferences
// This is called after login to persist the token
auto lock = Host::GetSettingsLock();
SettingsInterface* si = Host::GetSettingsInterface();
if (!si)
{
__android_log_print(ANDROID_LOG_ERROR, "PCSX2", "No settings interface available");
return;
}
// Get achievements credentials from settings
std::string username = si->GetStringValue("Achievements", "Username", "");
std::string token = si->GetStringValue("Achievements", "Token", "");
std::string loginTimestamp = si->GetStringValue("Achievements", "LoginTimestamp", "");
if (username.empty() && token.empty())
{
// Nothing to save
return;
}
__android_log_print(ANDROID_LOG_INFO, "PCSX2", "Saving achievements credentials to SharedPreferences");
// Call the Java method to save to SharedPreferences
// We'll use JNI to call NativeApp.saveAchievementsCredentials()
JavaVM* jvm = AchievementsJNI::GetJavaVM();
if (!jvm)
{
__android_log_print(ANDROID_LOG_ERROR, "PCSX2", "JavaVM not available");
return;
}
JNIEnv* env = nullptr;
bool attached = false;
// Get JNI environment
if (jvm->GetEnv(reinterpret_cast<void**>(&env), JNI_VERSION_1_6) != JNI_OK)
{
// Try to attach current thread
if (jvm->AttachCurrentThread(&env, nullptr) == JNI_OK)
{
attached = true;
}
else
{
__android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Failed to attach thread to JVM");
return;
}
}
// Find the NativeApp class and saveAchievementsCredentials method
jclass nativeAppClass = env->FindClass("com/izzy2lost/psx2/NativeApp");
if (nativeAppClass)
{
jmethodID saveMethod = env->GetStaticMethodID(nativeAppClass, "saveAchievementsCredentials",
"(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V");
if (saveMethod)
{
jstring jUsername = env->NewStringUTF(username.c_str());
jstring jToken = env->NewStringUTF(token.c_str());
jstring jTimestamp = env->NewStringUTF(loginTimestamp.c_str());
env->CallStaticVoidMethod(nativeAppClass, saveMethod, jUsername, jToken, jTimestamp);
env->DeleteLocalRef(jUsername);
env->DeleteLocalRef(jToken);
env->DeleteLocalRef(jTimestamp);
__android_log_print(ANDROID_LOG_INFO, "PCSX2", "Achievements credentials saved successfully");
}
else
{
__android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Could not find saveAchievementsCredentials method");
env->ExceptionClear();
}
env->DeleteLocalRef(nativeAppClass);
}
else
{
__android_log_print(ANDROID_LOG_ERROR, "PCSX2", "Could not find NativeApp class");
env->ExceptionClear();
}
// Detach thread if we attached it
if (attached)
{
jvm->DetachCurrentThread();
}
}
void Host::LoadSettings(SettingsInterface& si, std::unique_lock<std::mutex>& lock)
@@ -282,7 +282,7 @@ public class AchievementsDialogFragment extends DialogFragment {
editor.putString(PREF_USERNAME, username);
editor.putBoolean(PREF_REMEMBER_ME, rememberMe);
// Save password only if remember me is checked
// Save password only if remember me is checked (for fallback)
if (rememberMe) {
editor.putString(PREF_SAVED_PASSWORD, password);
} else {
@@ -296,6 +296,9 @@ public class AchievementsDialogFragment extends DialogFragment {
NativeApp.achievementsLogin(username, password);
android.util.Log.d("Achievements", "Native login call completed");
// The token will be automatically saved by Host::CommitBaseSettingChanges()
// which is called from the native login callback
// Wait a bit for the login to process
try {
Thread.sleep(2000);
@@ -309,7 +312,7 @@ public class AchievementsDialogFragment extends DialogFragment {
android.util.Log.d("Achievements", "After login - isActive: " + isActive);
if (isActive) {
Toast.makeText(requireContext(), "Login successful!", Toast.LENGTH_SHORT).show();
Toast.makeText(requireContext(), "Login successful! Token saved for auto-login.", Toast.LENGTH_SHORT).show();
} else {
Toast.makeText(requireContext(), "Login may have failed - check logs", Toast.LENGTH_LONG).show();
}
@@ -323,9 +326,11 @@ public class AchievementsDialogFragment extends DialogFragment {
private void performLogout() {
NativeApp.achievementsLogout();
// Clear saved password on logout
// Clear saved credentials including token
getPrefs().edit()
.remove(PREF_SAVED_PASSWORD)
.remove("token")
.remove("login_timestamp")
.putBoolean(PREF_REMEMBER_ME, false)
.apply();
@@ -29,6 +29,7 @@ public class GameSettingsDialogFragment extends DialogFragment {
// File picker state
private ActivityResultLauncher<Intent> mPnachPicker;
private ActivityResultLauncher<Intent> mCoverPicker;
private boolean mImportAsCheats = true;
public static GameSettingsDialogFragment newInstance(String gameTitle, String gameUri, String gameSerial, String gameCrc) {
@@ -114,6 +115,96 @@ public class GameSettingsDialogFragment extends DialogFragment {
}
});
}
// Register cover picker
if (mCoverPicker == null) {
mCoverPicker = registerForActivityResult(new ActivityResultContracts.StartActivityForResult(), result -> {
try {
if (result.getResultCode() != Activity.RESULT_OK) return;
Intent data = result.getData(); if (data == null) return;
Uri uri = data.getData(); if (uri == null) return;
Bundle args = getArguments();
String gameSerial = args != null ? args.getString(ARG_GAME_SERIAL, "") : "";
if (gameSerial == null || gameSerial.isEmpty()) {
try { gameSerial = NativeApp.getCurrentGameSerial(); } catch (Throwable ignored) {}
}
if (gameSerial == null || gameSerial.isEmpty()) {
android.widget.Toast.makeText(ctx, "Serial unknown; cannot set cover", android.widget.Toast.LENGTH_SHORT).show();
return;
}
final String serial = gameSerial;
android.widget.Toast.makeText(ctx, "Processing cover...", android.widget.Toast.LENGTH_SHORT).show();
// Process in background to avoid blocking UI
new Thread(() -> {
try {
// Load and resize the image
android.content.ContentResolver cr = ctx.getContentResolver();
java.io.InputStream in = cr.openInputStream(uri);
if (in == null) throw new Exception("Cannot open image");
android.graphics.Bitmap original = android.graphics.BitmapFactory.decodeStream(in);
in.close();
if (original == null) throw new Exception("Cannot decode image");
// Resize to standard cover dimensions (567x878 for PS2 covers)
int targetWidth = 567;
int targetHeight = 878;
android.graphics.Bitmap resized = android.graphics.Bitmap.createScaledBitmap(
original, targetWidth, targetHeight, true);
original.recycle();
// Save to SAF location only
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(ctx, new String[]{"covers"}, serial + ".png");
if (existing != null && existing.exists()) {
existing.delete();
}
androidx.documentfile.provider.DocumentFile newFile = SafManager.createChild(ctx, new String[]{"covers"}, serial + ".png", "image/png");
if (newFile != null) {
java.io.OutputStream out = ctx.getContentResolver().openOutputStream(newFile.getUri(), "w");
if (out != null) {
resized.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out);
out.flush();
out.close();
}
}
resized.recycle();
// Mark as custom cover
ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
.edit()
.putBoolean("custom_cover:" + serial, true)
.apply();
// Clear Glide cache on background thread
try {
com.bumptech.glide.Glide.get(ctx).clearDiskCache();
} catch (Throwable ignored) {}
// Show success message and clear memory cache on UI thread
if (getActivity() != null && !getActivity().isFinishing()) {
getActivity().runOnUiThread(() -> {
try {
com.bumptech.glide.Glide.get(ctx).clearMemory();
} catch (Throwable ignored) {}
android.widget.Toast.makeText(ctx, "Cover saved for " + serial, android.widget.Toast.LENGTH_SHORT).show();
});
}
} catch (Exception e) {
android.util.Log.e("GameSettings", "Error saving cover: " + e.getMessage(), e);
if (getActivity() != null && !getActivity().isFinishing()) {
getActivity().runOnUiThread(() -> {
android.widget.Toast.makeText(ctx, "Failed to save cover: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
});
}
}
}).start();
} catch (Exception e) {
android.widget.Toast.makeText(ctx, "Cover import failed: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
}
});
}
Bundle args = getArguments();
String gameTitle = args != null ? args.getString(ARG_GAME_TITLE, "Unknown Game") : "Unknown Game";
@@ -391,6 +482,43 @@ public class GameSettingsDialogFragment extends DialogFragment {
.show();
});
}
// Custom Cover button wiring
com.google.android.material.button.MaterialButton btnCustomCover = view.findViewById(R.id.btn_set_custom_cover);
if (btnCustomCover != null) {
btnCustomCover.setOnClickListener(v -> {
// Check if this game has a custom cover
android.content.SharedPreferences prefs = ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
boolean hasCustomCover = prefs.getBoolean("custom_cover:" + gameSerial, false);
if (hasCustomCover) {
// Show options: Set New or Delete Custom
final String[] choices = new String[]{"Set New Custom Cover", "Delete Custom Cover"};
new MaterialAlertDialogBuilder(ctx,
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
.setCustomTitle(UiUtils.centeredDialogTitle(ctx, "Custom Cover"))
.setItems(choices, (dlg, which) -> {
if (which == 0) {
// Set new custom cover
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
mCoverPicker.launch(intent);
} else {
// Delete custom cover
deleteCustomCover(ctx, gameSerial);
}
})
.show();
} else {
// No custom cover exists, directly open picker
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
intent.addCategory(Intent.CATEGORY_OPENABLE);
intent.setType("image/*");
mCoverPicker.launch(intent);
}
});
}
return builder.create();
}
@@ -500,4 +628,116 @@ public class GameSettingsDialogFragment extends DialogFragment {
} catch (Throwable ignored) {
}
}
private void importCustomCover(Context ctx, Uri sourceUri, String gameSerial) throws Exception {
// Load the image
android.content.ContentResolver cr = ctx.getContentResolver();
java.io.InputStream in = cr.openInputStream(sourceUri);
if (in == null) throw new Exception("Cannot open image");
android.graphics.Bitmap originalBitmap = android.graphics.BitmapFactory.decodeStream(in);
in.close();
if (originalBitmap == null) throw new Exception("Invalid image format");
// Resize to standard PS2 cover dimensions (567x878)
final int TARGET_WIDTH = 567;
final int TARGET_HEIGHT = 878;
android.graphics.Bitmap resizedBitmap = android.graphics.Bitmap.createScaledBitmap(
originalBitmap, TARGET_WIDTH, TARGET_HEIGHT, true);
originalBitmap.recycle();
// Save to both locations
String fileName = gameSerial + ".png";
// Save to internal storage
java.io.File baseDir = ctx.getExternalFilesDir("covers");
if (baseDir == null) baseDir = new java.io.File(ctx.getFilesDir(), "covers");
if (!baseDir.exists()) baseDir.mkdirs();
java.io.File outFile = new java.io.File(baseDir, fileName);
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
resizedBitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, fos);
fos.flush();
fos.close();
// Save to SAF location if set
android.net.Uri dataRoot = SafManager.getDataRootUri(ctx);
if (dataRoot != null) {
try {
// Delete existing if present
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(ctx, new String[]{"covers"}, fileName);
if (existing != null && existing.exists()) {
existing.delete();
}
// Create new file
androidx.documentfile.provider.DocumentFile target = SafManager.createChild(ctx, new String[]{"covers"}, fileName, "image/png");
if (target != null) {
java.io.OutputStream os = cr.openOutputStream(target.getUri(), "w");
if (os != null) {
resizedBitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, os);
os.flush();
os.close();
}
}
} catch (Exception e) {
android.util.Log.w("GameSettings", "Failed to save to SAF: " + e.getMessage());
}
}
resizedBitmap.recycle();
}
private void deleteCustomCover(Context ctx, String gameSerial) {
if (gameSerial == null || gameSerial.isEmpty()) {
android.widget.Toast.makeText(ctx, "Cannot delete cover: serial unknown", android.widget.Toast.LENGTH_SHORT).show();
return;
}
new Thread(() -> {
try {
String fileName = gameSerial + ".png";
boolean deleted = false;
// Delete from SAF location
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(ctx, new String[]{"covers"}, fileName);
if (existing != null && existing.exists()) {
deleted = existing.delete();
}
// Clear custom cover flag
ctx.getSharedPreferences("app_prefs", Context.MODE_PRIVATE)
.edit()
.putBoolean("custom_cover:" + gameSerial, false)
.apply();
// Clear Glide cache
try {
com.bumptech.glide.Glide.get(ctx).clearDiskCache();
} catch (Throwable ignored) {}
// Show result on UI thread
final boolean success = deleted;
if (getActivity() != null && !getActivity().isFinishing()) {
getActivity().runOnUiThread(() -> {
try {
com.bumptech.glide.Glide.get(ctx).clearMemory();
} catch (Throwable ignored) {}
if (success) {
android.widget.Toast.makeText(ctx, "Custom cover deleted for " + gameSerial, android.widget.Toast.LENGTH_SHORT).show();
} else {
android.widget.Toast.makeText(ctx, "No custom cover found to delete", android.widget.Toast.LENGTH_SHORT).show();
}
});
}
} catch (Exception e) {
android.util.Log.e("GameSettings", "Error deleting custom cover: " + e.getMessage(), e);
if (getActivity() != null && !getActivity().isFinishing()) {
getActivity().runOnUiThread(() -> {
android.widget.Toast.makeText(ctx, "Failed to delete cover: " + e.getMessage(), android.widget.Toast.LENGTH_SHORT).show();
});
}
}
}).start();
}
}
@@ -26,6 +26,7 @@ import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.PagerSnapHelper;
import androidx.recyclerview.widget.RecyclerView;
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
import com.google.android.material.button.MaterialButtonToggleGroup;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import androidx.core.view.GravityCompat;
@@ -47,8 +48,8 @@ public class GamesCoverDialogFragment extends DialogFragment {
private String[] uris;
private String[] coverUrls;
private String[] localPaths;
private String[] origTitles;
private String[] origUris;
String[] origTitles; // Package-private for GameSettingsDialogFragment access
String[] origUris; // Package-private for GameSettingsDialogFragment access
private String[] origCoverUrls;
private String[] origLocalPaths;
private RecyclerView rv;
@@ -119,13 +120,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
// The layout file is cached when dialog is created, so we need to recreate the dialog
// to get the correct layout for the new orientation
dismiss();
// Recreate the dialog with the correct layout for new orientation
if (getParentFragmentManager() != null) {
GamesCoverDialogFragment newDialog = GamesCoverDialogFragment.newInstance(origTitles, origUris);
newDialog.show(getParentFragmentManager(), getTag());
}
recreateDialogWithCurrentState();
}
@NonNull
@@ -153,6 +148,12 @@ public class GamesCoverDialogFragment extends DialogFragment {
// Post to re-assert immersive after layout
try { root.post(this::forceDialogImmersive); } catch (Throwable ignored) {}
// Disable SwipeRefreshLayout since it conflicts with horizontal scrolling
SwipeRefreshLayout swipeRefresh = root.findViewById(R.id.swipe_refresh);
if (swipeRefresh != null) {
swipeRefresh.setEnabled(false);
}
rv = root.findViewById(R.id.recycler_covers);
rv.setHasFixedSize(true);
llm = new LinearLayoutManager(requireContext(), LinearLayoutManager.HORIZONTAL, false);
@@ -213,21 +214,12 @@ public class GamesCoverDialogFragment extends DialogFragment {
serial = buildSerialFromUri(uris[i]);
}
coverUrls[i] = buildCoverUrlFromSerial(serial);
// Prefer SAF content URI if data root is set and the file already exists.
// Do NOT pre-create empty placeholder files here (they cause confusing zero-byte files
// alongside downloaded covers). If the SAF file doesn't exist yet, fall back to a
// filesystem path and let the downloader create the SAF file when performing the
// actual download (startDownloadCovers will create the SAF child as needed).
android.net.Uri dataRoot = SafManager.getDataRootUri(requireContext());
if (dataRoot != null) {
androidx.documentfile.provider.DocumentFile f = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
if (f != null && f.exists()) {
localPaths[i] = f.getUri().toString();
} else {
// Don't create an empty file here; use the file-system fallback path instead.
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
}
// Get existing SAF file URI if it exists, otherwise use placeholder path
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
if (existing != null && existing.exists()) {
localPaths[i] = existing.getUri().toString();
} else {
// Use file path as placeholder - will be replaced when downloaded
localPaths[i] = new java.io.File(getCoversDir(), serial + ".png").getAbsolutePath();
}
}
@@ -422,19 +414,8 @@ public class GamesCoverDialogFragment extends DialogFragment {
// Refresh drawer settings before opening
refreshDialogDrawerSettings();
androidx.drawerlayout.widget.DrawerLayout drawer = root.findViewById(R.id.dlg_drawer_layout);
if (drawer != null) {
// Defer the open to avoid layout/reentrancy races similar to activity path
drawer.post(() -> {
try {
drawer.openDrawer(GravityCompat.START);
} catch (Throwable t) {
android.util.Log.e("GamesCoverDialog", "Error opening dialog drawer (posted): " + t.getMessage());
}
});
}
} catch (Throwable t) {
android.util.Log.e("GamesCoverDialog", "Error scheduling dialog drawer open: " + t.getMessage());
}
if (drawer != null) drawer.openDrawer(GravityCompat.START);
} catch (Throwable ignored) {}
});
// Setup drawer listener for pause/resume tracking
@@ -572,9 +553,29 @@ public class GamesCoverDialogFragment extends DialogFragment {
} catch (Throwable ignored) {}
View btnDownload = root.findViewById(R.id.btn_download);
if (btnDownload != null) btnDownload.setOnClickListener(v -> startDownloadCovers());
View btnRefresh = root.findViewById(R.id.btn_refresh);
if (btnRefresh != null) btnRefresh.setOnClickListener(v -> refreshDialog());
return root;
}
// Refresh dialog like orientation change does
private void refreshDialog() {
recreateDialogWithCurrentState();
}
// Helper method to recreate dialog with current state (used by both orientation change and refresh button)
private void recreateDialogWithCurrentState() {
dismiss();
if (getParentFragmentManager() != null) {
GamesCoverDialogFragment newDialog = GamesCoverDialogFragment.newInstance(origTitles, origUris);
// Preserve current sort mode and search query
newDialog.sortMode = this.sortMode;
newDialog.query = this.query;
newDialog.show(getParentFragmentManager(), getTag());
}
}
@Override
public void onStart() {
@@ -629,18 +630,8 @@ public class GamesCoverDialogFragment extends DialogFragment {
// Refresh drawer settings before opening
refreshDialogDrawerSettings();
androidx.drawerlayout.widget.DrawerLayout drawer = root.findViewById(R.id.dlg_drawer_layout);
if (drawer != null) {
drawer.post(() -> {
try {
drawer.openDrawer(androidx.core.view.GravityCompat.START);
} catch (Throwable t) {
android.util.Log.e("GamesCoverDialog", "Error opening dialog drawer (posted): " + t.getMessage());
}
});
}
} catch (Throwable t) {
android.util.Log.e("GamesCoverDialog", "Error scheduling dialog drawer open: " + t.getMessage());
}
if (drawer != null) drawer.openDrawer(androidx.core.view.GravityCompat.START);
} catch (Throwable ignored) {}
}
private void setupDialogDrawerSettings(View header) {
@@ -1001,6 +992,44 @@ public class GamesCoverDialogFragment extends DialogFragment {
}
private void startDownloadCovers() {
// Check if any games have custom covers
SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
java.util.ArrayList<String> customCoverGames = new java.util.ArrayList<>();
for (int i = 0; i < uris.length; i++) {
try {
String serial = prefs.getString("serial:" + uris[i], null);
if (serial == null || serial.isEmpty()) {
try { serial = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {}
}
if (serial == null || serial.isEmpty()) {
serial = buildSerialFromUri(uris[i]);
}
serial = normalizeSerial(serial);
if (prefs.getBoolean("custom_cover:" + serial, false)) {
customCoverGames.add(titles[i]);
}
} catch (Exception ignored) {}
}
// If custom covers exist, ask user what to do
if (!customCoverGames.isEmpty()) {
String message = "Found " + customCoverGames.size() + " game(s) with custom covers.\n\nWhat would you like to do?";
new MaterialAlertDialogBuilder(requireContext(),
com.google.android.material.R.style.ThemeOverlay_Material3_MaterialAlertDialog)
.setTitle("Custom Covers Detected")
.setMessage(message)
.setNegativeButton("Cancel", null)
.setNeutralButton("Skip Custom", (d, w) -> downloadCoversInternal(true))
.setPositiveButton("Delete All Custom", (d, w) -> deleteCustomCovers())
.show();
} else {
downloadCoversInternal(false);
}
}
private void downloadCoversInternal(boolean skipCustomCovers) {
Toast.makeText(requireContext(), "Downloading covers in background", Toast.LENGTH_SHORT).show();
new Thread(() -> {
SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
@@ -1028,24 +1057,121 @@ public class GamesCoverDialogFragment extends DialogFragment {
int total = coverUrls.length;
int ok = 0;
java.io.File dir = getCoversDir();
if (!dir.exists()) dir.mkdirs();
int skipped = 0;
for (int i = 0; i < total; i++) {
String url = coverUrls[i];
String outPath = localPaths[i];
if (isFileValid(outPath)) { ok++; continue; }
// Get serial for this game
String serial = null;
try {
if (downloadToTarget(url, outPath)) ok++;
serial = prefs.getString("serial:" + uris[i], null);
if (serial == null || serial.isEmpty()) {
try { serial = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {}
}
if (serial == null || serial.isEmpty()) {
serial = buildSerialFromUri(uris[i]);
}
serial = normalizeSerial(serial);
} catch (Exception ignored) {}
// Check if this is a custom cover and should be skipped
if (skipCustomCovers && serial != null) {
if (prefs.getBoolean("custom_cover:" + serial, false)) {
skipped++;
if (isFileValid(outPath)) ok++;
continue;
}
}
// Check if file exists in SAF
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
if (existing != null && existing.exists() && existing.length() > 0) {
ok++;
continue;
}
try {
if (downloadToTarget(url, serial)) {
ok++;
// Update localPaths with SAF URI after successful download
androidx.documentfile.provider.DocumentFile downloaded = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
if (downloaded != null && downloaded.exists()) {
localPaths[i] = downloaded.getUri().toString();
}
// Clear custom flag after successful download
if (serial != null) {
editor.putBoolean("custom_cover:" + serial, false);
}
}
} catch (Exception ignored) { }
}
editor.apply();
// Cleanup: delete any 0-byte PNG files in covers folder
cleanupEmptyCovers();
final int downloaded = ok;
final int skippedCount = skipped;
if (isAdded()) requireActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(), "Covers ready: " + downloaded + "/" + total, Toast.LENGTH_SHORT).show();
String msg = "Covers ready: " + downloaded + "/" + total;
if (skippedCount > 0) {
msg += " (" + skippedCount + " custom skipped)";
}
Toast.makeText(requireContext(), msg, Toast.LENGTH_SHORT).show();
if (adapter != null) adapter.notifyDataSetChanged();
});
}).start();
}
private void deleteCustomCovers() {
Toast.makeText(requireContext(), "Deleting custom covers...", Toast.LENGTH_SHORT).show();
new Thread(() -> {
SharedPreferences prefs = requireContext().getSharedPreferences("app_prefs", Context.MODE_PRIVATE);
SharedPreferences.Editor editor = prefs.edit();
int deletedCount = 0;
for (int i = 0; i < uris.length; i++) {
try {
String serial = prefs.getString("serial:" + uris[i], null);
if (serial == null || serial.isEmpty()) {
try { serial = NativeApp.getGameSerialSafe(uris[i]); } catch (Throwable ignored) {}
}
if (serial == null || serial.isEmpty()) {
serial = buildSerialFromUri(uris[i]);
}
serial = normalizeSerial(serial);
// Check if this game has a custom cover
if (prefs.getBoolean("custom_cover:" + serial, false)) {
// Delete the custom cover file
try {
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
if (existing != null && existing.exists()) {
existing.delete();
deletedCount++;
}
} catch (Exception e) {
android.util.Log.w("GamesCoverDialog", "Error deleting custom cover for " + serial + ": " + e.getMessage());
}
// Clear the custom cover flag
editor.putBoolean("custom_cover:" + serial, false);
}
} catch (Exception e) {
android.util.Log.w("GamesCoverDialog", "Error processing game " + i + ": " + e.getMessage());
}
}
editor.apply();
final int deleted = deletedCount;
if (isAdded()) requireActivity().runOnUiThread(() -> {
Toast.makeText(requireContext(), "Deleted " + deleted + " custom cover(s)", Toast.LENGTH_SHORT).show();
if (adapter != null) adapter.notifyDataSetChanged();
});
}).start();
}
private static String serialFromUrl(String url) {
if (url == null) return null;
int slash = url.lastIndexOf('/');
@@ -1066,7 +1192,7 @@ public class GamesCoverDialogFragment extends DialogFragment {
return f.exists() && f.length() > 0;
}
private boolean downloadToTarget(String urlStr, String outPath) throws Exception {
private boolean downloadToTarget(String urlStr, String serial) throws Exception {
java.net.URL url = new java.net.URL(urlStr);
java.net.HttpURLConnection conn = (java.net.HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
@@ -1076,31 +1202,44 @@ public class GamesCoverDialogFragment extends DialogFragment {
int code = conn.getResponseCode();
if (code != 200) { conn.disconnect(); return false; }
java.io.InputStream in = conn.getInputStream();
if (outPath.startsWith("content://")) {
android.net.Uri uri = android.net.Uri.parse(outPath);
try (java.io.OutputStream os = requireContext().getContentResolver().openOutputStream(uri, "w")) {
if (os == null) { conn.disconnect(); return false; }
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
os.flush();
}
in.close();
} else {
java.io.File outFile = new java.io.File(outPath);
java.io.File parent = outFile.getParentFile();
if (parent != null && !parent.exists()) parent.mkdirs();
java.io.FileOutputStream fos = new java.io.FileOutputStream(outFile);
// Delete existing file if present
androidx.documentfile.provider.DocumentFile existing = SafManager.getChild(requireContext(), new String[]{"covers"}, serial + ".png");
if (existing != null && existing.exists()) {
existing.delete();
}
// Create new file in SAF
androidx.documentfile.provider.DocumentFile newFile = SafManager.createChild(requireContext(), new String[]{"covers"}, serial + ".png", "image/png");
if (newFile == null) { conn.disconnect(); in.close(); return false; }
try (java.io.OutputStream os = requireContext().getContentResolver().openOutputStream(newFile.getUri(), "w")) {
if (os == null) { conn.disconnect(); in.close(); return false; }
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) fos.write(buf, 0, n);
fos.flush();
fos.close();
in.close();
while ((n = in.read(buf)) != -1) os.write(buf, 0, n);
os.flush();
}
in.close();
conn.disconnect();
return true;
}
private void cleanupEmptyCovers() {
try {
// Cleanup SAF folder only
androidx.documentfile.provider.DocumentFile coversDir = SafManager.getChild(requireContext(), new String[]{"covers"}, null);
if (coversDir != null && coversDir.isDirectory()) {
for (androidx.documentfile.provider.DocumentFile file : coversDir.listFiles()) {
if (file.isFile() && file.getName() != null && file.getName().endsWith(".png") && file.length() == 0) {
file.delete();
}
}
}
} catch (Throwable ignored) {}
}
private void refreshDialogDrawerSettings() {
try {
@@ -456,6 +456,9 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
// Initialize RetroAchievements
RetroAchievementsManager.initialize(this);
// Load and auto-login with saved credentials if available
NativeApp.loadAndLoginAchievements();
// Initialize controller input handler
mControllerInputHandler = new ControllerInputHandler(this);
@@ -574,20 +577,13 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
if (btn_settings != null) {
btn_settings.setOnClickListener(v -> {
try {
// Open the drawer via post() to avoid reentrancy/layout timing issues
// (programmatic open can sometimes race with layout/insets handling)
// Just open the drawer - let the drawer listener handle pausing
DrawerLayout drawer = findViewById(R.id.drawer_layout);
if (drawer != null) {
drawer.post(() -> {
try {
drawer.openDrawer(androidx.core.view.GravityCompat.START);
} catch (Throwable t) {
android.util.Log.e("MainActivity", "Error opening settings drawer (posted): " + t.getMessage());
}
});
drawer.openDrawer(androidx.core.view.GravityCompat.START);
}
} catch (Throwable t) {
android.util.Log.e("MainActivity", "Error scheduling settings drawer open: " + t.getMessage());
android.util.Log.e("MainActivity", "Error opening settings drawer: " + t.getMessage());
}
});
}
@@ -692,20 +688,6 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
} catch (Throwable ignored) {}
});
}
// Achievements button
View btnAchievements = header.findViewById(R.id.drawer_btn_achievements);
if (btnAchievements != null) {
btnAchievements.setOnClickListener(v -> {
try {
android.util.Log.d("MainActivity", "Achievements button clicked");
AchievementsDialogFragment dialog = AchievementsDialogFragment.newInstance();
dialog.show(getSupportFragmentManager(), "achievements_dialog");
} catch (Throwable e) {
android.util.Log.e("MainActivity", "Error showing achievements dialog: " + e.getMessage());
}
});
}
// Setup drawer settings controls to mirror quick actions
setupDrawerSettings(header);
@@ -1607,36 +1589,6 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
btn_pause_play.setIcon(ContextCompat.getDrawable(this, R.drawable.pause_circle_24px));
}
});
// Auto-initialize and login to achievements if enabled
SharedPreferences prefs = getSharedPreferences("RetroAchievements", MODE_PRIVATE);
boolean achievementsEnabled = prefs.getBoolean("enabled", false);
if (achievementsEnabled) {
String username = prefs.getString("username", "");
boolean rememberMe = prefs.getBoolean("remember_me", false);
String savedPassword = prefs.getString("saved_password", "");
new Thread(() -> {
try {
Thread.sleep(3000); // Wait 3 seconds for game to start
// Initialize if not already active
if (!NativeApp.achievementsIsActive()) {
android.util.Log.d("Achievements", "Auto-initializing achievements for game");
NativeApp.achievementsInitialize();
Thread.sleep(500); // Wait for initialization
}
// Auto-login if credentials are saved
if (!username.isEmpty() && rememberMe && !savedPassword.isEmpty()) {
android.util.Log.d("Achievements", "Auto-logging in as: " + username);
NativeApp.achievementsLogin(username, savedPassword);
}
} catch (Exception e) {
android.util.Log.e("Achievements", "Failed to auto-initialize/login: " + e.getMessage());
}
}).start();
}
}
}
@@ -2423,9 +2375,9 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
}
})
.setNegativeButton("View License", (dialog, which) -> {
// Open LICENSE file or GitHub link
// Open License on GitHub Pages
Intent intent = new Intent(Intent.ACTION_VIEW);
intent.setData(Uri.parse("https://github.com/izzy2lost/PSX2/blob/master/LICENSE"));
intent.setData(Uri.parse("https://izzy2lost.github.io/PSX2/license.html"));
try {
startActivity(intent);
} catch (Exception e) {
@@ -2818,5 +2770,4 @@ public class MainActivity extends AppCompatActivity implements GamesCoverDialogF
}
}
}
@@ -168,6 +168,64 @@ public class NativeApp {
public static native void achievementsShutdown();
public static native Achievement[] achievementsGetAchievementList();
public static native void achievementsSetHardcoreMode(boolean enabled);
public static native void achievementsLoginWithToken(String username, String token);
// Save achievements credentials to SharedPreferences (called from native code)
public static void saveAchievementsCredentials(String username, String token, String loginTimestamp) {
Context context = getContext();
if (context == null) {
android.util.Log.e("Achievements", "Cannot save credentials: context is null");
return;
}
android.content.SharedPreferences prefs = context.getSharedPreferences("RetroAchievements", Context.MODE_PRIVATE);
android.content.SharedPreferences.Editor editor = prefs.edit();
editor.putString("username", username);
editor.putString("token", token);
editor.putString("login_timestamp", loginTimestamp);
editor.apply();
android.util.Log.i("Achievements", "Credentials saved: username=" + username + ", has_token=" + (!token.isEmpty()));
}
// Load achievements credentials from SharedPreferences and attempt auto-login
public static void loadAndLoginAchievements() {
Context context = getContext();
if (context == null) {
android.util.Log.e("Achievements", "Cannot load credentials: context is null");
return;
}
android.content.SharedPreferences prefs = context.getSharedPreferences("RetroAchievements", Context.MODE_PRIVATE);
boolean enabled = prefs.getBoolean("enabled", false);
if (!enabled) {
android.util.Log.d("Achievements", "Achievements not enabled, skipping auto-login");
return;
}
String username = prefs.getString("username", "");
String token = prefs.getString("token", "");
if (username.isEmpty() || token.isEmpty()) {
android.util.Log.d("Achievements", "No saved credentials found");
return;
}
android.util.Log.i("Achievements", "Attempting auto-login with saved token for user: " + username);
// Initialize achievements system first
new Thread(() -> {
try {
achievementsInitialize();
Thread.sleep(500); // Give it time to initialize
achievementsLoginWithToken(username, token);
android.util.Log.i("Achievements", "Auto-login initiated");
} catch (Exception e) {
android.util.Log.e("Achievements", "Auto-login failed: " + e.getMessage());
}
}).start();
}
public static native void onNativeSurfaceCreated();
public static native void onNativeSurfaceChanged(Surface surface, int w, int h);
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@color/md_theme_onSurface"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
@@ -31,6 +31,16 @@
android:padding="4dp"
android:src="@drawable/menu_24px"
app:tint="@color/brand_primary" />
<ImageButton
android:id="@+id/btn_refresh"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:contentDescription="Refresh"
android:padding="4dp"
android:src="@drawable/refresh_24px"
app:tint="@color/brand_primary" />
<View
android:layout_width="0dp"
@@ -31,6 +31,16 @@
android:padding="4dp"
android:src="@drawable/menu_24px"
app:tint="@color/brand_primary" />
<ImageButton
android:id="@+id/btn_refresh"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:contentDescription="Refresh"
android:padding="4dp"
android:src="@drawable/refresh_24px"
app:tint="@color/brand_primary" />
<View
android:layout_width="0dp"
+26 -9
View File
@@ -31,6 +31,16 @@
android:padding="4dp"
android:src="@drawable/menu_24px"
app:tint="@color/brand_primary" />
<ImageButton
android:id="@+id/btn_refresh"
android:layout_width="40dp"
android:layout_height="40dp"
android:background="@android:color/transparent"
android:contentDescription="Refresh"
android:padding="4dp"
android:src="@drawable/refresh_24px"
app:tint="@color/brand_primary" />
<View
android:layout_width="0dp"
@@ -61,20 +71,27 @@
app:iconTint="@color/brand_primary"
app:iconGravity="textStart"
app:iconPadding="8dp"
app:iconSize="24dp" />
app:iconSize="24dp" />
</LinearLayout>
<!-- Letters row removed -->
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_covers"
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipe_refresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="6dp"
android:paddingBottom="0dp"
android:clipToPadding="false" />
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_covers"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:paddingStart="12dp"
android:paddingEnd="12dp"
android:paddingTop="6dp"
android:paddingBottom="0dp"
android:clipToPadding="false" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
<!-- Hint below covers grid: yellow bulb right next to text -->
<LinearLayout
@@ -51,7 +51,14 @@
android:textColor="@color/md_theme_tertiaryFixedDim"
android:layout_marginBottom="8dp"/>
<!-- Custom Cover Button -->
<com.google.android.material.button.MaterialButton
android:id="@+id/btn_set_custom_cover"
style="@style/PSX2.ElevatedTransparentButton"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Set Custom Cover"
android:layout_marginBottom="16dp" />
<!-- Graphics Settings -->
<TextView
+155 -32
View File
@@ -5,32 +5,101 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PSX2 - PlayStation 2 Emulator</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
max-width: 800px;
font-family: 'Courier New', monospace;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
background-color: #f8f9fa;
}
.container {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header {
text-align: center;
margin-bottom: 2rem;
margin-bottom: 3rem;
padding: 3rem 2rem;
background: rgba(0, 255, 200, 0.1);
border: 3px solid #00ffc8;
border-radius: 8px;
box-shadow: 0 0 30px rgba(0, 255, 200, 0.4);
}
.header h1 {
color: #333;
font-size: 3.5rem;
color: #00ffc8;
text-shadow: 0 0 20px #00ffc8, 0 0 40px rgba(0, 255, 200, 0.5);
margin-bottom: 0.5rem;
font-weight: bold;
letter-spacing: 3px;
}
.header p {
color: #666;
font-size: 1.2rem;
color: #00ffc8;
font-size: 1.3rem;
opacity: 0.9;
text-shadow: 0 0 10px #00ffc8;
}
.description {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #00ffc8;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: inset 0 0 10px rgba(0, 255, 200, 0.1);
}
.description p {
margin-bottom: 1rem;
font-size: 1.1rem;
line-height: 1.8;
}
.features {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #00ffc8;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: inset 0 0 10px rgba(0, 255, 200, 0.1);
}
.features h2 {
color: #00ffc8;
margin-bottom: 1rem;
font-size: 1.5rem;
text-shadow: 0 0 10px #00ffc8;
}
.features ul {
list-style: none;
margin-left: 0;
}
.features li {
padding: 0.5rem 0;
padding-left: 1.5rem;
position: relative;
}
.features li:before {
content: "▶";
position: absolute;
left: 0;
color: #00ffc8;
}
.links {
display: flex;
flex-wrap: wrap;
@@ -38,57 +107,111 @@
justify-content: center;
margin: 2rem 0;
}
.link-button {
display: inline-block;
padding: 0.75rem 1.5rem;
background-color: #007bff;
color: white;
padding: 1rem 2rem;
background: linear-gradient(135deg, #00ffc8, #00cc99);
color: #1a1a2e;
text-decoration: none;
border-radius: 5px;
transition: background-color 0.3s;
border-radius: 4px;
font-weight: bold;
transition: all 0.3s;
box-shadow: 0 0 15px rgba(0, 255, 200, 0.5);
border: 2px solid #00ffc8;
}
.link-button:hover {
background-color: #0056b3;
box-shadow: 0 0 25px rgba(0, 255, 200, 0.8);
transform: translateY(-3px);
background: linear-gradient(135deg, #00ffdd, #00dd99);
}
.link-button.secondary {
background-color: #6c757d;
background: rgba(0, 255, 200, 0.1);
color: #00ffc8;
border: 2px solid #00ffc8;
}
.link-button.secondary:hover {
background-color: #545b62;
}
.description {
margin: 2rem 0;
color: #555;
background: rgba(0, 255, 200, 0.2);
box-shadow: 0 0 25px rgba(0, 255, 200, 0.8);
}
.footer {
text-align: center;
padding: 2rem;
border-top: 2px solid #00ffc8;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #eee;
color: #666;
}
.footer p {
margin-bottom: 0.5rem;
}
.footer a {
color: #00ffc8;
text-decoration: none;
margin: 0 0.5rem;
transition: all 0.3s;
}
.footer a:hover {
text-shadow: 0 0 10px #00ffc8;
}
.retro-badge {
display: inline-block;
background: #00ffc8;
color: #1a1a2e;
padding: 0.25rem 0.75rem;
border-radius: 2px;
font-weight: bold;
font-size: 0.9rem;
margin-left: 0.5rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>PSX2</h1>
<h1>PSX2 <span class="retro-badge">RETRO</span></h1>
<p>PlayStation 2 Emulator for Mobile</p>
</div>
<div class="description">
<p>PSX2 is a powerful PlayStation 2 emulator that allows you to play your favorite PS2 games on mobile devices. Experience classic gaming with enhanced performance and modern convenience.</p>
<p>PSX2 is a powerful PlayStation 2 emulator that brings your favorite PS2 games to mobile devices. Experience classic gaming with enhanced performance, modern convenience, and authentic retro vibes.</p>
<p>Built on the legendary PCSX2 emulator and optimized for ARM64 Android devices, PSX2 delivers high compatibility and smooth gameplay.</p>
</div>
<div class="features">
<h2>Key Features</h2>
<ul>
<li>High Compatibility - Play thousands of PS2 games</li>
<li>Enhanced Graphics - Upscaling, texture filtering, and modern rendering</li>
<li>Multiple Formats - ISO, CHD, CSO, ZSO, and compressed formats</li>
<li>Touch Controls - Intuitive on-screen gamepad with customizable layout</li>
<li>External Controllers - Full support for Bluetooth and USB gamepads</li>
<li>Save States - Quick save and load functionality</li>
<li>Game Covers - Automatic cover art downloading and display</li>
<li>Per-Game Settings - Individual configuration for optimal performance</li>
</ul>
</div>
<div class="links">
<a href="https://github.com/izzy2lost/PSX2" class="link-button">View on GitHub</a>
<a href="https://play.google.com/store/apps/details?id=com.izzy2lost.psx2" class="link-button">Download on Play Store</a>
<a href="license.html" class="link-button secondary">License</a>
<a href="privacy-policy.html" class="link-button secondary">Privacy Policy</a>
</div>
<div class="footer">
<p>© 2025 PSX2. Licensed under GPL-3.0.</p>
<p>Contact: <a href="mailto:izzynochill@gmail.com">izzynochill@gmail.com</a></p>
<p>
<a href="https://github.com/izzy2lost/PSX2">GitHub</a> |
<a href="mailto:izzynochill@gmail.com">Contact</a>
</p>
<p style="margin-top: 1rem; opacity: 0.7; font-size: 0.9rem;">PlayStation and PlayStation 2 are trademarks of Sony Interactive Entertainment.</p>
</div>
</div>
</body>
+210
View File
@@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PSX2 - License</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Courier New', monospace;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
color: #e0e0e0;
line-height: 1.6;
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 2rem;
}
.header {
text-align: center;
margin-bottom: 3rem;
padding: 2rem;
background: rgba(0, 255, 200, 0.1);
border: 3px solid #00ffc8;
border-radius: 8px;
box-shadow: 0 0 20px rgba(0, 255, 200, 0.3);
}
.header h1 {
font-size: 2.5rem;
color: #00ffc8;
text-shadow: 0 0 10px #00ffc8;
margin-bottom: 0.5rem;
font-weight: bold;
letter-spacing: 2px;
}
.header p {
color: #00ffc8;
font-size: 1.1rem;
opacity: 0.9;
}
.license-content {
background: rgba(0, 0, 0, 0.5);
border: 2px solid #00ffc8;
border-radius: 4px;
padding: 2rem;
margin-bottom: 2rem;
max-height: 600px;
overflow-y: auto;
box-shadow: inset 0 0 10px rgba(0, 255, 200, 0.1);
}
.license-content h2 {
color: #00ffc8;
margin-top: 1.5rem;
margin-bottom: 1rem;
font-size: 1.3rem;
text-shadow: 0 0 5px #00ffc8;
}
.license-content h3 {
color: #00ffc8;
margin-top: 1rem;
margin-bottom: 0.5rem;
font-size: 1.1rem;
}
.license-content p {
margin-bottom: 1rem;
text-align: justify;
}
.license-content ul, .license-content ol {
margin-left: 2rem;
margin-bottom: 1rem;
}
.license-content li {
margin-bottom: 0.5rem;
}
.license-content a {
color: #00ffc8;
text-decoration: none;
border-bottom: 1px dotted #00ffc8;
}
.license-content a:hover {
text-decoration: underline;
}
.footer {
text-align: center;
padding: 2rem;
border-top: 2px solid #00ffc8;
margin-top: 2rem;
}
.footer a {
color: #00ffc8;
text-decoration: none;
margin: 0 1rem;
transition: all 0.3s;
}
.footer a:hover {
text-shadow: 0 0 10px #00ffc8;
}
.back-button {
display: inline-block;
padding: 0.75rem 1.5rem;
background: linear-gradient(135deg, #00ffc8, #00cc99);
color: #1a1a2e;
text-decoration: none;
border-radius: 4px;
font-weight: bold;
margin-bottom: 2rem;
transition: all 0.3s;
box-shadow: 0 0 10px rgba(0, 255, 200, 0.5);
}
.back-button:hover {
box-shadow: 0 0 20px rgba(0, 255, 200, 0.8);
transform: translateY(-2px);
}
/* Scrollbar styling */
.license-content::-webkit-scrollbar {
width: 8px;
}
.license-content::-webkit-scrollbar-track {
background: rgba(0, 255, 200, 0.1);
}
.license-content::-webkit-scrollbar-thumb {
background: #00ffc8;
border-radius: 4px;
}
.license-content::-webkit-scrollbar-thumb:hover {
background: #00ffdd;
}
</style>
</head>
<body>
<div class="container">
<a href="index.html" class="back-button">← Back to Home</a>
<div class="header">
<h1>PSX2 LICENSE</h1>
<p>GNU General Public License v3.0</p>
</div>
<div class="license-content">
<h2>GNU GENERAL PUBLIC LICENSE</h2>
<p><strong>Version 3, 29 June 2007</strong></p>
<p>Copyright (C) 2007 Free Software Foundation, Inc. <a href="https://fsf.org/">https://fsf.org/</a></p>
<p>Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.</p>
<h2>Preamble</h2>
<p>The GNU General Public License is a free, copyleft license for software and other kinds of works.</p>
<p>The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.</p>
<p>When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.</p>
<p>To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.</p>
<p>For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.</p>
<p>Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.</p>
<p>For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.</p>
<p>Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.</p>
<p>Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.</p>
<p>The precise terms and conditions for copying, distribution and modification follow.</p>
<h2>TERMS AND CONDITIONS</h2>
<h3>0. Definitions.</h3>
<p>"This License" refers to version 3 of the GNU General Public License.</p>
<p>"Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.</p>
<p>"The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations.</p>
<p>To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work.</p>
<p>A "covered work" means either the unmodified Program or a work based on the Program.</p>
<p>To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.</p>
<p>To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.</p>
<p><strong>For the full license text, please visit:</strong> <a href="https://www.gnu.org/licenses/gpl-3.0.html">https://www.gnu.org/licenses/gpl-3.0.html</a></p>
</div>
<div class="footer">
<p>PSX2 is licensed under the GNU General Public License v3.0</p>
<p>
<a href="index.html">Home</a> |
<a href="privacy-policy.html">Privacy Policy</a> |
<a href="https://github.com/izzy2lost/PSX2">GitHub</a>
</p>
<p style="margin-top: 1rem; opacity: 0.7;">© 2025 PSX2. All rights reserved.</p>
</div>
</div>
</body>
</html>