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) =
|
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())
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
|
@ -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.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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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>,
|
||||||
|
|
Loading…
Reference in a new issue