image: basic per-song album covers
Without any good caching support, so this will immediately break down.
This commit is contained in:
parent
8b7b916489
commit
bd330f0c71
7 changed files with 58 additions and 47 deletions
|
@ -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)
|
|
@ -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 {
|
||||||
|
|
|
@ -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?
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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()
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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].
|
||||||
*
|
*
|
||||||
|
|
Loading…
Reference in a new issue