From 0e3ffb973b208c58d1db9eb7057eec595665a21e Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sat, 20 Nov 2021 08:57:52 -0700 Subject: [PATCH] coil: completely refactor image loading Upgrade to coil 2.0.0 and completely refactor the usage of coil to work with the new library structure. This also fixes the issue where error icons will just re-appear due to blocking calls. I had to add a fix on my end and also use the new caching system in coil 2.0.0. --- app/build.gradle | 3 +- .../main/java/org/oxycblt/auxio/AuxioApp.kt | 15 +- .../java/org/oxycblt/auxio/MainFragment.kt | 1 + .../org/oxycblt/auxio/coil/AlbumArtFetcher.kt | 234 ---------------- .../org/oxycblt/auxio/coil/AuxioFetcher.kt | 251 ++++++++++++++++++ .../java/org/oxycblt/auxio/coil/CoilUtils.kt | 66 ++--- .../oxycblt/auxio/coil/CrossfadeTransition.kt | 88 ++++++ .../java/org/oxycblt/auxio/coil/Fetchers.kt | 128 +++++++++ .../org/oxycblt/auxio/coil/MosaicFetcher.kt | 148 ----------- .../java/org/oxycblt/auxio/coil/MusicKeyer.kt | 14 + .../org/oxycblt/auxio/home/HomeViewModel.kt | 1 + .../home/fastscroll/FastScrollRecyclerView.kt | 1 - .../auxio/playback/PlaybackBarLayout.kt | 2 +- ...pactPlaybackView.kt => PlaybackBarView.kt} | 4 +- .../org/oxycblt/auxio/search/SearchAdapter.kt | 1 + .../auxio/settings/SettingsListFragment.kt | 3 +- .../oxycblt/auxio/widgets/WidgetProvider.kt | 2 - app/src/main/res/layout/dialog_tabs.xml | 2 +- .../main/res/layout/view_compact_playback.xml | 2 +- app/src/main/res/layout/view_seek_bar.xml | 6 +- app/src/main/res/values/dimens.xml | 3 + app/src/main/res/values/styles_android.xml | 1 - app/src/main/res/values/styles_ui.xml | 1 - build.gradle | 2 +- 24 files changed, 535 insertions(+), 444 deletions(-) delete mode 100644 app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt delete mode 100644 app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt rename app/src/main/java/org/oxycblt/auxio/playback/{CompactPlaybackView.kt => PlaybackBarView.kt} (97%) diff --git a/app/build.gradle b/app/build.gradle index f33a18072..0bd164b57 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -37,6 +37,7 @@ android { kotlinOptions { jvmTarget = "1.8" + freeCompilerArgs += "-Xjvm-default=all" } compileOptions { @@ -97,7 +98,7 @@ dependencies { implementation "com.google.android.exoplayer:exoplayer-core:2.16.0" // Image loading - implementation 'io.coil-kt:coil:1.4.0' + implementation 'io.coil-kt:coil:2.0.0-alpha03' // Material implementation "com.google.android.material:material:1.5.0-beta01" diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt index 1dab7a0c2..adb86e355 100644 --- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt +++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt @@ -22,6 +22,11 @@ import android.app.Application import coil.ImageLoader import coil.ImageLoaderFactory import coil.request.CachePolicy +import org.oxycblt.auxio.coil.AlbumArtFetcher +import org.oxycblt.auxio.coil.ArtistImageFetcher +import org.oxycblt.auxio.coil.CrossfadeTransition +import org.oxycblt.auxio.coil.GenreImageFetcher +import org.oxycblt.auxio.coil.MusicKeyer import org.oxycblt.auxio.settings.SettingsManager @Suppress("UNUSED") @@ -36,9 +41,15 @@ class AuxioApp : Application(), ImageLoaderFactory { override fun newImageLoader(): ImageLoader { return ImageLoader.Builder(applicationContext) + .components { + add(AlbumArtFetcher.SongFactory()) + add(AlbumArtFetcher.AlbumFactory()) + add(ArtistImageFetcher.Factory()) + add(GenreImageFetcher.Factory()) + add(MusicKeyer()) + } + .transitionFactory(CrossfadeTransition.Factory()) .diskCachePolicy(CachePolicy.DISABLED) // Not downloading anything, so no disk-caching - .crossfade(true) - .placeholder(android.R.color.transparent) .build() } } diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 35d8b7fd7..a3afea6d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -133,6 +133,7 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback { snackbar.show() } + else -> {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt deleted file mode 100644 index 2796bd07c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt +++ /dev/null @@ -1,234 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * AlbumArtFetcher.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.coil - -import android.content.Context -import android.media.MediaMetadataRetriever -import coil.bitmap.BitmapPool -import coil.decode.DataSource -import coil.decode.Options -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.size.Size -import okio.buffer -import okio.source -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.toAlbumArtURI -import org.oxycblt.auxio.music.toURI -import org.oxycblt.auxio.settings.SettingsManager -import java.io.ByteArrayInputStream - -/** - * Fetcher that returns the album art for a given [Album]. Handles settings on whether to use - * quality covers or not. - * @author OxygenCobalt - */ -class AlbumArtFetcher(private val context: Context) : Fetcher { - override suspend fun fetch( - pool: BitmapPool, - data: Album, - size: Size, - options: Options - ): FetchResult { - val settingsManager = SettingsManager.getInstance() - - if (!settingsManager.showCovers) { - error("Covers are disabled") - } - - val result = if (settingsManager.useQualityCovers) { - fetchQualityCovers(data.songs[0]) - } else { - // If we're fetching plain MediaStore covers, optimize for speed and don't go through - // the wild goose chase that we do for quality covers. - fetchMediaStoreCovers(data) - } - - checkNotNull(result) { - "No cover art was found for ${data.name}" - } - - return result - } - - private fun fetchQualityCovers(song: Song): FetchResult? { - // Loading quality covers basically means to parse the file metadata ourselves - // and then extract the cover. - - // First try MediaMetadataRetriever. We will always do this first, as it supports - // a variety of formats, has multiple levels of fault tolerance, and is pretty fast - // for a manual parser. - // However, Samsung seems to cripple this class as to force people to use their ad-infested - // music app which relies on proprietary OneUI extensions instead of AOSP. That means - // we have to have another layer of redundancy to retain quality. Thanks samsung. Prick. - val result = fetchAospMetadataCovers(song) - - if (result != null) { - return result - } -// -// // Our next fallback is to rely on ExoPlayer's largely half-baked and undocumented -// // metadata system. -// val exoResult = fetchExoplayerCover(song) -// -// if (exoResult != null) { -// return exoResult -// } - - // If the previous two failed, we resort to MediaStore's covers despite it literally - // going against the point of this setting. The previous two calls are just too unreliable - // and we can't do any filesystem traversing due to scoped storage. - val mediaStoreResult = fetchMediaStoreCovers(song.album) - - if (mediaStoreResult != null) { - return mediaStoreResult - } - - // There is no cover we could feasibly fetch. Give up. - return null - } - - private fun fetchMediaStoreCovers(data: Album): FetchResult? { - val uri = data.id.toAlbumArtURI() - val stream = context.contentResolver.openInputStream(uri) - - if (stream != null) { - // Don't close the stream here as it will cause an error later from an attempted read. - // This stream still seems to close itself at some point, so its fine. - return SourceResult( - source = stream.source().buffer(), - mimeType = context.contentResolver.getType(uri), - dataSource = DataSource.DISK - ) - } - - return null - } - - private fun fetchAospMetadataCovers(song: Song): FetchResult? { - val extractor = MediaMetadataRetriever() - - extractor.use { ext -> - val songUri = song.id.toURI() - ext.setDataSource(context, songUri) - - // Get the embedded picture from MediaMetadataRetriever, which will return a full - // ByteArray of the cover without any compression artifacts. - // If its null [a.k.a there is no embedded cover], than just ignore it and move on - ext.embeddedPicture?.let { coverBytes -> - val stream = ByteArrayInputStream(coverBytes) - - stream.use { stm -> - return SourceResult( - source = stm.source().buffer(), - mimeType = null, - dataSource = DataSource.DISK - ) - } - } - } - - return null - } - -// Disabled until I can figure out how the hell I can get a blocking call to play along in -// a suspend function. I doubt it's possible. -// private fun fetchExoplayerCover(song: Song): FetchResult? { -// val uri = song.id.toURI() -// -// val future = MetadataRetriever.retrieveMetadata( -// context, MediaItem.fromUri(song.id.toURI()) -// ) -// -// // Coil is async, we can just spin until the loading has ended -// while (future.isDone) { /* no-op */ } -// -// val tracks = try { -// future.get() -// } catch (e: Exception) { -// null -// } -// -// if (tracks == null || tracks.isEmpty) { -// // Unrecognized format. This is expected, as ExoPlayer only supports a -// // subset of formats. -// return null -// } -// -// // The metadata extraction process of ExoPlayer is normalized into a superclass. -// // That means we have to iterate through and find the cover art ourselves. -// val metadata = tracks[0].getFormat(0).metadata -// -// if (metadata == null || metadata.length() == 0) { -// // No (parsable) metadata. This is also expected. -// return null -// } -// -// var stream: ByteArrayInputStream? = null -// -// for (i in 0 until metadata.length()) { -// // We can only extract pictures from two tags with this method, ID3v2's APIC or -// // FLAC's PICTURE. -// val pic: ByteArray? -// val type: Int -// -// when (val entry = metadata.get(i)) { -// is ApicFrame -> { -// pic = entry.pictureData -// type = entry.pictureType -// } -// is PictureFrame -> { -// pic = entry.pictureData -// type = entry.pictureType -// } -// else -> continue -// } -// -// // Ensure the picture type here is a front cover image so that we don't extract -// // an incorrect cover image. -// // Yes, this does add some latency, but its quality covers so we can prioritize -// // correctness over speed. -// if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) { -// logD("Front cover successfully found") -// -// // We have a front cover image. Great. -// stream = ByteArrayInputStream(pic) -// break -// } else if (stream != null) { -// // In the case a front cover is not found, use the first image in the tag instead. -// // This can be corrected later on if a front cover frame is found. -// logD("Image not a front cover, assigning image of type $type for now") -// -// stream = ByteArrayInputStream(pic) -// } -// } -// -// return stream?.use { stm -> -// return SourceResult( -// source = stm.source().buffer(), -// mimeType = context.contentResolver.getType(uri), -// dataSource = DataSource.DISK -// ) -// } -// } - - override fun key(data: Album) = data.id.toString() -} diff --git a/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt new file mode 100644 index 000000000..2b22ab5ce --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt @@ -0,0 +1,251 @@ +package org.oxycblt.auxio.coil + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.media.MediaMetadataRetriever +import androidx.core.graphics.drawable.toDrawable +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.exoplayer2.MetadataRetriever +import com.google.android.exoplayer2.metadata.flac.PictureFrame +import com.google.android.exoplayer2.metadata.id3.ApicFrame +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okio.buffer +import okio.source +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.toAlbumArtURI +import org.oxycblt.auxio.music.toURI +import org.oxycblt.auxio.settings.SettingsManager +import org.oxycblt.auxio.util.logD +import java.io.ByteArrayInputStream +import java.io.InputStream + +abstract class AuxioFetcher : Fetcher { + private val settingsManager = SettingsManager.getInstance() + + protected suspend fun fetchArt(context: Context, album: Album): InputStream? { + if (!settingsManager.showCovers) { + return null + } + + return if (settingsManager.useQualityCovers) { + fetchQualityCovers(context, album) + } else { + fetchMediaStoreCovers(context, album) + } + } + + /** + * Create a mosaic image from multiple image views, Code adapted from Phonograph + * https://github.com/kabouzeid/Phonograph + */ + protected fun createMosaic(context: Context, streams: List): FetchResult? { + if (streams.size < 4) { + return streams.getOrNull(0)?.let { stream -> + return SourceResult( + source = ImageSource(stream.source().buffer(), context), + mimeType = null, + dataSource = DataSource.DISK + ) + } + } + + // Use a fixed 512x512 canvas for the mosaics. Preferably we would adapt this mosaic to + // target ImageView size, but Coil seems to start image loading before we can even get + // a width/height for the view, making that impractical. + val mosaicBitmap = Bitmap.createBitmap( + MOSAIC_BITMAP_SIZE, MOSAIC_BITMAP_SIZE, Bitmap.Config.ARGB_8888 + ) + + val canvas = Canvas(mosaicBitmap) + + var x = 0 + var y = 0 + + // For each stream, create a bitmap scaled to 1/4th of the mosaics combined size + // and place it on a corner of the canvas. + for (stream in streams) { + if (y == MOSAIC_BITMAP_SIZE) { + break + } + + val bitmap = Bitmap.createScaledBitmap( + BitmapFactory.decodeStream(stream), + MOSAIC_BITMAP_INCREMENT, + MOSAIC_BITMAP_INCREMENT, + true + ) + + canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) + + x += MOSAIC_BITMAP_INCREMENT + + if (x == MOSAIC_BITMAP_SIZE) { + x = 0 + y += MOSAIC_BITMAP_INCREMENT + } + } + + return DrawableResult( + drawable = mosaicBitmap.toDrawable(context.resources), + isSampled = false, + dataSource = DataSource.DISK + ) + } + + private suspend fun fetchQualityCovers(context: Context, album: Album): InputStream? { + // Loading quality covers basically means to parse the file metadata ourselves + // and then extract the cover. + + // First try MediaMetadataRetriever. We will always do this first, as it supports + // a variety of formats, has multiple levels of fault tolerance, and is pretty fast + // for a manual parser. + // However, Samsung seems to cripple this class as to force people to use their ad-infested + // music app which relies on proprietary OneUI extensions instead of AOSP. That means + // we have to have another layer of redundancy to retain quality. Thanks samsung. Prick. + val result = fetchAospMetadataCovers(context, album) + + if (result != null) { + return result + } + + // Our next fallback is to rely on ExoPlayer's largely half-baked and undocumented + // metadata system. + val exoResult = fetchExoplayerCover(context, album) + + if (exoResult != null) { + return exoResult + } + + // If the previous two failed, we resort to MediaStore's covers despite it literally + // going against the point of this setting. The previous two calls are just too unreliable + // and we can't do any filesystem traversing due to scoped storage. + val mediaStoreResult = fetchMediaStoreCovers(context, album) + + if (mediaStoreResult != null) { + return mediaStoreResult + } + + // There is no cover we could feasibly fetch. Give up. + return null + } + + @Suppress("BlockingMethodInNonBlockingContext") + private suspend fun fetchMediaStoreCovers(context: Context, data: Album): InputStream? { + val uri = data.id.toAlbumArtURI() + + // Eliminate any chance that this blocking call might mess up the cancellation process + return withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri) + } + } + + private suspend fun fetchAospMetadataCovers(context: Context, album: Album): InputStream? { + val extractor = MediaMetadataRetriever() + + extractor.use { ext -> + // To be safe, just make sure that this blocking call is wrapped so it doesn't + // cause problems + ext.setDataSource(context, album.songs[0].id.toURI()) + + // Get the embedded picture from MediaMetadataRetriever, which will return a full + // ByteArray of the cover without any compression artifacts. + // If its null [a.k.a there is no embedded cover], than just ignore it and move on + return ext.embeddedPicture?.let { coverBytes -> + ByteArrayInputStream(coverBytes) + } + } + } + + private suspend fun fetchExoplayerCover(context: Context, album: Album): InputStream? { + val uri = album.songs[0].id.toURI() + + val future = MetadataRetriever.retrieveMetadata( + context, MediaItem.fromUri(uri) + ) + + // future.get is a blocking call that makes the us spin until the future is done. + // This is bad for a co-routine, as it prevents cancellation and by extension + // messes with the image loading process and causes frustrating bugs. + // To fix this we wrap this around in a withContext call to make it suspend and make + // sure that the runner can do other coroutines. + @Suppress("BlockingMethodInNonBlockingContext") + val tracks = withContext(Dispatchers.IO) { + try { + future.get() + } catch (e: Exception) { + null + } + } + + if (tracks == null || tracks.isEmpty) { + // Unrecognized format. This is expected, as ExoPlayer only supports a + // subset of formats. + return null + } + + // The metadata extraction process of ExoPlayer is normalized into a superclass. + // That means we have to iterate through and find the cover art ourselves. + val metadata = tracks[0].getFormat(0).metadata + + if (metadata == null || metadata.length() == 0) { + // No (parsable) metadata. This is also expected. + return null + } + + var stream: ByteArrayInputStream? = null + + for (i in 0 until metadata.length()) { + // We can only extract pictures from two tags with this method, ID3v2's APIC or + // FLAC's PICTURE. + val pic: ByteArray? + val type: Int + + when (val entry = metadata.get(i)) { + is ApicFrame -> { + pic = entry.pictureData + type = entry.pictureType + } + is PictureFrame -> { + pic = entry.pictureData + type = entry.pictureType + } + else -> continue + } + + // Ensure the picture type here is a front cover image so that we don't extract + // an incorrect cover image. + // Yes, this does add some latency, but its quality covers so we can prioritize + // correctness over speed. + if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) { + logD("Front cover successfully found") + + // We have a front cover image. Great. + stream = ByteArrayInputStream(pic) + break + } else if (stream != null) { + // In the case a front cover is not found, use the first image in the tag instead. + // This can be corrected later on if a front cover frame is found. + logD("Image not a front cover, assigning image of type $type for now") + + stream = ByteArrayInputStream(pic) + } + } + + return stream + } + + companion object { + private const val MOSAIC_BITMAP_SIZE = 512 + private const val MOSAIC_BITMAP_INCREMENT = 256 + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt index ff181f2f9..1f5b16096 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt @@ -21,21 +21,18 @@ package org.oxycblt.auxio.coil import android.content.Context import android.graphics.Bitmap import android.widget.ImageView -import androidx.annotation.DrawableRes import androidx.core.graphics.drawable.toBitmap import androidx.databinding.BindingAdapter -import coil.Coil -import coil.clear -import coil.fetch.Fetcher +import coil.dispose +import coil.imageLoader +import coil.load import coil.request.ImageRequest import coil.size.OriginalSize import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.BaseModel import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.settings.SettingsManager // --- BINDING ADAPTERS --- @@ -44,7 +41,11 @@ import org.oxycblt.auxio.settings.SettingsManager */ @BindingAdapter("albumArt") fun ImageView.bindAlbumArt(song: Song?) { - load(song?.album, R.drawable.ic_album, AlbumArtFetcher(context)) + dispose() + + load(song) { + error(R.drawable.ic_album) + } } /** @@ -52,7 +53,11 @@ fun ImageView.bindAlbumArt(song: Song?) { */ @BindingAdapter("albumArt") fun ImageView.bindAlbumArt(album: Album?) { - load(album, R.drawable.ic_album, AlbumArtFetcher(context)) + dispose() + + load(album) { + error(R.drawable.ic_album) + } } /** @@ -60,7 +65,11 @@ fun ImageView.bindAlbumArt(album: Album?) { */ @BindingAdapter("artistImage") fun ImageView.bindArtistImage(artist: Artist?) { - load(artist, R.drawable.ic_artist, MosaicFetcher(context)) + dispose() + + load(artist) { + error(R.drawable.ic_artist) + } } /** @@ -68,32 +77,11 @@ fun ImageView.bindArtistImage(artist: Artist?) { */ @BindingAdapter("genreImage") fun ImageView.bindGenreImage(genre: Genre?) { - load(genre, R.drawable.ic_genre, MosaicFetcher(context)) -} + dispose() -/** - * Custom extension function similar to the stock coil load extensions, but handles whether - * to show images and custom fetchers. - * @param T Any datatype that inherits [BaseModel]. This can be null, but keep in mind that it will cause loading to fail. - * @param data The data itself - * @param error Drawable resource to use when loading failed/should not occur. - * @param fetcher Required fetcher that uses [T] as its datatype - */ -inline fun ImageView.load( - data: T?, - @DrawableRes error: Int, - fetcher: Fetcher, -) { - clear() - - Coil.imageLoader(context).enqueue( - ImageRequest.Builder(context) - .target(this) - .data(data) - .fetcher(fetcher) - .error(error) - .build() - ) + load(genre?.songs?.get(0)?.album) { + error(R.drawable.ic_genre) + } } // --- OTHER FUNCTIONS --- @@ -108,17 +96,9 @@ fun loadBitmap( song: Song, onDone: (Bitmap?) -> Unit ) { - val settingsManager = SettingsManager.getInstance() - - if (!settingsManager.showCovers) { - onDone(null) - return - } - - Coil.imageLoader(context).enqueue( + context.imageLoader.enqueue( ImageRequest.Builder(context) .data(song.album) - .fetcher(AlbumArtFetcher(context)) .size(OriginalSize) .target( onError = { onDone(null) }, diff --git a/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt b/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt new file mode 100644 index 000000000..940bb1ea4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt @@ -0,0 +1,88 @@ +package org.oxycblt.auxio.coil + +import android.widget.ImageView +import coil.decode.DataSource +import coil.drawable.CrossfadeDrawable +import coil.request.ErrorResult +import coil.request.ImageResult +import coil.request.SuccessResult +import coil.size.Scale +import coil.transition.Transition +import coil.transition.TransitionTarget + +/** + * A modified variant of coil's CrossfadeTransition that actually animates error results. + * You know. Like it used to. + * + * @author Coil Team + */ +class CrossfadeTransition @JvmOverloads constructor( + private val target: TransitionTarget, + private val result: ImageResult, + private val durationMillis: Int = CrossfadeDrawable.DEFAULT_DURATION, + private val preferExactIntrinsicSize: Boolean = false +) : Transition { + + init { + require(durationMillis > 0) { "durationMillis must be > 0." } + } + + override fun transition() { + val drawable = CrossfadeDrawable( + start = target.drawable, + end = result.drawable, + scale = (target.view as? ImageView)?.scale ?: Scale.FIT, + durationMillis = durationMillis, + fadeStart = !(result is SuccessResult && result.isPlaceholderCached), + preferExactIntrinsicSize = preferExactIntrinsicSize + ) + + when (result) { + is SuccessResult -> target.onSuccess(drawable) + is ErrorResult -> target.onError(drawable) + } + } + + val ImageView.scale: Scale + get() = when (scaleType) { + ImageView.ScaleType.FIT_START, ImageView.ScaleType.FIT_CENTER, + ImageView.ScaleType.FIT_END, ImageView.ScaleType.CENTER_INSIDE -> Scale.FIT + else -> Scale.FILL + } + + class Factory @JvmOverloads constructor( + val durationMillis: Int = CrossfadeDrawable.DEFAULT_DURATION, + val preferExactIntrinsicSize: Boolean = false + ) : Transition.Factory { + + init { + require(durationMillis > 0) { "durationMillis must be > 0." } + } + + override fun create(target: TransitionTarget, result: ImageResult): Transition { + // !!!!!!!!!!!!!! MODIFICATION !!!!!!!!!!!!!! + // Remove the error check for this transition. Usually when something errors in + // Auxio it will stay erroring, so not crossfading on an error looks weird. + + // Don't animate if the request was fulfilled by the memory cache. + if (result is SuccessResult && result.dataSource == DataSource.MEMORY_CACHE) { + return Transition.Factory.NONE.create(target, result) + } + + return CrossfadeTransition(target, result, durationMillis, preferExactIntrinsicSize) + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + return other is Factory && + durationMillis == other.durationMillis && + preferExactIntrinsicSize == other.preferExactIntrinsicSize + } + + override fun hashCode(): Int { + var result = durationMillis + result = 31 * result + preferExactIntrinsicSize.hashCode() + return result + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt new file mode 100644 index 000000000..95385490b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2021 Auxio Project + * Fetchers.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.coil + +import android.content.Context +import coil.ImageLoader +import coil.decode.DataSource +import coil.decode.ImageSource +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult +import coil.request.Options +import okio.buffer +import okio.source +import org.oxycblt.auxio.music.Album +import org.oxycblt.auxio.music.Artist +import org.oxycblt.auxio.music.Genre +import org.oxycblt.auxio.music.Song +import kotlin.math.min + +/** + * Fetcher that returns the album art for a given [Album]. Handles settings on whether to use + * quality covers or not. + * @author OxygenCobalt + */ +class AlbumArtFetcher private constructor( + private val context: Context, + private val album: Album +) : AuxioFetcher() { + override suspend fun fetch(): FetchResult? { + return fetchArt(context, album)?.let { stream -> + SourceResult( + source = ImageSource(stream.source().buffer(), context), + mimeType = null, + dataSource = DataSource.DISK + ) + } + } + + class SongFactory : Fetcher.Factory { + override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher? { + return AlbumArtFetcher(options.context, data.album) + } + } + + class AlbumFactory : Fetcher.Factory { + override fun create(data: Album, options: Options, imageLoader: ImageLoader): Fetcher? { + return AlbumArtFetcher(options.context, data) + } + } +} + +class ArtistImageFetcher private constructor( + private val context: Context, + private val artist: Artist +) : AuxioFetcher() { + override suspend fun fetch(): FetchResult? { + val end = min(4, artist.albums.size) + val results = artist.albums.mapN(end) { album -> + fetchArt(context, album) + } + + return createMosaic(context, results) + } + + class Factory : Fetcher.Factory { + override fun create(data: Artist, options: Options, imageLoader: ImageLoader): Fetcher? { + return ArtistImageFetcher(options.context, data) + } + } +} + +class GenreImageFetcher private constructor( + private val context: Context, + private val genre: Genre +) : AuxioFetcher() { + override suspend fun fetch(): FetchResult? { + val albums = genre.songs.groupBy { it.album }.keys + val end = min(4, albums.size) + val results = albums.mapN(end) { album -> + fetchArt(context, album) + } + + return createMosaic(context, results) + } + + class Factory : Fetcher.Factory { + override fun create(data: Genre, options: Options, imageLoader: ImageLoader): Fetcher? { + return GenreImageFetcher(options.context, data) + } + } +} + +/** + * Map only [n] items from a collection. [transform] is called for each item that is eligible. + * If null is returned, then that item will be skipped. + */ +private inline fun Iterable.mapN(n: Int, transform: (T) -> R?): List { + val out = mutableListOf() + + for (item in this) { + if (out.size >= n) { + break + } + + transform(item)?.let { + out.add(it) + } + } + + return out +} diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt deleted file mode 100644 index da27f041b..000000000 --- a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt +++ /dev/null @@ -1,148 +0,0 @@ -/* - * Copyright (c) 2021 Auxio Project - * MosaicFetcher.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.coil - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import android.graphics.Canvas -import androidx.core.graphics.drawable.toDrawable -import coil.bitmap.BitmapPool -import coil.decode.DataSource -import coil.decode.Options -import coil.fetch.DrawableResult -import coil.fetch.FetchResult -import coil.fetch.Fetcher -import coil.fetch.SourceResult -import coil.size.OriginalSize -import coil.size.Size -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.MusicParent -import java.lang.Exception - -/** - * A [Fetcher] that takes an [Artist] or [Genre] and returns a mosaic of its albums. - * @author OxygenCobalt - */ -class MosaicFetcher(private val context: Context) : Fetcher { - override suspend fun fetch( - pool: BitmapPool, - data: MusicParent, - size: Size, - options: Options - ): FetchResult { - // Get the URIs for either a genre or artist - val albums = mutableListOf() - - when (data) { - is Artist -> data.albums.forEachIndexed { index, album -> - if (index < 4) { albums.add(album) } - } - - is Genre -> data.songs.groupBy { it.album }.keys.forEachIndexed { index, album -> - if (index < 4) { albums.add(album) } - } - - else -> {} - } - - // Fetch our cover art using AlbumArtFetcher, as that respects any settings and is - // generally resilient to frustrating MediaStore issues - val results = mutableListOf() - val artFetcher = AlbumArtFetcher(context) - - // Load MediaStore streams - albums.forEach { album -> - try { - results.add(artFetcher.fetch(pool, album, OriginalSize, options) as SourceResult) - } catch (e: Exception) { - // Whatever. - } - } - - // If so many fetches failed that there's not enough images to make a mosaic, then - // just return the first cover image. - if (results.size < 4) { - // Dont even bother if ALL the streams have failed. - check(results.isNotEmpty()) { "All streams have failed. " } - - return results[0] - } - - val bitmap = drawMosaic(results) - - return DrawableResult( - drawable = bitmap.toDrawable(context.resources), - isSampled = false, - dataSource = DataSource.DISK - ) - } - - /** - * Create the mosaic image, Code adapted from Phonograph - * https://github.com/kabouzeid/Phonograph - */ - private fun drawMosaic(results: List): Bitmap { - // Use a fixed 512x512 canvas for the mosaics. Preferably we would adapt this mosaic to - // target ImageView size, but Coil seems to start image loading before we can even get - // a width/height for the view, making that impractical. - val mosaicBitmap = Bitmap.createBitmap( - MOSAIC_BITMAP_SIZE, MOSAIC_BITMAP_SIZE, Bitmap.Config.ARGB_8888 - ) - - val canvas = Canvas(mosaicBitmap) - - var x = 0 - var y = 0 - - // For each stream, create a bitmap scaled to 1/4th of the mosaics combined size - // and place it on a corner of the canvas. - results.forEach { result -> - if (y == MOSAIC_BITMAP_SIZE) return@forEach - - val bitmap = Bitmap.createScaledBitmap( - BitmapFactory.decodeStream(result.source.inputStream()), - MOSAIC_BITMAP_INCREMENT, - MOSAIC_BITMAP_INCREMENT, - true - ) - - canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null) - - x += MOSAIC_BITMAP_INCREMENT - - if (x == MOSAIC_BITMAP_SIZE) { - x = 0 - y += MOSAIC_BITMAP_INCREMENT - } - } - - return mosaicBitmap - } - - override fun key(data: MusicParent): String = data.hashCode().toString() - override fun handles(data: MusicParent) = data !is Album // Albums are not used here - - companion object { - private const val MOSAIC_BITMAP_SIZE = 512 - private const val MOSAIC_BITMAP_INCREMENT = 256 - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt b/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt new file mode 100644 index 000000000..ba3ab2b0f --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt @@ -0,0 +1,14 @@ +package org.oxycblt.auxio.coil + +import coil.key.Keyer +import coil.request.Options +import org.oxycblt.auxio.music.Music + +/** + * A basic keyer for music data. + */ +class MusicKeyer : Keyer { + override fun key(data: Music, options: Options): String? { + return "${data::class.simpleName}: ${data.id}" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index fe82693e5..e610ebeba 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -118,6 +118,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCal settingsManager.libGenreSort = sort mGenres.value = sort.sortParents(mGenres.value!!) } + else -> {} } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt index 467f0798d..d4977bc16 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt @@ -68,7 +68,6 @@ import kotlin.math.abs * - Added drag listener * - TODO: Added documentation * - TODO: Popup will center itself to the thumb when possible - * - TODO: Stabilize how I'm using padding */ class FastScrollRecyclerView @JvmOverloads constructor( context: Context, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt index 1ce4bb516..07a034a6e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt @@ -49,7 +49,7 @@ class PlaybackBarLayout @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, @StyleRes defStyleRes: Int = 0 ) : ViewGroup(context, attrs, defStyleAttr, defStyleRes) { - private val playbackView = CompactPlaybackView(context) + private val playbackView = PlaybackBarView(context) private var barDragHelper = ViewDragHelper.create(this, BarDragCallback()) private var lastInsets: WindowInsets? = null diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt similarity index 97% rename from app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt rename to app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt index 2d256982d..46103f476 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt @@ -39,7 +39,7 @@ import org.oxycblt.auxio.util.systemBarsCompat * A view displaying the playback state in a compact manner. This is only meant to be used * by [PlaybackBarLayout]. */ -class CompactPlaybackView @JvmOverloads constructor( +class PlaybackBarView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = -1 @@ -60,7 +60,7 @@ class CompactPlaybackView @JvmOverloads constructor( // MaterialShapeDrawable at runtime and allowing this code to work on API 21. background = R.drawable.ui_shape_ripple.resolveDrawable(context).apply { val backgroundDrawable = MaterialShapeDrawable.createWithElevationOverlay(context).apply { - elevation = this@CompactPlaybackView.elevation + elevation = this@PlaybackBarView.elevation fillColor = ColorStateList.valueOf(R.attr.colorSurface.resolveAttr(context)) } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt index 48b780591..999143278 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt @@ -88,6 +88,7 @@ class SearchAdapter( is Album -> (holder as AlbumViewHolder).bind(item) is Song -> (holder as SongViewHolder).bind(item) is Header -> (holder as HeaderViewHolder).bind(item) + else -> {} } } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt index 93b51c343..60403ca58 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt @@ -148,8 +148,7 @@ class SettingsListFragment : PreferenceFragmentCompat() { SettingsManager.KEY_SHOW_COVERS, SettingsManager.KEY_QUALITY_COVERS -> { onPreferenceChangeListener = Preference.OnPreferenceChangeListener { _, _ -> Coil.imageLoader(requireContext()).apply { - bitmapPool.clear() - memoryCache.clear() + this.memoryCache?.clear() } true diff --git a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt index 27ee2ede8..e6edbdbcc 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -35,7 +35,6 @@ import coil.request.ImageRequest import coil.transform.RoundedCornersTransformation import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R -import org.oxycblt.auxio.coil.AlbumArtFetcher import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.isLandscape @@ -98,7 +97,6 @@ class WidgetProvider : AppWidgetProvider() { val coverRequest = ImageRequest.Builder(context) .data(song.album) - .fetcher(AlbumArtFetcher(context)) .size(imageSize) // If we are on Android 12 or higher, round out the album cover so that the widget is diff --git a/app/src/main/res/layout/dialog_tabs.xml b/app/src/main/res/layout/dialog_tabs.xml index 3cb03ef72..181a4b739 100644 --- a/app/src/main/res/layout/dialog_tabs.xml +++ b/app/src/main/res/layout/dialog_tabs.xml @@ -8,7 +8,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:overScrollMode="never" - android:paddingTop="@dimen/spacing_medium" + android:paddingTop="@dimen/spacing_small" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" app:layout_constraintBottom_toTopOf="@+id/accent_cancel" app:layout_constraintTop_toBottomOf="@+id/accent_header" diff --git a/app/src/main/res/layout/view_compact_playback.xml b/app/src/main/res/layout/view_compact_playback.xml index a692fb314..99b4011c5 100644 --- a/app/src/main/res/layout/view_compact_playback.xml +++ b/app/src/main/res/layout/view_compact_playback.xml @@ -2,7 +2,7 @@ + tools:context=".playback.PlaybackBarView"> diff --git a/app/src/main/res/layout/view_seek_bar.xml b/app/src/main/res/layout/view_seek_bar.xml index 6c55d6638..e79eb3f9f 100644 --- a/app/src/main/res/layout/view_seek_bar.xml +++ b/app/src/main/res/layout/view_seek_bar.xml @@ -17,9 +17,9 @@ android:paddingEnd="@dimen/spacing_small" app:labelBehavior="gone" android:valueFrom="0" - android:valueTo="0" - app:thumbRadius="6dp" - app:haloRadius="14dp" + android:valueTo="1" + app:thumbRadius="@dimen/slider_thumb_radius" + app:haloRadius="@dimen/slider_halo_radius" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 9567644ec..2d5f82d65 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -30,6 +30,9 @@ 78dp 28dp + 6dp + 12dp + 176dp 110dp @dimen/widget_width_min diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml index 5992b6efa..37275b1e6 100644 --- a/app/src/main/res/values/styles_android.xml +++ b/app/src/main/res/values/styles_android.xml @@ -16,7 +16,6 @@ diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index f007fd7b9..ff0dfad26 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -135,7 +135,6 @@