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.
This commit is contained in:
OxygenCobalt 2021-11-20 08:57:52 -07:00
parent e3f4a6fefa
commit 0e3ffb973b
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
24 changed files with 535 additions and 444 deletions

View file

@ -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"

View file

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

View file

@ -133,6 +133,7 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback {
snackbar.show()
}
else -> {}
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Album> {
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()
}

View file

@ -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<InputStream>): 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
}
}

View file

@ -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 <reified T : BaseModel> ImageView.load(
data: T?,
@DrawableRes error: Int,
fetcher: Fetcher<T>,
) {
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) },

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher? {
return AlbumArtFetcher(options.context, data.album)
}
}
class AlbumFactory : Fetcher.Factory<Album> {
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<Artist> {
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<Genre> {
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 <T : Any, R : Any> Iterable<T>.mapN(n: Int, transform: (T) -> R?): List<R> {
val out = mutableListOf<R>()
for (item in this) {
if (out.size >= n) {
break
}
transform(item)?.let {
out.add(it)
}
}
return out
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<MusicParent> {
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<Album>()
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<SourceResult>()
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<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.
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
}
}

View file

@ -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<Music> {
override fun key(data: Music, options: Options): String? {
return "${data::class.simpleName}: ${data.id}"
}
}

View file

@ -118,6 +118,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCal
settingsManager.libGenreSort = sort
mGenres.value = sort.sortParents(mGenres.value!!)
}
else -> {}
}
}

View file

@ -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,

View file

@ -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

View file

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

View file

@ -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 -> {}
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -2,7 +2,7 @@
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".playback.CompactPlaybackView">
tools:context=".playback.PlaybackBarView">
<data>

View file

@ -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" />

View file

@ -30,6 +30,9 @@
<dimen name="popup_min_width">78dp</dimen>
<dimen name="popup_padding_end">28dp</dimen>
<dimen name="slider_thumb_radius">6dp</dimen>
<dimen name="slider_halo_radius">12dp</dimen>
<dimen name="widget_width_min">176dp</dimen>
<dimen name="widget_height_min">110dp</dimen>
<dimen name="widget_width_def">@dimen/widget_width_min</dimen>

View file

@ -16,7 +16,6 @@
<!-- Custom dialog title theme -->
<style name="Widget.Auxio.Dialog.TextView" parent="MaterialAlertDialog.Material3.Title.Text">
<item name="android:fontFamily">@font/inter_semibold</item>
<item name="android:textAppearance">@style/TextAppearance.Auxio.TitleMidLarge</item>
</style>

View file

@ -135,7 +135,6 @@
</style>
<style name="Widget.Auxio.Button.Primary" parent="Widget.Material3.Button">
<!-- TODO: Report elevation bug -->
<item name="android:textAppearance">@style/TextAppearance.Auxio.LabelLarger</item>
<item name="android:stateListAnimator">@null</item>
<item name="android:elevation">0dp</item>

View file

@ -1,6 +1,6 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
ext.kotlin_version = "1.5.31"
ext.kotlin_version = '1.6.0'
ext.navigation_version = "2.3.5"
repositories {