From cb6d02feccea57ea6d0038d22fb250d589318283 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Fri, 22 Oct 2021 06:34:39 -0600 Subject: [PATCH] coil: use exoplayer when loading quality covers Make AlbumArtFetcher fall back to ExoPlayer's metadata system when fetching covers. This is because some OEMs seem to cripple MediaMetadataRetriever, which makes relying on that difficult. This also modifies MosaicFetcher to rely on AlbumArtFetcher. Resolves #51 --- .../org/oxycblt/auxio/coil/AlbumArtFetcher.kt | 133 ++++++++++++++++-- .../java/org/oxycblt/auxio/coil/CoilUtils.kt | 2 +- .../org/oxycblt/auxio/coil/MosaicFetcher.kt | 51 ++++--- .../oxycblt/auxio/widgets/WidgetProvider.kt | 34 +++-- 4 files changed, 162 insertions(+), 58 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt index f2274edaa..737456373 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt @@ -27,36 +27,88 @@ import coil.fetch.FetchResult import coil.fetch.Fetcher import coil.fetch.SourceResult import coil.size.Size +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.MetadataRetriever +import com.google.android.exoplayer2.metadata.flac.PictureFrame +import com.google.android.exoplayer2.metadata.id3.ApicFrame 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 +// LEFT-OFF: Make MosaicFetcher use these covers + /** * 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 { - private val settingsManager = SettingsManager.getInstance() - override suspend fun fetch( pool: BitmapPool, data: Album, size: Size, options: Options ): FetchResult { - return if (settingsManager.useQualityCovers) { - loadQualityCovers(data) + val settingsManager = SettingsManager.getInstance() + + val result = if (settingsManager.useQualityCovers) { + fetchQualityCovers(data.songs[0]) } else { - loadMediaStoreCovers(data) + // 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 loadMediaStoreCovers(data: Album): SourceResult { + 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) @@ -70,14 +122,14 @@ class AlbumArtFetcher(private val context: Context) : Fetcher { ) } - error("No cover art for album ${data.name}") + return null } - private fun loadQualityCovers(data: Album): SourceResult { + private fun fetchAospMetadataCovers(song: Song): FetchResult? { val extractor = MediaMetadataRetriever() extractor.use { ext -> - val songUri = data.songs[0].id.toURI() + val songUri = song.id.toURI() ext.setDataSource(context, songUri) // Get the embedded picture from MediaMetadataRetriever, which will return a full @@ -94,9 +146,66 @@ class AlbumArtFetcher(private val context: Context) : Fetcher { } } - // If we are here, the extractor likely failed so instead attempt to return the compressed - // cover instead. - return loadMediaStoreCovers(data) + return null + } + + 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 = future.get() + + 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 = when (val entry = metadata.get(i)) { + is ApicFrame -> entry.pictureData + is PictureFrame -> entry.pictureData + else -> null + } + + if (pic != null) { + // We found a cover, great. + // TODO: Make sure that this is a correct front cover picture and pick the first + // one if one cannot be found + stream = ByteArrayInputStream(pic) + break + } + } + + return if (stream != null) { + return SourceResult( + source = stream.source().buffer(), + mimeType = context.contentResolver.getType(uri), + dataSource = DataSource.DISK + ) + } else { + // No dice. + null + } } override fun key(data: Album) = data.id.toString() 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 127f477ba..4d70cf9b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt @@ -129,8 +129,8 @@ fun loadBitmap( ImageRequest.Builder(context) .data(song.album) .fetcher(AlbumArtFetcher(context)) - .size(OriginalSize) .transformations(RoundedCornersTransformation(cornerRadius)) + .size(OriginalSize) .target( onError = { onDone(null) }, onSuccess = { onDone(it.toBitmap()) } diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt index fb4108054..de8d6c140 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt @@ -22,7 +22,6 @@ import android.content.Context import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.Canvas -import android.net.Uri import androidx.core.graphics.drawable.toDrawable import coil.bitmap.BitmapPool import coil.decode.DataSource @@ -31,16 +30,15 @@ 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 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.Parent -import org.oxycblt.auxio.music.toAlbumArtURI import java.io.Closeable -import java.io.InputStream +import java.lang.Exception /** * A [Fetcher] that takes an [Artist] or [Genre] and returns a mosaic of its albums. @@ -54,45 +52,44 @@ class MosaicFetcher(private val context: Context) : Fetcher { options: Options ): FetchResult { // Get the URIs for either a genre or artist - val uris = mutableListOf() + val albums = mutableListOf() when (data) { is Artist -> data.albums.forEachIndexed { index, album -> - if (index < 4) { uris.add(album.id.toAlbumArtURI()) } + if (index < 4) { albums.add(album) } } - is Genre -> data.songs.groupBy { it.album.id }.keys.forEachIndexed { index, id -> - if (index < 4) { uris.add(id.toAlbumArtURI()) } + is Genre -> data.songs.groupBy { it.album }.keys.forEachIndexed { index, album -> + if (index < 4) { albums.add(album) } } else -> {} } - val streams = mutableListOf() + // 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 - uris.forEach { uri -> - val stream: InputStream? = context.contentResolver.openInputStream(uri) - - if (stream != null) { - streams.add(stream) + albums.forEach { album -> + try { + results.add(artFetcher.fetch(pool, album, OriginalSize, options) as SourceResult) + } catch (e: Exception) { + // Whatever. } } - // If so many streams failed that there's not enough images to make a mosaic, then + // If so many fetches failed that there's not enough images to make a mosaic, then // just return the first cover image. - if (streams.size < 4) { + if (results.size < 4) { // Dont even bother if ALL the streams have failed. - check(streams.isNotEmpty()) { "All streams have failed. " } + check(results.isNotEmpty()) { "All streams have failed. " } - return SourceResult( - source = streams[0].source().buffer(), - mimeType = context.contentResolver.getType(uris[0]), - dataSource = DataSource.DISK - ) + return results[0] } - val bitmap = drawMosaic(streams) + val bitmap = drawMosaic(results) return DrawableResult( drawable = bitmap.toDrawable(context.resources), @@ -105,7 +102,7 @@ class MosaicFetcher(private val context: Context) : Fetcher { * Create the mosaic image, Code adapted from Phonograph * https://github.com/kabouzeid/Phonograph */ - private fun drawMosaic(streams: List): Bitmap { + 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. @@ -120,11 +117,11 @@ class MosaicFetcher(private val context: Context) : Fetcher { // For each stream, create a bitmap scaled to 1/4th of the mosaics combined size // and place it on a corner of the canvas. - streams.useForEach { stream -> - if (y == MOSAIC_BITMAP_SIZE) return@useForEach + results.forEach { result -> + if (y == MOSAIC_BITMAP_SIZE) return@forEach val bitmap = Bitmap.createScaledBitmap( - BitmapFactory.decodeStream(stream), + BitmapFactory.decodeStream(result.source.inputStream()), MOSAIC_BITMAP_INCREMENT, MOSAIC_BITMAP_INCREMENT, 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 e5b8392a0..1a3d73f71 100644 --- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt @@ -91,26 +91,24 @@ class WidgetProvider : AppWidgetProvider() { } private fun loadWidgetBitmap(context: Context, song: Song, onDone: (Bitmap?) -> Unit) { - val cornerRadius = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - context.resources.getDimensionPixelSize( - android.R.dimen.system_app_widget_inner_radius - ).toFloat() - } else { - 0f + val builder = ImageRequest.Builder(context) + .data(song.album) + .fetcher(AlbumArtFetcher(context)) + .size(OriginalSize) + .target( + onError = { onDone(null) }, + onSuccess = { onDone(it.toBitmap()) } + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + builder.transformations(RoundedCornersTransformation( + context.resources.getDimensionPixelSize( + android.R.dimen.system_app_widget_inner_radius + ).toFloat() + )) } - Coil.imageLoader(context).enqueue( - ImageRequest.Builder(context) - .data(song.album) - .fetcher(AlbumArtFetcher(context)) - .size(OriginalSize) - .transformations(RoundedCornersTransformation(cornerRadius)) - .target( - onError = { onDone(null) }, - onSuccess = { onDone(it.toBitmap()) } - ) - .build() - ) + Coil.imageLoader(context).enqueue(builder.build()) } /*