Merge pull request #11408 from t895/coil

Android: Rewrite image loading with Kotlin and Coil
This commit is contained in:
JosJuice 2023-02-20 20:51:36 +01:00 committed by GitHub
commit 0fb9105700
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 609 additions and 822 deletions

View File

@ -155,7 +155,7 @@ dependencies {
implementation 'com.android.volley:volley:1.2.1'
// For loading game covers from disk and GameTDB
implementation 'com.github.bumptech.glide:glide:4.13.2'
implementation 'io.coil-kt:coil:2.2.2'
implementation 'com.nononsenseapps:filepicker:4.2.1'
}

View File

@ -1,184 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.adapters;
import android.app.Activity;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.animation.Animation;
import android.view.animation.AnimationUtils;
import androidx.annotation.NonNull;
import androidx.fragment.app.FragmentActivity;
import androidx.recyclerview.widget.RecyclerView;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.activities.EmulationActivity;
import org.dolphinemu.dolphinemu.databinding.CardGameBinding;
import org.dolphinemu.dolphinemu.dialogs.GamePropertiesDialog;
import org.dolphinemu.dolphinemu.model.GameFile;
import org.dolphinemu.dolphinemu.services.GameFileCacheManager;
import org.dolphinemu.dolphinemu.utils.GlideUtils;
import java.util.ArrayList;
import java.util.List;
public final class GameAdapter extends RecyclerView.Adapter<GameAdapter.GameViewHolder> implements
View.OnClickListener,
View.OnLongClickListener
{
private List<GameFile> mGameFiles;
private Activity mActivity;
/**
* Initializes the adapter's observer, which watches for changes to the dataset. The adapter will
* display no data until swapDataSet is called.
*/
public GameAdapter(Activity activity)
{
mGameFiles = new ArrayList<>();
mActivity = activity;
}
/**
* Called by the LayoutManager when it is necessary to create a new view.
*
* @param parent The RecyclerView (I think?) the created view will be thrown into.
* @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView.
* @return The created ViewHolder with references to all the child view's members.
*/
@NonNull
@Override
public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType)
{
CardGameBinding binding = CardGameBinding.inflate(LayoutInflater.from(parent.getContext()));
binding.getRoot().setOnClickListener(this);
binding.getRoot().setOnLongClickListener(this);
// Use that view to create a ViewHolder.
return new GameViewHolder(binding);
}
/**
* Called by the LayoutManager when a new view is not necessary because we can recycle
* an existing one (for example, if a view just scrolled onto the screen from the bottom, we
* can use the view that just scrolled off the top instead of inflating a new one.)
*
* @param holder A ViewHolder representing the view we're recycling.
* @param position The position of the 'new' view in the dataset.
*/
@Override
public void onBindViewHolder(GameViewHolder holder, int position)
{
Context context = holder.itemView.getContext();
GameFile gameFile = mGameFiles.get(position);
GlideUtils.loadGameCover(holder, holder.binding.imageGameScreen, gameFile, mActivity);
if (GameFileCacheManager.findSecondDisc(gameFile) != null)
{
holder.binding.textGameCaption
.setText(context.getString(R.string.disc_number, gameFile.getDiscNumber() + 1));
}
else
{
holder.binding.textGameCaption.setText(gameFile.getCompany());
}
holder.gameFile = gameFile;
Animation animateIn = AnimationUtils.loadAnimation(context, R.anim.anim_card_game_in);
animateIn.setFillAfter(true);
Animation animateOut = AnimationUtils.loadAnimation(context, R.anim.anim_card_game_out);
animateOut.setFillAfter(true);
holder.binding.getRoot().setOnFocusChangeListener((v, hasFocus) ->
holder.binding.cardGameArt.startAnimation(hasFocus ? animateIn : animateOut));
}
public static class GameViewHolder extends RecyclerView.ViewHolder
{
public GameFile gameFile;
public CardGameBinding binding;
public GameViewHolder(@NonNull CardGameBinding binding)
{
super(binding.getRoot());
binding.getRoot().setTag(this);
this.binding = binding;
}
}
/**
* Called by the LayoutManager to find out how much data we have.
*
* @return Size of the dataset.
*/
@Override
public int getItemCount()
{
return mGameFiles.size();
}
/**
* Tell Android whether or not each item in the dataset has a stable identifier.
*
* @param hasStableIds ignored.
*/
@Override
public void setHasStableIds(boolean hasStableIds)
{
super.setHasStableIds(false);
}
/**
* When a load is finished, call this to replace the existing data
* with the newly-loaded data.
*/
public void swapDataSet(List<GameFile> gameFiles)
{
mGameFiles = gameFiles;
notifyDataSetChanged();
}
/**
* Re-fetches game metadata from the game file cache.
*/
public void refetchMetadata()
{
notifyItemRangeChanged(0, getItemCount());
}
/**
* Launches the game that was clicked on.
*
* @param view The card representing the game the user wants to play.
*/
@Override
public void onClick(View view)
{
GameViewHolder holder = (GameViewHolder) view.getTag();
String[] paths = GameFileCacheManager.findSecondDiscAndGetPaths(holder.gameFile);
EmulationActivity.launch((FragmentActivity) view.getContext(), paths, false);
}
/**
* Launches the details activity for this Game, using an ID stored in the
* details button's Tag.
*
* @param view The Card button that was long-clicked.
*/
@Override
public boolean onLongClick(View view)
{
GameViewHolder holder = (GameViewHolder) view.getTag();
GamePropertiesDialog fragment = GamePropertiesDialog.newInstance(holder.gameFile);
((FragmentActivity) view.getContext()).getSupportFragmentManager().beginTransaction()
.add(fragment, GamePropertiesDialog.TAG).commit();
return true;
}
}

