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
* CoverUri.kt is part of Auxio.
* Cover.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
@ -24,9 +24,9 @@ import android.net.Uri
* Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading
* 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
* an album cover.
* @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.
*/
suspend fun extract(songs: Collection<Song>, size: Size): FetchResult? {
val albums = computeCoverOrdering(songs)
val covers = computeCoverOrdering(songs)
val streams = mutableListOf<InputStream>()
for (album in albums) {
openCoverInputStream(album)?.let(streams::add)
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.
@ -116,40 +116,33 @@ constructor(
* 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.
*/
fun computeCoverOrdering(songs: Collection<Song>): List<Album> {
// TODO: Start short-circuiting in more places
if (songs.isEmpty()) return listOf()
if (songs.size == 1) return listOf(songs.first().album)
fun computeCoverOrdering(songs: Collection<Song>) =
Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING)
.songs(songs)
.distinctBy { (it.cover.perceptualHash ?: it.uri).toString() }
.map { it.cover }
val sortedMap =
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) =
private suspend fun openCoverInputStream(cover: Cover) =
try {
when (imageSettings.coverMode) {
CoverMode.OFF -> null
CoverMode.MEDIA_STORE -> extractMediaStoreCover(album)
CoverMode.QUALITY -> extractQualityCover(album)
CoverMode.MEDIA_STORE -> extractMediaStoreCover(cover)
CoverMode.QUALITY -> extractQualityCover(cover)
}
} catch (e: Exception) {
logE("Unable to extract album cover due to an error: $e")
null
}
private suspend fun extractQualityCover(album: Album) =
extractAospMetadataCover(album)
?: extractExoplayerCover(album) ?: extractMediaStoreCover(album)
private suspend fun extractQualityCover(cover: Cover) =
extractAospMetadataCover(cover)
?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover)
private fun extractAospMetadataCover(album: Album): InputStream? =
private fun extractAospMetadataCover(cover: Cover): 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, album.coverUri.song)
setDataSource(context, cover.songUri)
// Get the embedded picture from MediaMetadataRetriever, which will return a full
// ByteArray of the cover without any compression artifacts.
@ -157,10 +150,9 @@ constructor(
embeddedPicture?.let { ByteArrayInputStream(it) }.also { release() }
}
private suspend fun extractExoplayerCover(album: Album): InputStream? {
private suspend fun extractExoplayerCover(cover: Cover): InputStream? {
val tracks =
MetadataRetriever.retrieveMetadata(
mediaSourceFactory, MediaItem.fromUri(album.coverUri.song))
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(cover.songUri))
.asDeferred()
.await()
@ -204,11 +196,9 @@ constructor(
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
withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(album.coverUri.mediaStore)
}
withContext(Dispatchers.IO) { context.contentResolver.openInputStream(cover.mediaStoreUri) }
/** Derived from phonograph: https://github.com/kabouzeid/Phonograph */
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 kotlinx.parcelize.IgnoredOnParcel
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.music.fs.MimeType
import org.oxycblt.auxio.music.fs.Path
@ -246,6 +246,8 @@ 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.
@ -293,11 +295,8 @@ interface Album : MusicParent {
* [ReleaseType.Album].
*/
val releaseType: ReleaseType
/**
* The URI to a MediaStore-provided album cover. These images will be fast to load, but at the
* cost of image quality.
*/
val coverUri: CoverUri
/** Cover information from the template song used for the album. */
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. */
@ -326,6 +325,8 @@ interface Artist : MusicParent {
* songs.
*/
val durationMs: Long?
/** Useful information to quickly obtain a (single) cover for a Genre. */
val cover: Cover
/** The [Genre]s of this artist. */
val genres: List<Genre>
}
@ -340,6 +341,8 @@ interface Genre : MusicParent {
val artists: Collection<Artist>
/** 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: Cover
}
/**
@ -352,6 +355,8 @@ interface Playlist : MusicParent {
override val songs: List<Song>
/** 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: Cover?
}
/**

View file

@ -19,7 +19,7 @@
package org.oxycblt.auxio.music.device
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.music.Album
import org.oxycblt.auxio.music.Artist
@ -112,6 +112,8 @@ class SongImpl(
override val genres: List<Genre>
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
* [Album].
@ -291,9 +293,9 @@ class AlbumImpl(
override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName)
override val dates: Date.Range?
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 dateAdded: Long
override val cover = grouping.raw.src.cover
private val _artists = mutableListOf<ArtistImpl>()
override val artists: List<Artist>
@ -419,6 +421,12 @@ class ArtistImpl(
override val explicitAlbums: Set<Album>
override val implicitAlbums: Set<Album>
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>
@ -528,6 +536,7 @@ class GenreImpl(
override val songs: Set<Song>
override val artists: Set<Artist>
override val durationMs: Long
override val cover = grouping.raw.src.cover
private var hashCode = uid.hashCode()

View file

@ -102,7 +102,12 @@ fun Long.toAudioUri() =
* @return An external storage image [Uri]. May not exist.
* @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 ---
// 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)
.setIsPlayable(true)
.setIsBrowsable(false)
.setArtworkUri(album.coverUri.mediaStore)
.setArtworkUri(album.cover.mediaStoreUri)
.setExtras(
Bundle().apply {
putString("uid", mediaSessionUID.toString())
@ -105,7 +105,7 @@ fun Album.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(coverUri.mediaStore)
.setArtworkUri(cover.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
@ -136,7 +136,7 @@ fun Artist.toMediaItem(context: Context): MediaItem {
.setIsPlayable(true)
.setIsBrowsable(true)
.setGenre(genres.resolveNames(context))
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setArtworkUri(cover.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
@ -159,7 +159,7 @@ fun Genre.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_GENRE)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setArtworkUri(cover.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()
@ -182,7 +182,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem {
.setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST)
.setIsPlayable(true)
.setIsBrowsable(true)
.setArtworkUri(songs.firstOrNull()?.album?.coverUri?.mediaStore)
.setArtworkUri(cover?.mediaStoreUri)
.setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) })
.build()
return MediaItem.Builder()

View file

@ -46,6 +46,8 @@ private constructor(
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].
*