diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 42f1d2e91..0d3bc3d9d 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -280,7 +280,13 @@ class PlaylistDetailFragment : private fun updateEditedPlaylist(editedPlaylist: List?) { // 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) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index 32bc3cd14..bd19c3a87 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -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)) diff --git a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt index 3f8652a7c..449f489fc 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/ImageGroup.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt index 3f9f58671..9c6b137d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/StyledImageView.kt @@ -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)) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index f557c6946..4e8e6d6d6 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -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 { - override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}" +class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : + Keyer> { + override fun key(data: List, options: Options) = + "${coverExtractor.computeAlbumOrdering(data).hashCode()}" } -// TODO: Key on the actual mosaic items used -class ParentKeyer @Inject constructor() : Keyer { - 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 { - 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 { - 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, 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 { - 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> { + override fun create(data: List, 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 { - 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 { - 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 Collection.mapAtMostNotNull( - n: Int, - transform: (T) -> R? -): List { - val until = min(size, n) - val out = mutableListOf() - - for (item in this) { - if (out.size >= until) { - break - } - - // Still have more data we can transform. - transform(item)?.let(out::add) - } - - return out -} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index a89931fba..6b1965c58 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -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, size: Size): FetchResult? { + val albums = computeAlbumOrdering(songs) + val streams = mutableListOf() + 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): Collection = + 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, 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 + } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt index 82ec32e07..5f4145479 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt @@ -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()) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt deleted file mode 100644 index 9be96132b..000000000 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Images.kt +++ /dev/null @@ -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 . - */ - -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, - 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 - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt index 05b203771..7db98bb97 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionToolbarOverlay.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 0760a6f4a..7962533de 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt index b4c7ef6a4..618babd4d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserModule.kt @@ -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() diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt similarity index 92% rename from app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt rename to app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt index 3377b172a..361d4f85f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserMusicDatabase.kt @@ -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 }