image: simplify implementation

Reduce the accepted datatype of extractors down to a list of songs,
moving the other datatypes to the UI layer.

This massively reduces the amount of components that must be managed,
and enables functionality related to playlist editing.
This commit is contained in:
Alexander Capehart 2023-05-19 14:09:40 -06:00
parent 996c86b361
commit 5fff1bd0b3
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
12 changed files with 129 additions and 287 deletions

View file

@ -280,7 +280,13 @@ class PlaylistDetailFragment :
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
// TODO: Disable check item when no edits have been made
// TODO: Improve how this state change looks
// TODO: Massively improve how this UI is indicated:
// - Make playlist header dynamically respond to song changes
// - Disable play and pause buttons
// - Add an additional toolbar to indicate editing
// - Header should flip to re-sort button eventually
playlistListAdapter.setEditing(editedPlaylist != null)
}

View file

@ -95,7 +95,7 @@ constructor(
target
.onConfigRequest(
ImageRequest.Builder(context)
.data(song)
.data(listOf(song))
// Use ORIGINAL sizing, as we are not loading into any View-like component.
.size(Size.ORIGINAL)
.transformations(SquareFrameTransform.INSTANCE))

View file

@ -49,6 +49,9 @@ import org.oxycblt.auxio.util.getInteger
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Rework content descriptions here
* TODO: Attempt unification with StyledImageView with some kind of dynamic configuration to avoid
* superfluous elements
* TODO: Handle non-square covers by gracefully placing them in the layout
*/
class ImageGroup
@JvmOverloads

View file

@ -96,7 +96,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*
* @param song The [Song] to bind.
*/
fun bind(song: Song) = bindImpl(song, R.drawable.ic_song_24, R.string.desc_album_cover)
fun bind(song: Song) = bind(song.album)
/**
* Bind an [Album]'s cover to this view, also updating the content description.
@ -130,15 +130,15 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
/**
* Internally bind a [Music]'s image to this view.
*
* @param music The music to find.
* @param parent The music to bind, in the form of it's [MusicParent]s.
* @param errorRes The error drawable resource to use if the music cannot be loaded.
* @param descRes The content description string resource to use. The resource must have one
* field for the name of the [Music].
*/
private fun bindImpl(music: Music, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
private fun bindImpl(parent: MusicParent, @DrawableRes errorRes: Int, @StringRes descRes: Int) {
val request =
ImageRequest.Builder(context)
.data(music)
.data(parent.songs)
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
.transformations(SquareFrameTransform.INSTANCE)
.target(this)
@ -147,7 +147,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
CoilUtils.dispose(this)
imageLoader.enqueue(request)
// Update the content description to the specified resource.
contentDescription = context.getString(descRes, music.name.resolve(context))
contentDescription = context.getString(descRes, parent.name.resolve(context))
}
/**

View file

@ -18,163 +18,31 @@
package org.oxycblt.auxio.image.extractor
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.key.Keyer
import coil.request.Options
import coil.size.Size
import javax.inject.Inject
import kotlin.math.min
import okio.buffer
import okio.source
import org.oxycblt.auxio.list.Sort
import org.oxycblt.auxio.music.*
class SongKeyer @Inject constructor() : Keyer<Song> {
override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}"
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
Keyer<List<Song>> {
override fun key(data: List<Song>, options: Options) =
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
}
// TODO: Key on the actual mosaic items used
class ParentKeyer @Inject constructor() : Keyer<MusicParent> {
override fun key(data: MusicParent, options: Options) = "${data.uid}${data.hashCode()}"
}
/**
* Generic [Fetcher] for [Album] covers. Works with both [Album] and [Song]. Use [SongFactory] or
* [AlbumFactory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class AlbumCoverFetcher
class SongCoverFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val album: Album
) : Fetcher {
override suspend fun fetch(): FetchResult? =
extractor.extract(album)?.run {
SourceResult(
source = ImageSource(source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
class SongFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Song> {
override fun create(data: Song, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, coverExtractor, data.album)
}
class AlbumFactory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Album> {
override fun create(data: Album, options: Options, imageLoader: ImageLoader) =
AlbumCoverFetcher(options.context, coverExtractor, data)
}
}
/**
* [Fetcher] for [Artist] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class ArtistImageFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val songs: List<Song>,
private val size: Size,
private val artist: Artist
private val coverExtractor: CoverExtractor,
) : Fetcher {
override suspend fun fetch(): FetchResult? {
// Pick the "most prominent" albums (i.e albums with the most songs) to show in the image.
val albums = Sort(Sort.Mode.ByCount, Sort.Direction.DESCENDING).albums(artist.albums)
val results = albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
return Images.createMosaic(context, results, size)
}
override suspend fun fetch() = coverExtractor.extract(songs, size)
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Artist> {
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
ArtistImageFetcher(options.context, extractor, options.size, data)
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<List<Song>> {
override fun create(data: List<Song>, options: Options, imageLoader: ImageLoader) =
SongCoverFetcher(data, options.size, coverExtractor)
}
}
/**
* [Fetcher] for [Genre] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class GenreImageFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val size: Size,
private val genre: Genre
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val results = genre.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
return Images.createMosaic(context, results, size)
}
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Genre> {
override fun create(data: Genre, options: Options, imageLoader: ImageLoader) =
GenreImageFetcher(options.context, extractor, options.size, data)
}
}
/**
* [Fetcher] for [Playlist] images. Use [Factory] for instantiation.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class PlaylistImageFetcher
private constructor(
private val context: Context,
private val extractor: CoverExtractor,
private val size: Size,
private val playlist: Playlist
) : Fetcher {
override suspend fun fetch(): FetchResult? {
val results = playlist.albums.mapAtMostNotNull(4) { album -> extractor.extract(album) }
return Images.createMosaic(context, results, size)
}
class Factory @Inject constructor(private val extractor: CoverExtractor) :
Fetcher.Factory<Playlist> {
override fun create(data: Playlist, options: Options, imageLoader: ImageLoader) =
PlaylistImageFetcher(options.context, extractor, options.size, data)
}
}
/**
* Map at most N [T] items a collection into a collection of [R], ignoring [T] that cannot be
* transformed into [R].
*
* @param n The maximum amount of items to map.
* @param transform The function that transforms data [T] from the original list into data [R] in
* the new list. Can return null if the [T] cannot be transformed into an [R].
* @return A new list of at most N non-null [R] items.
*/
private inline fun <T : Any, R : Any> Collection<T>.mapAtMostNotNull(
n: Int,
transform: (T) -> R?
): List<R> {
val until = min(size, n)
val out = mutableListOf<R>()
for (item in this) {
if (out.size >= until) {
break
}
// Still have more data we can transform.
transform(item)?.let(out::add)
}
return out
}

View file

@ -19,13 +19,26 @@
package org.oxycblt.auxio.image.extractor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.media.MediaMetadataRetriever
import android.util.Size as AndroidSize
import androidx.core.graphics.drawable.toDrawable
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import coil.decode.DataSource
import coil.decode.ImageSource
import coil.fetch.DrawableResult
import coil.fetch.FetchResult
import coil.fetch.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
@ -33,9 +46,12 @@ import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
@ -46,7 +62,28 @@ constructor(
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
suspend fun extract(album: Album): InputStream? =
suspend fun extract(songs: List<Song>, size: Size): FetchResult? {
val albums = computeAlbumOrdering(songs)
val streams = mutableListOf<InputStream>()
for (album in albums) {
if (streams.size == 4) {
return createMosaic(streams, size)
}
openInputStream(album)?.let(streams::add)
}
return streams.firstOrNull()?.let { stream ->
SourceResult(
source = ImageSource(stream.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
}
fun computeAlbumOrdering(songs: List<Song>): Collection<Album> =
songs.groupByTo(sortedMapOf(compareByDescending { it.songs.size })) { it.album }.keys
private suspend fun openInputStream(album: Album): InputStream? =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
@ -125,4 +162,58 @@ constructor(
private suspend fun extractMediaStoreCover(album: Album) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
/** 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 = AndroidSize(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
}
// Run the bitmap through a transform to reflect the configuration of other images.
val bitmap =
SquareFrameTransform.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 DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
/**
* Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided.
*/
private fun Dimension.mosaicSize(): Int {
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -36,23 +36,13 @@ class ExtractorModule {
fun imageLoader(
@ApplicationContext context: Context,
songKeyer: SongKeyer,
parentKeyer: ParentKeyer,
songFactory: AlbumCoverFetcher.SongFactory,
albumFactory: AlbumCoverFetcher.AlbumFactory,
artistFactory: ArtistImageFetcher.Factory,
genreFactory: GenreImageFetcher.Factory,
playlistFactory: PlaylistImageFetcher.Factory
songFactory: SongCoverFetcher.Factory
) =
ImageLoader.Builder(context)
.components {
// Add fetchers for Music components to make them usable with ImageRequest
add(songKeyer)
add(parentKeyer)
add(songFactory)
add(albumFactory)
add(artistFactory)
add(genreFactory)
add(playlistFactory)
}
// Use our own crossfade with error drawable support
.transitionFactory(ErrorCrossfadeTransitionFactory())

View file

@ -1,118 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* Images.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.extractor
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Canvas
import android.util.Size as AndroidSize
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.SourceResult
import coil.size.Dimension
import coil.size.Size
import coil.size.pxOrElse
import java.io.InputStream
import okio.buffer
import okio.source
/**
* Utilities for constructing Artist and Genre images.
*
* @author Alexander Capehart (OxygenCobalt), Karim Abou Zeid
*/
object Images {
/**
* Create a mosaic image from the given image [InputStream]s. Derived from phonograph:
* https://github.com/kabouzeid/Phonograph
*
* @param context [Context] required to generate the mosaic.
* @param streams [InputStream]s of image data to create the mosaic out of.
* @param size [Size] of the Mosaic to generate.
*/
suspend fun createMosaic(
context: Context,
streams: List<InputStream>,
size: Size
): FetchResult? {
if (streams.size < 4) {
return streams.firstOrNull()?.let { stream ->
SourceResult(
source = ImageSource(stream.source().buffer(), context),
mimeType = null,
dataSource = DataSource.DISK)
}
}
// Use whatever size coil gives us to create the mosaic.
val mosaicSize = AndroidSize(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
}
// Run the bitmap through a transform to reflect the configuration of other images.
val bitmap =
SquareFrameTransform.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 DrawableResult(
drawable = mosaicBitmap.toDrawable(context.resources),
isSampled = true,
dataSource = DataSource.DISK)
}
/**
* Get an image dimension suitable to create a mosaic with.
*
* @return A pixel dimension derived from the given [Dimension] that will always be even,
* allowing it to be sub-divided.
*/
private fun Dimension.mosaicSize(): Int {
val size = pxOrElse { 512 }
return if (size.mod(2) > 0) size + 1 else size
}
}

View file

@ -35,6 +35,8 @@ import org.oxycblt.auxio.util.logD
* current selection state.
*
* @author Alexander Capehart (OxygenCobalt)
*
* TODO: Generalize this into a "view flipper" class and then derive it through other means?
*/
class SelectionToolbarOverlay
@JvmOverloads

View file

@ -147,7 +147,7 @@ private class UserLibraryImpl(
@Synchronized
override fun deletePlaylist(playlist: Playlist) {
playlistMap.remove(playlist.uid)
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
}
@Synchronized

View file

@ -36,12 +36,12 @@ interface UserModule {
@Module
@InstallIn(SingletonComponent::class)
class UserRoomModule {
@Provides fun playlistDao(database: PlaylistDatabase) = database.playlistDao()
@Provides fun playlistDao(database: UserMusicDatabase) = database.playlistDao()
@Provides
fun playlistDatabase(@ApplicationContext context: Context) =
fun userMusicDatabase(@ApplicationContext context: Context) =
Room.databaseBuilder(
context.applicationContext, PlaylistDatabase::class.java, "playlists.db")
context.applicationContext, UserMusicDatabase::class.java, "user_music.db")
.fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationFrom(0)
.fallbackToDestructiveMigrationOnDowngrade()

View file

@ -1,6 +1,6 @@
/*
* Copyright (c) 2023 Auxio Project
* PlaylistDatabase.kt is part of Auxio.
* UserMusicDatabase.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
@ -26,7 +26,7 @@ import org.oxycblt.auxio.music.Music
version = 28,
exportSchema = false)
@TypeConverters(Music.UID.TypeConverters::class)
abstract class PlaylistDatabase : RoomDatabase() {
abstract class UserMusicDatabase : RoomDatabase() {
abstract fun playlistDao(): PlaylistDao
}