From b99cd967262f1e4180f84490db9f8a2d1d75bfa0 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 16:06:18 -0600 Subject: [PATCH 1/5] playback: fix task removal --- .../auxio/playback/service/MediaSessionServiceFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 65caa80af..4d87ae4ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -100,7 +100,7 @@ constructor( } fun handleTaskRemoved() { - if (playbackManager.progression.isPlaying) { + if (!playbackManager.progression.isPlaying) { playbackManager.endSession() } } From 8b7b916489cd6d842baeb4b60ed5b98cca29af83 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 18:48:54 -0600 Subject: [PATCH 2/5] playback: fix notif issues on older devices - Slight coroutine delay in cover fetch causes the notif to flicker - Default play/pause actions look absolutely hideous --- .../auxio/image/service/CoilBitmapLoader.kt | 18 +++++- .../auxio/music/service/MediaItemBrowser.kt | 21 +------ .../service/ExoPlaybackStateHolder.kt | 3 +- .../playback/service/MediaSessionPlayer.kt | 18 ++++++ .../service/MediaSessionServiceFragment.kt | 2 +- ...ckReciever.kt => PlaybackActionHandler.kt} | 57 ++++++++++++++++++- .../java/org/oxycblt/auxio/tasker/Tasker.kt | 19 ++++++- media | 2 +- 8 files changed, 116 insertions(+), 24 deletions(-) rename app/src/main/java/org/oxycblt/auxio/playback/service/{SystemPlaybackReciever.kt => PlaybackActionHandler.kt} (81%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt index 58e8b609d..ca94f66cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt @@ -18,22 +18,31 @@ package org.oxycblt.auxio.image.service +import android.content.Context import android.graphics.Bitmap import android.net.Uri import androidx.media3.common.MediaMetadata import androidx.media3.common.util.BitmapLoader +import coil.ImageLoader +import coil.memory.MemoryCache +import coil.request.Options import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.SettableFuture +import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.image.BitmapProvider +import org.oxycblt.auxio.image.extractor.SongKeyer import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.service.MediaSessionUID class MediaSessionBitmapLoader @Inject constructor( + @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, - private val bitmapProvider: BitmapProvider + private val bitmapProvider: BitmapProvider, + private val songKeyer: SongKeyer, + private val imageLoader: ImageLoader, ) : BitmapLoader { override fun decodeBitmap(data: ByteArray): ListenableFuture { throw NotImplementedError() @@ -58,6 +67,13 @@ constructor( else -> return null } ?: return null + // Even launching a coroutine to obtained cached covers is enough to make the notification + // go without covers. + val key = songKeyer.key(listOf(song), Options(context)) + if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) { + future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap) + return future + } bitmapProvider.load( song, object : BitmapProvider.Target { diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 5c8c35ede..63b68925d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -15,7 +15,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - + package org.oxycblt.auxio.music.service import android.content.Context @@ -141,10 +141,8 @@ constructor( is MediaSessionUID.Category -> return uid.toMediaItem(context) is MediaSessionUID.Single -> musicRepository.find(uid.uid)?.let { musicRepository.find(it.uid) } - is MediaSessionUID.Joined -> musicRepository.find(uid.childUid)?.let { musicRepository.find(it.uid) } - null -> null } ?: return null @@ -179,40 +177,32 @@ constructor( when (mediaSessionUID) { MediaSessionUID.Category.ROOT -> MediaSessionUID.Category.IMPORTANT.map { it.toMediaItem(context) } - MediaSessionUID.Category.SONGS -> listSettings.songSort.songs(deviceLibrary.songs).map { it.toMediaItem(context, null) } - MediaSessionUID.Category.ALBUMS -> listSettings.albumSort.albums(deviceLibrary.albums).map { it.toMediaItem(context) } - MediaSessionUID.Category.ARTISTS -> listSettings.artistSort.artists(deviceLibrary.artists).map { it.toMediaItem(context) } - MediaSessionUID.Category.GENRES -> listSettings.genreSort.genres(deviceLibrary.genres).map { it.toMediaItem(context) } - MediaSessionUID.Category.PLAYLISTS -> userLibrary.playlists.map { it.toMediaItem(context) } } } - is MediaSessionUID.Single -> { getChildMediaItems(mediaSessionUID.uid) } - is MediaSessionUID.Joined -> { getChildMediaItems(mediaSessionUID.childUid) } - null -> { return null } @@ -225,24 +215,20 @@ constructor( val songs = listSettings.albumSongSort.songs(item.songs) songs.map { it.toMediaItem(context, item) } } - is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } } - is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) artists.map { it.toMediaItem(context) } + - songs.map { it.toMediaItem(context, null) } + songs.map { it.toMediaItem(context, null) } } - is Playlist -> { item.songs.map { it.toMediaItem(context, item) } } - is Song, null -> return null } @@ -339,8 +325,7 @@ constructor( deviceLibrary.albums, deviceLibrary.artists, deviceLibrary.genres, - userLibrary.playlists - ) + userLibrary.playlists) val results = searchEngine.search(items, query) for (entry in searchSubscribers.entries) { if (entry.value == query) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 3666f1795..070432bc8 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -106,7 +106,8 @@ class ExoPlaybackStateHolder( private set val mediaSessionPlayer: Player - get() = MediaSessionPlayer(player, playbackManager, commandFactory, musicRepository) + get() = + MediaSessionPlayer(context, player, playbackManager, commandFactory, musicRepository) override val progression: Progression get() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index fb662ef2f..6b56d334a 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -18,6 +18,8 @@ package org.oxycblt.auxio.playback.service +import android.content.Context +import android.os.Bundle import android.view.Surface import android.view.SurfaceHolder import android.view.SurfaceView @@ -31,6 +33,7 @@ import androidx.media3.common.PlaybackParameters import androidx.media3.common.Player import androidx.media3.common.TrackSelectionParameters import java.lang.Exception +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre @@ -60,6 +63,7 @@ import org.oxycblt.auxio.util.logE * @author Alexander Capehart */ class MediaSessionPlayer( + private val context: Context, player: Player, private val playbackManager: PlaybackStateManager, private val commandFactory: PlaybackCommand.Factory, @@ -86,6 +90,20 @@ class MediaSessionPlayer( setMediaItems(mediaItems, C.INDEX_UNSET, C.TIME_UNSET) } + override fun getMediaMetadata() = + super.getMediaMetadata().run { + val existingExtras = extras + val newExtras = existingExtras?.let { Bundle(it) } ?: Bundle() + newExtras.apply { + putString( + "parent", + playbackManager.parent?.name?.resolve(context) + ?: context.getString(R.string.lbl_all_songs)) + } + + buildUpon().setExtras(newExtras).build() + } + override fun setMediaItems( mediaItems: MutableList, startIndex: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index 4d87ae4ce..a626cd4b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -255,7 +255,7 @@ constructor( mediaSession.setCustomLayout(layout) } - override fun invalidate(ids: Map){ + override fun invalidate(ids: Map) { for (id in ids) { mediaSession.notifyChildrenChanged(id.key, id.value, null) } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt similarity index 81% rename from app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt rename to app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 5d89acd4c..7aa37e36b 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/SystemPlaybackReciever.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * SystemPlaybackReciever.kt is part of Auxio. + * PlaybackActionHandler.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 @@ -25,7 +25,9 @@ import android.content.IntentFilter import android.media.AudioManager import android.os.Bundle import androidx.core.content.ContextCompat +import androidx.media3.common.Player import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommands import dagger.hilt.android.qualifiers.ApplicationContext @@ -36,6 +38,7 @@ import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.ActionMode import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.state.PlaybackStateManager +import org.oxycblt.auxio.playback.state.Progression import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.widgets.WidgetComponent @@ -102,6 +105,13 @@ constructor( .setDisplayName(context.getString(R.string.desc_change_repeat)) .setSessionCommand( SessionCommand(PlaybackActions.ACTION_INC_REPEAT_MODE, Bundle())) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt( + DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, + 0) + }) .build()) } ActionMode.SHUFFLE -> { @@ -113,16 +123,56 @@ constructor( .setDisplayName(context.getString(R.string.lbl_shuffle)) .setSessionCommand( SessionCommand(PlaybackActions.ACTION_INVERT_SHUFFLE, Bundle())) + .setEnabled(true) .build()) } else -> {} } + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_skip_prev_24) + .setDisplayName(context.getString(R.string.desc_skip_prev)) + .setPlayerCommand(Player.COMMAND_SEEK_TO_PREVIOUS) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 1) + }) + .build()) + + actions.add( + CommandButton.Builder() + .setIconResId( + if (playbackManager.progression.isPlaying) R.drawable.ic_pause_24 + else R.drawable.ic_play_24) + .setDisplayName(context.getString(R.string.desc_play_pause)) + .setPlayerCommand(Player.COMMAND_PLAY_PAUSE) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 2) + }) + .build()) + + actions.add( + CommandButton.Builder() + .setIconResId(R.drawable.ic_skip_next_24) + .setDisplayName(context.getString(R.string.desc_skip_next)) + .setPlayerCommand(Player.COMMAND_SEEK_TO_NEXT) + .setEnabled(true) + .setExtras( + Bundle().apply { + putInt(DefaultMediaNotificationProvider.COMMAND_KEY_COMPACT_VIEW_INDEX, 3) + }) + .build()) + actions.add( CommandButton.Builder() .setIconResId(R.drawable.ic_close_24) .setDisplayName(context.getString(R.string.desc_exit)) .setSessionCommand(SessionCommand(PlaybackActions.ACTION_EXIT, Bundle())) + .setEnabled(true) .build()) return actions @@ -133,6 +183,11 @@ constructor( callback?.onCustomLayoutChanged(createCustomLayout()) } + override fun onProgressionChanged(progression: Progression) { + super.onProgressionChanged(progression) + callback?.onCustomLayoutChanged(createCustomLayout()) + } + override fun onRepeatModeChanged(repeatMode: RepeatMode) { super.onRepeatModeChanged(repeatMode) callback?.onCustomLayoutChanged(createCustomLayout()) diff --git a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt index e823bb338..ec2d6ac99 100644 --- a/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt +++ b/app/src/main/java/org/oxycblt/auxio/tasker/Tasker.kt @@ -1,2 +1,19 @@ +/* + * Copyright (c) 2024 Auxio Project + * Tasker.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 . + */ + package org.oxycblt.auxio.tasker - diff --git a/media b/media index bfa4c10f7..6c77cfa13 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit bfa4c10f773bb9336d9c7dade490463318b12ab6 +Subproject commit 6c77cfa13c83bf2ae5188603d2c9a51ec4cb3ac3 From bd330f0c713da03fa280f34a43af7a11c75a6c4b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 19 Apr 2024 22:16:22 -0600 Subject: [PATCH 3/5] 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]. * From 51406deaa7783c272d7d7c8978b04c55755a5104 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 Apr 2024 14:30:10 -0600 Subject: [PATCH 4/5] image: complete per-song album covers - Implement perceptual hashing algorithm to efficiently cache images - Efficiently pre-sort cover sources to make cover images load without freezing and look more pleasing Resolbes #342. --- .../org/oxycblt/auxio/image/BitmapProvider.kt | 2 +- .../java/org/oxycblt/auxio/image/CoverView.kt | 37 ++++----- .../auxio/image/extractor/Components.kt | 20 ++--- .../oxycblt/auxio/image/extractor/Cover.kt | 24 +++++- .../auxio/image/extractor/CoverExtractor.kt | 82 ++++++++----------- .../oxycblt/auxio/image/extractor/DHash.kt | 59 +++++++++++++ .../auxio/image/extractor/ExtractorModule.kt | 8 +- ...pLoader.kt => MediaSessionBitmapLoader.kt} | 8 +- .../java/org/oxycblt/auxio/music/Music.kt | 9 +- .../auxio/music/cache/CacheDatabase.kt | 7 +- .../auxio/music/device/DeviceMusicImpl.kt | 27 ++++-- .../oxycblt/auxio/music/device/RawMusic.kt | 2 + .../oxycblt/auxio/music/metadata/TagWorker.kt | 19 ++++- .../music/service/MediaItemTranslation.kt | 10 +-- .../oxycblt/auxio/music/user/PlaylistImpl.kt | 3 +- 15 files changed, 205 insertions(+), 112 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt rename app/src/main/java/org/oxycblt/auxio/image/service/{CoilBitmapLoader.kt => MediaSessionBitmapLoader.kt} (94%) diff --git a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt index ad81c25a9..59dcb877d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BitmapProvider.kt @@ -94,7 +94,7 @@ constructor( target .onConfigRequest( ImageRequest.Builder(context) - .data(listOf(song)) + .data(listOf(song.cover)) // Use ORIGINAL sizing, as we are not loading into any View-like component. .size(Size.ORIGINAL)) .target( diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index 792755dc7..4d0057c40 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -48,6 +48,7 @@ import com.google.android.material.shape.MaterialShapeDrawable import dagger.hilt.android.AndroidEntryPoint import javax.inject.Inject import org.oxycblt.auxio.R +import org.oxycblt.auxio.image.extractor.Cover import org.oxycblt.auxio.image.extractor.RoundedRectTransformation import org.oxycblt.auxio.image.extractor.SquareCropTransformation import org.oxycblt.auxio.music.Album @@ -101,14 +102,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr private val indicatorMatrixSrc = RectF() private val indicatorMatrixDst = RectF() - private data class Cover( - val songs: Collection, - val desc: String, - @DrawableRes val errorRes: Int - ) - - private var currentCover: Cover? = null - init { // Obtain some StyledImageView attributes to use later when theming the custom view. @SuppressLint("CustomViewStyleable") @@ -342,8 +335,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param song The [Song] to bind to the view. */ fun bind(song: Song) = - bind( - listOf(song), + bindImpl( + listOf(song.cover), context.getString(R.string.desc_album_cover, song.album.name), R.drawable.ic_album_24) @@ -353,8 +346,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param album The [Album] to bind to the view. */ fun bind(album: Album) = - bind( - album.songs, + bindImpl( + album.cover.all, context.getString(R.string.desc_album_cover, album.name), R.drawable.ic_album_24) @@ -364,8 +357,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param artist The [Artist] to bind to the view. */ fun bind(artist: Artist) = - bind( - artist.songs, + bindImpl( + artist.cover.all, context.getString(R.string.desc_artist_image, artist.name), R.drawable.ic_artist_24) @@ -375,8 +368,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param genre The [Genre] to bind to the view. */ fun bind(genre: Genre) = - bind( - genre.songs, + bindImpl( + genre.cover.all, context.getString(R.string.desc_genre_image, genre.name), R.drawable.ic_genre_24) @@ -386,8 +379,8 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param playlist the [Playlist] to bind. */ fun bind(playlist: Playlist) = - bind( - playlist.songs, + bindImpl( + playlist.cover?.all ?: emptyList(), context.getString(R.string.desc_playlist_image, playlist.name), R.drawable.ic_playlist_24) @@ -398,10 +391,13 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr * @param desc The content description to describe the bound data. * @param errorRes The resource of the error drawable to use if the cover cannot be loaded. */ - fun bind(songs: Collection, desc: String, @DrawableRes errorRes: Int) { + fun bind(songs: List, desc: String, @DrawableRes errorRes: Int) = + bindImpl(Cover.order(songs), desc, errorRes) + + private fun bindImpl(covers: List, desc: String, @DrawableRes errorRes: Int) { val request = ImageRequest.Builder(context) - .data(songs) + .data(covers) .error(StyledDrawable(context, context.getDrawableCompat(errorRes), iconSizeRes)) .target(image) @@ -417,7 +413,6 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr CoilUtils.dispose(image) imageLoader.enqueue(request.build()) contentDescription = desc - currentCover = Cover(songs, desc, errorRes) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index b7a7183db..4e3083316 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -24,25 +24,23 @@ import coil.key.Keyer import coil.request.Options import coil.size.Size import javax.inject.Inject -import org.oxycblt.auxio.music.Song -class SongKeyer @Inject constructor(private val coverExtractor: CoverExtractor) : - Keyer> { - override fun key(data: Collection, options: Options) = - "${coverExtractor.computeCoverOrdering(data).hashCode()}" +class CoverKeyer @Inject constructor() : Keyer> { + override fun key(data: Collection, options: Options) = + "${data.map { it.perceptualHash }.hashCode()}" } -class SongCoverFetcher +class CoverFetcher private constructor( - private val songs: Collection, + private val covers: Collection, private val size: Size, private val coverExtractor: CoverExtractor, ) : Fetcher { - override suspend fun fetch() = coverExtractor.extract(songs, size) + override suspend fun fetch() = coverExtractor.extract(covers, size) class Factory @Inject constructor(private val coverExtractor: CoverExtractor) : - Fetcher.Factory> { - override fun create(data: Collection, options: Options, imageLoader: ImageLoader) = - SongCoverFetcher(data, options.size, coverExtractor) + Fetcher.Factory> { + override fun create(data: Collection, options: Options, imageLoader: ImageLoader) = + CoverFetcher(data, options.size, coverExtractor) } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt index 3be13c02f..4595a0dcf 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Cover.kt @@ -19,6 +19,8 @@ package org.oxycblt.auxio.image.extractor import android.net.Uri +import org.oxycblt.auxio.list.sort.Sort +import org.oxycblt.auxio.music.Song /** * Bundle of [Uri] information used in [CoverExtractor] to ensure consistent [Uri] use when loading @@ -29,4 +31,24 @@ import android.net.Uri * an album cover. * @author Alexander Capehart (OxygenCobalt) */ -data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) +data class Cover(val perceptualHash: String?, val mediaStoreUri: Uri, val songUri: Uri) { + companion object { + private val FALLBACK_SORT = Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) + + fun order(songs: Collection) = + FALLBACK_SORT.songs(songs) + .map { it.cover } + .groupBy { it.perceptualHash } + .entries + .sortedByDescending { it.value.size } + .map { it.value.first() } + } +} + +data class ParentCover(val single: Cover, val all: List) { + companion object { + fun from(song: Song, songs: Collection) = from(song.cover, songs) + + fun from(src: Cover, songs: Collection) = ParentCover(src, Cover.order(songs)) + } +} 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 bb8eef06a..556b11445 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 @@ -27,6 +27,7 @@ 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 @@ -50,8 +51,6 @@ import okio.buffer import okio.source import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings -import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.util.logE @@ -70,14 +69,13 @@ constructor( /** * Extract an image (in the form of [FetchResult]) to represent the given [Song]s. * - * @param songs The [Song]s to load. + * @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 four album covers ordered by * [computeCoverOrdering]. Otherwise, a [SourceResult] of one album cover will be returned. */ - suspend fun extract(songs: Collection, size: Size): FetchResult? { - val covers = computeCoverOrdering(songs) + suspend fun extract(covers: Collection, size: Size): FetchResult? { val streams = mutableListOf() for (cover in covers) { openCoverInputStream(cover)?.let(streams::add) @@ -108,19 +106,37 @@ constructor( dataSource = DataSource.DISK) } - /** - * Creates an [Album] list representing the order that album covers would be used in [extract]. - * - * @param songs A hypothetical list of [Song]s that would be used in [extract]. - * @return A list of [Album]s first ordered by the "representation" within the [Song]s, and then - * 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) = - Sort(Sort.Mode.ByAlbum, Sort.Direction.ASCENDING) - .songs(songs) - .distinctBy { (it.cover.perceptualHash ?: it.uri).toString() } - .map { it.cover } + 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 { @@ -165,35 +181,7 @@ constructor( return null } - 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 + return findCoverDataInMetadata(metadata) } private suspend fun extractMediaStoreCover(cover: Cover) = diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt new file mode 100644 index 000000000..0b0949efd --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt @@ -0,0 +1,59 @@ +/* + * 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 . + */ + +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 + +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) +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt index 5f4145479..44c4d3166 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/ExtractorModule.kt @@ -35,14 +35,14 @@ class ExtractorModule { @Provides fun imageLoader( @ApplicationContext context: Context, - songKeyer: SongKeyer, - songFactory: SongCoverFetcher.Factory + keyer: CoverKeyer, + factory: CoverFetcher.Factory ) = ImageLoader.Builder(context) .components { // Add fetchers for Music components to make them usable with ImageRequest - add(songKeyer) - add(songFactory) + add(keyer) + add(factory) } // Use our own crossfade with error drawable support .transitionFactory(ErrorCrossfadeTransitionFactory()) diff --git a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt b/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt similarity index 94% rename from app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt rename to app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt index ca94f66cf..3d51677e6 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/service/CoilBitmapLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/service/MediaSessionBitmapLoader.kt @@ -1,6 +1,6 @@ /* * Copyright (c) 2024 Auxio Project - * CoilBitmapLoader.kt is part of Auxio. + * MediaSessionBitmapLoader.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 @@ -31,7 +31,7 @@ import com.google.common.util.concurrent.SettableFuture import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.image.BitmapProvider -import org.oxycblt.auxio.image.extractor.SongKeyer +import org.oxycblt.auxio.image.extractor.CoverKeyer import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.service.MediaSessionUID @@ -41,7 +41,7 @@ constructor( @ApplicationContext private val context: Context, private val musicRepository: MusicRepository, private val bitmapProvider: BitmapProvider, - private val songKeyer: SongKeyer, + private val keyer: CoverKeyer, private val imageLoader: ImageLoader, ) : BitmapLoader { override fun decodeBitmap(data: ByteArray): ListenableFuture { @@ -69,7 +69,7 @@ constructor( ?: return null // Even launching a coroutine to obtained cached covers is enough to make the notification // go without covers. - val key = songKeyer.key(listOf(song), Options(context)) + val key = keyer.key(listOf(song.cover), Options(context)) if (imageLoader.memoryCache?.get(MemoryCache.Key(key)) != null) { future.set(imageLoader.memoryCache?.get(MemoryCache.Key(key))?.bitmap) return future 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 f3bf1bd06..359afd9c3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -28,6 +28,7 @@ import kotlin.math.max import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import org.oxycblt.auxio.image.extractor.Cover +import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.music.fs.MimeType import org.oxycblt.auxio.music.fs.Path @@ -296,7 +297,7 @@ interface Album : MusicParent { */ val releaseType: ReleaseType /** Cover information from the template song used for the album. */ - val cover: Cover + val cover: ParentCover /** 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,7 +327,7 @@ interface Artist : MusicParent { */ val durationMs: Long? /** Useful information to quickly obtain a (single) cover for a Genre. */ - val cover: Cover + val cover: ParentCover /** The [Genre]s of this artist. */ val genres: List } @@ -342,7 +343,7 @@ interface Genre : MusicParent { /** 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 + val cover: ParentCover } /** @@ -356,7 +357,7 @@ interface Playlist : MusicParent { /** 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? + val cover: ParentCover? } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 63e4ccf98..00f8eb43b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 42, exportSchema = false) +@Database(entities = [CachedSong::class], version = 45, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } @@ -80,6 +80,8 @@ data class CachedSong( var subtitle: String? = null, /** @see RawSong.date */ var date: Date? = null, + /** @see RawSong.coverPerceptualHash */ + var coverPerceptualHash: String? = null, /** @see RawSong.albumMusicBrainzId */ var albumMusicBrainzId: String? = null, /** @see RawSong.albumName */ @@ -119,6 +121,8 @@ data class CachedSong( rawSong.subtitle = subtitle rawSong.date = date + rawSong.coverPerceptualHash = coverPerceptualHash + rawSong.albumMusicBrainzId = albumMusicBrainzId rawSong.albumName = albumName rawSong.albumSortName = albumSortName @@ -167,6 +171,7 @@ data class CachedSong( disc = rawSong.disc, subtitle = rawSong.subtitle, date = rawSong.date, + coverPerceptualHash = rawSong.coverPerceptualHash, albumMusicBrainzId = rawSong.albumMusicBrainzId, albumName = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, albumSortName = rawSong.albumSortName, 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 53453eb98..6604c3579 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 @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.device import org.oxycblt.auxio.R import org.oxycblt.auxio.image.extractor.Cover +import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist @@ -112,7 +113,8 @@ class SongImpl( override val genres: List get() = _genres - override val cover = Cover("", requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) + override val cover = + Cover(rawSong.coverPerceptualHash, requireNotNull(rawSong.mediaStoreId).toCoverUri(), uri) /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an @@ -295,7 +297,7 @@ class AlbumImpl( override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val durationMs: Long override val dateAdded: Long - override val cover = grouping.raw.src.cover + override val cover: ParentCover private val _artists = mutableListOf() override val artists: List @@ -339,6 +341,8 @@ class AlbumImpl( durationMs = totalDuration dateAdded = earliestDateAdded + cover = ParentCover.from(grouping.raw.src.cover, songs) + hashCode = 31 * hashCode + rawAlbum.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() @@ -421,12 +425,7 @@ 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 val cover: ParentCover override lateinit var genres: List @@ -459,6 +458,14 @@ class ArtistImpl( implicitAlbums = albums.filterNotTo(mutableSetOf()) { albumMap[it] == true } durationMs = songs.sumOf { it.durationMs }.positiveOrNull() + val singleCover = + when (val src = grouping.raw.src) { + is SongImpl -> src.cover + is AlbumImpl -> src.cover.single + else -> error("Unexpected input source $src in $name ${src::class.simpleName}") + } + cover = ParentCover.from(singleCover, songs) + hashCode = 31 * hashCode + rawArtist.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() @@ -536,7 +543,7 @@ class GenreImpl( override val songs: Set override val artists: Set override val durationMs: Long - override val cover = grouping.raw.src.cover + override val cover: ParentCover private var hashCode = uid.hashCode() @@ -554,6 +561,8 @@ class GenreImpl( artists = distinctArtists durationMs = totalDuration + cover = ParentCover.from(grouping.raw.src.cover, songs) + hashCode = 31 * hashCode + rawGenre.hashCode() hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt index 2f3b6ec73..5b1b6df03 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/RawMusic.kt @@ -67,6 +67,8 @@ data class RawSong( var subtitle: String? = null, /** @see Song.date */ var date: Date? = null, + /** @see Song.cover */ + var coverPerceptualHash: String? = null, /** @see RawAlbum.mediaStoreId */ var albumMediaStoreId: Long? = null, /** @see RawAlbum.musicBrainzId */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 78669caee..202f364df 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -18,6 +18,7 @@ package org.oxycblt.auxio.music.metadata +import android.graphics.BitmapFactory import androidx.core.text.isDigitsOnly import androidx.media3.common.MediaItem import androidx.media3.exoplayer.MetadataRetriever @@ -25,6 +26,8 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject +import org.oxycblt.auxio.image.extractor.CoverExtractor +import org.oxycblt.auxio.image.extractor.dHash import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.info.Date @@ -60,7 +63,10 @@ interface TagWorker { class TagWorkerFactoryImpl @Inject -constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Factory { +constructor( + private val mediaSourceFactory: MediaSource.Factory, + private val coverExtractor: CoverExtractor +) : TagWorker.Factory { override fun create(rawSong: RawSong): TagWorker = // Note that we do not leverage future callbacks. This is because errors in the // (highly fallible) extraction process will not bubble up to Indexer when a @@ -70,12 +76,14 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : TagWorker.Fac MetadataRetriever.retrieveMetadata( mediaSourceFactory, MediaItem.fromUri( - requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri()))) + requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No id" }.toAudioUri())), + coverExtractor) } private class TagWorkerImpl( private val rawSong: RawSong, - private val future: Future + private val future: Future, + private val coverExtractor: CoverExtractor ) : TagWorker { override fun poll(): RawSong? { if (!future.isDone) { @@ -98,6 +106,11 @@ private class TagWorkerImpl( populateWithId3v2(textTags.id3v2) populateWithVorbis(textTags.vorbis) + val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata) + val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } + rawSong.coverPerceptualHash = bitmap?.dHash() + bitmap?.recycle() + // OPUS base gain interpretation code: This is likely not needed, as the media player // should be using the base gain already. Uncomment if that's not the case. // if (format.sampleMimeType == MimeTypes.AUDIO_OPUS 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 3e05e4118..776fed706 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.cover.mediaStoreUri) + .setArtworkUri(album.cover.single.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(cover.mediaStoreUri) + .setArtworkUri(cover.single.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(cover.mediaStoreUri) + .setArtworkUri(cover.single.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(cover.mediaStoreUri) + .setArtworkUri(cover.single.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(cover?.mediaStoreUri) + .setArtworkUri(cover?.single?.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 6d53bb41b..2e12f1bff 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 @@ -18,6 +18,7 @@ package org.oxycblt.auxio.music.user +import org.oxycblt.auxio.image.extractor.ParentCover import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist @@ -46,7 +47,7 @@ private constructor( override fun toString() = "Playlist(uid=$uid, name=$name)" - override val cover = songs.firstOrNull()?.cover + override val cover = songs.takeIf { it.isNotEmpty() }?.let { ParentCover.from(it.first(), it) } /** * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. From 657b8267f19cec9777052f0a3bbd413d3d17810b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 20 Apr 2024 14:54:26 -0600 Subject: [PATCH 5/5] list: clamp item drag speed Resolves #686 --- .../list/recycler/MaterialDragCallback.kt | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt index 28112ca61..97eed4987 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/recycler/MaterialDragCallback.kt @@ -25,6 +25,10 @@ import android.view.animation.AccelerateDecelerateInterpolator import androidx.core.view.isInvisible import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min +import kotlin.math.sign import org.oxycblt.auxio.R import org.oxycblt.auxio.list.recycler.MaterialDragCallback.ViewHolder import org.oxycblt.auxio.util.getDimen @@ -53,6 +57,27 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { 0 } + override fun interpolateOutOfBoundsScroll( + recyclerView: RecyclerView, + viewSize: Int, + viewSizeOutOfBounds: Int, + totalSize: Int, + msSinceStartScroll: Long + ): Int { + // Clamp the scroll speed to prevent thefrom freaking out + // Adapted from NewPipe: https://github.com/TeamNewPipe/NewPipe + val standardSpeed = + super.interpolateOutOfBoundsScroll( + recyclerView, viewSize, viewSizeOutOfBounds, totalSize, msSinceStartScroll) + + val clampedAbsVelocity = + max( + MINIMUM_INITIAL_DRAG_VELOCITY, + min(abs(standardSpeed), MAXIMUM_INITIAL_DRAG_VELOCITY)) + + return clampedAbsVelocity * sign(viewSizeOutOfBounds.toDouble()).toInt() + } + final override fun onChildDraw( c: Canvas, recyclerView: RecyclerView, @@ -150,4 +175,9 @@ abstract class MaterialDragCallback : ItemTouchHelper.Callback() { /** The drawable of the [body] background that can be elevated. */ val background: Drawable } + + companion object { + const val MINIMUM_INITIAL_DRAG_VELOCITY = 10 + const val MAXIMUM_INITIAL_DRAG_VELOCITY = 25 + } }