View File

@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.adapters
import android.annotation.SuppressLint
import androidx.recyclerview.widget.RecyclerView
import org.dolphinemu.dolphinemu.adapters.GameAdapter.GameViewHolder
import android.view.View.OnLongClickListener
import org.dolphinemu.dolphinemu.model.GameFile
import android.view.ViewGroup
import android.view.LayoutInflater
import android.view.View
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
import org.dolphinemu.dolphinemu.R
import android.view.animation.AnimationUtils
import org.dolphinemu.dolphinemu.activities.EmulationActivity
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.dolphinemu.dolphinemu.databinding.CardGameBinding
import org.dolphinemu.dolphinemu.dialogs.GamePropertiesDialog
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting
import org.dolphinemu.dolphinemu.utils.CoilUtils
import java.util.ArrayList
class GameAdapter(private val mActivity: FragmentActivity) : RecyclerView.Adapter<GameViewHolder>(),
View.OnClickListener, OnLongClickListener {
private var mGameFiles: List<GameFile> = ArrayList()
/**
* Called by the LayoutManager when it is necessary to create a new view.
*
* @param parent The RecyclerView (I think?) the created view will be thrown into.
* @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView.
* @return The created ViewHolder with references to all the child view's members.
*/
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): GameViewHolder {
val binding = CardGameBinding.inflate(LayoutInflater.from(parent.context))
binding.root.apply {
setOnClickListener(this@GameAdapter)
setOnLongClickListener(this@GameAdapter)
}
// Use that view to create a ViewHolder.
return GameViewHolder(binding)
}
/**
* Called by the LayoutManager when a new view is not necessary because we can recycle
* an existing one (for example, if a view just scrolled onto the screen from the bottom, we
* can use the view that just scrolled off the top instead of inflating a new one.)
*
* @param holder A ViewHolder representing the view we're recycling.
* @param position The position of the 'new' view in the dataset.
*/
override fun onBindViewHolder(holder: GameViewHolder, position: Int) {
val context = holder.itemView.context
val gameFile = mGameFiles[position]
holder.apply {
if (BooleanSetting.MAIN_SHOW_GAME_TITLES.booleanGlobal) {
binding.textGameTitle.text = gameFile.title
binding.textGameTitle.visibility = View.VISIBLE
binding.textGameTitleInner.visibility = View.GONE
binding.textGameCaption.visibility = View.VISIBLE
} else {
binding.textGameTitleInner.text = gameFile.title
binding.textGameTitleInner.visibility = View.VISIBLE
binding.textGameTitle.visibility = View.GONE
binding.textGameCaption.visibility = View.GONE
}
}
mActivity.lifecycleScope.launchWhenStarted {
withContext(Dispatchers.IO) {
val customCoverUri = CoilUtils.findCustomCover(gameFile)
withContext(Dispatchers.Main) {
CoilUtils.loadGameCover(
holder,
holder.binding.imageGameScreen,
gameFile,
customCoverUri
)
}
}
}
val animateIn = AnimationUtils.loadAnimation(context, R.anim.anim_card_game_in)
animateIn.fillAfter = true
val animateOut = AnimationUtils.loadAnimation(context, R.anim.anim_card_game_out)
animateOut.fillAfter = true
holder.apply {
if (GameFileCacheManager.findSecondDisc(gameFile) != null) {
binding.textGameCaption.text =
context.getString(R.string.disc_number, gameFile.discNumber + 1)
} else {
binding.textGameCaption.text = gameFile.company
}
holder.gameFile = gameFile
binding.root.onFocusChangeListener =
View.OnFocusChangeListener { _: View?, hasFocus: Boolean ->
binding.cardGameArt.startAnimation(if (hasFocus) animateIn else animateOut)
}
}
}
class GameViewHolder(var binding: CardGameBinding) : RecyclerView.ViewHolder(binding.root) {
var gameFile: GameFile? = null
init {
binding.root.tag = this
}
}
/**
* Called by the LayoutManager to find out how much data we have.
*
* @return Size of the dataset.
*/
override fun getItemCount(): Int {
return mGameFiles.size
}
/**
* Tell Android whether or not each item in the dataset has a stable identifier.
*
* @param hasStableIds ignored.
*/
override fun setHasStableIds(hasStableIds: Boolean) {
super.setHasStableIds(false)
}
/**
* When a load is finished, call this to replace the existing data
* with the newly-loaded data.
*/
@SuppressLint("NotifyDataSetChanged")
fun swapDataSet(gameFiles: List<GameFile>) {
mGameFiles = gameFiles
notifyDataSetChanged()
}
/**
* Re-fetches game metadata from the game file cache.
*/
fun refetchMetadata() {
notifyItemRangeChanged(0, itemCount)
}
/**
* Launches the game that was clicked on.
*
* @param view The card representing the game the user wants to play.
*/
override fun onClick(view: View) {
val holder = view.tag as GameViewHolder
val paths = GameFileCacheManager.findSecondDiscAndGetPaths(holder.gameFile)
EmulationActivity.launch(view.context as FragmentActivity, paths, false)
}
/**
* Launches the details activity for this Game, using an ID stored in the
* details button's Tag.
*
* @param view The Card button that was long-clicked.
*/
override fun onLongClick(view: View): Boolean {
val holder = view.tag as GameViewHolder
val fragment = GamePropertiesDialog.newInstance(holder.gameFile)
(view.context as FragmentActivity).supportFragmentManager.beginTransaction()
.add(fragment, GamePropertiesDialog.TAG).commit()
return true
}
}

