Replace Glide with Coil

This commit is contained in:
Niels van Velzen 2023-07-08 22:43:14 +02:00 committed by Niels van Velzen
parent 40f186f4a0
commit 387baf93b6
11 changed files with 120 additions and 154 deletions

View File

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

View File

@ -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?) {

View File

@ -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)
}
}

View File

@ -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

View File

@ -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()) }

View File

@ -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<ApiClient>()
val userPreferences = get<UserPreferences>()
val mediaManager = get<MediaManager>()
val imageLoader = get<ImageLoader>()
val context = LocalContext.current
var libraryShowcase by remember { mutableStateOf<DreamContent.LibraryShowcase?>(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)

View File

@ -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<ImageLoader>()
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<Bitmap>() {
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
@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()
}
override fun onLoadCleared(placeholder: Drawable?) = 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
}
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
}
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.

View File

@ -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<ImageLoader>()
/**
* 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())
}
}
}

View File

@ -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<ImageButton, Drawable>(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<in Drawable>?) {
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()

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
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"
android:orientation="vertical">
@ -40,13 +41,21 @@
android:layout_width="8dp"
android:layout_height="0dp" />
<ImageButton
<FrameLayout
android:id="@+id/switch_users"
style="@style/Button.Icon"
android:layout_width="41dp"
android:layout_height="41dp"
android:contentDescription="@string/lbl_switch_user"
android:src="@drawable/ic_switch_users" />
android:contentDescription="@string/lbl_switch_user">
<org.jellyfin.androidtv.ui.AsyncImageView
android:id="@+id/switch_users_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="4dp"
app:circleCrop="true"
tools:src="@drawable/ic_user" />
</FrameLayout>
</LinearLayout>
</org.jellyfin.androidtv.ui.shared.ToolbarView>

View File

@ -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",