From bd330f0c713da03fa280f34a43af7a11c75a6c4b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 22:16:22 -0600 Subject: [PATCH] image: basic per-song album covers Without any good caching support, so this will immediately break down. --- .../image/extractor/{CoverUri.kt => Cover.kt} | 6 +-- .../auxio/image/extractor/CoverExtractor.kt | 50 ++++++++----------- .../java/org/oxycblt/auxio/music/Music.kt | 17 ++++--- .../auxio/music/device/DeviceMusicImpl.kt | 13 ++++- .../org/oxycblt/auxio/music/fs/StorageUtil.kt | 7 ++- .../music/service/MediaItemTranslation.kt | 10 ++-- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 2 + 7 files changed, 58 insertions(+), 47 deletions(-) rename app/src/main/java/org/oxycblt/auxio/image/extractor/{CoverUri.kt => Cover.kt} (84%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt similarity index 84% rename from app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt rename to app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index 5e32d09ff..3be13c02f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverUri.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index 899867eb0..bb8eef06a 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -77,10 +77,10 @@ constructor( * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. */ suspend fun extract(songs: Collection, size: Size): FetchResult? { - val albums = computeCoverOrdering(songs) + val covers = computeCoverOrdering(songs) val streams = mutableListOf() - 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): List { - // 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) = + Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) + .songs(songs) + .distinctBy { (it.cover.perceptualHash ?: it.uri).toString() } + .map { it.cover } - val sortedMap = - sortedMapOf(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, size: Size): FetchResult { diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 1bf23aaf6..f3bf1bd06 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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 } @@ -340,6 +341,8 @@ interface Genre : MusicParent { val artists: Collection /** 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 /** 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? } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 7b16070cc..53453eb98 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -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 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() override val artists: List @@ -419,6 +421,12 @@ class ArtistImpl( override val explicitAlbums: Set override val implicitAlbums: Set 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 @@ -528,6 +536,7 @@ class GenreImpl( override val songs: Set override val artists: Set override val durationMs: Long + override val cover = grouping.raw.src.cover private var hashCode = uid.hashCode() diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt index 87eff7081..da61d613b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/StorageUtil.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index 9cadbeda5..3e05e4118 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -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() diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index fe4418894..6d53bb41b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -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]. *