View File

@ -1,88 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.adapters;
import android.content.Context;
import android.graphics.drawable.Drawable;
import android.view.ViewGroup;
import android.widget.ImageView;
import androidx.core.content.ContextCompat;
import androidx.fragment.app.FragmentActivity;
import androidx.leanback.widget.ImageCardView;
import androidx.leanback.widget.Presenter;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.dialogs.GamePropertiesDialog;
import org.dolphinemu.dolphinemu.model.GameFile;
import org.dolphinemu.dolphinemu.services.GameFileCacheManager;
import org.dolphinemu.dolphinemu.utils.GlideUtils;
import org.dolphinemu.dolphinemu.viewholders.TvGameViewHolder;
/**
* The Leanback library / docs call this a Presenter, but it works very
* similarly to a RecyclerView.Adapter.
*/
public final class GameRowPresenter extends Presenter
{
@Override
public ViewHolder onCreateViewHolder(ViewGroup parent)
{
// Create a new view.
ImageCardView gameCard = new ImageCardView(parent.getContext());
gameCard.setMainImageAdjustViewBounds(true);
gameCard.setMainImageDimensions(240, 336);
gameCard.setMainImageScaleType(ImageView.ScaleType.CENTER_CROP);
gameCard.setFocusable(true);
gameCard.setFocusableInTouchMode(true);
// Use that view to create a ViewHolder.
return new TvGameViewHolder(gameCard);
}
@Override
public void onBindViewHolder(ViewHolder viewHolder, Object item)
{
TvGameViewHolder holder = (TvGameViewHolder) viewHolder;
Context context = holder.cardParent.getContext();
GameFile gameFile = (GameFile) item;
holder.imageScreenshot.setImageDrawable(null);
GlideUtils.loadGameCover(null, holder.imageScreenshot, gameFile, null);
holder.cardParent.setTitleText(gameFile.getTitle());
if (GameFileCacheManager.findSecondDisc(gameFile) != null)
{
holder.cardParent.setContentText(
context.getString(R.string.disc_number, gameFile.getDiscNumber() + 1));
}
else
{
holder.cardParent.setContentText(gameFile.getCompany());
}
holder.gameFile = gameFile;
// Set the background color of the card
Drawable background = ContextCompat.getDrawable(context, R.drawable.tv_card_background);
holder.cardParent.setInfoAreaBackground(background);
holder.cardParent.setOnLongClickListener((view) ->
{
FragmentActivity activity = (FragmentActivity) view.getContext();
GamePropertiesDialog fragment = GamePropertiesDialog.newInstance(holder.gameFile);
activity.getSupportFragmentManager().beginTransaction()
.add(fragment, GamePropertiesDialog.TAG).commit();
return true;
});
}
@Override
public void onUnbindViewHolder(ViewHolder viewHolder)
{
// no op
}
}

View File

@ -0,0 +1,89 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.adapters
import androidx.leanback.widget.Presenter
import android.view.ViewGroup
import androidx.leanback.widget.ImageCardView
import org.dolphinemu.dolphinemu.viewholders.TvGameViewHolder
import org.dolphinemu.dolphinemu.model.GameFile
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
import org.dolphinemu.dolphinemu.R
import android.view.View
import androidx.core.content.ContextCompat
import android.widget.ImageView
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.dolphinemu.dolphinemu.dialogs.GamePropertiesDialog
import org.dolphinemu.dolphinemu.utils.CoilUtils
/**
* The Leanback library / docs call this a Presenter, but it works very
* similarly to a RecyclerView.Adapter.
*/
class GameRowPresenter(private val mActivity: FragmentActivity) : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
// Create a new view.
val gameCard = ImageCardView(parent.context)
gameCard.apply {
setMainImageAdjustViewBounds(true)
setMainImageDimensions(240, 336)
setMainImageScaleType(ImageView.ScaleType.CENTER_CROP)
isFocusable = true
isFocusableInTouchMode = true
}
// Use that view to create a ViewHolder.
return TvGameViewHolder(gameCard)
}
override fun onBindViewHolder(viewHolder: ViewHolder, item: Any) {
val holder = viewHolder as TvGameViewHolder
val context = holder.cardParent.context
val gameFile = item as GameFile
holder.apply {
imageScreenshot.setImageDrawable(null)
cardParent.titleText = gameFile.title
holder.gameFile = gameFile
// Set the background color of the card
val background = ContextCompat.getDrawable(context, R.drawable.tv_card_background)
cardParent.infoAreaBackground = background
cardParent.setOnClickListener { view: View ->
val activity = view.context as FragmentActivity
val fragment = GamePropertiesDialog.newInstance(holder.gameFile)
activity.supportFragmentManager.beginTransaction()
.add(fragment, GamePropertiesDialog.TAG).commit()
}
if (GameFileCacheManager.findSecondDisc(gameFile) != null) {
holder.cardParent.contentText =
context.getString(R.string.disc_number, gameFile.discNumber + 1)
} else {
holder.cardParent.contentText = gameFile.company
}
}
mActivity.lifecycleScope.launchWhenStarted {
withContext(Dispatchers.IO) {
val customCoverUri = CoilUtils.findCustomCover(gameFile)
withContext(Dispatchers.Main) {
CoilUtils.loadGameCover(
null,
holder.imageScreenshot,
gameFile,
customCoverUri
)
}
}
}
}
override fun onUnbindViewHolder(viewHolder: ViewHolder) {
// no op
}
}

