image: implement extractors and new cover data

This commit is contained in:
Alexander Capehart 2024-11-27 20:11:55 -07:00
parent 37697abfce
commit 7a7774a4db
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
24 changed files with 582 additions and 450 deletions

View file

@ -316,7 +316,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(song: Song) = fun bind(song: Song) =
bindImpl( bindImpl(
listOf(song.cover), song.cover,
context.getString(R.string.desc_album_cover, song.album.name), context.getString(R.string.desc_album_cover, song.album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -327,7 +327,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(album: Album) = fun bind(album: Album) =
bindImpl( bindImpl(
album.cover.all, album.cover,
context.getString(R.string.desc_album_cover, album.name), context.getString(R.string.desc_album_cover, album.name),
R.drawable.ic_album_24) R.drawable.ic_album_24)
@ -338,7 +338,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(artist: Artist) = fun bind(artist: Artist) =
bindImpl( bindImpl(
artist.cover.all, artist.cover,
context.getString(R.string.desc_artist_image, artist.name), context.getString(R.string.desc_artist_image, artist.name),
R.drawable.ic_artist_24) R.drawable.ic_artist_24)
@ -349,7 +349,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(genre: Genre) = fun bind(genre: Genre) =
bindImpl( bindImpl(
genre.cover.all, genre.cover,
context.getString(R.string.desc_genre_image, genre.name), context.getString(R.string.desc_genre_image, genre.name),
R.drawable.ic_genre_24) R.drawable.ic_genre_24)
@ -360,7 +360,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
*/ */
fun bind(playlist: Playlist) = fun bind(playlist: Playlist) =
bindImpl( bindImpl(
playlist.cover?.all ?: emptyList(), playlist.cover ?: Cover.nil(),
context.getString(R.string.desc_playlist_image, playlist.name), context.getString(R.string.desc_playlist_image, playlist.name),
R.drawable.ic_playlist_24) R.drawable.ic_playlist_24)
@ -372,12 +372,12 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
* @param errorRes The resource of the error drawable to use if the cover cannot be loaded. * @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) = fun bind(songs: List<Song>, desc: String, @DrawableRes errorRes: Int) =
bindImpl(Cover.order(songs), desc, errorRes) bindImpl(Cover.multi(songs), desc, errorRes)
private fun bindImpl(covers: List<Cover>, desc: String, @DrawableRes errorRes: Int) { private fun bindImpl(cover: Cover, desc: String, @DrawableRes errorRes: Int) {
val request = val request =
ImageRequest.Builder(context) ImageRequest.Builder(context)
.data(covers) .data(cover)
.error( .error(
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize) StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
.asImage()) .asImage())

View file

@ -18,29 +18,138 @@
package org.oxycblt.auxio.image.extractor package org.oxycblt.auxio.image.extractor
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.ImageLoader
import coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.Fetcher import coil3.fetch.Fetcher
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.key.Keyer import coil3.key.Keyer
import coil3.request.Options import coil3.request.Options
import coil3.size.Dimension
import coil3.size.Size import coil3.size.Size
import coil3.size.pxOrElse
import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.stack.extractor.CoverExtractor
class CoverKeyer @Inject constructor() : Keyer<Collection<Cover>> { class CoverKeyer @Inject constructor() : Keyer<Cover> {
override fun key(data: Collection<Cover>, options: Options) = override fun key(data: Cover, options: Options) = "${data.key}&${options.size}"
"${data.map { it.key }.hashCode()}"
} }
class CoverFetcher class CoverFetcher
private constructor( private constructor(
private val covers: Collection<Cover>, private val context: Context,
private val cover: Cover,
private val size: Size, private val size: Size,
private val coverExtractor: CoverExtractor, private val coverExtractor: CoverExtractor,
) : Fetcher { ) : Fetcher {
override suspend fun fetch() = coverExtractor.extract(covers, size) override suspend fun fetch(): FetchResult? {
val streams =
when (val cover = cover) {
is Cover.Single -> listOfNotNull(coverExtractor.extract(cover))
is Cover.Multi ->
buildList {
for (single in cover.all) {
coverExtractor.extract(single)?.let { add(it) }
if (size == 4) {
break
}
}
}
}
// 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.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a mosaic.
return createMosaic(streams, size).also {
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
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceFetchResult(
source = ImageSource(first.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(private val coverExtractor: CoverExtractor) : class Factory @Inject constructor(private val coverExtractor: CoverExtractor) :
Fetcher.Factory<Collection<Cover>> { Fetcher.Factory<Cover> {
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) = override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
CoverFetcher(data, options.size, coverExtractor) CoverFetcher(options.context, data, options.size, coverExtractor)
} }
} }

View file

@ -18,49 +18,35 @@
package org.oxycblt.auxio.image.extractor package org.oxycblt.auxio.image.extractor
import android.net.Uri
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
sealed interface Cover { sealed interface Cover {
val key: String val key: String
val mediaStoreCoverUri: Uri
/** class Single(song: Song) : Cover {
* The song has an embedded cover art we support, so we can operate with it on a per-song basis. override val key = "${song.uid}@${song.lastModified}"
*/ val uri = song.uri
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
Cover {
override val mediaStoreCoverUri = songCoverUri
override val key = perceptualHash
} }
/** class Multi(val all: List<Single>) : Cover {
* We couldn't find any embedded cover art ourselves, but the android system might have some override val key = "multi@${all.map { it.key }.hashCode()}"
* through a cover.jpg file or something similar.
*/
data class External(val albumCoverUri: Uri) : Cover {
override val mediaStoreCoverUri = albumCoverUri
override val key = albumCoverUri.toString()
} }
companion object { companion object {
private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
fun order(songs: Collection<Song>) = fun nil() = Multi(listOf())
fun single(song: Song) = Single(song)
fun multi(songs: Collection<Song>) = order(songs).run { Multi(this) }
private fun order(songs: Collection<Song>) =
FALLBACK_SORT.songs(songs) FALLBACK_SORT.songs(songs)
.map { it.cover } .groupBy { it.album }
.groupBy { it.key }
.entries .entries
.sortedByDescending { it.value.size } .sortedByDescending { it.value.size }
.map { it.value.first() } .map { it.value.first().cover }
}
}
data class ParentCover(val single: Cover, val all: List<Cover>) {
companion object {
fun from(song: Song, songs: Collection<Song>) = from(song.cover, songs)
fun from(src: Cover, songs: Collection<Song>) = ParentCover(src, Cover.order(songs))
} }
} }

View file

@ -1,255 +0,0 @@
/*
* Copyright (c) 2023 Auxio Project
* CoverExtractor.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.annotation.SuppressLint
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.common.Metadata
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 coil3.asImage
import coil3.decode.DataSource
import coil3.decode.ImageSource
import coil3.fetch.FetchResult
import coil3.fetch.ImageFetchResult
import coil3.fetch.SourceFetchResult
import coil3.size.Dimension
import coil3.size.Size
import coil3.size.pxOrElse
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.guava.asDeferred
import kotlinx.coroutines.withContext
import okio.FileSystem
import okio.buffer
import okio.source
import org.oxycblt.auxio.image.CoverMode
import org.oxycblt.auxio.image.ImageSettings
import org.oxycblt.auxio.music.Song
import timber.log.Timber as L
/**
* Provides functionality for extracting album cover information. Meant for internal use only.
*
* @author Alexander Capehart (OxygenCobalt)
*/
class CoverExtractor
@Inject
constructor(
@ApplicationContext private val context: Context,
private val imageSettings: ImageSettings,
private val mediaSourceFactory: MediaSource.Factory
) {
/**
* Extract an image (in the form of [FetchResult]) to represent the given [Song]s.
*
* @param covers The [Cover]s to load.
* @param size The [Size] of the image to load.
* @return If four distinct album covers could be extracted from the [Song]s, a [DrawableResult]
* will be returned of a mosaic composed of the first four loaded album covers. Otherwise, a
* [SourceResult] of one album cover will be returned.
*/
suspend fun extract(covers: Collection<Cover>, size: Size): FetchResult? {
val streams = mutableListOf<InputStream>()
for (cover in covers) {
openCoverInputStream(cover)?.let(streams::add)
// 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.
if (streams.size == 4) {
// Make sure we free the InputStreams once we've transformed them into a mosaic.
return createMosaic(streams, size).also {
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
// All but the first stream will be unused, free their resources
withContext(Dispatchers.IO) {
for (i in 1 until streams.size) {
streams[i].close()
}
}
return SourceFetchResult(
source = ImageSource(first.source().buffer(), FileSystem.SYSTEM, null),
mimeType = null,
dataSource = DataSource.DISK)
}
fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
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
// Vorbis picture comments.
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
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (cover) {
is Cover.Embedded ->
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
is Cover.External -> {
extractMediaStoreCover(cover)
}
}
} catch (e: Exception) {
L.e("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(cover: Cover.Embedded) =
extractExoplayerCover(cover)
?: extractAospMetadataCover(cover)
?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? =
MediaMetadataRetriever().run {
// This call is time-consuming but it also doesn't seem to hold up the main thread,
// so it's probably fine not to wrap it.rmt
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
// If its null [i.e there is no embedded cover], than just ignore it and move on
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(cover: Cover.Embedded): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
@SuppressLint("Recycle")
private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) {
// Coil will recycle this InputStream, so we don't need to worry about it.
context.contentResolver.openInputStream(cover.mediaStoreCoverUri)
}
/** 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
}
// 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
}
}

View file

@ -1,60 +0,0 @@
/*
* Copyright (c) 2024 Auxio Project
* DHash.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.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.ColorMatrix
import android.graphics.ColorMatrixColorFilter
import android.graphics.Paint
import java.math.BigInteger
@Suppress("UNUSED")
fun Bitmap.dHash(hashSize: Int = 16): String {
// Step 1: Resize the bitmap to a fixed size
val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true)
// Step 2: Convert the bitmap to grayscale
val grayBitmap =
Bitmap.createBitmap(resizedBitmap.width, resizedBitmap.height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(grayBitmap)
val paint = Paint()
val colorMatrix = ColorMatrix()
colorMatrix.setSaturation(0f)
val filter = ColorMatrixColorFilter(colorMatrix)
paint.colorFilter = filter
canvas.drawBitmap(resizedBitmap, 0f, 0f, paint)
// Step 3: Compute the difference between adjacent pixels
var hash = BigInteger.valueOf(0)
val one = BigInteger.valueOf(1)
for (y in 0 until hashSize) {
for (x in 0 until hashSize) {
val pixel1 = grayBitmap.getPixel(x, y)
val pixel2 = grayBitmap.getPixel(x + 1, y)
val diff = Color.red(pixel1) - Color.red(pixel2)
if (diff > 0) {
hash = hash.or(one.shl(y * hashSize + x))
}
}
}
return hash.toString(16)
}

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverRetriever.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.stack
import java.io.InputStream
import javax.inject.Inject
import org.oxycblt.auxio.image.stack.cache.CoverCache
import org.oxycblt.auxio.music.Song
interface CoverRetriever {
suspend fun retrieve(song: Song): InputStream?
}
class CoverRetrieverImpl
@Inject
constructor(private val coverCache: CoverCache, private val coverRetriever: CoverRetriever) :
CoverRetriever {
override suspend fun retrieve(song: Song) =
coverCache.read(song) ?: coverRetriever.retrieve(song)?.also { coverCache.write(song, it) }
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 Auxio Project
* StackModule.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.stack
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface StackModule {
@Binds fun coverRetriever(impl: CoverRetrieverImpl): CoverRetriever
}

View file

@ -1,21 +1,39 @@
/*
* Copyright (c) 2024 Auxio Project
* AppFiles.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.stack.cache package org.oxycblt.auxio.image.stack.cache
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
interface AppFiles { interface AppFiles {
suspend fun read(file: String): InputStream? suspend fun read(file: String): InputStream?
suspend fun write(file: String, inputStream: InputStream): Boolean suspend fun write(file: String, inputStream: InputStream): Boolean
} }
class AppFilesImpl @Inject constructor( class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) :
@ApplicationContext private val context: Context AppFiles {
) : AppFiles {
override suspend fun read(file: String): InputStream? = override suspend fun read(file: String): InputStream? =
withContext(context = Dispatchers.IO) { withContext(context = Dispatchers.IO) {
try { try {
@ -38,5 +56,4 @@ class AppFilesImpl @Inject constructor(
inputStream.close() inputStream.close()
} }
} }
}
}

View file

@ -1,7 +1,24 @@
/*
* Copyright (c) 2024 Auxio Project
* CacheModule.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.stack.cache package org.oxycblt.auxio.image.stack.cache
import android.content.Context import android.content.Context
import androidx.media3.datasource.cache.Cache
import androidx.room.Room import androidx.room.Room
import dagger.Binds import dagger.Binds
import dagger.Module import dagger.Module
@ -9,9 +26,6 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.oxycblt.auxio.music.stack.explore.cache.TagCache
import org.oxycblt.auxio.music.stack.explore.cache.TagCacheImpl
import org.oxycblt.auxio.music.stack.explore.cache.TagDatabase
import javax.inject.Singleton import javax.inject.Singleton
@Module @Module
@ -19,8 +33,6 @@ import javax.inject.Singleton
interface StackModule { interface StackModule {
@Binds fun appFiles(impl: AppFilesImpl): AppFiles @Binds fun appFiles(impl: AppFilesImpl): AppFiles
@Binds fun cache(impl: CoverCacheImpl): Cache
@Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash @Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache @Binds fun coverCache(cache: CoverCacheImpl): CoverCache
@ -32,7 +44,8 @@ class StoredCoversDatabaseModule {
@Singleton @Singleton
@Provides @Provides
fun database(@ApplicationContext context: Context) = fun database(@ApplicationContext context: Context) =
Room.databaseBuilder(context.applicationContext, StoredCoversDatabase::class.java, "stored_covers.db") Room.databaseBuilder(
context.applicationContext, StoredCoversDatabase::class.java, "stored_covers.db")
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
} }

View file

@ -1,68 +1,89 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverCache.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.stack.cache package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.os.Build import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.Song
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.Song
interface CoverCache { interface CoverCache {
suspend fun read(song: Song): InputStream? suspend fun read(song: Song): InputStream?
suspend fun write(song: Song, inputStream: InputStream): Boolean suspend fun write(song: Song, inputStream: InputStream): Boolean
} }
class CoverCacheImpl
class CoverCacheImpl @Inject constructor( @Inject
constructor(
private val storedCoversDao: StoredCoversDao, private val storedCoversDao: StoredCoversDao,
private val appFiles: AppFiles, private val appFiles: AppFiles,
private val perceptualHash: PerceptualHash private val perceptualHash: PerceptualHash
) : CoverCache { ) : CoverCache {
override suspend fun read(song: Song): InputStream? { override suspend fun read(song: Song): InputStream? {
val perceptualHash = storedCoversDao.getCoverFile(song.uid, song.lastModified) val perceptualHash =
?: return null storedCoversDao.getCoverFile(song.uid, song.lastModified) ?: return null
return appFiles.read(fileName(perceptualHash)) return appFiles.read(fileName(perceptualHash))
} }
override suspend fun write(song: Song, inputStream: InputStream): Boolean = withContext(Dispatchers.IO) { override suspend fun write(song: Song, inputStream: InputStream): Boolean =
val bitmap = BitmapFactory.decodeStream(inputStream) withContext(Dispatchers.IO) {
val perceptualHash = perceptualHash.hash(bitmap) val bitmap = BitmapFactory.decodeStream(inputStream)
val perceptualHash = perceptualHash.hash(bitmap)
// Compress bitmap down to webp into another inputstream // Compress bitmap down to webp into another inputstream
val compressedStream = ByteArrayOutputStream().use { outputStream -> val compressedStream =
bitmap.compress(COVER_CACHE_FORMAT, 80, outputStream) ByteArrayOutputStream().use { outputStream ->
ByteArrayInputStream(outputStream.toByteArray()) bitmap.compress(COVER_CACHE_FORMAT, 80, outputStream)
ByteArrayInputStream(outputStream.toByteArray())
}
val writeSuccess = appFiles.write(fileName(perceptualHash), compressedStream)
if (writeSuccess) {
storedCoversDao.setCoverFile(
StoredCover(
uid = song.uid,
lastModified = song.lastModified,
perceptualHash = perceptualHash))
}
writeSuccess
} }
val writeSuccess = appFiles.write(fileName(perceptualHash), compressedStream)
if (writeSuccess) {
storedCoversDao.setCoverFile(
StoredCover(
uid = song.uid,
lastModified = song.lastModified,
perceptualHash = perceptualHash
)
)
}
writeSuccess
}
private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png" private fun fileName(perceptualHash: String) = "cover_$perceptualHash.png"
private companion object { private companion object {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
val COVER_CACHE_FORMAT = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val COVER_CACHE_FORMAT =
Bitmap.CompressFormat.WEBP_LOSSY if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
} else { Bitmap.CompressFormat.WEBP_LOSSY
Bitmap.CompressFormat.WEBP } else {
} Bitmap.CompressFormat.WEBP
}
} }
} }

View file

@ -1,3 +1,21 @@
/*
* Copyright (c) 2024 Auxio Project
* PerceptualHash.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.stack.cache package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap import android.graphics.Bitmap
@ -45,5 +63,4 @@ class PerceptualHashImpl : PerceptualHash {
return hash.toString(16) return hash.toString(16)
} }
}
}

View file

@ -1,3 +1,21 @@
/*
* Copyright (c) 2024 Auxio Project
* StoredCoversDatabase.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.stack.cache package org.oxycblt.auxio.image.stack.cache
import androidx.room.Dao import androidx.room.Dao
@ -18,18 +36,18 @@ abstract class StoredCoversDatabase : RoomDatabase() {
@Dao @Dao
interface StoredCoversDao { interface StoredCoversDao {
@Query("SELECT perceptualHash FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified") @Query(
"SELECT perceptualHash FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
@TypeConverters(Music.UID.TypeConverters::class)
fun getCoverFile(uid: Music.UID, lastModified: Long): String? fun getCoverFile(uid: Music.UID, lastModified: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE) fun setCoverFile(storedCover: StoredCover)
fun setCoverFile(storedCover: StoredCover)
} }
@Entity @Entity
@TypeConverters(Music.UID.TypeConverters::class) @TypeConverters(Music.UID.TypeConverters::class)
data class StoredCover( data class StoredCover(
@PrimaryKey @PrimaryKey val uid: Music.UID,
val uid: Music.UID,
val lastModified: Long, val lastModified: Long,
val perceptualHash: String val perceptualHash: String
) )

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 Auxio Project
* AOSPCoverSource.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.stack.extractor
import android.media.MediaMetadataRetriever
import android.net.Uri
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AOSPCoverSource @Inject constructor() : CoverSource {
override suspend fun extract(fileUri: Uri): InputStream? {
val mediaMetadataRetriever = MediaMetadataRetriever()
val cover =
withContext(Dispatchers.IO) {
mediaMetadataRetriever.setDataSource(fileUri.toString())
mediaMetadataRetriever.embeddedPicture
} ?: return null
return cover.inputStream()
}
}

View file

@ -0,0 +1,47 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverExtractor.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.stack.extractor
import android.net.Uri
import java.io.InputStream
import javax.inject.Inject
import org.oxycblt.auxio.image.extractor.Cover
interface CoverExtractor {
suspend fun extract(cover: Cover.Single): InputStream?
}
data class CoverSources(val sources: List<CoverSource>)
interface CoverSource {
suspend fun extract(fileUri: Uri): InputStream?
}
class CoverExtractorImpl @Inject constructor(private val coverSources: CoverSources) :
CoverExtractor {
override suspend fun extract(cover: Cover.Single): InputStream? {
for (coverSource in coverSources.sources) {
val stream = coverSource.extract(cover.uri)
if (stream != null) {
return stream
}
}
return null
}
}

View file

@ -0,0 +1,86 @@
/*
* Copyright (c) 2024 Auxio Project
* ExoPlayerCoverSource.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.stack.extractor
import android.net.Uri
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
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 java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.guava.asDeferred
class ExoPlayerCoverSource
@Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) : CoverSource {
override suspend fun extract(fileUri: Uri): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(fileUri))
.asDeferred()
.await()
// The metadata extraction process of ExoPlayer results in a dump of all metadata
// it found, which must be iterated through.
val metadata = tracks[0].getFormat(0).metadata
if (metadata == null || metadata.length() == 0) {
// No (parsable) metadata. This is also expected.
return null
}
return findCoverDataInMetadata(metadata)
}
private fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
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
// Vorbis picture comments.
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
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
}
}
return stream
}
}

View file

@ -0,0 +1,39 @@
/*
* Copyright (c) 2024 Auxio Project
* ExtractorModule.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.stack.extractor
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
@Module
@InstallIn(SingletonComponent::class)
interface ExtractorModule {
@Binds fun coverExtractor(impl: CoverExtractorImpl): CoverExtractor
}
@Module
@InstallIn(SingletonComponent::class)
class CoverSourcesModule {
@Provides
fun coverSources(exoPlayerCoverSource: ExoPlayerCoverSource, aospCoverSource: AOSPCoverSource) =
CoverSources(listOf(exoPlayerCoverSource, aospCoverSource))
}

View file

@ -28,7 +28,6 @@ import kotlin.math.max
import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.image.extractor.ParentCover
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
@ -269,8 +268,6 @@ interface Song : Music {
* audio file in a way that is scoped-storage-safe. * audio file in a way that is scoped-storage-safe.
*/ */
val uri: Uri val uri: Uri
/** Useful information to quickly obtain the album cover. */
val cover: Cover
/** /**
* The [Path] to this audio file. This is only intended for display, [uri] should be favored * The [Path] to this audio file. This is only intended for display, [uri] should be favored
* instead for accessing the audio file. * instead for accessing the audio file.
@ -284,9 +281,12 @@ interface Song : Music {
val durationMs: Long val durationMs: Long
/** The ReplayGain adjustment to apply during playback. */ /** The ReplayGain adjustment to apply during playback. */
val replayGainAdjustment: ReplayGainAdjustment val replayGainAdjustment: ReplayGainAdjustment
/** The date last modified the audio file was last modified, as a unix epoch timestamp. */
val lastModified: Long val lastModified: Long
/** The date the audio file was added to the device, as a unix epoch timestamp. */ /** The date the audio file was added to the device, as a unix epoch timestamp. */
val dateAdded: Long val dateAdded: Long
/** Useful information to quickly obtain the album cover. */
val cover: Cover.Single
/** /**
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used * The parent [Album]. If the metadata did not specify an album, it's parent directory is used
* instead. * instead.
@ -319,8 +319,8 @@ interface Album : MusicParent {
* [ReleaseType.Album]. * [ReleaseType.Album].
*/ */
val releaseType: ReleaseType val releaseType: ReleaseType
/** Cover information from the template song used for the album. */ /** Cover information from album's songs. */
val cover: ParentCover val cover: Cover
/** The duration of all songs in the album, in milliseconds. */ /** The duration of all songs in the album, in milliseconds. */
val durationMs: Long val durationMs: Long
/** The earliest date a song in this album was added, as a unix epoch timestamp. */ /** The earliest date a song in this album was added, as a unix epoch timestamp. */
@ -350,7 +350,7 @@ interface Artist : MusicParent {
*/ */
val durationMs: Long? val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover val cover: Cover
/** The [Genre]s of this artist. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -366,7 +366,7 @@ interface Genre : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover val cover: Cover
} }
/** /**
@ -380,7 +380,7 @@ interface Playlist : MusicParent {
/** The total duration of the songs in this genre, in milliseconds. */ /** The total duration of the songs in this genre, in milliseconds. */
val durationMs: Long val durationMs: Long
/** Useful information to quickly obtain a (single) cover for a Genre. */ /** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: ParentCover? val cover: Cover
} }
/** /**

View file

@ -318,6 +318,7 @@ constructor(private val indexer: Indexer, private val musicSettings: MusicSettin
} }
override suspend fun index(worker: IndexingWorker, withCache: Boolean) { override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
L.d("Begin index [cache=$withCache]")
try { try {
indexImpl(withCache) indexImpl(withCache)
} catch (e: CancellationException) { } catch (e: CancellationException) {

View file

@ -114,7 +114,7 @@ fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescrip
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
.setSubtitle(artists.resolveNames(context)) .setSubtitle(artists.resolveNames(context))
.setDescription(album.name.resolve(context)) .setDescription(album.name.resolve(context))
.setIconUri(cover.mediaStoreCoverUri) // .setIconUri(cover.mediaStoreCoverUri)
.setMediaUri(uri) .setMediaUri(uri)
.setExtras(extras) .setExtras(extras)
.build() .build()
@ -134,7 +134,7 @@ fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
.setSubtitle(artists.resolveNames(context)) .setSubtitle(artists.resolveNames(context))
.setDescription(counts) .setDescription(counts)
.setIconUri(cover.single.mediaStoreCoverUri) // .setIconUri(cover.single.mediaStoreCoverUri)
.setExtras(extras) .setExtras(extras)
.build() .build()
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)
@ -162,7 +162,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
.setSubtitle(counts) .setSubtitle(counts)
.setDescription(genres.resolveNames(context)) .setDescription(genres.resolveNames(context))
.setIconUri(cover.single.mediaStoreCoverUri) // .setIconUri(cover.single.mediaStoreCoverUri)
.setExtras(extras) .setExtras(extras)
.build() .build()
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)
@ -182,7 +182,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
.setMediaId(mediaSessionUID.toString()) .setMediaId(mediaSessionUID.toString())
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
.setSubtitle(counts) .setSubtitle(counts)
.setIconUri(cover.single.mediaStoreCoverUri) // .setIconUri(cover.single.mediaStoreCoverUri)
.setExtras(extras) .setExtras(extras)
.build() .build()
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)
@ -203,7 +203,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
.setTitle(name.resolve(context)) .setTitle(name.resolve(context))
.setSubtitle(counts) .setSubtitle(counts)
.setDescription(durationMs.formatDurationDs(true)) .setDescription(durationMs.formatDurationDs(true))
.setIconUri(cover?.single?.mediaStoreCoverUri) // .setIconUri(cover?.single?.mediaStoreCoverUri)
.setExtras(extras) .setExtras(extras)
.build() .build()
return MediaItem(description, MediaItem.FLAG_BROWSABLE) return MediaItem(description, MediaItem.FLAG_BROWSABLE)

View file

@ -33,7 +33,6 @@ import androidx.media3.exoplayer.LoadingInfo
import androidx.media3.exoplayer.analytics.PlayerId import androidx.media3.exoplayer.analytics.PlayerId
import androidx.media3.exoplayer.source.MediaPeriod import androidx.media3.exoplayer.source.MediaPeriod
import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MediaSource.Factory
import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.upstream.Allocator import androidx.media3.exoplayer.upstream.Allocator
import androidx.media3.exoplayer.upstream.DefaultAllocator import androidx.media3.exoplayer.upstream.DefaultAllocator

View file

@ -19,7 +19,7 @@
package org.oxycblt.auxio.music.stack.interpret.model package org.oxycblt.auxio.music.stack.interpret.model
import kotlin.math.min import kotlin.math.min
import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.list.sort.Sort
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
@ -49,7 +49,6 @@ class SongImpl(linkedSong: LinkedSong) : Song {
override val disc = preSong.disc override val disc = preSong.disc
override val date = preSong.date override val date = preSong.date
override val uri = preSong.uri override val uri = preSong.uri
override val cover = preSong.cover
override val path = preSong.path override val path = preSong.path
override val mimeType = preSong.mimeType override val mimeType = preSong.mimeType
override val size = preSong.size override val size = preSong.size
@ -57,6 +56,7 @@ class SongImpl(linkedSong: LinkedSong) : Song {
override val replayGainAdjustment = preSong.replayGainAdjustment override val replayGainAdjustment = preSong.replayGainAdjustment
override val lastModified = preSong.lastModified override val lastModified = preSong.lastModified
override val dateAdded = preSong.dateAdded override val dateAdded = preSong.dateAdded
override val cover = Cover.single(this)
override val album = linkedSong.album.resolve(this) override val album = linkedSong.album.resolve(this)
override val artists = linkedSong.artists.resolve(this) override val artists = linkedSong.artists.resolve(this)
override val genres = linkedSong.genres.resolve(this) override val genres = linkedSong.genres.resolve(this)
@ -93,7 +93,7 @@ class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
override val releaseType = preAlbum.releaseType override val releaseType = preAlbum.releaseType
override var durationMs = 0L override var durationMs = 0L
override var dateAdded = 0L override var dateAdded = 0L
override lateinit var cover: ParentCover override var cover = Cover.nil()
override var dates: Date.Range? = null override var dates: Date.Range? = null
override val artists = linkedAlbum.artists.resolve(this) override val artists = linkedAlbum.artists.resolve(this)
@ -123,9 +123,7 @@ class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
} }
} }
fun finalize() { fun finalize() {}
cover = ParentCover(songs.first().cover, songs.map { it.cover })
}
} }
/** /**
@ -148,7 +146,7 @@ class ArtistImpl(private val preArtist: PreArtist) : Artist {
override var genres = listOf<Genre>() override var genres = listOf<Genre>()
override var durationMs = 0L override var durationMs = 0L
override lateinit var cover: ParentCover override var cover = Cover.nil()
private var hashCode = 31 * uid.hashCode() + preArtist.hashCode() private var hashCode = 31 * uid.hashCode() + preArtist.hashCode()
@ -179,7 +177,7 @@ class ArtistImpl(private val preArtist: PreArtist) : Artist {
fun finalize() { fun finalize() {
explicitAlbums.addAll(albums) explicitAlbums.addAll(albums)
implicitAlbums.addAll(songs.mapTo(mutableSetOf()) { it.album } - albums.toSet()) implicitAlbums.addAll(songs.mapTo(mutableSetOf()) { it.album } - albums.toSet())
cover = ParentCover(songs.first().cover, songs.map { it.cover }) cover = Cover.multi(songs)
genres = genres =
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING) Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
.genres(songs.flatMapTo(mutableSetOf()) { it.genres }) .genres(songs.flatMapTo(mutableSetOf()) { it.genres })
@ -199,7 +197,7 @@ class GenreImpl(private val preGenre: PreGenre) : Genre {
override val songs = mutableSetOf<Song>() override val songs = mutableSetOf<Song>()
override val artists = mutableSetOf<Artist>() override val artists = mutableSetOf<Artist>()
override var durationMs = 0L override var durationMs = 0L
override lateinit var cover: ParentCover override var cover = Cover.nil()
private var hashCode = uid.hashCode() private var hashCode = uid.hashCode()
@ -214,10 +212,10 @@ class GenreImpl(private val preGenre: PreGenre) : Genre {
songs.add(song) songs.add(song)
durationMs += song.durationMs durationMs += song.durationMs
hashCode = 31 * hashCode + song.hashCode() hashCode = 31 * hashCode + song.hashCode()
cover = ParentCover(song.cover, emptyList())
} }
fun finalize() { fun finalize() {
cover = Cover.multi(songs)
artists.addAll(songs.flatMapTo(mutableSetOf()) { it.artists }) artists.addAll(songs.flatMapTo(mutableSetOf()) { it.artists })
} }
} }

View file

@ -18,7 +18,7 @@
package org.oxycblt.auxio.music.stack.interpret.model package org.oxycblt.auxio.music.stack.interpret.model
import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Playlist
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.stack.interpret.linker.LinkedPlaylist import org.oxycblt.auxio.music.stack.interpret.linker.LinkedPlaylist
@ -29,7 +29,7 @@ class PlaylistImpl(linkedPlaylist: LinkedPlaylist) : Playlist {
override val name: Name.Known = prePlaylist.name override val name: Name.Known = prePlaylist.name
override val songs = linkedPlaylist.songs.resolve(this) override val songs = linkedPlaylist.songs.resolve(this)
override val durationMs = songs.sumOf { it.durationMs } override val durationMs = songs.sumOf { it.durationMs }
override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) } override val cover = Cover.multi(songs)
private var hashCode = uid.hashCode() private var hashCode = uid.hashCode()
init { init {

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.stack.interpret.prepare
import android.net.Uri import android.net.Uri
import java.util.UUID import java.util.UUID
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Music
import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.MusicType
import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Date
@ -41,7 +40,6 @@ data class PreSong(
val disc: Disc?, val disc: Disc?,
val date: Date?, val date: Date?,
val uri: Uri, val uri: Uri,
val cover: Cover,
val path: Path, val path: Path,
val mimeType: MimeType, val mimeType: MimeType,
val size: Long, val size: Long,

View file

@ -22,7 +22,6 @@ import javax.inject.Inject
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Disc
import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.Name
import org.oxycblt.auxio.music.info.ReleaseType import org.oxycblt.auxio.music.info.ReleaseType
@ -70,7 +69,6 @@ class PreparerImpl @Inject constructor() : Preparer {
disc = audioFile.disc?.let { Disc(it, audioFile.subtitle) }, disc = audioFile.disc?.let { Disc(it, audioFile.subtitle) },
date = audioFile.date, date = audioFile.date,
uri = uri, uri = uri,
cover = inferCover(audioFile),
path = need(audioFile, "path", audioFile.deviceFile.path), path = need(audioFile, "path", audioFile.deviceFile.path),
mimeType = mimeType =
MimeType(need(audioFile, "mime type", audioFile.deviceFile.mimeType), null), MimeType(need(audioFile, "mime type", audioFile.deviceFile.mimeType), null),
@ -92,10 +90,6 @@ class PreparerImpl @Inject constructor() : Preparer {
private fun <T> need(audioFile: AudioFile, what: String, value: T?) = private fun <T> need(audioFile: AudioFile, what: String, value: T?) =
requireNotNull(value) { "Invalid $what for song ${audioFile.deviceFile.path}: No $what" } requireNotNull(value) { "Invalid $what for song ${audioFile.deviceFile.path}: No $what" }
private fun inferCover(audioFile: AudioFile): Cover {
return Cover.Embedded(audioFile.deviceFile.uri, audioFile.deviceFile.uri, "")
}
private fun makePreAlbum( private fun makePreAlbum(
audioFile: AudioFile, audioFile: AudioFile,
individualPreArtists: List<PreArtist>, individualPreArtists: List<PreArtist>,