diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java new file mode 100644 index 000000000..4ccb1682a --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameGridFragment.java @@ -0,0 +1,159 @@ +package com.github.stenzek.duckstation; + +import android.content.Context; +import android.content.res.Configuration; +import android.os.AsyncTask; +import android.os.Bundle; +import android.util.TypedValue; +import android.view.Gravity; +import android.view.LayoutInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.widget.PopupMenu; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public class GameGridFragment extends Fragment implements GameList.OnRefreshListener { + private static final int SPACING_DIPS = 25; + private static final int WIDTH_DIPS = 160; + + private MainActivity mParent; + private RecyclerView mRecyclerView; + private ViewAdapter mAdapter; + private GridAutofitLayoutManager mLayoutManager; + + public GameGridFragment(MainActivity parent) { + super(R.layout.fragment_game_grid); + this.mParent = parent; + } + + private GameList getGameList() { + return mParent.getGameList(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mAdapter = new ViewAdapter(mParent, getGameList()); + getGameList().addRefreshListener(this); + + mRecyclerView = view.findViewById(R.id.game_list_view); + mRecyclerView.setAdapter(mAdapter); + + final int columnWidth = Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, + WIDTH_DIPS + SPACING_DIPS, getResources().getDisplayMetrics())); + mLayoutManager = new GridAutofitLayoutManager(getContext(), columnWidth); + mRecyclerView.setLayoutManager(mLayoutManager); + } + + @Override + public void onDestroyView() { + super.onDestroyView(); + + getGameList().removeRefreshListener(this); + } + + @Override + public void onGameListRefresh() { + mAdapter.notifyDataSetChanged(); + } + + private static class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener, View.OnLongClickListener { + private MainActivity mParent; + private ImageView mImageView; + private GameListEntry mEntry; + + public ViewHolder(@NonNull MainActivity parent, @NonNull View itemView) { + super(itemView); + mParent = parent; + mImageView = itemView.findViewById(R.id.imageView); + mImageView.setOnClickListener(this); + mImageView.setOnLongClickListener(this); + } + + public void bindToEntry(GameListEntry entry) { + mEntry = entry; + + final String coverPath = entry.getCoverPath(); + if (coverPath == null) { + mImageView.setImageDrawable(ContextCompat.getDrawable(mParent, R.drawable.ic_media_cdrom)); + return; + } + + new ImageLoadTask(mImageView).executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR, coverPath); + } + + @Override + public void onClick(View v) { + mParent.startEmulation(mEntry.getPath(), mParent.shouldResumeStateByDefault()); + } + + @Override + public boolean onLongClick(View v) { + PopupMenu menu = new PopupMenu(mParent, v, Gravity.RIGHT | Gravity.TOP); + menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); + menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + int id = item.getItemId(); + if (id == R.id.game_list_entry_menu_start_game) { + mParent.startEmulation(mEntry.getPath(), false); + return true; + } else if (id == R.id.game_list_entry_menu_resume_game) { + mParent.startEmulation(mEntry.getPath(), true); + return true; + } else if (id == R.id.game_list_entry_menu_properties) { + mParent.openGameProperties(mEntry.getPath()); + return true; + } + return false; + } + }); + menu.show(); + return true; + } + } + + private static class ViewAdapter extends RecyclerView.Adapter { + private MainActivity mParent; + private LayoutInflater mInflater; + private GameList mGameList; + + public ViewAdapter(@NonNull MainActivity parent, @NonNull GameList gameList) { + mParent = parent; + mInflater = LayoutInflater.from(parent); + mGameList = gameList; + } + + @NonNull + @Override + public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + return new ViewHolder(mParent, mInflater.inflate(R.layout.layout_game_grid_entry, parent, false)); + } + + @Override + public void onBindViewHolder(@NonNull ViewHolder holder, int position) { + GameListEntry entry = mGameList.getEntry(position); + holder.bindToEntry(entry); + } + + @Override + public int getItemCount() { + return mGameList.getEntryCount(); + } + + @Override + public int getItemViewType(int position) { + return R.layout.layout_game_grid_entry; + } + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java index 48824c1b5..f136b579b 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameList.java @@ -8,13 +8,19 @@ import android.view.View; import android.view.ViewGroup; import android.widget.BaseAdapter; +import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; public class GameList { + public interface OnRefreshListener { + void onGameListRefresh(); + } + private Activity mContext; private GameListEntry[] mEntries; private ListViewAdapter mAdapter; + private ArrayList mRefreshListeners = new ArrayList<>(); public GameList(Activity context) { mContext = context; @@ -22,6 +28,13 @@ public class GameList { mEntries = new GameListEntry[0]; } + public void addRefreshListener(OnRefreshListener listener) { + mRefreshListeners.add(listener); + } + public void removeRefreshListener(OnRefreshListener listener) { + mRefreshListeners.remove(listener); + } + private class GameListEntryComparator implements Comparator { @Override public int compare(GameListEntry left, GameListEntry right) { @@ -29,7 +42,6 @@ public class GameList { } } - public void refresh(boolean invalidateCache, boolean invalidateDatabase, Activity parentActivity) { // Search and get entries from native code AndroidProgressCallback progressCallback = new AndroidProgressCallback(mContext); @@ -47,6 +59,8 @@ public class GameList { } mEntries = newEntries; mAdapter.notifyDataSetChanged(); + for (OnRefreshListener listener : mRefreshListeners) + listener.onGameListRefresh(); }); }); } diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java index a821661e1..a450e907b 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameListEntry.java @@ -96,6 +96,8 @@ public class GameListEntry { return mCompatibilityRating; } + public String getCoverPath() { return mCoverPath; } + public static String getFileNameForPath(String path) { int lastSlash = path.lastIndexOf('/'); if (lastSlash > 0 && lastSlash < path.length() - 1) diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java b/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java new file mode 100644 index 000000000..394fea861 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GameListFragment.java @@ -0,0 +1,68 @@ +package com.github.stenzek.duckstation; + +import android.os.Bundle; +import android.view.Gravity; +import android.view.MenuItem; +import android.view.View; +import android.widget.AdapterView; +import android.widget.ListView; +import android.widget.PopupMenu; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +public class GameListFragment extends Fragment { + private MainActivity mParent; + private ListView mGameListView; + + public GameListFragment(MainActivity parent) { + super(R.layout.fragment_game_list); + this.mParent = parent; + } + + private GameList getGameList() { + return mParent.getGameList(); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + mGameListView = view.findViewById(R.id.game_list_view); + mGameListView.setAdapter(getGameList().getListViewAdapter()); + mGameListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { + @Override + public void onItemClick(AdapterView parent, View view, int position, long id) { + mParent.startEmulation(getGameList().getEntry(position).getPath(), mParent.shouldResumeStateByDefault()); + } + }); + mGameListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { + @Override + public boolean onItemLongClick(AdapterView parent, View view, int position, + long id) { + PopupMenu menu = new PopupMenu(getContext(), view, Gravity.RIGHT | Gravity.TOP); + menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); + menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { + @Override + public boolean onMenuItemClick(MenuItem item) { + int id = item.getItemId(); + if (id == R.id.game_list_entry_menu_start_game) { + mParent.startEmulation(getGameList().getEntry(position).getPath(), false); + return true; + } else if (id == R.id.game_list_entry_menu_resume_game) { + mParent.startEmulation(getGameList().getEntry(position).getPath(), true); + return true; + } else if (id == R.id.game_list_entry_menu_properties) { + mParent.openGameProperties(getGameList().getEntry(position).getPath()); + return true; + } + return false; + } + }); + menu.show(); + return true; + } + }); + } +} diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/GridAutofitLayoutManager.java b/android/app/src/main/java/com/github/stenzek/duckstation/GridAutofitLayoutManager.java new file mode 100644 index 000000000..f7e3325c2 --- /dev/null +++ b/android/app/src/main/java/com/github/stenzek/duckstation/GridAutofitLayoutManager.java @@ -0,0 +1,73 @@ +package com.github.stenzek.duckstation; + +// https://stackoverflow.com/questions/26666143/recyclerview-gridlayoutmanager-how-to-auto-detect-span-count + +import android.content.Context; +import android.util.TypedValue; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.GridLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +public class GridAutofitLayoutManager extends GridLayoutManager +{ + private int columnWidth; + private boolean isColumnWidthChanged = true; + private int lastWidth; + private int lastHeight; + + public GridAutofitLayoutManager(@NonNull final Context context, final int columnWidth) { + /* Initially set spanCount to 1, will be changed automatically later. */ + super(context, 1); + setColumnWidth(checkedColumnWidth(context, columnWidth)); + } + + public GridAutofitLayoutManager( + @NonNull final Context context, + final int columnWidth, + final int orientation, + final boolean reverseLayout) { + + /* Initially set spanCount to 1, will be changed automatically later. */ + super(context, 1, orientation, reverseLayout); + setColumnWidth(checkedColumnWidth(context, columnWidth)); + } + + private int checkedColumnWidth(@NonNull final Context context, int columnWidth) { + if (columnWidth <= 0) { + /* Set default columnWidth value (48dp here). It is better to move this constant + to static constant on top, but we need context to convert it to dp, so can't really + do so. */ + columnWidth = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 48, + context.getResources().getDisplayMetrics()); + } + return columnWidth; + } + + public void setColumnWidth(final int newColumnWidth) { + if (newColumnWidth > 0 && newColumnWidth != columnWidth) { + columnWidth = newColumnWidth; + isColumnWidthChanged = true; + } + } + + @Override + public void onLayoutChildren(@NonNull final RecyclerView.Recycler recycler, @NonNull final RecyclerView.State state) { + final int width = getWidth(); + final int height = getHeight(); + if (columnWidth > 0 && width > 0 && height > 0 && (isColumnWidthChanged || lastWidth != width || lastHeight != height)) { + final int totalSpace; + if (getOrientation() == VERTICAL) { + totalSpace = width - getPaddingRight() - getPaddingLeft(); + } else { + totalSpace = height - getPaddingTop() - getPaddingBottom(); + } + final int spanCount = Math.max(1, totalSpace / columnWidth); + setSpanCount(spanCount); + isColumnWidthChanged = false; + } + lastWidth = width; + lastHeight = height; + super.onLayoutChildren(recycler, state); + } +} \ No newline at end of file diff --git a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java index 325e4238f..e0421cf76 100644 --- a/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java +++ b/android/app/src/main/java/com/github/stenzek/duckstation/MainActivity.java @@ -12,30 +12,28 @@ import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.util.Log; -import android.view.Gravity; import android.view.Menu; import android.view.MenuItem; import android.view.View; -import android.widget.AdapterView; import android.widget.ListView; -import android.widget.PopupMenu; import android.widget.Toast; +import androidx.annotation.NonNull; import androidx.appcompat.app.AlertDialog; import androidx.appcompat.app.AppCompatActivity; import androidx.appcompat.app.AppCompatDelegate; import androidx.appcompat.widget.Toolbar; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentFactory; import androidx.preference.PreferenceManager; import java.io.ByteArrayOutputStream; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; -import java.util.HashSet; import java.util.Locale; -import java.util.Set; public class MainActivity extends AppCompatActivity { private static final int REQUEST_EXTERNAL_STORAGE_PERMISSIONS = 1; @@ -47,7 +45,32 @@ public class MainActivity extends AppCompatActivity { private GameList mGameList; private ListView mGameListView; + private GameListFragment mGameListFragment; + private GameGridFragment mGameGridFragment; private boolean mHasExternalStoragePermissions = false; + private boolean mIsShowingGameGrid = false; + + public MainActivity() { + getSupportFragmentManager().setFragmentFactory(createFragmentFactory()); + } + + private static String getTitleString() { + String scmVersion = AndroidHostInterface.getScmVersion(); + final int gitHashPos = scmVersion.indexOf("-g"); + if (gitHashPos > 0) + scmVersion = scmVersion.substring(0, gitHashPos); + + return String.format("DuckStation %s", scmVersion); + } + + public GameList getGameList() { + return mGameList; + } + + public boolean shouldResumeStateByDefault() { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + return prefs.getBoolean("Main/SaveStateOnExit", true); + } private void setLanguage() { String language = PreferenceManager.getDefaultSharedPreferences(this).getString("Main/Language", "none"); @@ -86,20 +109,42 @@ public class MainActivity extends AppCompatActivity { private void loadSettings() { setLanguage(); setTheme(); + + mIsShowingGameGrid = PreferenceManager.getDefaultSharedPreferences(this).getBoolean("Main/GameGridView", false); } - private boolean shouldResumeStateByDefault() { - SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - return prefs.getBoolean("Main/SaveStateOnExit", true); + private FragmentFactory createFragmentFactory() { + return new FragmentFactory() { + @NonNull + @Override + public Fragment instantiate(@NonNull ClassLoader classLoader, @NonNull String className) { + if (className == GameListFragment.class.getName()) + return new GameListFragment(MainActivity.this); + else if (className == GameGridFragment.class.getName()) + return new GameGridFragment(MainActivity.this); + + return super.instantiate(classLoader, className); + } + }; } - private static String getTitleString() { - String scmVersion = AndroidHostInterface.getScmVersion(); - final int gitHashPos = scmVersion.indexOf("-g"); - if (gitHashPos > 0) - scmVersion = scmVersion.substring(0, gitHashPos); + private void switchGameListView() { + mIsShowingGameGrid = !mIsShowingGameGrid; + PreferenceManager.getDefaultSharedPreferences(this) + .edit() + .putBoolean("Main/GameGridView", mIsShowingGameGrid) + .commit(); - return String.format("DuckStation %s", scmVersion); + updateGameListFragment(); + invalidateOptionsMenu(); + } + + private void updateGameListFragment() { + getSupportFragmentManager() + .beginTransaction() + .setReorderingAllowed(true). + replace(R.id.content_fragment, mIsShowingGameGrid ? mGameGridFragment : mGameListFragment) + .commit(); } @Override @@ -127,42 +172,9 @@ public class MainActivity extends AppCompatActivity { // Set up game list view. mGameList = new GameList(this); - mGameListView = findViewById(R.id.game_list_view); - mGameListView.setAdapter(mGameList.getListViewAdapter()); - mGameListView.setOnItemClickListener(new AdapterView.OnItemClickListener() { - @Override - public void onItemClick(AdapterView parent, View view, int position, long id) { - startEmulation(mGameList.getEntry(position).getPath(), shouldResumeStateByDefault()); - } - }); - mGameListView.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() { - @Override - public boolean onItemLongClick(AdapterView parent, View view, int position, - long id) { - PopupMenu menu = new PopupMenu(MainActivity.this, view, - Gravity.RIGHT | Gravity.TOP); - menu.getMenuInflater().inflate(R.menu.menu_game_list_entry, menu.getMenu()); - menu.setOnMenuItemClickListener(new PopupMenu.OnMenuItemClickListener() { - @Override - public boolean onMenuItemClick(MenuItem item) { - int id = item.getItemId(); - if (id == R.id.game_list_entry_menu_start_game) { - startEmulation(mGameList.getEntry(position).getPath(), false); - return true; - } else if (id == R.id.game_list_entry_menu_resume_game) { - startEmulation(mGameList.getEntry(position).getPath(), true); - return true; - } else if (id == R.id.game_list_entry_menu_properties) { - openGameProperties(mGameList.getEntry(position).getPath()); - return true; - } - return false; - } - }); - menu.show(); - return true; - } - }); + mGameListFragment = new GameListFragment(this); + mGameGridFragment = new GameGridFragment(this); + updateGameListFragment(); mHasExternalStoragePermissions = checkForExternalStoragePermissions(); if (mHasExternalStoragePermissions) @@ -194,6 +206,13 @@ public class MainActivity extends AppCompatActivity { public boolean onCreateOptionsMenu(Menu menu) { // Inflate the menu; this adds items to the action bar if it is present. getMenuInflater().inflate(R.menu.menu_main, menu); + + final MenuItem switchViewItem = menu.findItem(R.id.action_switch_view); + if (switchViewItem != null) { + switchViewItem.setTitle(mIsShowingGameGrid ? R.string.action_show_game_list : R.string.action_show_game_grid); + switchViewItem.setIcon(mIsShowingGameGrid ? R.drawable.ic_baseline_view_list_24 : R.drawable.ic_baseline_grid_view_24); + } + return true; } @@ -227,6 +246,9 @@ public class MainActivity extends AppCompatActivity { Intent intent = new Intent(this, ControllerMappingActivity.class); startActivity(intent); return true; + } else if (id == R.id.action_switch_view) { + switchGameListView(); + return true; } else if (id == R.id.action_show_version) { showVersion(); return true; @@ -325,14 +347,14 @@ public class MainActivity extends AppCompatActivity { } } - private boolean openGameProperties(String path) { + public boolean openGameProperties(String path) { Intent intent = new Intent(this, GamePropertiesActivity.class); intent.putExtra("path", path); startActivity(intent); return true; } - private boolean startEmulation(String bootPath, boolean resumeState) { + public boolean startEmulation(String bootPath, boolean resumeState) { if (!doBIOSCheck()) return false; diff --git a/android/app/src/main/res/drawable/ic_baseline_grid_view_24.xml b/android/app/src/main/res/drawable/ic_baseline_grid_view_24.xml new file mode 100644 index 000000000..6d4f4a563 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_grid_view_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/ic_baseline_view_list_24.xml b/android/app/src/main/res/drawable/ic_baseline_view_list_24.xml new file mode 100644 index 000000000..9884aeb83 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_baseline_view_list_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index dcf582e14..1ab518e99 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -33,7 +33,12 @@ - + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/content_main.xml b/android/app/src/main/res/layout/fragment_game_list.xml similarity index 79% rename from android/app/src/main/res/layout/content_main.xml rename to android/app/src/main/res/layout/fragment_game_list.xml index 7d56bef48..627c0178b 100644 --- a/android/app/src/main/res/layout/content_main.xml +++ b/android/app/src/main/res/layout/fragment_game_list.xml @@ -3,10 +3,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="match_parent" - app:layout_behavior="@string/appbar_scrolling_view_behavior" - tools:context=".MainActivity" - tools:showIn="@layout/activity_main"> + android:layout_height="match_parent"> + + + + \ No newline at end of file diff --git a/android/app/src/main/res/layout/game_list_view_entry.xml b/android/app/src/main/res/layout/layout_game_list_entry.xml similarity index 100% rename from android/app/src/main/res/layout/game_list_view_entry.xml rename to android/app/src/main/res/layout/layout_game_list_entry.xml diff --git a/android/app/src/main/res/menu/menu_main.xml b/android/app/src/main/res/menu/menu_main.xml index 35c13c08b..cecad6d8a 100644 --- a/android/app/src/main/res/menu/menu_main.xml +++ b/android/app/src/main/res/menu/menu_main.xml @@ -14,6 +14,12 @@ + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 78a003057..6af7c5174 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -227,4 +227,6 @@ Touchscreen Controller Buttons Touchscreen Controller Settings Logs debug messages printed by games. + List View + Grid View