View File

@ -1,172 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.dialogs;
import android.app.Dialog;
import android.os.Bundle;
import android.view.View;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import androidx.fragment.app.DialogFragment;
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
import org.dolphinemu.dolphinemu.NativeLibrary;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.databinding.DialogGameDetailsBinding;
import org.dolphinemu.dolphinemu.databinding.DialogGameDetailsTvBinding;
import org.dolphinemu.dolphinemu.model.GameFile;
import org.dolphinemu.dolphinemu.services.GameFileCacheManager;
import org.dolphinemu.dolphinemu.utils.GlideUtils;
public final class GameDetailsDialog extends DialogFragment
{
private static final String ARG_GAME_PATH = "game_path";
public static GameDetailsDialog newInstance(String gamePath)
{
GameDetailsDialog fragment = new GameDetailsDialog();
Bundle arguments = new Bundle();
arguments.putString(ARG_GAME_PATH, gamePath);
fragment.setArguments(arguments);
return fragment;
}
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState)
{
GameFile gameFile = GameFileCacheManager.addOrGet(getArguments().getString(ARG_GAME_PATH));
String country = getResources().getStringArray(R.array.countryNames)[gameFile.getCountry()];
String description = gameFile.getDescription();
String fileSize = NativeLibrary.FormatSize(gameFile.getFileSize(), 2);
// TODO: Remove dialog_game_details_tv if we switch to an AppCompatActivity for leanback
DialogGameDetailsBinding binding;
DialogGameDetailsTvBinding tvBinding;
MaterialAlertDialogBuilder builder = new MaterialAlertDialogBuilder(requireContext());
if (requireActivity() instanceof AppCompatActivity)
{
binding = DialogGameDetailsBinding.inflate(getLayoutInflater());
binding.textGameTitle.setText(gameFile.getTitle());
binding.textDescription.setText(gameFile.getDescription());
if (description.isEmpty())
{
binding.textDescription.setVisibility(View.GONE);
}
binding.textCountry.setText(country);
binding.textCompany.setText(gameFile.getCompany());
binding.textGameId.setText(gameFile.getGameId());
binding.textRevision.setText(String.valueOf(gameFile.getRevision()));
if (!gameFile.shouldShowFileFormatDetails())
{
binding.labelFileFormat.setText(R.string.game_details_file_size);
binding.textFileFormat.setText(fileSize);
binding.labelCompression.setVisibility(View.GONE);
binding.textCompression.setVisibility(View.GONE);
binding.labelBlockSize.setVisibility(View.GONE);
binding.textBlockSize.setVisibility(View.GONE);
}
else
{
long blockSize = gameFile.getBlockSize();
String compression = gameFile.getCompressionMethod();
binding.textFileFormat.setText(
getResources().getString(R.string.game_details_size_and_format,
gameFile.getFileFormatName(), fileSize));
if (compression.isEmpty())
{
binding.textCompression.setText(R.string.game_details_no_compression);
}
else
{
binding.textCompression.setText(gameFile.getCompressionMethod());
}
if (blockSize > 0)
{
binding.textBlockSize.setText(NativeLibrary.FormatSize(blockSize, 0));
}
else
{
binding.labelBlockSize.setVisibility(View.GONE);
binding.textBlockSize.setVisibility(View.GONE);
}
}
GlideUtils.loadGameBanner(binding.banner, gameFile);
builder.setView(binding.getRoot());
}
else
{
tvBinding = DialogGameDetailsTvBinding.inflate(getLayoutInflater());
tvBinding.textGameTitle.setText(gameFile.getTitle());
tvBinding.textDescription.setText(gameFile.getDescription());
if (description.isEmpty())
{
tvBinding.textDescription.setVisibility(View.GONE);
}
tvBinding.textCountry.setText(country);
tvBinding.textCompany.setText(gameFile.getCompany());
tvBinding.textGameId.setText(gameFile.getGameId());
tvBinding.textRevision.setText(String.valueOf(gameFile.getRevision()));
if (!gameFile.shouldShowFileFormatDetails())
{
tvBinding.labelFileFormat.setText(R.string.game_details_file_size);
tvBinding.textFileFormat.setText(fileSize);
tvBinding.labelCompression.setVisibility(View.GONE);
tvBinding.textCompression.setVisibility(View.GONE);
tvBinding.labelBlockSize.setVisibility(View.GONE);
tvBinding.textBlockSize.setVisibility(View.GONE);
}
else
{
long blockSize = gameFile.getBlockSize();
String compression = gameFile.getCompressionMethod();
tvBinding.textFileFormat.setText(
getResources().getString(R.string.game_details_size_and_format,
gameFile.getFileFormatName(), fileSize));
if (compression.isEmpty())
{
tvBinding.textCompression.setText(R.string.game_details_no_compression);
}
else
{
tvBinding.textCompression.setText(gameFile.getCompressionMethod());
}
if (blockSize > 0)
{
tvBinding.textBlockSize.setText(NativeLibrary.FormatSize(blockSize, 0));
}
else
{
tvBinding.labelBlockSize.setVisibility(View.GONE);
tvBinding.textBlockSize.setVisibility(View.GONE);
}
}
GlideUtils.loadGameBanner(tvBinding.banner, gameFile);
builder.setView(tvBinding.getRoot());
}
return builder.create();
}
}

