android: frontend: Implement game grid view. (#9)
This commit is contained in:
parent
5ed8d46340
commit
0e52d11ede
15 changed files with 272 additions and 174 deletions
|
@ -11,7 +11,7 @@ def abiFilter = "arm64-v8a" //, "x86"
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 32
|
compileSdkVersion 32
|
||||||
ndkVersion "25.1.8937393"
|
ndkVersion "25.2.9519653"
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
|
|
@ -141,9 +141,9 @@ public final class NativeLibrary {
|
||||||
* Gets the embedded icon within the given ROM.
|
* Gets the embedded icon within the given ROM.
|
||||||
*
|
*
|
||||||
* @param filename the file path to the ROM.
|
* @param filename the file path to the ROM.
|
||||||
* @return an integer array containing the color data for the icon.
|
* @return a byte array containing the JPEG data for the icon.
|
||||||
*/
|
*/
|
||||||
public static native int[] GetIcon(String filename);
|
public static native byte[] GetIcon(String filename);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the embedded title of the given ISO/ROM.
|
* Gets the embedded title of the given ISO/ROM.
|
||||||
|
@ -204,6 +204,11 @@ public final class NativeLibrary {
|
||||||
*/
|
*/
|
||||||
public static native void StopEmulation();
|
public static native void StopEmulation();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets the in-memory ROM metadata cache.
|
||||||
|
*/
|
||||||
|
public static native void ResetRomMetadata();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true if emulation is running (or is paused).
|
* Returns true if emulation is running (or is paused).
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -86,11 +86,7 @@ public final class GameAdapter extends RecyclerView.Adapter<GameViewHolder> impl
|
||||||
mCursor.getString(GameDatabase.GAME_COLUMN_PATH));
|
mCursor.getString(GameDatabase.GAME_COLUMN_PATH));
|
||||||
|
|
||||||
holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " "));
|
holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " "));
|
||||||
holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
|
holder.textGameCaption.setText(mCursor.getString(GameDatabase.GAME_COLUMN_CAPTION));
|
||||||
|
|
||||||
String filepath = mCursor.getString(GameDatabase.GAME_COLUMN_PATH);
|
|
||||||
String filename = FileUtil.getFilename(YuzuApplication.getAppContext(), filepath);
|
|
||||||
holder.textFileName.setText(filename);
|
|
||||||
|
|
||||||
// TODO These shouldn't be necessary once the move to a DB-based model is complete.
|
// TODO These shouldn't be necessary once the move to a DB-based model is complete.
|
||||||
holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID);
|
holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID);
|
||||||
|
@ -98,7 +94,7 @@ public final class GameAdapter extends RecyclerView.Adapter<GameViewHolder> impl
|
||||||
holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE);
|
holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE);
|
||||||
holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION);
|
holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION);
|
||||||
holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS);
|
holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS);
|
||||||
holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY);
|
holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_CAPTION);
|
||||||
|
|
||||||
final int backgroundColorId = isValidGame(holder.path) ? R.color.view_background : R.color.view_disabled;
|
final int backgroundColorId = isValidGame(holder.path) ? R.color.view_background : R.color.view_disabled;
|
||||||
View itemView = holder.getItemView();
|
View itemView = holder.getItemView();
|
||||||
|
|
|
@ -47,7 +47,7 @@ public final class Game {
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_REGIONS),
|
cursor.getString(GameDatabase.GAME_COLUMN_REGIONS),
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_PATH),
|
cursor.getString(GameDatabase.GAME_COLUMN_PATH),
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID),
|
cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID),
|
||||||
cursor.getString(GameDatabase.GAME_COLUMN_COMPANY));
|
cursor.getString(GameDatabase.GAME_COLUMN_CAPTION));
|
||||||
}
|
}
|
||||||
|
|
||||||
public String getTitle() {
|
public String getTitle() {
|
||||||
|
|
|
@ -29,7 +29,7 @@ public final class GameDatabase extends SQLiteOpenHelper {
|
||||||
public static final int GAME_COLUMN_DESCRIPTION = 3;
|
public static final int GAME_COLUMN_DESCRIPTION = 3;
|
||||||
public static final int GAME_COLUMN_REGIONS = 4;
|
public static final int GAME_COLUMN_REGIONS = 4;
|
||||||
public static final int GAME_COLUMN_GAME_ID = 5;
|
public static final int GAME_COLUMN_GAME_ID = 5;
|
||||||
public static final int GAME_COLUMN_COMPANY = 6;
|
public static final int GAME_COLUMN_CAPTION = 6;
|
||||||
public static final int FOLDER_COLUMN_PATH = 1;
|
public static final int FOLDER_COLUMN_PATH = 1;
|
||||||
public static final String KEY_DB_ID = "_id";
|
public static final String KEY_DB_ID = "_id";
|
||||||
public static final String KEY_GAME_PATH = "path";
|
public static final String KEY_GAME_PATH = "path";
|
||||||
|
@ -176,6 +176,9 @@ public final class GameDatabase extends SQLiteOpenHelper {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure keys are loaded so that ROM metadata can be decrypted.
|
||||||
|
NativeLibrary.ReloadKeys();
|
||||||
|
|
||||||
MinimalDocumentFile[] children = FileUtil.listFiles(context, parent);
|
MinimalDocumentFile[] children = FileUtil.listFiles(context, parent);
|
||||||
for (MinimalDocumentFile file : children) {
|
for (MinimalDocumentFile file : children) {
|
||||||
if (file.isDirectory()) {
|
if (file.isDirectory()) {
|
||||||
|
|
|
@ -161,6 +161,7 @@ public final class MainActivity extends AppCompatActivity implements MainView {
|
||||||
if (FileUtil.copyUriToInternalStorage(this, result.getData(), dstPath, "prod.keys")) {
|
if (FileUtil.copyUriToInternalStorage(this, result.getData(), dstPath, "prod.keys")) {
|
||||||
if (NativeLibrary.ReloadKeys()) {
|
if (NativeLibrary.ReloadKeys()) {
|
||||||
Toast.makeText(this, R.string.install_keys_success, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.install_keys_success, Toast.LENGTH_SHORT).show();
|
||||||
|
refreshFragment();
|
||||||
} else {
|
} else {
|
||||||
Toast.makeText(this, R.string.install_keys_failure, Toast.LENGTH_SHORT).show();
|
Toast.makeText(this, R.string.install_keys_failure, Toast.LENGTH_SHORT).show();
|
||||||
launchFileListActivity(MainPresenter.REQUEST_INSTALL_KEYS);
|
launchFileListActivity(MainPresenter.REQUEST_INSTALL_KEYS);
|
||||||
|
@ -184,6 +185,7 @@ public final class MainActivity extends AppCompatActivity implements MainView {
|
||||||
|
|
||||||
private void refreshFragment() {
|
private void refreshFragment() {
|
||||||
if (mPlatformGamesFragment != null) {
|
if (mPlatformGamesFragment != null) {
|
||||||
|
NativeLibrary.ResetRomMetadata();
|
||||||
mPlatformGamesFragment.refresh();
|
mPlatformGamesFragment.refresh();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ import android.os.Bundle;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
|
import android.view.ViewTreeObserver;
|
||||||
import android.widget.TextView;
|
import android.widget.TextView;
|
||||||
|
|
||||||
import androidx.core.content.ContextCompat;
|
import androidx.core.content.ContextCompat;
|
||||||
|
@ -13,6 +14,7 @@ import androidx.recyclerview.widget.GridLayoutManager;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout;
|
||||||
|
|
||||||
|
import org.yuzu.yuzu_emu.NativeLibrary;
|
||||||
import org.yuzu.yuzu_emu.YuzuApplication;
|
import org.yuzu.yuzu_emu.YuzuApplication;
|
||||||
import org.yuzu.yuzu_emu.R;
|
import org.yuzu.yuzu_emu.R;
|
||||||
import org.yuzu.yuzu_emu.adapters.GameAdapter;
|
import org.yuzu.yuzu_emu.adapters.GameAdapter;
|
||||||
|
@ -43,19 +45,34 @@ public final class PlatformGamesFragment extends Fragment implements PlatformGam
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onViewCreated(View view, Bundle savedInstanceState) {
|
public void onViewCreated(View view, Bundle savedInstanceState) {
|
||||||
int columns = getResources().getInteger(R.integer.game_grid_columns);
|
|
||||||
RecyclerView.LayoutManager layoutManager = new GridLayoutManager(getActivity(), columns);
|
|
||||||
mAdapter = new GameAdapter();
|
mAdapter = new GameAdapter();
|
||||||
|
|
||||||
|
// Organize our grid layout based on the current view.
|
||||||
|
if (isAdded()) {
|
||||||
|
view.getViewTreeObserver()
|
||||||
|
.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
|
||||||
|
@Override
|
||||||
|
public void onGlobalLayout() {
|
||||||
|
if (view.getMeasuredWidth() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int columns = view.getMeasuredWidth() /
|
||||||
|
requireContext().getResources().getDimensionPixelSize(R.dimen.card_width);
|
||||||
|
if (columns == 0) {
|
||||||
|
columns = 1;
|
||||||
|
}
|
||||||
|
view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
|
||||||
|
GridLayoutManager layoutManager = new GridLayoutManager(getActivity(), columns);
|
||||||
mRecyclerView.setLayoutManager(layoutManager);
|
mRecyclerView.setLayoutManager(layoutManager);
|
||||||
mRecyclerView.setAdapter(mAdapter);
|
mRecyclerView.setAdapter(mAdapter);
|
||||||
mRecyclerView.addItemDecoration(new GameAdapter.SpacesItemDecoration(ContextCompat.getDrawable(getActivity(), R.drawable.gamelist_divider), 1));
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Add swipe down to refresh gesture
|
// Add swipe down to refresh gesture
|
||||||
final SwipeRefreshLayout pullToRefresh = view.findViewById(R.id.refresh_grid_games);
|
final SwipeRefreshLayout pullToRefresh = view.findViewById(R.id.swipe_refresh);
|
||||||
pullToRefresh.setOnRefreshListener(() -> {
|
pullToRefresh.setOnRefreshListener(() -> {
|
||||||
GameDatabase databaseHelper = YuzuApplication.databaseHelper;
|
|
||||||
databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
|
|
||||||
refresh();
|
refresh();
|
||||||
pullToRefresh.setRefreshing(false);
|
pullToRefresh.setRefreshing(false);
|
||||||
});
|
});
|
||||||
|
@ -63,6 +80,8 @@ public final class PlatformGamesFragment extends Fragment implements PlatformGam
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void refresh() {
|
public void refresh() {
|
||||||
|
GameDatabase databaseHelper = YuzuApplication.databaseHelper;
|
||||||
|
databaseHelper.scanLibrary(databaseHelper.getWritableDatabase());
|
||||||
mPresenter.refresh();
|
mPresenter.refresh();
|
||||||
updateTextView();
|
updateTextView();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package org.yuzu.yuzu_emu.utils;
|
package org.yuzu.yuzu_emu.utils;
|
||||||
|
|
||||||
import android.graphics.Bitmap;
|
import android.graphics.Bitmap;
|
||||||
|
import android.graphics.BitmapFactory;
|
||||||
|
|
||||||
import com.squareup.picasso.Picasso;
|
import com.squareup.picasso.Picasso;
|
||||||
import com.squareup.picasso.Request;
|
import com.squareup.picasso.Request;
|
||||||
|
@ -13,15 +14,16 @@ import java.nio.IntBuffer;
|
||||||
public class GameIconRequestHandler extends RequestHandler {
|
public class GameIconRequestHandler extends RequestHandler {
|
||||||
@Override
|
@Override
|
||||||
public boolean canHandleRequest(Request data) {
|
public boolean canHandleRequest(Request data) {
|
||||||
return "iso".equals(data.uri.getScheme());
|
return "content".equals(data.uri.getScheme());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result load(Request request, int networkPolicy) {
|
public Result load(Request request, int networkPolicy) {
|
||||||
String url = request.uri.getHost() + request.uri.getPath();
|
String gamePath = request.uri.toString();
|
||||||
int[] vector = NativeLibrary.GetIcon(url);
|
byte[] data = NativeLibrary.GetIcon(gamePath);
|
||||||
Bitmap bitmap = Bitmap.createBitmap(48, 48, Bitmap.Config.RGB_565);
|
BitmapFactory.Options options = new BitmapFactory.Options();
|
||||||
bitmap.copyPixelsFromBuffer(IntBuffer.wrap(vector));
|
options.inMutable = true;
|
||||||
|
Bitmap bitmap = BitmapFactory.decodeByteArray(data, 0, data.length, options);
|
||||||
return new Result(bitmap, Picasso.LoadedFrom.DISK);
|
return new Result(bitmap, Picasso.LoadedFrom.DISK);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -31,7 +31,7 @@ public class PicassoUtils {
|
||||||
public static void loadGameIcon(ImageView imageView, String gamePath) {
|
public static void loadGameIcon(ImageView imageView, String gamePath) {
|
||||||
Picasso
|
Picasso
|
||||||
.get()
|
.get()
|
||||||
.load(Uri.parse("iso:/" + gamePath))
|
.load(Uri.parse(gamePath))
|
||||||
.fit()
|
.fit()
|
||||||
.centerInside()
|
.centerInside()
|
||||||
.config(Bitmap.Config.RGB_565)
|
.config(Bitmap.Config.RGB_565)
|
||||||
|
|
|
@ -16,8 +16,7 @@ public class GameViewHolder extends RecyclerView.ViewHolder {
|
||||||
private View itemView;
|
private View itemView;
|
||||||
public ImageView imageIcon;
|
public ImageView imageIcon;
|
||||||
public TextView textGameTitle;
|
public TextView textGameTitle;
|
||||||
public TextView textCompany;
|
public TextView textGameCaption;
|
||||||
public TextView textFileName;
|
|
||||||
|
|
||||||
public String gameId;
|
public String gameId;
|
||||||
|
|
||||||
|
@ -36,8 +35,7 @@ public class GameViewHolder extends RecyclerView.ViewHolder {
|
||||||
|
|
||||||
imageIcon = itemView.findViewById(R.id.image_game_screen);
|
imageIcon = itemView.findViewById(R.id.image_game_screen);
|
||||||
textGameTitle = itemView.findViewById(R.id.text_game_title);
|
textGameTitle = itemView.findViewById(R.id.text_game_title);
|
||||||
textCompany = itemView.findViewById(R.id.text_company);
|
textGameCaption = itemView.findViewById(R.id.text_game_caption);
|
||||||
textFileName = itemView.findViewById(R.id.text_filename);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public View getItemView() {
|
public View getItemView() {
|
||||||
|
|
|
@ -24,6 +24,7 @@
|
||||||
#include "core/file_sys/vfs_real.h"
|
#include "core/file_sys/vfs_real.h"
|
||||||
#include "core/hid/hid_core.h"
|
#include "core/hid/hid_core.h"
|
||||||
#include "core/hle/service/filesystem/filesystem.h"
|
#include "core/hle/service/filesystem/filesystem.h"
|
||||||
|
#include "core/loader/loader.h"
|
||||||
#include "core/perf_stats.h"
|
#include "core/perf_stats.h"
|
||||||
#include "jni/config.h"
|
#include "jni/config.h"
|
||||||
#include "jni/emu_window/emu_window.h"
|
#include "jni/emu_window/emu_window.h"
|
||||||
|
@ -34,7 +35,11 @@ namespace {
|
||||||
|
|
||||||
class EmulationSession final {
|
class EmulationSession final {
|
||||||
public:
|
public:
|
||||||
EmulationSession() = default;
|
EmulationSession() {
|
||||||
|
m_system.Initialize();
|
||||||
|
m_vfs = std::make_shared<FileSys::RealVfsFilesystem>();
|
||||||
|
}
|
||||||
|
|
||||||
~EmulationSession() = default;
|
~EmulationSession() = default;
|
||||||
|
|
||||||
static EmulationSession& GetInstance() {
|
static EmulationSession& GetInstance() {
|
||||||
|
@ -42,151 +47,205 @@ public:
|
||||||
}
|
}
|
||||||
|
|
||||||
const Core::System& System() const {
|
const Core::System& System() const {
|
||||||
return system;
|
return m_system;
|
||||||
}
|
}
|
||||||
|
|
||||||
Core::System& System() {
|
Core::System& System() {
|
||||||
return system;
|
return m_system;
|
||||||
}
|
}
|
||||||
|
|
||||||
const EmuWindow_Android& Window() const {
|
const EmuWindow_Android& Window() const {
|
||||||
return *window;
|
return *m_window;
|
||||||
}
|
}
|
||||||
|
|
||||||
EmuWindow_Android& Window() {
|
EmuWindow_Android& Window() {
|
||||||
return *window;
|
return *m_window;
|
||||||
}
|
}
|
||||||
|
|
||||||
ANativeWindow* NativeWindow() const {
|
ANativeWindow* NativeWindow() const {
|
||||||
return native_window;
|
return m_native_window;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SetNativeWindow(ANativeWindow* native_window_) {
|
void SetNativeWindow(ANativeWindow* m_native_window_) {
|
||||||
native_window = native_window_;
|
m_native_window = m_native_window_;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool IsRunning() const {
|
bool IsRunning() const {
|
||||||
std::scoped_lock lock(mutex);
|
std::scoped_lock lock(m_mutex);
|
||||||
return is_running;
|
return m_is_running;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Core::PerfStatsResults& PerfStats() const {
|
const Core::PerfStatsResults& PerfStats() const {
|
||||||
std::scoped_lock perf_stats_lock(perf_stats_mutex);
|
std::scoped_lock m_perf_stats_lock(m_perf_stats_mutex);
|
||||||
return perf_stats;
|
return m_perf_stats;
|
||||||
}
|
}
|
||||||
|
|
||||||
void SurfaceChanged() {
|
void SurfaceChanged() {
|
||||||
if (!IsRunning()) {
|
if (!IsRunning()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window->OnSurfaceChanged(native_window);
|
m_window->OnSurfaceChanged(m_native_window);
|
||||||
}
|
}
|
||||||
|
|
||||||
Core::SystemResultStatus InitializeEmulation(const std::string& filepath) {
|
Core::SystemResultStatus InitializeEmulation(const std::string& filepath) {
|
||||||
std::scoped_lock lock(mutex);
|
std::scoped_lock lock(m_mutex);
|
||||||
|
|
||||||
// Loads the configuration.
|
// Loads the configuration.
|
||||||
Config{};
|
Config{};
|
||||||
|
|
||||||
// Create the render window.
|
// Create the render window.
|
||||||
window = std::make_unique<EmuWindow_Android>(&input_subsystem, native_window);
|
m_window = std::make_unique<EmuWindow_Android>(&m_input_subsystem, m_native_window);
|
||||||
|
|
||||||
// Initialize system.
|
// Initialize system.
|
||||||
system.SetShuttingDown(false);
|
m_system.SetShuttingDown(false);
|
||||||
system.Initialize();
|
m_system.Initialize();
|
||||||
system.ApplySettings();
|
m_system.ApplySettings();
|
||||||
system.HIDCore().ReloadInputDevices();
|
m_system.HIDCore().ReloadInputDevices();
|
||||||
system.SetContentProvider(std::make_unique<FileSys::ContentProviderUnion>());
|
m_system.SetContentProvider(std::make_unique<FileSys::ContentProviderUnion>());
|
||||||
system.SetFilesystem(std::make_shared<FileSys::RealVfsFilesystem>());
|
m_system.SetFilesystem(std::make_shared<FileSys::RealVfsFilesystem>());
|
||||||
system.GetFileSystemController().CreateFactories(*system.GetFilesystem());
|
m_system.GetFileSystemController().CreateFactories(*m_system.GetFilesystem());
|
||||||
|
|
||||||
// Load the ROM.
|
// Load the ROM.
|
||||||
load_result = system.Load(EmulationSession::GetInstance().Window(), filepath);
|
m_load_result = m_system.Load(EmulationSession::GetInstance().Window(), filepath);
|
||||||
if (load_result != Core::SystemResultStatus::Success) {
|
if (m_load_result != Core::SystemResultStatus::Success) {
|
||||||
return load_result;
|
return m_load_result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Complete initialization.
|
// Complete initialization.
|
||||||
system.GPU().Start();
|
m_system.GPU().Start();
|
||||||
system.GetCpuManager().OnGpuReady();
|
m_system.GetCpuManager().OnGpuReady();
|
||||||
system.RegisterExitCallback([&] { HaltEmulation(); });
|
m_system.RegisterExitCallback([&] { HaltEmulation(); });
|
||||||
|
|
||||||
return Core::SystemResultStatus::Success;
|
return Core::SystemResultStatus::Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
void ShutdownEmulation() {
|
void ShutdownEmulation() {
|
||||||
std::scoped_lock lock(mutex);
|
std::scoped_lock lock(m_mutex);
|
||||||
|
|
||||||
is_running = false;
|
m_is_running = false;
|
||||||
|
|
||||||
// Unload user input.
|
// Unload user input.
|
||||||
system.HIDCore().UnloadInputDevices();
|
m_system.HIDCore().UnloadInputDevices();
|
||||||
|
|
||||||
// Shutdown the main emulated process
|
// Shutdown the main emulated process
|
||||||
if (load_result == Core::SystemResultStatus::Success) {
|
if (m_load_result == Core::SystemResultStatus::Success) {
|
||||||
system.DetachDebugger();
|
m_system.DetachDebugger();
|
||||||
system.ShutdownMainProcess();
|
m_system.ShutdownMainProcess();
|
||||||
detached_tasks.WaitForAllTasks();
|
m_detached_tasks.WaitForAllTasks();
|
||||||
load_result = Core::SystemResultStatus::ErrorNotInitialized;
|
m_load_result = Core::SystemResultStatus::ErrorNotInitialized;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down the render window.
|
// Tear down the render window.
|
||||||
window.reset();
|
m_window.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PauseEmulation() {
|
||||||
|
std::scoped_lock lock(m_mutex);
|
||||||
|
m_system.Pause();
|
||||||
|
}
|
||||||
|
|
||||||
|
void UnPauseEmulation() {
|
||||||
|
std::scoped_lock lock(m_mutex);
|
||||||
|
m_system.Run();
|
||||||
}
|
}
|
||||||
|
|
||||||
void HaltEmulation() {
|
void HaltEmulation() {
|
||||||
std::scoped_lock lock(mutex);
|
std::scoped_lock lock(m_mutex);
|
||||||
is_running = false;
|
m_is_running = false;
|
||||||
cv.notify_one();
|
m_cv.notify_one();
|
||||||
}
|
}
|
||||||
|
|
||||||
void RunEmulation() {
|
void RunEmulation() {
|
||||||
{
|
{
|
||||||
std::scoped_lock lock(mutex);
|
std::scoped_lock lock(m_mutex);
|
||||||
is_running = true;
|
m_is_running = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void(system.Run());
|
void(m_system.Run());
|
||||||
|
|
||||||
if (system.DebuggerEnabled()) {
|
if (m_system.DebuggerEnabled()) {
|
||||||
system.InitializeDebugger();
|
m_system.InitializeDebugger();
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
{
|
{
|
||||||
std::unique_lock lock(mutex);
|
std::unique_lock lock(m_mutex);
|
||||||
if (cv.wait_for(lock, std::chrono::milliseconds(100),
|
if (m_cv.wait_for(lock, std::chrono::milliseconds(100),
|
||||||
[&]() { return !is_running; })) {
|
[&]() { return !m_is_running; })) {
|
||||||
// Emulation halted.
|
// Emulation halted.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
// Refresh performance stats.
|
// Refresh performance stats.
|
||||||
std::scoped_lock perf_stats_lock(perf_stats_mutex);
|
std::scoped_lock m_perf_stats_lock(m_perf_stats_mutex);
|
||||||
perf_stats = system.GetAndResetPerfStats();
|
m_perf_stats = m_system.GetAndResetPerfStats();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
std::string GetRomTitle(const std::string& path) {
|
||||||
|
return GetRomMetadata(path).title;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<u8> GetRomIcon(const std::string& path) {
|
||||||
|
return GetRomMetadata(path).icon;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ResetRomMetadata() {
|
||||||
|
m_rom_metadata_cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private:
|
||||||
|
struct RomMetadata {
|
||||||
|
std::string title;
|
||||||
|
std::vector<u8> icon;
|
||||||
|
};
|
||||||
|
|
||||||
|
RomMetadata GetRomMetadata(const std::string& path) {
|
||||||
|
if (auto search = m_rom_metadata_cache.find(path); search != m_rom_metadata_cache.end()) {
|
||||||
|
return search->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CacheRomMetadata(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
RomMetadata CacheRomMetadata(const std::string& path) {
|
||||||
|
const auto file = Core::GetGameFileFromPath(m_vfs, path);
|
||||||
|
const auto loader = Loader::GetLoader(EmulationSession::GetInstance().System(), file, 0, 0);
|
||||||
|
|
||||||
|
RomMetadata entry;
|
||||||
|
loader->ReadTitle(entry.title);
|
||||||
|
loader->ReadIcon(entry.icon);
|
||||||
|
|
||||||
|
m_rom_metadata_cache[path] = entry;
|
||||||
|
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
static EmulationSession s_instance;
|
static EmulationSession s_instance;
|
||||||
|
|
||||||
ANativeWindow* native_window{};
|
// Frontend management
|
||||||
|
std::unordered_map<std::string, RomMetadata> m_rom_metadata_cache;
|
||||||
|
|
||||||
InputCommon::InputSubsystem input_subsystem;
|
// Window management
|
||||||
Common::DetachedTasks detached_tasks;
|
std::unique_ptr<EmuWindow_Android> m_window;
|
||||||
Core::System system;
|
ANativeWindow* m_native_window{};
|
||||||
|
|
||||||
Core::PerfStatsResults perf_stats{};
|
// Core emulation
|
||||||
|
Core::System m_system;
|
||||||
|
InputCommon::InputSubsystem m_input_subsystem;
|
||||||
|
Common::DetachedTasks m_detached_tasks;
|
||||||
|
Core::PerfStatsResults m_perf_stats{};
|
||||||
|
std::shared_ptr<FileSys::RealVfsFilesystem> m_vfs;
|
||||||
|
Core::SystemResultStatus m_load_result{Core::SystemResultStatus::ErrorNotInitialized};
|
||||||
|
bool m_is_running{};
|
||||||
|
|
||||||
std::unique_ptr<EmuWindow_Android> window;
|
// Synchronization
|
||||||
std::condition_variable_any cv;
|
std::condition_variable_any m_cv;
|
||||||
bool is_running{};
|
mutable std::mutex m_perf_stats_mutex;
|
||||||
Core::SystemResultStatus load_result{Core::SystemResultStatus::ErrorNotInitialized};
|
mutable std::mutex m_mutex;
|
||||||
|
|
||||||
mutable std::mutex perf_stats_mutex;
|
|
||||||
mutable std::mutex mutex;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/*static*/ EmulationSession EmulationSession::s_instance;
|
/*static*/ EmulationSession EmulationSession::s_instance;
|
||||||
|
@ -275,16 +334,25 @@ jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_ReloadKeys(JNIEnv* env,
|
||||||
}
|
}
|
||||||
|
|
||||||
void Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation([[maybe_unused]] JNIEnv* env,
|
void Java_org_yuzu_yuzu_1emu_NativeLibrary_UnPauseEmulation([[maybe_unused]] JNIEnv* env,
|
||||||
[[maybe_unused]] jclass clazz) {}
|
[[maybe_unused]] jclass clazz) {
|
||||||
|
EmulationSession::GetInstance().UnPauseEmulation();
|
||||||
|
}
|
||||||
|
|
||||||
void Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation([[maybe_unused]] JNIEnv* env,
|
void Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation([[maybe_unused]] JNIEnv* env,
|
||||||
[[maybe_unused]] jclass clazz) {}
|
[[maybe_unused]] jclass clazz) {
|
||||||
|
EmulationSession::GetInstance().PauseEmulation();
|
||||||
|
}
|
||||||
|
|
||||||
void Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation([[maybe_unused]] JNIEnv* env,
|
void Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation([[maybe_unused]] JNIEnv* env,
|
||||||
[[maybe_unused]] jclass clazz) {
|
[[maybe_unused]] jclass clazz) {
|
||||||
EmulationSession::GetInstance().HaltEmulation();
|
EmulationSession::GetInstance().HaltEmulation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void Java_org_yuzu_yuzu_1emu_NativeLibrary_ResetRomMetadata([[maybe_unused]] JNIEnv* env,
|
||||||
|
[[maybe_unused]] jclass clazz) {
|
||||||
|
EmulationSession::GetInstance().ResetRomMetadata();
|
||||||
|
}
|
||||||
|
|
||||||
jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning([[maybe_unused]] JNIEnv* env,
|
jboolean Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning([[maybe_unused]] JNIEnv* env,
|
||||||
[[maybe_unused]] jclass clazz) {
|
[[maybe_unused]] jclass clazz) {
|
||||||
return static_cast<jboolean>(EmulationSession::GetInstance().IsRunning());
|
return static_cast<jboolean>(EmulationSession::GetInstance().IsRunning());
|
||||||
|
@ -347,16 +415,21 @@ void Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved([[maybe_unused]] JNIEnv*
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jintArray Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon([[maybe_unused]] JNIEnv* env,
|
jbyteArray Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon([[maybe_unused]] JNIEnv* env,
|
||||||
[[maybe_unused]] jclass clazz,
|
[[maybe_unused]] jclass clazz,
|
||||||
[[maybe_unused]] jstring j_file) {
|
[[maybe_unused]] jstring j_filename) {
|
||||||
return {};
|
auto icon_data = EmulationSession::GetInstance().GetRomIcon(GetJString(env, j_filename));
|
||||||
|
jbyteArray icon = env->NewByteArray(static_cast<jsize>(icon_data.size()));
|
||||||
|
env->SetByteArrayRegion(icon, 0, env->GetArrayLength(icon),
|
||||||
|
reinterpret_cast<jbyte*>(icon_data.data()));
|
||||||
|
return icon;
|
||||||
}
|
}
|
||||||
|
|
||||||
jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle([[maybe_unused]] JNIEnv* env,
|
jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle([[maybe_unused]] JNIEnv* env,
|
||||||
[[maybe_unused]] jclass clazz,
|
[[maybe_unused]] jclass clazz,
|
||||||
[[maybe_unused]] jstring j_filename) {
|
[[maybe_unused]] jstring j_filename) {
|
||||||
return env->NewStringUTF("");
|
auto title = EmulationSession::GetInstance().GetRomTitle(GetJString(env, j_filename));
|
||||||
|
return env->NewStringUTF(title.c_str());
|
||||||
}
|
}
|
||||||
|
|
||||||
jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_GetDescription([[maybe_unused]] JNIEnv* env,
|
jstring Java_org_yuzu_yuzu_1emu_NativeLibrary_GetDescription([[maybe_unused]] JNIEnv* env,
|
||||||
|
|
|
@ -19,6 +19,9 @@ JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_PauseEmulation(JNIE
|
||||||
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation(JNIEnv* env,
|
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_StopEmulation(JNIEnv* env,
|
||||||
jclass clazz);
|
jclass clazz);
|
||||||
|
|
||||||
|
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_ResetRomMetadata(JNIEnv* env,
|
||||||
|
jclass clazz);
|
||||||
|
|
||||||
JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning(JNIEnv* env,
|
JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_IsRunning(JNIEnv* env,
|
||||||
jclass clazz);
|
jclass clazz);
|
||||||
|
|
||||||
|
@ -39,7 +42,8 @@ JNIEXPORT jboolean JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchEvent(JN
|
||||||
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, jclass clazz,
|
JNIEXPORT void JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_onTouchMoved(JNIEnv* env, jclass clazz,
|
||||||
jfloat x, jfloat y);
|
jfloat x, jfloat y);
|
||||||
|
|
||||||
JNIEXPORT jintArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon(JNIEnv* env, jclass clazz,
|
JNIEXPORT jbyteArray JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetIcon(JNIEnv* env,
|
||||||
|
jclass clazz,
|
||||||
jstring j_file);
|
jstring j_file);
|
||||||
|
|
||||||
JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle(JNIEnv* env, jclass clazz,
|
JNIEXPORT jstring JNICALL Java_org_yuzu_yuzu_1emu_NativeLibrary_GetTitle(JNIEnv* env, jclass clazz,
|
||||||
|
|
|
@ -1,81 +1,76 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:background="?attr/selectableItemBackground"
|
||||||
android:clickable="true"
|
android:clickable="true"
|
||||||
|
android:clipToPadding="false"
|
||||||
android:focusable="true"
|
android:focusable="true"
|
||||||
android:foreground="?android:attr/selectableItemBackground"
|
android:paddingStart="4dp"
|
||||||
android:transitionName="card_game"
|
android:paddingTop="8dp"
|
||||||
tools:layout_width="match_parent">
|
android:paddingEnd="4dp"
|
||||||
|
android:paddingBottom="8dp"
|
||||||
|
android:transitionName="card_game">
|
||||||
|
|
||||||
<androidx.constraintlayout.widget.ConstraintLayout
|
<androidx.cardview.widget.CardView
|
||||||
android:id="@+id/linearLayout"
|
android:id="@+id/card_game_art"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="150dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="150dp"
|
||||||
android:padding="8dp">
|
app:cardCornerRadius="4dp"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/image_game_screen"
|
android:id="@+id/image_game_screen"
|
||||||
android:layout_width="56dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="56dp"
|
android:layout_height="match_parent"
|
||||||
android:adjustViewBounds="false"
|
android:layout_weight="1" />
|
||||||
android:cropToPadding="false"
|
|
||||||
android:scaleType="fitCenter"
|
<TextView
|
||||||
app:layout_constraintBottom_toBottomOf="parent"
|
android:id="@+id/text_game_title_inner"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
style="@android:style/TextAppearance.Material.Subhead"
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
android:layout_width="match_parent"
|
||||||
tools:scaleType="fitCenter" />
|
android:layout_height="match_parent"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:gravity="center|top"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:paddingLeft="2dp"
|
||||||
|
android:paddingRight="2dp"
|
||||||
|
android:paddingTop="8dp"
|
||||||
|
android:visibility="visible"
|
||||||
|
tools:text="The Legend of Zelda: The Wind Waker" />
|
||||||
|
|
||||||
|
</androidx.cardview.widget.CardView>
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_game_title"
|
android:id="@+id/text_game_title"
|
||||||
style="@android:style/TextAppearance.Material.Subhead"
|
style="@android:style/TextAppearance.Material.Subhead"
|
||||||
android:layout_width="0dp"
|
android:layout_width="150dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
|
||||||
android:baselineAligned="false"
|
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:gravity="center_vertical"
|
android:maxLines="2"
|
||||||
android:lines="1"
|
android:paddingTop="8dp"
|
||||||
android:maxLines="1"
|
app:layout_constraintEnd_toEndOf="@+id/card_game_art"
|
||||||
android:textAlignment="viewStart"
|
app:layout_constraintStart_toStartOf="@+id/card_game_art"
|
||||||
app:layout_constraintEnd_toEndOf="parent"
|
app:layout_constraintTop_toBottomOf="@+id/card_game_art"
|
||||||
app:layout_constraintStart_toEndOf="@+id/image_game_screen"
|
tools:text="The Legend of Zelda: The Wind Waker" />
|
||||||
app:layout_constraintTop_toTopOf="parent"
|
|
||||||
tools:text="The Legend of Zelda\nOcarina of Time 3D"
|
|
||||||
android:textColor="@color/header_text" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/text_company"
|
android:id="@+id/text_game_caption"
|
||||||
style="@android:style/TextAppearance.Material.Caption"
|
style="@android:style/TextAppearance.Material.Caption"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="150dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:lines="1"
|
android:lines="1"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/image_game_screen"
|
android:paddingTop="8dp"
|
||||||
app:layout_constraintStart_toStartOf="@+id/text_game_title"
|
app:layout_constraintEnd_toEndOf="@+id/card_game_art"
|
||||||
|
app:layout_constraintStart_toStartOf="@+id/card_game_art"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/text_game_title"
|
app:layout_constraintTop_toBottomOf="@+id/text_game_title"
|
||||||
app:layout_constraintVertical_bias="0.842"
|
tools:text="Nintendo" />
|
||||||
tools:text="Nintendo"
|
|
||||||
android:textColor="@color/header_subtext" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/text_filename"
|
|
||||||
style="@android:style/TextAppearance.Material.Caption"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:ellipsize="end"
|
|
||||||
android:lines="1"
|
|
||||||
android:maxLines="1"
|
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/image_game_screen"
|
|
||||||
app:layout_constraintStart_toStartOf="@+id/text_game_title"
|
|
||||||
app:layout_constraintTop_toBottomOf="@+id/text_game_title"
|
|
||||||
app:layout_constraintVertical_bias="0.0"
|
|
||||||
tools:text="Pilotwings_Resort.cxi"
|
|
||||||
android:textColor="@color/header_subtext" />
|
|
||||||
|
|
||||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</androidx.cardview.widget.CardView>
|
|
||||||
|
|
|
@ -5,8 +5,8 @@
|
||||||
android:layout_height="match_parent">
|
android:layout_height="match_parent">
|
||||||
|
|
||||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||||
android:id="@+id/refresh_grid_games"
|
android:id="@+id/swipe_refresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content">
|
android:layout_height="wrap_content">
|
||||||
|
|
||||||
<RelativeLayout
|
<RelativeLayout
|
||||||
|
@ -26,6 +26,7 @@
|
||||||
android:id="@+id/grid_games"
|
android:id="@+id/grid_games"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
|
android:clipToPadding="false"
|
||||||
tools:listitem="@layout/card_game" />
|
tools:listitem="@layout/card_game" />
|
||||||
</RelativeLayout>
|
</RelativeLayout>
|
||||||
|
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
<dimen name="spacing_list">64dp</dimen>
|
<dimen name="spacing_list">64dp</dimen>
|
||||||
<dimen name="spacing_fab">72dp</dimen>
|
<dimen name="spacing_fab">72dp</dimen>
|
||||||
<dimen name="menu_width">256dp</dimen>
|
<dimen name="menu_width">256dp</dimen>
|
||||||
<dimen name="card_width">135dp</dimen>
|
<dimen name="card_width">150dp</dimen>
|
||||||
|
|
||||||
<dimen name="dialog_margin">20dp</dimen>
|
<dimen name="dialog_margin">20dp</dimen>
|
||||||
<dimen name="elevated_app_bar">3dp</dimen>
|
<dimen name="elevated_app_bar">3dp</dimen>
|
||||||
|
|
Loading…
Reference in a new issue