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