View File

@ -0,0 +1,175 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.dialogs
import android.app.Dialog
import android.graphics.Bitmap
import android.os.Bundle
import android.view.View
import android.widget.ImageView
import org.dolphinemu.dolphinemu.services.GameFileCacheManager
import org.dolphinemu.dolphinemu.R
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.DialogFragment
import androidx.lifecycle.lifecycleScope
import coil.imageLoader
import coil.request.ImageRequest
import kotlinx.coroutines.launch
import org.dolphinemu.dolphinemu.NativeLibrary
import org.dolphinemu.dolphinemu.databinding.DialogGameDetailsBinding
import org.dolphinemu.dolphinemu.databinding.DialogGameDetailsTvBinding
import org.dolphinemu.dolphinemu.model.GameFile
class GameDetailsDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val gameFile = GameFileCacheManager.addOrGet(requireArguments().getString(ARG_GAME_PATH))
val country = resources.getStringArray(R.array.countryNames)[gameFile.country]
val fileSize = NativeLibrary.FormatSize(gameFile.fileSize, 2)
// TODO: Remove dialog_game_details_tv if we switch to an AppCompatActivity for leanback
val binding: DialogGameDetailsBinding
val tvBinding: DialogGameDetailsTvBinding
val builder = MaterialAlertDialogBuilder(requireContext())
if (requireActivity() is AppCompatActivity) {
binding = DialogGameDetailsBinding.inflate(layoutInflater)
binding.apply {
textGameTitle.text = gameFile.title
textDescription.text = gameFile.description
if (gameFile.description.isEmpty()) {
textDescription.visibility = View.GONE
}
textCountry.text = country
textCompany.text = gameFile.company
textGameId.text = gameFile.gameId
textRevision.text = gameFile.revision.toString()
if (!gameFile.shouldShowFileFormatDetails()) {
labelFileFormat.setText(R.string.game_details_file_size)
textFileFormat.text = fileSize
labelCompression.visibility = View.GONE
textCompression.visibility = View.GONE
labelBlockSize.visibility = View.GONE
textBlockSize.visibility = View.GONE
} else {
val blockSize = gameFile.blockSize
val compression = gameFile.compressionMethod
textFileFormat.text = resources.getString(
R.string.game_details_size_and_format,
gameFile.fileFormatName,
fileSize
)
if (compression.isEmpty()) {
textCompression.setText(R.string.game_details_no_compression)
} else {
textCompression.text = gameFile.compressionMethod
}
if (blockSize > 0) {
textBlockSize.text = NativeLibrary.FormatSize(blockSize, 0)
} else {
labelBlockSize.visibility = View.GONE
textBlockSize.visibility = View.GONE
}
}
}
this.lifecycleScope.launch {
loadGameBanner(binding.banner, gameFile)
}
builder.setView(binding.root)
} else {
tvBinding = DialogGameDetailsTvBinding.inflate(layoutInflater)
tvBinding.apply {
textGameTitle.text = gameFile.title
textDescription.text = gameFile.description
if (gameFile.description.isEmpty()) {
tvBinding.textDescription.visibility = View.GONE
}
textCountry.text = country
textCompany.text = gameFile.company
textGameId.text = gameFile.gameId
textRevision.text = gameFile.revision.toString()
if (!gameFile.shouldShowFileFormatDetails()) {
labelFileFormat.setText(R.string.game_details_file_size)
textFileFormat.text = fileSize
labelCompression.visibility = View.GONE
textCompression.visibility = View.GONE
labelBlockSize.visibility = View.GONE
textBlockSize.visibility = View.GONE
} else {
val blockSize = gameFile.blockSize
val compression = gameFile.compressionMethod
textFileFormat.text = resources.getString(
R.string.game_details_size_and_format,
gameFile.fileFormatName,
fileSize
)
if (compression.isEmpty()) {
textCompression.setText(R.string.game_details_no_compression)
} else {
textCompression.text = gameFile.compressionMethod
}
if (blockSize > 0) {
textBlockSize.text = NativeLibrary.FormatSize(blockSize, 0)
} else {
labelBlockSize.visibility = View.GONE
textBlockSize.visibility = View.GONE
}
}
}
this.lifecycleScope.launch {
loadGameBanner(tvBinding.banner, gameFile)
}
builder.setView(tvBinding.root)
}
return builder.create()
}
private suspend fun loadGameBanner(imageView: ImageView, gameFile: GameFile) {
val vector = gameFile.banner
val width = gameFile.bannerWidth
val height = gameFile.bannerHeight
imageView.scaleType = ImageView.ScaleType.FIT_CENTER
val request = ImageRequest.Builder(imageView.context)
.target(imageView)
.error(R.drawable.no_banner)
if (width > 0 && height > 0) {
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
bitmap.setPixels(vector, 0, width, 0, 0, width, height)
request.data(bitmap)
} else {
request.data(R.drawable.no_banner)
}
imageView.context.imageLoader.execute(request.build())
}
companion object {
private const val ARG_GAME_PATH = "game_path"
@JvmStatic
fun newInstance(gamePath: String?): GameDetailsDialog {
val fragment = GameDetailsDialog()
val arguments = Bundle()
arguments.putString(ARG_GAME_PATH, gamePath)
fragment.arguments = arguments
return fragment
}
}
}

