diff --git a/app/build.gradle b/app/build.gradle
index f33a18072..0bd164b57 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -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"
diff --git a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt
index 1dab7a0c2..adb86e355 100644
--- a/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt
+++ b/app/src/main/java/org/oxycblt/auxio/AuxioApp.kt
@@ -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()
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
index 35d8b7fd7..a3afea6d2 100644
--- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt
@@ -133,6 +133,7 @@ class MainFragment : Fragment(), PlaybackBarLayout.ActionCallback {
snackbar.show()
}
+ else -> {}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt
deleted file mode 100644
index 2796bd07c..000000000
--- a/app/src/main/java/org/oxycblt/auxio/coil/AlbumArtFetcher.kt
+++ /dev/null
@@ -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 .
- */
-
-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 {
- 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()
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt
new file mode 100644
index 000000000..2b22ab5ce
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/AuxioFetcher.kt
@@ -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): 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
+ }
+}
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 ff181f2f9..1f5b16096 100644
--- a/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt
+++ b/app/src/main/java/org/oxycblt/auxio/coil/CoilUtils.kt
@@ -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 ImageView.load(
- data: T?,
- @DrawableRes error: Int,
- fetcher: Fetcher,
-) {
- 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) },
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt b/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt
new file mode 100644
index 000000000..940bb1ea4
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/CrossfadeTransition.kt
@@ -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
+ }
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt
new file mode 100644
index 000000000..95385490b
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt
@@ -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 .
+ */
+
+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 {
+ override fun create(data: Song, options: Options, imageLoader: ImageLoader): Fetcher? {
+ return AlbumArtFetcher(options.context, data.album)
+ }
+ }
+
+ class AlbumFactory : Fetcher.Factory {
+ 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 {
+ 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 {
+ 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 Iterable.mapN(n: Int, transform: (T) -> R?): List {
+ val out = mutableListOf()
+
+ for (item in this) {
+ if (out.size >= n) {
+ break
+ }
+
+ transform(item)?.let {
+ out.add(it)
+ }
+ }
+
+ return out
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt b/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt
deleted file mode 100644
index da27f041b..000000000
--- a/app/src/main/java/org/oxycblt/auxio/coil/MosaicFetcher.kt
+++ /dev/null
@@ -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 .
- */
-
-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 {
- 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()
-
- 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()
- 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): 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
- }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt b/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt
new file mode 100644
index 000000000..ba3ab2b0f
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/coil/MusicKeyer.kt
@@ -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 {
+ override fun key(data: Music, options: Options): String? {
+ return "${data::class.simpleName}: ${data.id}"
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
index fe82693e5..e610ebeba 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt
@@ -118,6 +118,7 @@ class HomeViewModel : ViewModel(), SettingsManager.Callback, MusicStore.MusicCal
settingsManager.libGenreSort = sort
mGenres.value = sort.sortParents(mGenres.value!!)
}
+ else -> {}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
index 467f0798d..d4977bc16 100644
--- a/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/home/fastscroll/FastScrollRecyclerView.kt
@@ -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,
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt
index 1ce4bb516..07a034a6e 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarLayout.kt
@@ -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
diff --git a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
similarity index 97%
rename from app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt
rename to app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
index 2d256982d..46103f476 100644
--- a/app/src/main/java/org/oxycblt/auxio/playback/CompactPlaybackView.kt
+++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackBarView.kt
@@ -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))
}
diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
index 48b780591..999143278 100644
--- a/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
+++ b/app/src/main/java/org/oxycblt/auxio/search/SearchAdapter.kt
@@ -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 -> {}
}
}
}
diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
index 93b51c343..60403ca58 100644
--- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
+++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt
@@ -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
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 27ee2ede8..e6edbdbcc 100644
--- a/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
+++ b/app/src/main/java/org/oxycblt/auxio/widgets/WidgetProvider.kt
@@ -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
diff --git a/app/src/main/res/layout/dialog_tabs.xml b/app/src/main/res/layout/dialog_tabs.xml
index 3cb03ef72..181a4b739 100644
--- a/app/src/main/res/layout/dialog_tabs.xml
+++ b/app/src/main/res/layout/dialog_tabs.xml
@@ -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"
diff --git a/app/src/main/res/layout/view_compact_playback.xml b/app/src/main/res/layout/view_compact_playback.xml
index a692fb314..99b4011c5 100644
--- a/app/src/main/res/layout/view_compact_playback.xml
+++ b/app/src/main/res/layout/view_compact_playback.xml
@@ -2,7 +2,7 @@
+ tools:context=".playback.PlaybackBarView">
diff --git a/app/src/main/res/layout/view_seek_bar.xml b/app/src/main/res/layout/view_seek_bar.xml
index 6c55d6638..e79eb3f9f 100644
--- a/app/src/main/res/layout/view_seek_bar.xml
+++ b/app/src/main/res/layout/view_seek_bar.xml
@@ -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" />
diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml
index 9567644ec..2d5f82d65 100644
--- a/app/src/main/res/values/dimens.xml
+++ b/app/src/main/res/values/dimens.xml
@@ -30,6 +30,9 @@
78dp
28dp
+ 6dp
+ 12dp
+
176dp
110dp
@dimen/widget_width_min
diff --git a/app/src/main/res/values/styles_android.xml b/app/src/main/res/values/styles_android.xml
index 5992b6efa..37275b1e6 100644
--- a/app/src/main/res/values/styles_android.xml
+++ b/app/src/main/res/values/styles_android.xml
@@ -16,7 +16,6 @@
diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml
index f007fd7b9..ff0dfad26 100644
--- a/app/src/main/res/values/styles_ui.xml
+++ b/app/src/main/res/values/styles_ui.xml
@@ -135,7 +135,6 @@