Android: Replace Glide with Coil image loading

This commit is contained in:
Charles Lombardo 2023-01-06 15:15:30 -05:00
parent e6583f8bec
commit 28faca63a6
11 changed files with 210 additions and 297 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

@ -3,7 +3,6 @@
package org.dolphinemu.dolphinemu.adapters
import android.annotation.SuppressLint
import android.app.Activity
import androidx.recyclerview.widget.RecyclerView
import org.dolphinemu.dolphinemu.adapters.GameAdapter.GameViewHolder
import android.view.View.OnLongClickListener
@ -11,29 +10,23 @@ import org.dolphinemu.dolphinemu.model.GameFile
import android.view.ViewGroup
import android.view.LayoutInflater
import android.view.View
import org.dolphinemu.dolphinemu.utils.GlideUtils
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(activity: Activity) : RecyclerView.Adapter<GameViewHolder>(),
class GameAdapter(private val mActivity: FragmentActivity) : RecyclerView.Adapter<GameViewHolder>(),
View.OnClickListener, OnLongClickListener {
private var mGameFiles: List<GameFile>
private val mActivity: Activity
/**
* Initializes the adapter's observer, which watches for changes to the dataset. The adapter will
* display no data until swapDataSet is called.
*/
init {
mGameFiles = ArrayList()
mActivity = activity
}
private var mGameFiles: List<GameFile> = ArrayList()
/**
* Called by the LayoutManager when it is necessary to create a new view.
@ -65,7 +58,33 @@ class GameAdapter(activity: Activity) : RecyclerView.Adapter<GameViewHolder>(),
val context = holder.itemView.context
val gameFile = mGameFiles[position]
GlideUtils.loadGameCover(holder, holder.binding.imageGameScreen, gameFile, mActivity)
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
@ -86,15 +105,11 @@ class GameAdapter(activity: Activity) : RecyclerView.Adapter<GameViewHolder>(),
}
}
class GameViewHolder(binding: CardGameBinding) : RecyclerView.ViewHolder(binding.root) {
class GameViewHolder(var binding: CardGameBinding) : RecyclerView.ViewHolder(binding.root) {
var gameFile: GameFile? = null
@JvmField
var binding: CardGameBinding
init {
binding.root.tag = this
this.binding = binding
}
}

View File

@ -7,20 +7,24 @@ 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.utils.GlideUtils
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 : Presenter() {
class GameRowPresenter(private val mActivity: FragmentActivity) : Presenter() {
override fun onCreateViewHolder(parent: ViewGroup): ViewHolder {
// Create a new view.
val gameCard = ImageCardView(parent.context)
@ -63,7 +67,20 @@ class GameRowPresenter : Presenter() {
holder.cardParent.contentText = gameFile.company
}
}
GlideUtils.loadGameCover(null, holder.imageScreenshot, gameFile, null)
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) {

View File

@ -3,17 +3,23 @@
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.utils.GlideUtils
import org.dolphinemu.dolphinemu.model.GameFile
class GameDetailsDialog : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
@ -73,7 +79,9 @@ class GameDetailsDialog : DialogFragment() {
}
}
GlideUtils.loadGameBanner(binding.banner, gameFile)
this.lifecycleScope.launch {
loadGameBanner(binding.banner, gameFile)
}
builder.setView(binding.root)
} else {
@ -123,13 +131,35 @@ class GameDetailsDialog : DialogFragment() {
}
}
GlideUtils.loadGameBanner(tvBinding.banner, gameFile)
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"

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
@ -88,7 +89,7 @@ class GridOptionDialogFragment : BottomSheetDialogFragment() {
NativeConfig.LAYER_BASE,
mBindingMobile.switchDownloadCovers.isChecked
)
mView.reloadGrid()
(mView as Activity).recreate()
}
}
@ -118,7 +119,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

@ -3,44 +3,34 @@
package org.dolphinemu.dolphinemu.utils
import org.dolphinemu.dolphinemu.model.GameFile
import android.graphics.Bitmap
import java.io.FileOutputStream
import java.lang.Exception
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) {
0 -> "JA"
1 -> "US"
4 -> "KO"
2 -> when (game.country) {
3 -> "AU"
4 -> "FR"
5 -> "DE"
6 -> "IT"
8 -> "NL"
9 -> "RU"
10 -> "ES"
0 -> "EN"
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"
3 -> "EN" // Unknown
else -> "EN"
}
return region
}
fun saveCover(cover: Bitmap, path: String?) {
try {
val out = FileOutputStream(path)
cover.compress(Bitmap.CompressFormat.PNG, 100, out)
out.close()
} catch (ignored: Exception) {
}
}
}

View File

@ -1,233 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
package org.dolphinemu.dolphinemu.utils
import org.dolphinemu.dolphinemu.utils.CoverHelper.buildGameTDBUrl
import org.dolphinemu.dolphinemu.utils.CoverHelper.getRegion
import org.dolphinemu.dolphinemu.utils.CoverHelper.saveCover
import android.os.Looper
import org.dolphinemu.dolphinemu.model.GameFile
import com.bumptech.glide.Glide
import com.bumptech.glide.load.engine.DiskCacheStrategy
import org.dolphinemu.dolphinemu.R
import org.dolphinemu.dolphinemu.adapters.GameAdapter.GameViewHolder
import android.app.Activity
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import org.dolphinemu.dolphinemu.features.settings.model.BooleanSetting
import com.bumptech.glide.request.RequestListener
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Handler
import android.view.View
import android.widget.ImageView
import com.bumptech.glide.load.DataSource
import com.bumptech.glide.load.engine.GlideException
import com.bumptech.glide.request.target.CustomTarget
import com.bumptech.glide.request.target.Target
import com.bumptech.glide.request.transition.Transition
import java.io.File
import java.io.FileNotFoundException
import java.util.concurrent.Executors
object GlideUtils {
private val saveCoverExecutor = Executors.newSingleThreadExecutor()
private val unmangleExecutor = Executors.newSingleThreadExecutor()
private val unmangleHandler = Handler(Looper.getMainLooper())
fun loadGameBanner(imageView: ImageView, gameFile: GameFile) {
val context = imageView.context
val vector = gameFile.banner
val width = gameFile.bannerWidth
val height = gameFile.bannerHeight
if (width > 0 && height > 0) {
val 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)
}
}
fun loadGameCover(
gameViewHolder: GameViewHolder?,
imageView: ImageView,
gameFile: GameFile,
activity: Activity?
) {
gameViewHolder?.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.textGameTitle.visibility = View.GONE
binding.textGameCaption.visibility = View.GONE
}
}
unmangleExecutor.execute {
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) {
// Let customCoverExists remain false
} catch (ignored: SecurityException) {
}
} else {
customCoverUri = Uri.parse(customCoverPath)
customCoverExists = File(customCoverPath).exists()
}
val context = imageView.context
val finalCustomCoverExists = customCoverExists
val finalCustomCoverUri = customCoverUri
val cover = File(gameFile.getCoverPath(context))
val 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@post
}
}
if (finalCustomCoverExists) {
Glide.with(imageView)
.load(finalCustomCoverUri)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.error(R.drawable.no_banner)
.listener(object : RequestListener<Drawable?> {
override fun onLoadFailed(
e: GlideException?,
model: Any,
target: Target<Drawable?>,
isFirstResource: Boolean
): Boolean {
enableInnerTitle(gameViewHolder)
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any,
target: Target<Drawable?>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
disableInnerTitle(gameViewHolder)
return false
}
})
.into(imageView)
} else if (cachedCoverExists) {
Glide.with(imageView)
.load(cover)
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.error(R.drawable.no_banner)
.listener(object : RequestListener<Drawable?> {
override fun onLoadFailed(
e: GlideException?,
model: Any,
target: Target<Drawable?>,
isFirstResource: Boolean
): Boolean {
enableInnerTitle(gameViewHolder)
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any,
target: Target<Drawable?>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
disableInnerTitle(gameViewHolder)
return false
}
})
.into(imageView)
} else if (BooleanSetting.MAIN_USE_GAME_COVERS.booleanGlobal) {
Glide.with(context)
.load(buildGameTDBUrl(gameFile, getRegion(gameFile)))
.diskCacheStrategy(DiskCacheStrategy.NONE)
.centerCrop()
.error(R.drawable.no_banner)
.listener(object : RequestListener<Drawable?> {
override fun onLoadFailed(
e: GlideException?,
model: Any,
target: Target<Drawable?>,
isFirstResource: Boolean
): Boolean {
enableInnerTitle(gameViewHolder)
return false
}
override fun onResourceReady(
resource: Drawable?,
model: Any,
target: Target<Drawable?>,
dataSource: DataSource,
isFirstResource: Boolean
): Boolean {
disableInnerTitle(gameViewHolder)
return false
}
})
.into(object : CustomTarget<Drawable?>() {
override fun onLoadCleared(placeholder: Drawable?) {}
override fun onResourceReady(
resource: Drawable,
transition: Transition<in Drawable?>?
) {
val savedCover = (resource as BitmapDrawable).bitmap
saveCoverExecutor.execute {
saveCover(
savedCover,
gameFile.getCoverPath(context)
)
}
imageView.setImageBitmap(savedCover)
}
})
} else {
Glide.with(imageView.context)
.load(R.drawable.no_banner)
.into(imageView)
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
}
}
}

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);