View File

@ -1,5 +1,6 @@
package org.dolphinemu.dolphinemu.fragments
import android.app.Activity
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import android.view.LayoutInflater
import android.view.ViewGroup
@ -74,7 +75,7 @@ class GridOptionDialogFragment : BottomSheetDialogFragment() {
NativeConfig.LAYER_BASE,
mBindingMobile.switchDownloadCovers.isChecked
)
mView.reloadGrid()
(mView as Activity).recreate()
}
}
@ -104,7 +105,7 @@ class GridOptionDialogFragment : BottomSheetDialogFragment() {
NativeConfig.LAYER_BASE,
mBindingTv.switchDownloadCovers.isChecked
)
mView.reloadGrid()
(mView as Activity).recreate()
}
}
}

View File

@ -2,12 +2,15 @@
package org.dolphinemu.dolphinemu.model;
import android.content.Context;
import androidx.annotation.Keep;
public class GameFile
{
public static int REGION_NTSC_J = 0;
public static int REGION_NTSC_U = 1;
public static int REGION_PAL = 2;
public static int REGION_NTSC_K = 4;
@Keep
private long mPointer;
@ -68,11 +71,6 @@ public class GameFile
public native int getBannerHeight();
public String getCoverPath(Context context)
{
return context.getExternalCacheDir().getPath() + "/GameCovers/" + getGameTdbId() + ".png";
}
public String getCustomCoverPath()
{
return getPath().substring(0, getPath().lastIndexOf(".")) + ".cover.png";

View File

@ -349,7 +349,7 @@ public final class TvMainActivity extends FragmentActivity
}
// Create an adapter for this row.
ArrayObjectAdapter row = new ArrayObjectAdapter(new GameRowPresenter());
ArrayObjectAdapter row = new ArrayObjectAdapter(new GameRowPresenter(this));
row.addAll(0, gameFiles);
// Keep a reference to the row in case we need to refresh it.

View File

@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils
import android.net.Uri
import android.view.View
import android.widget.ImageView
import coil.load
import coil.target.ImageViewTarget
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.adapters.GameAdapter.GameViewHolder
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting
import org.dolphinemu.dolphinemu.model.GameFile
import java.io.File
import java.io.FileNotFoundException
object CoilUtils {
fun loadGameCover(
gameViewHolder: GameViewHolder?,
imageView: ImageView,
gameFile: GameFile,
customCoverUri: Uri?
) {
imageView.scaleType = ImageView.ScaleType.FIT_CENTER
val imageTarget = ImageViewTarget(imageView)
if (customCoverUri != null) {
imageView.load(customCoverUri) {
error(R.drawable.no_banner)
target(
onSuccess = { success ->
disableInnerTitle(gameViewHolder)
imageTarget.drawable = success
},
onError = { error ->
enableInnerTitle(gameViewHolder)
imageTarget.drawable = error
}
)
}
} else if (BooleanSetting.MAIN_USE_GAME_COVERS.booleanGlobal) {
imageView.load(CoverHelper.buildGameTDBUrl(gameFile, CoverHelper.getRegion(gameFile))) {
error(R.drawable.no_banner)
target(
onSuccess = { success ->
disableInnerTitle(gameViewHolder)
imageTarget.drawable = success
},
onError = { error ->
enableInnerTitle(gameViewHolder)
imageTarget.drawable = error
}
)
}
} else {
imageView.load(R.drawable.no_banner)
enableInnerTitle(gameViewHolder)
}
}
private fun enableInnerTitle(gameViewHolder: GameViewHolder?) {
if (gameViewHolder != null && !BooleanSetting.MAIN_SHOW_GAME_TITLES.booleanGlobal) {
gameViewHolder.binding.textGameTitleInner.visibility = View.VISIBLE
}
}
private fun disableInnerTitle(gameViewHolder: GameViewHolder?) {
if (gameViewHolder != null && !BooleanSetting.MAIN_SHOW_GAME_TITLES.booleanGlobal) {
gameViewHolder.binding.textGameTitleInner.visibility = View.GONE
}
}
fun findCustomCover(gameFile: GameFile): Uri? {
val customCoverPath = gameFile.customCoverPath
var customCoverUri: Uri? = null
var customCoverExists = false
if (ContentHandler.isContentUri(customCoverPath)) {
try {
customCoverUri = ContentHandler.unmangle(customCoverPath)
customCoverExists = true
} catch (ignored: FileNotFoundException) {
} catch (ignored: SecurityException) {
// Let customCoverExists remain false
}
} else {
customCoverUri = Uri.parse(customCoverPath)
customCoverExists = File(customCoverPath).exists()
}
return if (customCoverExists) {
customCoverUri
} else {
null
}
}
}

View File

@ -1,83 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils;
import android.graphics.Bitmap;
import org.dolphinemu.dolphinemu.model.GameFile;
import java.io.FileOutputStream;
public final class CoverHelper
{
public static String buildGameTDBUrl(GameFile game, String region)
{
String baseUrl = "https://art.gametdb.com/wii/cover/%s/%s.png";
return String.format(baseUrl, region, game.getGameTdbId());
}
public static String getRegion(GameFile game)
{
String region;
switch (game.getRegion())
{
case 0: // NTSC_J
region = "JA";
break;
case 1: // NTSC_U
region = "US";
break;
case 4: // NTSC_K
region = "KO";
break;
case 2: // PAL
switch (game.getCountry())
{
case 3: // Australia
region = "AU";
break;
case 4: // France
region = "FR";
break;
case 5: // Germany
region = "DE";
break;
case 6: // Italy
region = "IT";
break;
case 8: // Netherlands
region = "NL";
break;
case 9: // Russia
region = "RU";
break;
case 10: // Spain
region = "ES";
break;
case 0: // Europe
default:
region = "EN";
break;
}
break;
case 3: // Unknown
default:
region = "EN";
break;
}
return region;
}
public static void saveCover(Bitmap cover, String path)
{
try
{
FileOutputStream out = new FileOutputStream(path);
cover.compress(Bitmap.CompressFormat.PNG, 100, out);
out.close();
}
catch (Exception ignored)
{
}
}
}

View File

@ -0,0 +1,36 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils
import org.dolphinemu.dolphinemu.model.GameFile
object CoverHelper {
@JvmStatic
fun buildGameTDBUrl(game: GameFile, region: String?): String {
val baseUrl = "https://art.gametdb.com/wii/cover/%s/%s.png"
return String.format(baseUrl, region, game.gameTdbId)
}
@JvmStatic
fun getRegion(game: GameFile): String {
val region: String = when (game.region) {
GameFile.REGION_NTSC_J -> "JA"
GameFile.REGION_NTSC_U -> "US"
GameFile.REGION_NTSC_K -> "KO"
GameFile.REGION_PAL -> when (game.country) {
3 -> "AU" // Australia
4 -> "FR" // France
5 -> "DE" // Germany
6 -> "IT" // Italy
8 -> "NL" // Netherlands
9 -> "RU" // Russia
10 -> "ES" // Spain
0 -> "EN" // Europe
else -> "EN"
}
3 -> "EN" // Unknown
else -> "EN"
}
return region
}
}

View File

@ -1,248 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils;
import android.app.Activity;
import android.content.Context;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.widget.ImageView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import com.bumptech.glide.Glide;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.engine.DiskCacheStrategy;
import com.bumptech.glide.load.engine.GlideException;
import com.bumptech.glide.request.RequestListener;
import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.request.target.Target;
import com.bumptech.glide.request.transition.Transition;
import org.dolphinemu.dolphinemu.R;
import org.dolphinemu.dolphinemu.adapters.GameAdapter;
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting;
import org.dolphinemu.dolphinemu.model.GameFile;
import java.io.File;
import java.io.FileNotFoundException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class GlideUtils
{
private static final ExecutorService saveCoverExecutor = Executors.newSingleThreadExecutor();
private static final ExecutorService unmangleExecutor = Executors.newSingleThreadExecutor();
private static final Handler unmangleHandler = new Handler(Looper.getMainLooper());
public static void loadGameBanner(ImageView imageView, GameFile gameFile)
{
Context context = imageView.getContext();
int[] vector = gameFile.getBanner();
int width = gameFile.getBannerWidth();
int height = gameFile.getBannerHeight();
if (width > 0 && height > 0)
{
Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
bitmap.setPixels(vector, 0, width, 0, 0, width, height);
Glide.with(context)
.load(bitmap)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.into(imageView);
}
else
{
Glide.with(context)
.load(R.drawable.no_banner)
.into(imageView);
}
}
public static void loadGameCover(GameAdapter.GameViewHolder gameViewHolder, ImageView imageView,
GameFile gameFile, Activity activity)
{
if (BooleanSetting.MAIN_SHOW_GAME_TITLES.getBooleanGlobal() && gameViewHolder != null)
{
gameViewHolder.binding.textGameTitle.setText(gameFile.getTitle());
gameViewHolder.binding.textGameTitle.setVisibility(View.VISIBLE);
gameViewHolder.binding.textGameTitleInner.setVisibility(View.GONE);
gameViewHolder.binding.textGameCaption.setVisibility(View.VISIBLE);
}
else if (gameViewHolder != null)
{
gameViewHolder.binding.textGameTitleInner.setText(gameFile.getTitle());
gameViewHolder.binding.textGameTitle.setVisibility(View.GONE);
gameViewHolder.binding.textGameCaption.setVisibility(View.GONE);
}
unmangleExecutor.execute(() ->
{
String customCoverPath = gameFile.getCustomCoverPath();
Uri customCoverUri = null;
boolean customCoverExists = false;
if (ContentHandler.isContentUri(customCoverPath))
{
try
{
customCoverUri = ContentHandler.unmangle(customCoverPath);
customCoverExists = true;
}
catch (FileNotFoundException | SecurityException ignored)
{
// Let customCoverExists remain false
}
}
else
{
customCoverUri = Uri.parse(customCoverPath);
customCoverExists = new File(customCoverPath).exists();
}
Context context = imageView.getContext();
boolean finalCustomCoverExists = customCoverExists;
Uri finalCustomCoverUri = customCoverUri;
File cover = new File(gameFile.getCoverPath(context));
boolean cachedCoverExists = cover.exists();
unmangleHandler.post(() ->
{
// We can't get a reference to the current activity in the TV version.
// Luckily it won't attempt to start loads on destroyed activities.
if (activity != null)
{
// We can't start an image load on a destroyed activity
if (activity.isDestroyed())
{
return;
}
}
if (finalCustomCoverExists)
{
Glide.with(imageView)
.load(finalCustomCoverUri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.error(R.drawable.no_banner)
.listener(new RequestListener<Drawable>()
{
@Override public boolean onLoadFailed(@Nullable GlideException e, Object model,
Target<Drawable> target, boolean isFirstResource)
{
GlideUtils.enableInnerTitle(gameViewHolder);
return false;
}
@Override public boolean onResourceReady(Drawable resource, Object model,
Target<Drawable> target, DataSource dataSource, boolean isFirstResource)
{
GlideUtils.disableInnerTitle(gameViewHolder);
return false;
}
})
.into(imageView);
}
else if (cachedCoverExists)
{
Glide.with(imageView)
.load(cover)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.error(R.drawable.no_banner)
.listener(new RequestListener<Drawable>()
{
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model,
Target<Drawable> target, boolean isFirstResource)
{
GlideUtils.enableInnerTitle(gameViewHolder);
return false;
}
@Override
public boolean onResourceReady(Drawable resource, Object model,
Target<Drawable> target, DataSource dataSource, boolean isFirstResource)
{
GlideUtils.disableInnerTitle(gameViewHolder);
return false;
}
})
.into(imageView);
}
else if (BooleanSetting.MAIN_USE_GAME_COVERS.getBooleanGlobal())
{
Glide.with(context)
.load(CoverHelper.buildGameTDBUrl(gameFile, CoverHelper.getRegion(gameFile)))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.error(R.drawable.no_banner)
.listener(new RequestListener<Drawable>()
{
@Override
public boolean onLoadFailed(@Nullable GlideException e, Object model,
Target<Drawable> target, boolean isFirstResource)
{
GlideUtils.enableInnerTitle(gameViewHolder);
return false;
}
@Override
public boolean onResourceReady(Drawable resource, Object model,
Target<Drawable> target, DataSource dataSource, boolean isFirstResource)
{
GlideUtils.disableInnerTitle(gameViewHolder);
return false;
}
})
.into(new CustomTarget<Drawable>()
{
@Override
public void onResourceReady(@NonNull Drawable resource,
@Nullable Transition<? super Drawable> transition)
{
Bitmap cover = ((BitmapDrawable) resource).getBitmap();
saveCoverExecutor.execute(
() -> CoverHelper.saveCover(cover, gameFile.getCoverPath(context)));
imageView.setImageBitmap(cover);
}
@Override
public void onLoadCleared(@Nullable Drawable placeholder)
{
}
});
}
else
{
Glide.with(imageView.getContext())
.load(R.drawable.no_banner)
.into(imageView);
enableInnerTitle(gameViewHolder);
}
});
});
}
private static void enableInnerTitle(GameAdapter.GameViewHolder gameViewHolder)
{
if (gameViewHolder != null && !BooleanSetting.MAIN_SHOW_GAME_TITLES.getBooleanGlobal())
{
gameViewHolder.binding.textGameTitleInner.setVisibility(View.VISIBLE);
}
}
private static void disableInnerTitle(GameAdapter.GameViewHolder gameViewHolder)
{
if (gameViewHolder != null && !BooleanSetting.MAIN_SHOW_GAME_TITLES.getBooleanGlobal())
{
gameViewHolder.binding.textGameTitleInner.setVisibility(View.GONE);
}
}
}

View File

@ -186,9 +186,9 @@ public class TvUtil
}
}
if (contentUri == null && (cover = new File(game.getCoverPath(context))).exists())
if (contentUri == null)
{
contentUri = getUriForFile(context, getFileProvider(context), cover);
contentUri = Uri.parse(CoverHelper.buildGameTDBUrl(game, CoverHelper.getRegion(game)));
}
context.grantUriPermission(LEANBACK_PACKAGE, contentUri, FLAG_GRANT_READ_URI_PERMISSION);

