image: implement extractors and new cover data
This commit is contained in:
parent
37697abfce
commit
7a7774a4db
24 changed files with 582 additions and 450 deletions
|
@ -316,7 +316,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(song: Song) =
|
||||
bindImpl(
|
||||
listOf(song.cover),
|
||||
song.cover,
|
||||
context.getString(R.string.desc_album_cover, song.album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -327,7 +327,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(album: Album) =
|
||||
bindImpl(
|
||||
album.cover.all,
|
||||
album.cover,
|
||||
context.getString(R.string.desc_album_cover, album.name),
|
||||
R.drawable.ic_album_24)
|
||||
|
||||
|
@ -338,7 +338,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(artist: Artist) =
|
||||
bindImpl(
|
||||
artist.cover.all,
|
||||
artist.cover,
|
||||
context.getString(R.string.desc_artist_image, artist.name),
|
||||
R.drawable.ic_artist_24)
|
||||
|
||||
|
@ -349,7 +349,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(genre: Genre) =
|
||||
bindImpl(
|
||||
genre.cover.all,
|
||||
genre.cover,
|
||||
context.getString(R.string.desc_genre_image, genre.name),
|
||||
R.drawable.ic_genre_24)
|
||||
|
||||
|
@ -360,7 +360,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
*/
|
||||
fun bind(playlist: Playlist) =
|
||||
bindImpl(
|
||||
playlist.cover?.all ?: emptyList(),
|
||||
playlist.cover ?: Cover.nil(),
|
||||
context.getString(R.string.desc_playlist_image, playlist.name),
|
||||
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.
|
||||
*/
|
||||
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 =
|
||||
ImageRequest.Builder(context)
|
||||
.data(covers)
|
||||
.data(cover)
|
||||
.error(
|
||||
StyledDrawable(context, context.getDrawableCompat(errorRes), iconSize)
|
||||
.asImage())
|
||||
|
|
|
@ -18,29 +18,138 @@
|
|||
|
||||
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.asImage
|
||||
import coil3.decode.DataSource
|
||||
import coil3.decode.ImageSource
|
||||
import coil3.fetch.FetchResult
|
||||
import coil3.fetch.Fetcher
|
||||
import coil3.fetch.ImageFetchResult
|
||||
import coil3.fetch.SourceFetchResult
|
||||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import coil3.size.Dimension
|
||||
import coil3.size.Size
|
||||
import coil3.size.pxOrElse
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import 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>> {
|
||||
override fun key(data: Collection<Cover>, options: Options) =
|
||||
"${data.map { it.key }.hashCode()}"
|
||||
class CoverKeyer @Inject constructor() : Keyer<Cover> {
|
||||
override fun key(data: Cover, options: Options) = "${data.key}&${options.size}"
|
||||
}
|
||||
|
||||
class CoverFetcher
|
||||
private constructor(
|
||||
private val covers: Collection<Cover>,
|
||||
private val context: Context,
|
||||
private val cover: Cover,
|
||||
private val size: Size,
|
||||
private val coverExtractor: CoverExtractor,
|
||||
) : 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) :
|
||||
Fetcher.Factory<Collection<Cover>> {
|
||||
override fun create(data: Collection<Cover>, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(data, options.size, coverExtractor)
|
||||
Fetcher.Factory<Cover> {
|
||||
override fun create(data: Cover, options: Options, imageLoader: ImageLoader) =
|
||||
CoverFetcher(options.context, data, options.size, coverExtractor)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,49 +18,35 @@
|
|||
|
||||
package org.oxycblt.auxio.image.extractor
|
||||
|
||||
import android.net.Uri
|
||||
import org.oxycblt.auxio.list.sort.Sort
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
sealed interface Cover {
|
||||
val key: String
|
||||
val mediaStoreCoverUri: Uri
|
||||
|
||||
/**
|
||||
* The song has an embedded cover art we support, so we can operate with it on a per-song basis.
|
||||
*/
|
||||
data class Embedded(val songCoverUri: Uri, val songUri: Uri, val perceptualHash: String) :
|
||||
Cover {
|
||||
override val mediaStoreCoverUri = songCoverUri
|
||||
override val key = perceptualHash
|
||||
class Single(song: Song) : Cover {
|
||||
override val key = "${song.uid}@${song.lastModified}"
|
||||
val uri = song.uri
|
||||
}
|
||||
|
||||
/**
|
||||
* We couldn't find any embedded cover art ourselves, but the android system might have some
|
||||
* through a cover.jpg file or something similar.
|
||||
*/
|
||||
data class External(val albumCoverUri: Uri) : Cover {
|
||||
override val mediaStoreCoverUri = albumCoverUri
|
||||
override val key = albumCoverUri.toString()
|
||||
class Multi(val all: List<Single>) : Cover {
|
||||
override val key = "multi@${all.map { it.key }.hashCode()}"
|
||||
}
|
||||
|
||||
companion object {
|
||||
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)
|
||||
.map { it.cover }
|
||||
.groupBy { it.key }
|
||||
.groupBy { it.album }
|
||||
.entries
|
||||
.sortedByDescending { it.value.size }
|
||||
.map { it.value.first() }
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
.map { it.value.first().cover }
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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) }
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface AppFiles {
|
||||
suspend fun read(file: String): InputStream?
|
||||
|
||||
suspend fun write(file: String, inputStream: InputStream): Boolean
|
||||
}
|
||||
|
||||
class AppFilesImpl @Inject constructor(
|
||||
@ApplicationContext private val context: Context
|
||||
) : AppFiles {
|
||||
class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
AppFiles {
|
||||
override suspend fun read(file: String): InputStream? =
|
||||
withContext(context = Dispatchers.IO) {
|
||||
try {
|
||||
|
@ -38,5 +56,4 @@ class AppFilesImpl @Inject constructor(
|
|||
inputStream.close()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import android.content.Context
|
||||
import androidx.media3.datasource.cache.Cache
|
||||
import androidx.room.Room
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
|
@ -9,9 +26,6 @@ import dagger.Provides
|
|||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
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
|
||||
|
||||
@Module
|
||||
|
@ -19,8 +33,6 @@ import javax.inject.Singleton
|
|||
interface StackModule {
|
||||
@Binds fun appFiles(impl: AppFilesImpl): AppFiles
|
||||
|
||||
@Binds fun cache(impl: CoverCacheImpl): Cache
|
||||
|
||||
@Binds fun perceptualHash(perceptualHash: PerceptualHashImpl): PerceptualHash
|
||||
|
||||
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache
|
||||
|
@ -32,7 +44,8 @@ class StoredCoversDatabaseModule {
|
|||
@Singleton
|
||||
@Provides
|
||||
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()
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
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.ByteArrayOutputStream
|
||||
import java.io.InputStream
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.oxycblt.auxio.music.Song
|
||||
|
||||
interface CoverCache {
|
||||
suspend fun read(song: Song): InputStream?
|
||||
|
||||
suspend fun write(song: Song, inputStream: InputStream): Boolean
|
||||
}
|
||||
|
||||
|
||||
class CoverCacheImpl @Inject constructor(
|
||||
class CoverCacheImpl
|
||||
@Inject
|
||||
constructor(
|
||||
private val storedCoversDao: StoredCoversDao,
|
||||
private val appFiles: AppFiles,
|
||||
private val perceptualHash: PerceptualHash
|
||||
) : CoverCache {
|
||||
|
||||
override suspend fun read(song: Song): InputStream? {
|
||||
val perceptualHash = storedCoversDao.getCoverFile(song.uid, song.lastModified)
|
||||
?: return null
|
||||
val perceptualHash =
|
||||
storedCoversDao.getCoverFile(song.uid, song.lastModified) ?: return null
|
||||
|
||||
return appFiles.read(fileName(perceptualHash))
|
||||
}
|
||||
|
||||
override suspend fun write(song: Song, inputStream: InputStream): Boolean = withContext(Dispatchers.IO) {
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
val perceptualHash = perceptualHash.hash(bitmap)
|
||||
override suspend fun write(song: Song, inputStream: InputStream): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||
val perceptualHash = perceptualHash.hash(bitmap)
|
||||
|
||||
// Compress bitmap down to webp into another inputstream
|
||||
val compressedStream = ByteArrayOutputStream().use { outputStream ->
|
||||
bitmap.compress(COVER_CACHE_FORMAT, 80, outputStream)
|
||||
ByteArrayInputStream(outputStream.toByteArray())
|
||||
// Compress bitmap down to webp into another inputstream
|
||||
val compressedStream =
|
||||
ByteArrayOutputStream().use { outputStream ->
|
||||
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 companion object {
|
||||
@Suppress("DEPRECATION")
|
||||
val COVER_CACHE_FORMAT = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
val COVER_CACHE_FORMAT =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
Bitmap.CompressFormat.WEBP_LOSSY
|
||||
} else {
|
||||
Bitmap.CompressFormat.WEBP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import android.graphics.Bitmap
|
||||
|
@ -45,5 +63,4 @@ class PerceptualHashImpl : PerceptualHash {
|
|||
|
||||
return hash.toString(16)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
||||
import androidx.room.Dao
|
||||
|
@ -18,18 +36,18 @@ abstract class StoredCoversDatabase : RoomDatabase() {
|
|||
|
||||
@Dao
|
||||
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?
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
fun setCoverFile(storedCover: StoredCover)
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) fun setCoverFile(storedCover: StoredCover)
|
||||
}
|
||||
|
||||
@Entity
|
||||
@TypeConverters(Music.UID.TypeConverters::class)
|
||||
data class StoredCover(
|
||||
@PrimaryKey
|
||||
val uid: Music.UID,
|
||||
@PrimaryKey val uid: Music.UID,
|
||||
val lastModified: Long,
|
||||
val perceptualHash: String
|
||||
)
|
||||
)
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -28,7 +28,6 @@ import kotlin.math.max
|
|||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.image.extractor.ParentCover
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
import org.oxycblt.auxio.music.info.Disc
|
||||
|
@ -269,8 +268,6 @@ interface Song : Music {
|
|||
* audio file in a way that is scoped-storage-safe.
|
||||
*/
|
||||
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
|
||||
* instead for accessing the audio file.
|
||||
|
@ -284,9 +281,12 @@ interface Song : Music {
|
|||
val durationMs: Long
|
||||
/** The ReplayGain adjustment to apply during playback. */
|
||||
val replayGainAdjustment: ReplayGainAdjustment
|
||||
/** The date last modified the audio file was last modified, as a unix epoch timestamp. */
|
||||
val lastModified: Long
|
||||
/** The date the audio file was added to the device, as a unix epoch timestamp. */
|
||||
val dateAdded: Long
|
||||
/** Useful information to quickly obtain the album cover. */
|
||||
val cover: Cover.Single
|
||||
/**
|
||||
* The parent [Album]. If the metadata did not specify an album, it's parent directory is used
|
||||
* instead.
|
||||
|
@ -319,8 +319,8 @@ interface Album : MusicParent {
|
|||
* [ReleaseType.Album].
|
||||
*/
|
||||
val releaseType: ReleaseType
|
||||
/** Cover information from the template song used for the album. */
|
||||
val cover: ParentCover
|
||||
/** Cover information from album's songs. */
|
||||
val cover: Cover
|
||||
/** The duration of all songs in the album, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** The earliest date a song in this album was added, as a unix epoch timestamp. */
|
||||
|
@ -350,7 +350,7 @@ interface Artist : MusicParent {
|
|||
*/
|
||||
val durationMs: Long?
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val cover: ParentCover
|
||||
val cover: Cover
|
||||
/** The [Genre]s of this artist. */
|
||||
val genres: List<Genre>
|
||||
}
|
||||
|
@ -366,7 +366,7 @@ interface Genre : MusicParent {
|
|||
/** The total duration of the songs in this genre, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val cover: ParentCover
|
||||
val cover: Cover
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -380,7 +380,7 @@ interface Playlist : MusicParent {
|
|||
/** The total duration of the songs in this genre, in milliseconds. */
|
||||
val durationMs: Long
|
||||
/** Useful information to quickly obtain a (single) cover for a Genre. */
|
||||
val cover: ParentCover?
|
||||
val cover: Cover
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -318,6 +318,7 @@ constructor(private val indexer: Indexer, private val musicSettings: MusicSettin
|
|||
}
|
||||
|
||||
override suspend fun index(worker: IndexingWorker, withCache: Boolean) {
|
||||
L.d("Begin index [cache=$withCache]")
|
||||
try {
|
||||
indexImpl(withCache)
|
||||
} catch (e: CancellationException) {
|
||||
|
|
|
@ -114,7 +114,7 @@ fun Song.toMediaDescription(context: Context, vararg sugar: Sugar): MediaDescrip
|
|||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setDescription(album.name.resolve(context))
|
||||
.setIconUri(cover.mediaStoreCoverUri)
|
||||
// .setIconUri(cover.mediaStoreCoverUri)
|
||||
.setMediaUri(uri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
|
@ -134,7 +134,7 @@ fun Album.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
|||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(artists.resolveNames(context))
|
||||
.setDescription(counts)
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
// .setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
|
@ -162,7 +162,7 @@ fun Artist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
|||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setDescription(genres.resolveNames(context))
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
// .setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
|
@ -182,7 +182,7 @@ fun Genre.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
|||
.setMediaId(mediaSessionUID.toString())
|
||||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setIconUri(cover.single.mediaStoreCoverUri)
|
||||
// .setIconUri(cover.single.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
|
@ -203,7 +203,7 @@ fun Playlist.toMediaItem(context: Context, vararg sugar: Sugar): MediaItem {
|
|||
.setTitle(name.resolve(context))
|
||||
.setSubtitle(counts)
|
||||
.setDescription(durationMs.formatDurationDs(true))
|
||||
.setIconUri(cover?.single?.mediaStoreCoverUri)
|
||||
// .setIconUri(cover?.single?.mediaStoreCoverUri)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
return MediaItem(description, MediaItem.FLAG_BROWSABLE)
|
||||
|
|
|
@ -33,7 +33,6 @@ import androidx.media3.exoplayer.LoadingInfo
|
|||
import androidx.media3.exoplayer.analytics.PlayerId
|
||||
import androidx.media3.exoplayer.source.MediaPeriod
|
||||
import androidx.media3.exoplayer.source.MediaSource
|
||||
import androidx.media3.exoplayer.source.MediaSource.Factory
|
||||
import androidx.media3.exoplayer.source.TrackGroupArray
|
||||
import androidx.media3.exoplayer.upstream.Allocator
|
||||
import androidx.media3.exoplayer.upstream.DefaultAllocator
|
||||
|
|
|
@ -19,7 +19,7 @@
|
|||
package org.oxycblt.auxio.music.stack.interpret.model
|
||||
|
||||
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.music.Album
|
||||
import org.oxycblt.auxio.music.Artist
|
||||
|
@ -49,7 +49,6 @@ class SongImpl(linkedSong: LinkedSong) : Song {
|
|||
override val disc = preSong.disc
|
||||
override val date = preSong.date
|
||||
override val uri = preSong.uri
|
||||
override val cover = preSong.cover
|
||||
override val path = preSong.path
|
||||
override val mimeType = preSong.mimeType
|
||||
override val size = preSong.size
|
||||
|
@ -57,6 +56,7 @@ class SongImpl(linkedSong: LinkedSong) : Song {
|
|||
override val replayGainAdjustment = preSong.replayGainAdjustment
|
||||
override val lastModified = preSong.lastModified
|
||||
override val dateAdded = preSong.dateAdded
|
||||
override val cover = Cover.single(this)
|
||||
override val album = linkedSong.album.resolve(this)
|
||||
override val artists = linkedSong.artists.resolve(this)
|
||||
override val genres = linkedSong.genres.resolve(this)
|
||||
|
@ -93,7 +93,7 @@ class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
|
|||
override val releaseType = preAlbum.releaseType
|
||||
override var durationMs = 0L
|
||||
override var dateAdded = 0L
|
||||
override lateinit var cover: ParentCover
|
||||
override var cover = Cover.nil()
|
||||
override var dates: Date.Range? = null
|
||||
|
||||
override val artists = linkedAlbum.artists.resolve(this)
|
||||
|
@ -123,9 +123,7 @@ class AlbumImpl(linkedAlbum: LinkedAlbum) : Album {
|
|||
}
|
||||
}
|
||||
|
||||
fun finalize() {
|
||||
cover = ParentCover(songs.first().cover, songs.map { it.cover })
|
||||
}
|
||||
fun finalize() {}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,7 +146,7 @@ class ArtistImpl(private val preArtist: PreArtist) : Artist {
|
|||
|
||||
override var genres = listOf<Genre>()
|
||||
override var durationMs = 0L
|
||||
override lateinit var cover: ParentCover
|
||||
override var cover = Cover.nil()
|
||||
|
||||
private var hashCode = 31 * uid.hashCode() + preArtist.hashCode()
|
||||
|
||||
|
@ -179,7 +177,7 @@ class ArtistImpl(private val preArtist: PreArtist) : Artist {
|
|||
fun finalize() {
|
||||
explicitAlbums.addAll(albums)
|
||||
implicitAlbums.addAll(songs.mapTo(mutableSetOf()) { it.album } - albums.toSet())
|
||||
cover = ParentCover(songs.first().cover, songs.map { it.cover })
|
||||
cover = Cover.multi(songs)
|
||||
genres =
|
||||
Sort(Sort.Mode.ByName, Sort.Direction.ASCENDING)
|
||||
.genres(songs.flatMapTo(mutableSetOf()) { it.genres })
|
||||
|
@ -199,7 +197,7 @@ class GenreImpl(private val preGenre: PreGenre) : Genre {
|
|||
override val songs = mutableSetOf<Song>()
|
||||
override val artists = mutableSetOf<Artist>()
|
||||
override var durationMs = 0L
|
||||
override lateinit var cover: ParentCover
|
||||
override var cover = Cover.nil()
|
||||
|
||||
private var hashCode = uid.hashCode()
|
||||
|
||||
|
@ -214,10 +212,10 @@ class GenreImpl(private val preGenre: PreGenre) : Genre {
|
|||
songs.add(song)
|
||||
durationMs += song.durationMs
|
||||
hashCode = 31 * hashCode + song.hashCode()
|
||||
cover = ParentCover(song.cover, emptyList())
|
||||
}
|
||||
|
||||
fun finalize() {
|
||||
cover = Cover.multi(songs)
|
||||
artists.addAll(songs.flatMapTo(mutableSetOf()) { it.artists })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
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.info.Name
|
||||
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 songs = linkedPlaylist.songs.resolve(this)
|
||||
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()
|
||||
|
||||
init {
|
||||
|
|
|
@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.stack.interpret.prepare
|
|||
|
||||
import android.net.Uri
|
||||
import java.util.UUID
|
||||
import org.oxycblt.auxio.image.extractor.Cover
|
||||
import org.oxycblt.auxio.music.Music
|
||||
import org.oxycblt.auxio.music.MusicType
|
||||
import org.oxycblt.auxio.music.info.Date
|
||||
|
@ -41,7 +40,6 @@ data class PreSong(
|
|||
val disc: Disc?,
|
||||
val date: Date?,
|
||||
val uri: Uri,
|
||||
val cover: Cover,
|
||||
val path: Path,
|
||||
val mimeType: MimeType,
|
||||
val size: Long,
|
||||
|
|
|
@ -22,7 +22,6 @@ import javax.inject.Inject
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
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.Name
|
||||
import org.oxycblt.auxio.music.info.ReleaseType
|
||||
|
@ -70,7 +69,6 @@ class PreparerImpl @Inject constructor() : Preparer {
|
|||
disc = audioFile.disc?.let { Disc(it, audioFile.subtitle) },
|
||||
date = audioFile.date,
|
||||
uri = uri,
|
||||
cover = inferCover(audioFile),
|
||||
path = need(audioFile, "path", audioFile.deviceFile.path),
|
||||
mimeType =
|
||||
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?) =
|
||||
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(
|
||||
audioFile: AudioFile,
|
||||
individualPreArtists: List<PreArtist>,
|
||||
|
|
Loading…
Reference in a new issue