musikr.cover: refactor cover

Instead of using a weird sealed class, instead go for a
Cover/CoverCollection system instead that removes some implicit
design dependence in musikr.
This commit is contained in:
Alexander Capehart 2024-12-24 14:43:48 -05:00
parent a24d102a00
commit 7768d98632
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
15 changed files with 205 additions and 76 deletions

View file

@ -64,7 +64,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
/**
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
@ -327,7 +327,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(album: Album) =
bindImpl(
album.cover,
album.covers,
context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24)
@ -338,7 +338,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(artist: Artist) =
bindImpl(
artist.cover,
artist.covers,
context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24)
@ -349,7 +349,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(genre: Genre) =
bindImpl(
genre.cover,
genre.covers,
context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24)
@ -360,7 +360,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/
fun bind(playlist: Playlist) =
bindImpl(
playlist.cover,
playlist.covers,
context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24)
@ -372,9 +372,9 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded.
*/
fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(Cover.multi(songs), desc, errorRes)
bindImpl(CoverCollection.from(songs.mapNotNull { it.cover }), desc, errorRes)
private fun bindImpl(cover: Cover?, desc: String, @DrawableRes errorRes: Int) {
private fun bindImpl(cover: Any?, desc: String, @DrawableRes errorRes: Int) {
val request =
ImageRequest.Builder(context)
.data(cover)

View file

@ -36,14 +36,17 @@ class CoilModule {
@Provides
fun imageLoader(
@ApplicationContext context: Context,
keyer: CoverKeyer,
factory: CoverFetcher.Factory
coverKeyer: CoverKeyer,
coverFactory: CoverFetcher.Factory,
coverCollectionKeyer: CoverCollectionKeyer,
coverCollectionFactory: CoverCollectionFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(keyer)
add(factory)
add(coverKeyer)
add(coverFactory)
add(coverCollectionKeyer)
add(coverCollectionFactory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2021 Auxio Project
* Components.kt is part of Auxio.
* Copyright (c) 2024 Auxio Project
* CoverCollectionFetcher.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
@ -31,7 +31,6 @@ import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.key.Keyer
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size
@ -39,36 +38,24 @@ import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.asFlow
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverFetcher
class CoverCollectionFetcher
private constructor(
private val context: Context,
private val cover: Cover,
private val covers: CoverCollection,
private val size: Size,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val streams =
when (val cover = cover) {
is Cover.Single -> listOfNotNull(cover.open())
is Cover.Multi ->
buildList {
for (single in cover.all) {
single.open()?.let { add(it) }
if (size == 4) {
break
}
}
}
}
val streams = covers.covers.asFlow().mapNotNull { it.open() }.take(4).toList()
// We don't immediately check for mosaic feasibility from album count alone, as that
// does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use.
@ -79,6 +66,7 @@ private constructor(
withContext(Dispatchers.IO) { streams.forEach(InputStream::close) }
}
}
// Not enough covers for a mosaic, take the first one (if that even exists)
val first = streams.firstOrNull() ?: return null
@ -146,8 +134,8 @@ private constructor(
return if (size.mod(2) > 0) size + 1 else size
}
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(options.context, data, options.size)
class Factory @Inject constructor() : Fetcher.Factory<CoverCollection> {
override fun create(data: CoverCollection, options: Options, imageLoader: ImageLoader) =
CoverCollectionFetcher(options.context, data, options.size)
}
}

View file

@ -0,0 +1,110 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverFetcher.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.image.coil
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import androidx.core.graphics.drawable.toDrawable
import coil3.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.musikr.cover.Cover
class CoverFetcher private constructor(private val context: Context, private val cover: Cover) :
Fetcher {
override suspend fun fetch(): FetchResult? {
val stream = cover.open() ?: return null
return SourceFetchResult(
source = ImageSource(stream.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = android.util.Size(size.width.mosaicSize(), size.height.mosaicSize())
val mosaicFrameSize =
Size(Dimension(mosaicSize.width / 2), Dimension(mosaicSize.height / 2))
val mosaicBitmap =
Bitmap.createBitmap(mosaicSize.width, mosaicSize.height, 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 == mosaicSize.height) {
break
}
// Crop the bitmap down to a square so it leaves no empty space
// TODO: Work around this
val bitmap =
SquareCropTransformation.INSTANCE.transform(
BitmapFactory.decodeStream(stream), mosaicFrameSize)
canvas.drawBitmap(bitmap, x.toFloat(), y.toFloat(), null)
x += bitmap.width
if (x == mosaicSize.width) {
x = 0
y += bitmap.height
}
}
// It's way easier to map this into a drawable then try to serialize it into an
// BufferedSource. Just make sure we mark it as "sampled" so Coil doesn't try to
// load low-res mosaics into high-res ImageViews.
return ImageFetchResult(
image = mosaicBitmap.toDrawable(context.resources).asImage(),
isSampled = true,
dataSource = DataSource.DISK)
}
private fun Dimension.mosaicSize(): Int {
// Since we want the mosaic to be perfectly divisible into two, we need to round any
// odd image sizes upwards to prevent the mosaic creation from failing.
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
class Factory @Inject constructor() : Fetcher.Factory<Cover> {
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(options.context, data)
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2021 Auxio Project
* Keyers.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.image.coil
import coil3.key.Keyer
import coil3.request.Options
import javax.inject.Inject
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
}
class CoverCollectionKeyer @Inject constructor() : Keyer<CoverCollection> {
override fun key(data: CoverCollection, options: Options) =
"multi:${data.hashCode()}&${options.size}"
}

View file

@ -70,7 +70,7 @@ class MutableRevisionedStoredCovers(context: Context, private val revision: UUID
}
}
class RevisionedCover(private val revision: UUID, val inner: Cover.Single) : Cover.Single by inner {
class RevisionedCover(private val revision: UUID, val inner: Cover) : Cover by inner {
override val id: String
get() = "${inner.id}@${revision}"
}

View file

@ -26,6 +26,7 @@ import java.util.UUID
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.fs.Format
import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.tag.Date
@ -276,7 +277,7 @@ interface Song : Music {
/** The date the audio file was added to the device, as a unix epoch timestamp. */
val dateAdded: Long
/** Useful information to quickly obtain the album cover. */
val cover: Cover.Single?
val cover: Cover?
/**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead.
@ -310,7 +311,7 @@ interface Album : MusicParent {
*/
val releaseType: ReleaseType
/** Cover information from album's songs. */
val cover: Cover
val covers: CoverCollection
/** The duration of all songs in the album, in milliseconds. */
val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
@ -340,7 +341,7 @@ interface Artist : MusicParent {
*/
val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover
val covers: CoverCollection
/** The [Genre]s of this artist. */
val genres: List<Genre>
}
@ -356,7 +357,7 @@ interface Genre : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover
val covers: CoverCollection
}
/**
@ -370,5 +371,5 @@ interface Playlist : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover
val covers: CoverCollection
}

View file

@ -19,29 +19,22 @@
package org.oxycblt.musikr.cover
import java.io.InputStream
import org.oxycblt.musikr.Song
sealed interface Cover {
interface Cover {
val id: String
interface Single : Cover {
suspend fun open(): InputStream?
}
class Multi(val all: List<Single>) : Cover {
override val id = "multi@${all.hashCode()}"
}
suspend fun open(): InputStream?
}
class CoverCollection private constructor(val covers: List<Cover>) {
companion object {
fun multi(songs: Collection<Song>) = order(songs).run { Multi(this) }
private fun order(songs: Collection<Song>) =
songs
.mapNotNull { it.cover }
.groupBy { it.id }
.entries
.sortedByDescending { it.key }
.sortedByDescending { it.value.size }
.map { it.value.first() }
fun from(covers: Collection<Cover>) =
CoverCollection(
covers
.groupBy { it.id }
.entries
.sortedByDescending { it.key }
.sortedByDescending { it.value.size }
.map { it.value.first() })
}
}

View file

@ -21,7 +21,7 @@ package org.oxycblt.musikr.cover
import android.content.Context
interface StoredCovers {
suspend fun obtain(id: String): Cover.Single?
suspend fun obtain(id: String): Cover?
companion object {
fun from(context: Context, path: String, format: CoverFormat): MutableStoredCovers =
@ -30,7 +30,7 @@ interface StoredCovers {
}
interface MutableStoredCovers : StoredCovers {
suspend fun write(data: ByteArray): Cover.Single?
suspend fun write(data: ByteArray): Cover?
}
private class FileStoredCovers(
@ -45,6 +45,6 @@ private class FileStoredCovers(
}
}
private class FileCover(override val id: String, private val coverFile: CoverFile) : Cover.Single {
private class FileCover(override val id: String, private val coverFile: CoverFile) : Cover {
override suspend fun open() = coverFile.open()
}

View file

@ -22,7 +22,7 @@ import org.oxycblt.musikr.Album
import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.tag.Date
import org.oxycblt.musikr.tag.interpret.PreAlbum
import org.oxycblt.musikr.util.update
@ -56,7 +56,7 @@ internal class AlbumImpl(private val core: AlbumCore) : Album {
override val releaseType = preAlbum.releaseType
override val durationMs = core.songs.sumOf { it.durationMs }
override val dateAdded = core.songs.minOf { it.dateAdded }
override val cover = Cover.multi(core.songs)
override val covers = CoverCollection.from(core.songs.mapNotNull { it.cover })
override val dates: Date.Range? =
core.songs.mapNotNull { it.date }.ifEmpty { null }?.run { Date.Range(min(), max()) }

View file

@ -23,7 +23,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.tag.interpret.PreArtist
import org.oxycblt.musikr.util.update
@ -55,7 +55,7 @@ internal class ArtistImpl(private val core: ArtistCore) : Artist {
get() = core.resolveGenres().toList()
override val durationMs = core.songs.sumOf { it.durationMs }
override val cover = Cover.multi(core.songs)
override val covers = CoverCollection.from(core.songs.mapNotNull { it.cover })
private val hashCode =
31 * (31 * uid.hashCode() + core.preArtist.hashCode()) * core.songs.hashCode()

View file

@ -22,7 +22,7 @@ import org.oxycblt.musikr.Artist
import org.oxycblt.musikr.Genre
import org.oxycblt.musikr.Music
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.tag.interpret.PreGenre
import org.oxycblt.musikr.util.update
@ -44,7 +44,7 @@ internal class GenreImpl(private val core: GenreCore) : Genre {
override val songs = core.songs
override val artists = core.artists
override val durationMs = core.songs.sumOf { it.durationMs }
override val cover = Cover.multi(core.songs)
override val covers = CoverCollection.from(core.songs.mapNotNull { it.cover })
private val hashCode = 31 * (31 * uid.hashCode() + core.preGenre.hashCode()) + songs.hashCode()

View file

@ -20,7 +20,7 @@ package org.oxycblt.musikr.model
import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.Song
import org.oxycblt.musikr.cover.Cover
import org.oxycblt.musikr.cover.CoverCollection
import org.oxycblt.musikr.playlist.interpret.PrePlaylistInfo
import org.oxycblt.musikr.tag.Name
@ -33,7 +33,7 @@ internal class PlaylistImpl(val core: PlaylistCore) : Playlist {
override val uid = core.prePlaylist.handle.uid
override val name: Name.Known = core.prePlaylist.name
override val durationMs = core.songs.sumOf { it.durationMs }
override val cover = Cover.multi(core.songs)
override val covers = CoverCollection.from(core.songs.mapNotNull { it.cover })
override val songs = core.songs
private var hashCode =

View file

@ -163,7 +163,7 @@ internal data class RawSong(
val file: DeviceFile,
val properties: Properties,
val tags: ParsedTags,
val cover: Cover.Single?
val cover: Cover?
)
internal sealed interface ExtractedMusic {

View file

@ -48,7 +48,7 @@ internal data class PreSong(
val replayGainAdjustment: ReplayGainAdjustment,
val lastModified: Long,
val dateAdded: Long,
val cover: Cover.Single?,
val cover: Cover?,
val preAlbum: PreAlbum,
val preArtists: List<PreArtist>,
val preGenres: List<PreGenre>