From 052d50afcf69a2100364873ffa8c5f8ecd506ceb Mon Sep 17 00:00:00 2001 From: Niels van Velzen Date: Sat, 8 Jul 2023 22:43:14 +0200 Subject: [PATCH] Replace Glide with Coil --- app/build.gradle.kts | 4 +- .../jellyfin/androidtv/JellyfinApplication.kt | 8 --- .../jellyfin/androidtv/JellyfinGlideModule.kt | 22 ------- .../data/service/BackgroundService.kt | 30 +++------- .../org/jellyfin/androidtv/di/AppModule.kt | 18 +++++- .../integration/dream/composable/DreamHost.kt | 23 +++---- .../integration/provider/ImageProvider.kt | 60 +++++++++++-------- .../jellyfin/androidtv/ui/AsyncImageView.kt | 35 +++++------ .../androidtv/ui/home/HomeFragment.kt | 43 ++----------- app/src/main/res/layout/fragment_home.xml | 15 ++++- gradle/libs.versions.toml | 14 +++-- 11 files changed, 119 insertions(+), 153 deletions(-) delete mode 100644 app/src/main/java/org/jellyfin/androidtv/JellyfinGlideModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c1502c383c..ededbaba4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,6 @@ plugins { id("com.android.application") kotlin("android") - alias(libs.plugins.kotlin.ksp) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.aboutlibraries) } @@ -149,8 +148,7 @@ dependencies { // Image utility implementation(libs.blurhash) - implementation(libs.glide.core) - ksp(libs.glide.ksp) + implementation(libs.bundles.coil) // Crash Reporting implementation(libs.bundles.acra) diff --git a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt index d1a86189f5..3682ff67e5 100644 --- a/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt +++ b/app/src/main/java/org/jellyfin/androidtv/JellyfinApplication.kt @@ -9,7 +9,6 @@ import androidx.work.ExistingPeriodicWorkPolicy import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.await -import com.bumptech.glide.Glide import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -70,13 +69,6 @@ class JellyfinApplication : Application() { super.onLowMemory() BlurHash.clearCache() - Glide.with(this).onLowMemory() - } - - override fun onTrimMemory(level: Int) { - super.onTrimMemory(level) - - Glide.with(this).onTrimMemory(level) } override fun attachBaseContext(base: Context?) { diff --git a/app/src/main/java/org/jellyfin/androidtv/JellyfinGlideModule.kt b/app/src/main/java/org/jellyfin/androidtv/JellyfinGlideModule.kt deleted file mode 100644 index 36fc3b9c97..0000000000 --- a/app/src/main/java/org/jellyfin/androidtv/JellyfinGlideModule.kt +++ /dev/null @@ -1,22 +0,0 @@ -package org.jellyfin.androidtv - -import android.content.Context -import android.util.Log -import com.bumptech.glide.GlideBuilder -import com.bumptech.glide.annotation.GlideModule -import com.bumptech.glide.load.engine.DiskCacheStrategy -import com.bumptech.glide.module.AppGlideModule -import com.bumptech.glide.request.RequestOptions - -@GlideModule -class JellyfinGlideModule : AppGlideModule() { - override fun applyOptions(context: Context, builder: GlideBuilder): Unit = with(builder) { - setDefaultRequestOptions( - // Set default disk cache strategy - RequestOptions().diskCacheStrategy(DiskCacheStrategy.RESOURCE) - ) - - // Silence image load errors - setLogLevel(Log.ERROR) - } -} diff --git a/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt b/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt index 997c176330..a9651732c0 100644 --- a/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt +++ b/app/src/main/java/org/jellyfin/androidtv/data/service/BackgroundService.kt @@ -3,12 +3,12 @@ package org.jellyfin.androidtv.data.service import android.content.Context import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.asImageBitmap -import com.bumptech.glide.Glide +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.MainScope -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,9 +20,7 @@ import org.jellyfin.sdk.api.client.ApiClient import org.jellyfin.sdk.api.client.extensions.imageApi import org.jellyfin.sdk.model.api.BaseItemDto import org.jellyfin.sdk.model.api.ImageType -import timber.log.Timber import java.util.UUID -import java.util.concurrent.ExecutionException import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -32,6 +30,7 @@ class BackgroundService( private val jellyfin: Jellyfin, private val api: ApiClient, private val userPreferences: UserPreferences, + private val imageLoader: ImageLoader, ) { companion object { val SLIDESHOW_DURATION = 30.seconds @@ -106,22 +105,11 @@ class BackgroundService( // Cancel current loading job loadBackgroundsJob?.cancel() loadBackgroundsJob = scope.launch(Dispatchers.IO) { - _backgrounds = backdropUrls - .map { url -> - Glide.with(context).asBitmap().load(url).submit() - } - .map { future -> - async { - try { - future.get().asImageBitmap() - } catch (ex: ExecutionException) { - Timber.e(ex, "There was an error fetching the background image.") - null - } - } - } - .awaitAll() - .filterNotNull() + _backgrounds = backdropUrls.mapNotNull { url -> + imageLoader.execute( + request = ImageRequest.Builder(context).data(url).build() + ).drawable?.toBitmap()?.asImageBitmap() + } // Go to first background _currentIndex = 0 diff --git a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt index 1f3014cad3..7ac4a2f2ea 100644 --- a/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt +++ b/app/src/main/java/org/jellyfin/androidtv/di/AppModule.kt @@ -1,6 +1,11 @@ package org.jellyfin.androidtv.di import android.content.Context +import android.os.Build +import coil.ImageLoader +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder +import coil.decode.SvgDecoder import org.jellyfin.androidtv.BuildConfig import org.jellyfin.androidtv.auth.repository.ServerRepository import org.jellyfin.androidtv.auth.repository.UserRepository @@ -90,6 +95,17 @@ val appModule = module { ) } + // Coil (images) + single { + ImageLoader.Builder(androidContext()).apply { + components { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(ImageDecoderDecoder.Factory()) + else add(GifDecoder.Factory()) + add(SvgDecoder.Factory()) + } + }.build() + } + // Non API related single { DataRefreshService() } single { PlaybackControllerContainer() } @@ -110,7 +126,7 @@ val appModule = module { viewModel { ScreensaverViewModel(get()) } viewModel { SearchViewModel(get()) } - single { BackgroundService(get(), get(), get(), get()) } + single { BackgroundService(get(), get(), get(), get(), get()) } single { MarkdownRenderer(get()) } diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt index 3c6b6cdf55..bc8ddb4f45 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/dream/composable/DreamHost.kt @@ -8,7 +8,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext -import com.bumptech.glide.Glide +import androidx.core.graphics.drawable.toBitmap +import coil.ImageLoader +import coil.request.ImageRequest import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.withContext @@ -27,7 +29,6 @@ import org.jellyfin.sdk.model.api.ImageType import org.jellyfin.sdk.model.constant.ItemSortBy import org.koin.androidx.compose.get import timber.log.Timber -import java.util.concurrent.ExecutionException import kotlin.time.Duration.Companion.seconds @Composable @@ -35,6 +36,7 @@ fun DreamHost() { val api = get() val userPreferences = get() val mediaManager = get() + val imageLoader = get() val context = LocalContext.current var libraryShowcase by remember { mutableStateOf(null) } @@ -44,7 +46,7 @@ fun DreamHost() { delay(2.seconds) while (true) { - libraryShowcase = getRandomLibraryShowcase(api, context) + libraryShowcase = getRandomLibraryShowcase(api, imageLoader, context) delay(30.seconds) } } @@ -62,7 +64,11 @@ fun DreamHost() { ) } -private suspend fun getRandomLibraryShowcase(api: ApiClient, context: Context): DreamContent.LibraryShowcase? { +private suspend fun getRandomLibraryShowcase( + api: ApiClient, + imageLoader: ImageLoader, + context: Context +): DreamContent.LibraryShowcase? { try { val response by api.itemsApi.getItemsByUserId( includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES), @@ -89,12 +95,9 @@ private suspend fun getRandomLibraryShowcase(api: ApiClient, context: Context): ) val backdrop = withContext(Dispatchers.IO) { - try { - Glide.with(context).asBitmap().load(backdropUrl).submit().get() - } catch (err: ExecutionException) { - Timber.e("Unable to retrieve image for item ${item.id}", err) - null - } + imageLoader.execute( + request = ImageRequest.Builder(context).data(backdropUrl).build() + ).drawable?.toBitmap() } ?: return null return DreamContent.LibraryShowcase(item, backdrop) diff --git a/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt b/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt index c146208603..71a6f82f17 100644 --- a/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt +++ b/app/src/main/java/org/jellyfin/androidtv/integration/provider/ImageProvider.kt @@ -7,18 +7,18 @@ import android.graphics.drawable.Drawable import android.net.Uri import android.os.Build import android.os.ParcelFileDescriptor +import androidx.core.graphics.drawable.toBitmap import androidx.core.net.toUri -import androidx.lifecycle.ProcessLifecycleOwner -import androidx.lifecycle.lifecycleScope -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomTarget -import com.bumptech.glide.request.transition.Transition -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import coil.ImageLoader +import coil.request.ImageRequest import org.jellyfin.androidtv.BuildConfig import org.jellyfin.androidtv.R +import org.koin.android.ext.android.inject +import java.io.IOException class ImageProvider : ContentProvider() { + private val imageLoader by inject() + override fun onCreate(): Boolean = true override fun getType(uri: Uri) = null @@ -33,30 +33,40 @@ class ImageProvider : ContentProvider() { val (read, write) = ParcelFileDescriptor.createPipe() val outputStream = ParcelFileDescriptor.AutoCloseOutputStream(write) - ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) { - Glide.with(context!!) - .asBitmap() - .error(R.drawable.placeholder_icon) - .load(src) - .into(object : CustomTarget() { - override fun onResourceReady(resource: Bitmap, transition: Transition?) { - @Suppress("DEPRECATION") - val format = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Bitmap.CompressFormat.WEBP_LOSSY - else -> Bitmap.CompressFormat.WEBP - } - resource.compress(format, 95, outputStream) - outputStream.close() - } + imageLoader.enqueue(ImageRequest.Builder(context!!).apply { + data(src) + error(R.drawable.placeholder_icon) + target( + onSuccess = { drawable -> writeDrawable(drawable, outputStream) }, + onError = { drawable -> writeDrawable(requireNotNull(drawable), outputStream) } + ) + }.build()) + + return read + } - override fun onLoadCleared(placeholder: Drawable?) = outputStream.close() - }) + private fun writeDrawable( + drawable: Drawable, + outputStream: ParcelFileDescriptor.AutoCloseOutputStream + ) { + @Suppress("DEPRECATION") + val format = when { + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Bitmap.CompressFormat.WEBP_LOSSY + else -> Bitmap.CompressFormat.WEBP } - return read + try { + outputStream.use { + drawable.toBitmap().compress(format, COMPRESSION_QUALITY, outputStream) + } + } catch (_: IOException) { + // Ignore IOException as this is commonly thrown when the load request is cancelled + } } companion object { + private const val COMPRESSION_QUALITY = 95 + /** * Get a [Uri] that uses the [ImageProvider] to load an image. The input should be a valid * Jellyfin image URL created using the SDK. diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt b/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt index c3c2a45df4..cf391ea023 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/AsyncImageView.kt @@ -9,15 +9,16 @@ import androidx.core.graphics.drawable.toDrawable import androidx.core.view.doOnAttach import androidx.lifecycle.findViewTreeLifecycleOwner import androidx.lifecycle.lifecycleScope -import com.bumptech.glide.Glide -import com.bumptech.glide.load.model.GlideUrl -import com.bumptech.glide.load.model.LazyHeaders +import coil.ImageLoader +import coil.request.ImageRequest +import coil.transform.CircleCropTransformation import com.vanniktech.blurhash.BlurHash import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.jellyfin.androidtv.R -import org.jellyfin.sdk.api.client.ApiClient +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject import kotlin.math.round import kotlin.time.Duration.Companion.milliseconds @@ -30,9 +31,10 @@ class AsyncImageView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0, -) : AppCompatImageView(context, attrs, defStyleAttr) { +) : AppCompatImageView(context, attrs, defStyleAttr), KoinComponent { private val lifeCycleOwner get() = findViewTreeLifecycleOwner() private val styledAttributes = context.obtainStyledAttributes(attrs, R.styleable.AsyncImageView, defStyleAttr, 0) + private val imageLoader by inject() /** * The duration of the crossfade when changing switching the images of the url, blurhash and @@ -73,21 +75,20 @@ class AsyncImageView @JvmOverloads constructor( // Start loading image or placeholder if (url == null) { - Glide.with(this@AsyncImageView).load(placeholder).apply { - if (circleCrop) circleCrop() - }.into(this@AsyncImageView) - } else { - val glideUrl = GlideUrl(url, LazyHeaders.Builder().apply { - setHeader("Accept", ApiClient.HEADER_ACCEPT) + imageLoader.enqueue(ImageRequest.Builder(context).apply { + target(this@AsyncImageView) + data(placeholder) + if (circleCrop) transformations(CircleCropTransformation()) }.build()) - - Glide.with(this@AsyncImageView).load(glideUrl).apply { + } else { + imageLoader.enqueue(ImageRequest.Builder(context).apply { + crossfade(crossFadeDuration.inWholeMilliseconds.toInt()) + target(this@AsyncImageView) + data(url) placeholder(placeholderOrBlurHash) + if (circleCrop) transformations(CircleCropTransformation()) error(placeholder) - if (circleCrop) circleCrop() - // FIXME: Glide is unable to scale the image when transitions are enabled - //transition(DrawableTransitionOptions.withCrossFade(crossFadeDuration.inWholeMilliseconds.toInt())) - }.into(this@AsyncImageView) + }.build()) } } } diff --git a/app/src/main/java/org/jellyfin/androidtv/ui/home/HomeFragment.kt b/app/src/main/java/org/jellyfin/androidtv/ui/home/HomeFragment.kt index 655a4332c2..7e8f98f764 100644 --- a/app/src/main/java/org/jellyfin/androidtv/ui/home/HomeFragment.kt +++ b/app/src/main/java/org/jellyfin/androidtv/ui/home/HomeFragment.kt @@ -1,20 +1,15 @@ package org.jellyfin.androidtv.ui.home import android.content.Intent -import android.graphics.PorterDuff -import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.ImageButton +import androidx.core.content.ContextCompat import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.bumptech.glide.Glide -import com.bumptech.glide.request.target.CustomViewTarget -import com.bumptech.glide.request.transition.Transition import kotlinx.coroutines.launch import org.jellyfin.androidtv.R import org.jellyfin.androidtv.auth.repository.SessionRepository @@ -59,8 +54,10 @@ class HomeFragment : Fragment() { viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { userRepository.currentUser.collect { user -> if (user != null) { - val image = ImageUtils.getPrimaryImageUrl(user) - setUserImage(image) + binding.switchUsersImage.load( + url = ImageUtils.getPrimaryImageUrl(user), + placeholder = ContextCompat.getDrawable(requireContext(), R.drawable.ic_user) + ) } } } @@ -73,36 +70,6 @@ class HomeFragment : Fragment() { _binding = null } - private fun setUserImage(image: String?) { - Glide.with(this) - .load(image) - .placeholder(R.drawable.ic_switch_users) - .centerInside() - .circleCrop() - .into(object : CustomViewTarget(binding.switchUsers) { - override fun onLoadFailed(errorDrawable: Drawable?) { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - binding.switchUsers.imageTintMode = PorterDuff.Mode.SRC_IN - binding.switchUsers.setImageDrawable(errorDrawable) - } - } - - override fun onResourceReady(resource: Drawable, transition: Transition?) { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - binding.switchUsers.imageTintMode = null - binding.switchUsers.setImageDrawable(resource) - } - } - - override fun onResourceCleared(placeholder: Drawable?) { - if (lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { - binding.switchUsers.imageTintMode = PorterDuff.Mode.SRC_IN - binding.switchUsers.setImageDrawable(placeholder) - } - } - }) - } - private fun switchUser() { sessionRepository.destroyCurrentSession() diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index aef939c749..3f21d50a8b 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -1,6 +1,7 @@ @@ -40,13 +41,21 @@ android:layout_width="8dp" android:layout_height="0dp" /> - + android:contentDescription="@string/lbl_switch_user"> + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0d36adb17e..9ea6f5a5c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,9 +23,9 @@ androidx-tvprovider = "1.1.0-alpha01" androidx-window = "1.1.0" androidx-work = "2.8.1" blurhash = "0.1.0" +coil = "2.4.0" detekt = "1.23.0" exoplayer = "2.18.7" -glide = "4.15.1" gson = "2.8.9" jellyfin-apiclient = "v0.7.10" jellyfin-exoplayer-ffmpegextension = "2.18.7+1" @@ -35,7 +35,6 @@ koin = "3.4.2" koin-compose = "3.4.5" kotest = "5.6.2" kotlin = "1.8.22" -kotlin-ksp = "1.8.22-1.0.11" kotlinx-coroutines = "1.7.2" kotlinx-serialization = "1.5.1" leakcanary = "2.12" @@ -48,7 +47,6 @@ timber = "5.0.1" [plugins] aboutlibraries = { id = "com.mikepenz.aboutlibraries.plugin", version.ref = "aboutlibraries" } detekt = { id = "io.gitlab.arturbosch.detekt", version.ref = "detekt" } -kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "kotlin-ksp" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } [libraries] @@ -105,8 +103,9 @@ markwon-html = { module = "io.noties.markwon:html", version.ref = "markwon" } # Image utility blurhash = { module = "com.vanniktech:blurhash", version.ref = "blurhash" } -glide-core = { module = "com.github.bumptech.glide:glide", version.ref = "glide" } -glide-ksp = { module = "com.github.bumptech.glide:ksp", version.ref = "glide" } +coil-base = { module = "io.coil-kt:coil-base", version.ref = "coil" } +coil-gif = { module = "io.coil-kt:coil-gif", version.ref = "coil" } +coil-svg = { module = "io.coil-kt:coil-svg", version.ref = "coil" } # Crash Reporting acra-core = { module = "ch.acra:acra-core", version.ref = "acra" } @@ -146,6 +145,11 @@ androidx-lifecycle = [ "androidx-lifecycle-service", "androidx-lifecycle-viewmodel", ] +coil = [ + "coil-base", + "coil-gif", + "coil-svg", +] koin = [ "koin-android-compat", "koin-android-core",