View File

@ -1,34 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.viewholders;
import android.view.View;
import android.widget.ImageView;
import androidx.leanback.widget.ImageCardView;
import androidx.leanback.widget.Presenter;
import org.dolphinemu.dolphinemu.model.GameFile;
/**
* A simple class that stores references to views so that the GameAdapter doesn't need to
* keep calling findViewById(), which is expensive.
*/
public final class TvGameViewHolder extends Presenter.ViewHolder
{
public ImageCardView cardParent;
public ImageView imageScreenshot;
public GameFile gameFile;
public TvGameViewHolder(View itemView)
{
super(itemView);
itemView.setTag(this);
cardParent = (ImageCardView) itemView;
imageScreenshot = cardParent.getMainImageView();
}
}

View File

@ -0,0 +1,27 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.viewholders
import android.view.View
import android.widget.ImageView
import androidx.leanback.widget.Presenter
import androidx.leanback.widget.ImageCardView
import org.dolphinemu.dolphinemu.model.GameFile
/**
* A simple class that stores references to views so that the GameAdapter doesn't need to
* keep calling findViewById(), which is expensive.
*/
class TvGameViewHolder(itemView: View) : Presenter.ViewHolder(itemView) {
var cardParent: ImageCardView
var imageScreenshot: ImageView
@JvmField
var gameFile: GameFile? = null
init {
itemView.tag = this
cardParent = itemView as ImageCardView
imageScreenshot = cardParent.mainImageView
}
}