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
This commit is contained in:
OxygenCobalt 2021-10-22 06:34:39 -06:00
parent 36228d0536
commit cb6d02fecc
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 162 additions and 58 deletions

View file

@ -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<Album> {
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)
}
private fun loadMediaStoreCovers(data: Album): SourceResult {
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)
@ -70,14 +122,14 @@ class AlbumArtFetcher(private val context: Context) : Fetcher<Album> {
)
}
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<Album> {
}
}
// 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()

View file

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

View file

@ -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<Parent> {
options: Options
): FetchResult {
// Get the URIs for either a genre or artist
val uris = mutableListOf<Uri>()
val albums = mutableListOf<Album>()
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<InputStream>()
// Fetch our cover art using AlbumArtFetcher, as that respects any settings and is
// generally resilient to frustrating MediaStore issues
val results = mutableListOf<SourceResult>()
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<Parent> {
* Create the mosaic image, Code adapted from Phonograph
* https://github.com/kabouzeid/Phonograph
*/
private fun drawMosaic(streams: List<InputStream>): Bitmap {
private fun drawMosaic(results: List<SourceResult>): 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<Parent> {
// 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

View file

@ -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
}
Coil.imageLoader(context).enqueue(
ImageRequest.Builder(context)
val builder = ImageRequest.Builder(context)
.data(song.album)
.fetcher(AlbumArtFetcher(context))
.size(OriginalSize)
.transformations(RoundedCornersTransformation(cornerRadius))
.target(
onError = { onDone(null) },
onSuccess = { onDone(it.toBitmap()) }
)
.build()
)
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(builder.build())
}
/*