image: basic per-song album covers

Without any good caching support, so this will immediately break down.
This commit is contained in:
Alexander Capehart 2024-04-19 22:16:22 -06:00
parent 8b7b916489
commit bd330f0c71
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 58 additions and 47 deletions

View file

@ -1,6 +1,6 @@
/* /*
* Copyright (c) 2023 Auxio Project * Copyright (c) 2023 Auxio Project
* CoverUri.kt is part of Auxio. * Cover.kt is part of Auxio.
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -24,9 +24,9 @@ import android.net.Uri
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
* images. * images.
* *
* @param mediaStore The album cover [Uri] obtained from MediaStore. * @param mediaStoreUri The album cover [Uri] obtained from MediaStore.
* @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain * @param song The [Uri] of the first song (by track) of the album, which can also be used to obtain
* an album cover. * an album cover.
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
data class CoverUri(val mediaStore: Uri, val song: Uri) data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri)

View file

@ -77,10 +77,10 @@ constructor(
* [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned.
*/ */
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? { suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? {
val albums = computeCoverOrdering(songs) val covers = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>() val streams = mutableListOf<InputStream>()
for (album in albums) { for (cover in covers) {
openCoverInputStream(album)?.let(streams::add) openCoverInputStream(cover)?.let(streams::add)
// We don't immediately check for mosaic feasibility from album count alone, as that // 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 // does not factor in InputStreams failing to load. Instead, only check once we
// definitely have image data to use. // definitely have image data to use.
@ -116,40 +116,33 @@ constructor(
* by their names. "Representation" is defined by how many [Song]s were found to be linked to * by their names. "Representation" is defined by how many [Song]s were found to be linked to
* the given [Album] in the given [Song] list. * the given [Album] in the given [Song] list.
*/ */
fun computeCoverOrdering(songs: Collection<Song>): List<Album> { fun computeCoverOrdering(songs: Collection<Song>) =
// TODO: Start short-circuiting in more places Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
if (songs.isEmpty()) return listOf() .songs(songs)
if (songs.size == 1) return listOf(songs.first().album) .distinctBy { (it.cover.perceptualHash ?: it.uri).toString() }
.map { it.cover }
val sortedMap = private suspend fun openCoverInputStream(cover: Cover) =
sortedMapOf<Album, Int>(Sort.Mode.ByName.getAlbumComparator(Sort.Direction.ASCENDING))
for (song in songs) {
sortedMap[song.album] = (sortedMap[song.album] ?: 0) + 1
}
return sortedMap.keys.sortedByDescending { sortedMap[it] }
}
private suspend fun openCoverInputStream(album: Album) =
try { try {
when (imageSettings.coverMode) { when (imageSettings.coverMode) {
CoverMode.OFF -> null CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album) CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(album) CoverMode.QUALITY -> extractQualityCover(cover)
} }
} catch (e: Exception) { } catch (e: Exception) {
logE("Unable to extract album cover due to an error: $e") logE("Unable to extract album cover due to an error: $e")
null null
} }
private suspend fun extractQualityCover(album: Album) = private suspend fun extractQualityCover(cover: Cover) =
extractAospMetadataCover(album) extractAospMetadataCover(cover)
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album) ?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(album: Album): InputStream? = private fun extractAospMetadataCover(cover: Cover): InputStream? =
MediaMetadataRetriever().run { MediaMetadataRetriever().run {
// This call is time-consuming but it also doesn't seem to hold up the main thread, // 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 // so it's probably fine not to wrap it.rmt
setDataSource(context, album.coverUri.song) setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full // Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts. // ByteArray of the cover without any compression artifacts.
@ -157,10 +150,9 @@ constructor(
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() } embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
} }
private suspend fun extractExoplayerCover(album: Album): InputStream? { private suspend fun extractExoplayerCover(cover: Cover): InputStream? {
val tracks = val tracks =
MetadataRetriever.retrieveMetadata( MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
.asDeferred() .asDeferred()
.await() .await()
@ -204,11 +196,9 @@ constructor(
return stream return stream
} }
private suspend fun extractMediaStoreCover(album: Album) = private suspend fun extractMediaStoreCover(cover: Cover) =
// Eliminate any chance that this blocking call might mess up the loading process // Eliminate any chance that this blocking call might mess up the loading process
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreUri) }
context.contentResolver.openInputStream(album.coverUri.mediaStore)
}
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */ /** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult { private suspend fun createMosaic(streams: List<InputStream>, size: Size): FetchResult {

View file

@ -27,7 +27,7 @@ import java.util.UUID
import kotlin.math.max 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.CoverUri import org.oxycblt.auxio.image.extractor.Cover
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path import org.oxycblt.auxio.music.fs.Path
@ -246,6 +246,8 @@ 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.
@ -293,11 +295,8 @@ interface Album : MusicParent {
* [ReleaseType.Album]. * [ReleaseType.Album].
*/ */
val releaseType: ReleaseType val releaseType: ReleaseType
/** /** Cover information from the template song used for the album. */
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the val cover: Cover
* cost of image quality.
*/
val coverUri: CoverUri
/** 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. */
@ -326,6 +325,8 @@ interface Artist : MusicParent {
* songs. * songs.
*/ */
val durationMs: Long? val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover
/** The [Genre]s of this artist. */ /** The [Genre]s of this artist. */
val genres: List<Genre> val genres: List<Genre>
} }
@ -340,6 +341,8 @@ interface Genre : MusicParent {
val artists: Collection<Artist> val artists: Collection<Artist>
/** 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. */
val cover: Cover
} }
/** /**
@ -352,6 +355,8 @@ interface Playlist : MusicParent {
override val songs: List<Song> override val songs: List<Song>
/** 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. */
val cover: Cover?
} }
/** /**

View file

@ -19,7 +19,7 @@
package org.oxycblt.auxio.music.device package org.oxycblt.auxio.music.device
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.image.extractor.CoverUri 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
@ -112,6 +112,8 @@ class SongImpl(
override val genres: List<Genre> override val genres: List<Genre>
get() = _genres get() = _genres
override val cover = Cover("", requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri)
/** /**
* The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an
* [Album]. * [Album].
@ -291,9 +293,9 @@ class AlbumImpl(
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName) override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range? override val dates: Date.Range?
override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null)
override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri)
override val durationMs: Long override val durationMs: Long
override val dateAdded: Long override val dateAdded: Long
override val cover = grouping.raw.src.cover
private val _artists = mutableListOf<ArtistImpl>() private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist> override val artists: List<Artist>
@ -419,6 +421,12 @@ class ArtistImpl(
override val explicitAlbums: Set<Album> override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album> override val implicitAlbums: Set<Album>
override val durationMs: Long? override val durationMs: Long?
override val cover =
when (val src = grouping.raw.src) {
is AlbumImpl -> src.cover
is SongImpl -> src.cover
else -> error("Unexpected input music $src in $name ${src::class.simpleName}")
}
override lateinit var genres: List<Genre> override lateinit var genres: List<Genre>
@ -528,6 +536,7 @@ class GenreImpl(
override val songs: Set<Song> override val songs: Set<Song>
override val artists: Set<Artist> override val artists: Set<Artist>
override val durationMs: Long override val durationMs: Long
override val cover = grouping.raw.src.cover
private var hashCode = uid.hashCode() private var hashCode = uid.hashCode()

View file

@ -102,7 +102,12 @@ fun Long.toAudioUri() =
* @return An external storage image [Uri]. May not exist. * @return An external storage image [Uri]. May not exist.
* @see ContentUris.withAppendedId * @see ContentUris.withAppendedId
*/ */
fun Long.toCoverUri() = ContentUris.withAppendedId(externalCoversUri, this) fun Long.toCoverUri(): Uri =
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
appendPath(this@toCoverUri.toString())
appendPath("albumart")
build()
}
// --- STORAGEMANAGER UTILITIES --- // --- STORAGEMANAGER UTILITIES ---
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles // Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles

