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:
parent
36228d0536
commit
cb6d02fecc
4 changed files with 162 additions and 58 deletions
|
@ -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()
|
||||
|
|
|
@ -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()) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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())
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
Loading…
Reference in a new issue