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:
parent
996c86b361
commit
5fff1bd0b3
12 changed files with 129 additions and 287 deletions
|
|
@ -280,7 +280,13 @@ class PlaylistDetailFragment :
|
||||||
|
|
||||||
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
|
private fun updateEditedPlaylist(editedPlaylist: List<Song>?) {
|
||||||
// TODO: Disable check item when no edits have been made
|
// 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)
|
playlistListAdapter.setEditing(editedPlaylist != null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ constructor(
|
||||||
target
|
target
|
||||||
.onConfigRequest(
|
.onConfigRequest(
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(song)
|
.data(listOf(song))
|
||||||
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
// Use ORIGINAL sizing, as we are not loading into any View-like component.
|
||||||
.size(Size.ORIGINAL)
|
.size(Size.ORIGINAL)
|
||||||
.transformations(SquareFrameTransform.INSTANCE))
|
.transformations(SquareFrameTransform.INSTANCE))
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,9 @@ import org.oxycblt.auxio.util.getInteger
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
*
|
*
|
||||||
* TODO: Rework content descriptions here
|
* 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
|
class ImageGroup
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
*
|
*
|
||||||
* @param song The [Song] to bind.
|
* @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.
|
* 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.
|
* 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 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
|
* @param descRes The content description string resource to use. The resource must have one
|
||||||
* field for the name of the [Music].
|
* 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 =
|
val request =
|
||||||
ImageRequest.Builder(context)
|
ImageRequest.Builder(context)
|
||||||
.data(music)
|
.data(parent.songs)
|
||||||
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
.error(StyledDrawable(context, context.getDrawableCompat(errorRes)))
|
||||||
.transformations(SquareFrameTransform.INSTANCE)
|
.transformations(SquareFrameTransform.INSTANCE)
|
||||||
.target(this)
|
.target(this)
|
||||||
|
|
@ -147,7 +147,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
||||||
CoilUtils.dispose(this)
|
CoilUtils.dispose(this)
|
||||||
imageLoader.enqueue(request)
|
imageLoader.enqueue(request)
|
||||||
// Update the content description to the specified resource.
|
// Update the content description to the specified resource.
|
||||||
contentDescription = context.getString(descRes, music.name.resolve(context))
|
contentDescription = context.getString(descRes, parent.name.resolve(context))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -18,163 +18,31 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.image.extractor
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import coil.ImageLoader
|
import coil.ImageLoader
|
||||||
import coil.decode.DataSource
|
|
||||||
import coil.decode.ImageSource
|
|
||||||
import coil.fetch.FetchResult
|
|
||||||
import coil.fetch.Fetcher
|
import coil.fetch.Fetcher
|
||||||
import coil.fetch.SourceResult
|
|
||||||
import coil.key.Keyer
|
import coil.key.Keyer
|
||||||
import coil.request.Options
|
import coil.request.Options
|
||||||
import coil.size.Size
|
import coil.size.Size
|
||||||
import javax.inject.Inject
|
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.*
|
import org.oxycblt.auxio.music.*
|
||||||
|
|
||||||
class SongKeyer @Inject constructor() : Keyer<Song> {
|
class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
override fun key(data: Song, options: Options) = "${data.album.uid}${data.album.hashCode()}"
|
Keyer<List<Song>> {
|
||||||
|
override fun key(data: List<Song>, options: Options) =
|
||||||
|
"${coverExtractor.computeAlbumOrdering(data).hashCode()}"
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Key on the actual mosaic items used
|
class SongCoverFetcher
|
||||||
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
|
|
||||||
private constructor(
|
private constructor(
|
||||||
private val context: Context,
|
private val songs: List<Song>,
|
||||||
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 size: Size,
|
private val size: Size,
|
||||||
private val artist: Artist
|
private val coverExtractor: CoverExtractor,
|
||||||
) : Fetcher {
|
) : Fetcher {
|
||||||
override suspend fun fetch(): FetchResult? {
|
override suspend fun fetch() = coverExtractor.extract(songs, size)
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Factory @Inject constructor(private val extractor: CoverExtractor) :
|
class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
|
||||||
Fetcher.Factory<Artist> {
|
Fetcher.Factory<List<Song>> {
|
||||||
override fun create(data: Artist, options: Options, imageLoader: ImageLoader) =
|
override fun create(data: List<Song>, options: Options, imageLoader: ImageLoader) =
|
||||||
ArtistImageFetcher(options.context, extractor, options.size, data)
|
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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -19,13 +19,26 @@
|
||||||
package org.oxycblt.auxio.image.extractor
|
package org.oxycblt.auxio.image.extractor
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
import android.media.MediaMetadataRetriever
|
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.MediaItem
|
||||||
import androidx.media3.common.MediaMetadata
|
import androidx.media3.common.MediaMetadata
|
||||||
import androidx.media3.exoplayer.MetadataRetriever
|
import androidx.media3.exoplayer.MetadataRetriever
|
||||||
import androidx.media3.exoplayer.source.MediaSource
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
import androidx.media3.extractor.metadata.flac.PictureFrame
|
import androidx.media3.extractor.metadata.flac.PictureFrame
|
||||||
import androidx.media3.extractor.metadata.id3.ApicFrame
|
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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.ByteArrayInputStream
|
import java.io.ByteArrayInputStream
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
|
|
@ -33,9 +46,12 @@ import javax.inject.Inject
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.guava.asDeferred
|
import kotlinx.coroutines.guava.asDeferred
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okio.buffer
|
||||||
|
import okio.source
|
||||||
import org.oxycblt.auxio.image.CoverMode
|
import org.oxycblt.auxio.image.CoverMode
|
||||||
import org.oxycblt.auxio.image.ImageSettings
|
import org.oxycblt.auxio.image.ImageSettings
|
||||||
import org.oxycblt.auxio.music.Album
|
import org.oxycblt.auxio.music.Album
|
||||||
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
|
@ -46,7 +62,28 @@ constructor(
|
||||||
private val imageSettings: ImageSettings,
|
private val imageSettings: ImageSettings,
|
||||||
private val mediaSourceFactory: MediaSource.Factory
|
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 {
|
try {
|
||||||
when (imageSettings.coverMode) {
|
when (imageSettings.coverMode) {
|
||||||
CoverMode.OFF -> null
|
CoverMode.OFF -> null
|
||||||
|
|
@ -125,4 +162,58 @@ constructor(
|
||||||
private suspend fun extractMediaStoreCover(album: Album) =
|
private suspend fun extractMediaStoreCover(album: Album) =
|
||||||
// Eliminate any chance that this blocking call might mess up the loading process
|
// Eliminate any chance that this blocking call might mess up the loading process
|
||||||
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(album.coverUri) }
|
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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,23 +36,13 @@ class ExtractorModule {
|
||||||
fun imageLoader(
|
fun imageLoader(
|
||||||
@ApplicationContext context: Context,
|
@ApplicationContext context: Context,
|
||||||
songKeyer: SongKeyer,
|
songKeyer: SongKeyer,
|
||||||
parentKeyer: ParentKeyer,
|
songFactory: SongCoverFetcher.Factory
|
||||||
songFactory: AlbumCoverFetcher.SongFactory,
|
|
||||||
albumFactory: AlbumCoverFetcher.AlbumFactory,
|
|
||||||
artistFactory: ArtistImageFetcher.Factory,
|
|
||||||
genreFactory: GenreImageFetcher.Factory,
|
|
||||||
playlistFactory: PlaylistImageFetcher.Factory
|
|
||||||
) =
|
) =
|
||||||
ImageLoader.Builder(context)
|
ImageLoader.Builder(context)
|
||||||
.components {
|
.components {
|
||||||
// Add fetchers for Music components to make them usable with ImageRequest
|
// Add fetchers for Music components to make them usable with ImageRequest
|
||||||
add(songKeyer)
|
add(songKeyer)
|
||||||
add(parentKeyer)
|
|
||||||
add(songFactory)
|
add(songFactory)
|
||||||
add(albumFactory)
|
|
||||||
add(artistFactory)
|
|
||||||
add(genreFactory)
|
|
||||||
add(playlistFactory)
|
|
||||||
}
|
}
|
||||||
// Use our own crossfade with error drawable support
|
// Use our own crossfade with error drawable support
|
||||||
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
.transitionFactory(ErrorCrossfadeTransitionFactory())
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -35,6 +35,8 @@ import org.oxycblt.auxio.util.logD
|
||||||
* current selection state.
|
* current selection state.
|
||||||
*
|
*
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*
|
||||||
|
* TODO: Generalize this into a "view flipper" class and then derive it through other means?
|
||||||
*/
|
*/
|
||||||
class SelectionToolbarOverlay
|
class SelectionToolbarOverlay
|
||||||
@JvmOverloads
|
@JvmOverloads
|
||||||
|
|
|
||||||
|
|
@ -147,7 +147,7 @@ private class UserLibraryImpl(
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
override fun deletePlaylist(playlist: Playlist) {
|
override fun deletePlaylist(playlist: Playlist) {
|
||||||
playlistMap.remove(playlist.uid)
|
requireNotNull(playlistMap.remove(playlist.uid)) { "Cannot remove invalid playlist" }
|
||||||
}
|
}
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
|
|
|
||||||
|
|
@ -36,12 +36,12 @@ interface UserModule {
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
class UserRoomModule {
|
class UserRoomModule {
|
||||||
@Provides fun playlistDao(database: PlaylistDatabase) = database.playlistDao()
|
@Provides fun playlistDao(database: UserMusicDatabase) = database.playlistDao()
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun playlistDatabase(@ApplicationContext context: Context) =
|
fun userMusicDatabase(@ApplicationContext context: Context) =
|
||||||
Room.databaseBuilder(
|
Room.databaseBuilder(
|
||||||
context.applicationContext, PlaylistDatabase::class.java, "playlists.db")
|
context.applicationContext, UserMusicDatabase::class.java, "user_music.db")
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
.fallbackToDestructiveMigrationFrom(0)
|
.fallbackToDestructiveMigrationFrom(0)
|
||||||
.fallbackToDestructiveMigrationOnDowngrade()
|
.fallbackToDestructiveMigrationOnDowngrade()
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
/*
|
/*
|
||||||
* Copyright (c) 2023 Auxio Project
|
* 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
|
* 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
|
* 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,
|
version = 28,
|
||||||
exportSchema = false)
|
exportSchema = false)
|
||||||
@TypeConverters(Music.UID.TypeConverters::class)
|
@TypeConverters(Music.UID.TypeConverters::class)
|
||||||
abstract class PlaylistDatabase : RoomDatabase() {
|
abstract class UserMusicDatabase : RoomDatabase() {
|
||||||
abstract fun playlistDao(): PlaylistDao
|
abstract fun playlistDao(): PlaylistDao
|
||||||
}
|
}
|
||||||
|
|
||||||
Loading…
Reference in a new issue