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