View file

@ -74,7 +74,7 @@ fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC) .setMediaType(MediaMetadata.MEDIA_TYPE_MUSIC)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(false) .setIsBrowsable(false)
.setArtworkUri(album.coverUri.mediaStore) .setArtworkUri(album.cover.mediaStoreUri)
.setExtras( .setExtras(
Bundle().apply { Bundle().apply {
putString("uid", mediaSessionUID.toString()) putString("uid", mediaSessionUID.toString())
@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(coverUri.mediaStore) .setArtworkUri(cover.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setGenre(genres.resolveNames(context)) .setGenre(genres.resolveNames(context))
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) .setArtworkUri(cover.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) .setArtworkUri(cover.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()
@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true) .setIsPlayable(true)
.setIsBrowsable(true) .setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore) .setArtworkUri(cover?.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build() .build()
return MediaItem.Builder() return MediaItem.Builder()

View file

@ -46,6 +46,8 @@ private constructor(
override fun toString() = "Playlist(uid=$uid, name=$name)" override fun toString() = "Playlist(uid=$uid, name=$name)"
override val cover = songs.firstOrNull()?.cover
/** /**
* Clone the data in this instance to a new [PlaylistImpl] with the given [name]. * Clone the data in this instance to a new [PlaylistImpl] with the given [name].
* *