From 77a7262a33f865972cf46c93fd598fad52435654 Mon Sep 17 00:00:00 2001 From: Nathan Banks Date: Thu, 23 Apr 2026 22:50:27 +0000 Subject: [PATCH 1/5] feat: Android Auto support --- app/src/main/AndroidManifest.xml | 2 - .../logic/GramophonePlaybackService.kt | 315 +++++++++++++++++- 2 files changed, 309 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b634b874..b2a8d370a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,11 +69,9 @@ - >> { + val completion = SettableFuture.create>>() + lifecycleScope.launch(Dispatchers.Default) { + try { + val list = when (parentId) { + "root" -> { + val gridExtras = android.os.Bundle().apply { + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + listOf( + // createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED), + createFolderItem("albums", getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras), + createFolderItem("artists", getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras), + createFolderItem("playlists", getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + ) + } + // "songs" -> gramophoneApplication.reader.songListFlow.first() + "albums" -> gramophoneApplication.reader.albumListFlow.first().map { createFolderItem("album_${it.id}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = it.albumArtist ?: it.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = it.cover, isPlayable = true, isBrowsable = false) } + "artists" -> gramophoneApplication.reader.artistListFlow.first().map { createFolderItem("artist_${it.title}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = resources.getQuantityString(R.plurals.songs, it.songList.size, it.songList.size), artworkUri = it.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) } + "playlists" -> gramophoneApplication.reader.playlistListFlow.first().map { + val title = when (it) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> getString(R.string.recently_added) + is uk.akane.libphonograph.dynamicitem.Favorite -> getString(R.string.playlist_favourite) + else -> it.title ?: "" + } + val icon = when (it) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_recently}") + is uk.akane.libphonograph.dynamicitem.Favorite -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_favorite}") + else -> null + } + val id = when (it) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> "playlist_recently_added" + is uk.akane.libphonograph.dynamicitem.Favorite -> "playlist_favorite" + else -> "playlist_${it.id}" + } + createFolderItem(id, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon) + } + else -> { + if (parentId.startsWith("album_")) { + val albumId = parentId.removePrefix("album_").toLongOrNull() + gramophoneApplication.reader.songListFlow.first().filter { it.mediaMetadata.albumId == albumId } + } else if (parentId.startsWith("artist_")) { + val artistName = parentId.removePrefix("artist_") + gramophoneApplication.reader.songListFlow.first().filter { it.mediaMetadata.artist == artistName } + } else if (parentId.startsWith("playlist_")) { + val playlistIdStr = parentId.removePrefix("playlist_") + val playlist = gramophoneApplication.reader.playlistListFlow.first().find { + when (playlistIdStr) { + "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded + "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite + else -> it.id?.toString() == playlistIdStr + } + } + playlist?.songList ?: emptyList() + } else emptyList() + } + } + + val startIndex = (page * pageSize).coerceIn(0, list.size) + val endIndex = ((page + 1) * pageSize).coerceIn(0, list.size) + val pagedList = (if (page == 0 && pageSize == Int.MAX_VALUE) list else list.subList(startIndex, endIndex)).map { convertItem(it)!! } + + completion.set(LibraryResult.ofItemList(pagedList, params)) + } catch (e: Exception) { + completion.setException(e) + } + } + return completion + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val completion = SettableFuture.create>() + lifecycleScope.launch(Dispatchers.Default) { + try { + val item = if (mediaId == "root") { + MediaItem.Builder() + .setMediaId("root") + .setMediaMetadata(MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build()) + .build() + // } else if (mediaId == "songs") { + // createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + } else if (mediaId == "albums") { + val gridExtras = android.os.Bundle().apply { + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + createFolderItem("albums", getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras) + } else if (mediaId == "artists") { + val gridExtras = android.os.Bundle().apply { + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + createFolderItem("artists", getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras) + } else if (mediaId == "playlists") { + createFolderItem("playlists", getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + } else if (mediaId.startsWith("album_")) { + val albumId = mediaId.removePrefix("album_").toLongOrNull() + val album = gramophoneApplication.reader.albumListFlow.first().find { it.id == albumId } + if (album != null) createFolderItem("album_${album.id}", album.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = album.albumArtist ?: album.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = album.cover, isPlayable = true, isBrowsable = false) else null + } else if (mediaId.startsWith("artist_")) { + val artistName = mediaId.removePrefix("artist_") + val artist = gramophoneApplication.reader.artistListFlow.first().find { it.title == artistName } + if (artist != null) createFolderItem("artist_${artist.title}", artist.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = resources.getQuantityString(R.plurals.songs, artist.songList.size, artist.songList.size), artworkUri = artist.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) else null + } else if (mediaId.startsWith("playlist_")) { + val playlistIdStr = mediaId.removePrefix("playlist_") + val playlist = gramophoneApplication.reader.playlistListFlow.first().find { + when (playlistIdStr) { + "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded + "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite + else -> it.id?.toString() == playlistIdStr + } + } + if (playlist != null) { + val title = when (playlist) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> getString(R.string.recently_added) + is uk.akane.libphonograph.dynamicitem.Favorite -> getString(R.string.playlist_favourite) + else -> playlist.title ?: "" + } + val icon = when (playlist) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_recently}") + is uk.akane.libphonograph.dynamicitem.Favorite -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_favorite}") + else -> null + } + createFolderItem(mediaId, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon) + } else null + } else { + gramophoneApplication.reader.songListFlow.first().find { it.mediaId == mediaId } + } + + if (item != null) { + completion.set(LibraryResult.ofItem(convertItem(item)!!, null)) + } else { + completion.set(LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)) + } + } catch (e: Exception) { + completion.setException(e) + } + } + return completion + } + + private fun createFolderItem( + id: String, + title: String, + mediaType: @MediaMetadata.MediaType Int, + subtitle: String? = null, + extras: android.os.Bundle? = null, + artworkUri: android.net.Uri? = null, + isPlayable: Boolean = false, + isBrowsable: Boolean = true + ): MediaItem { + val metadataBuilder = MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setIsBrowsable(isBrowsable) + .setIsPlayable(isPlayable) + .setMediaType(mediaType) + if (extras != null) metadataBuilder.setExtras(extras) + if (artworkUri != null) metadataBuilder.setArtworkUri(artworkUri) + return MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata(metadataBuilder.build()) + .build() + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: LibraryParams? + ): ListenableFuture> { + val completion = SettableFuture.create>() + lifecycleScope.launch(Dispatchers.Default) { + try { + session.notifySearchResultChanged(browser, query, 0, params) + completion.set(LibraryResult.ofVoid()) + } catch (e: Exception) { + completion.setException(e) + } + } + return completion + } + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val completion = SettableFuture.create>>() + lifecycleScope.launch(Dispatchers.Default) { + try { + val list = searchForMediaItemSync(query) + val startIndex = (page * pageSize).coerceIn(0, list.size) + val endIndex = ((page + 1) * pageSize).coerceIn(0, list.size) + val pagedList = (if (page == 0 && pageSize == Int.MAX_VALUE) list else list.subList(startIndex, endIndex)).map { convertItem(it)!! } + completion.set(LibraryResult.ofItemList(pagedList, params)) + } catch (e: Exception) { + completion.setException(e) + } + } + return completion + } + + private suspend fun searchForMediaItemSync(query: String): List { + val text = query.trim() + val list = gramophoneApplication.reader.songListFlow.first() + return if (text == "") list else list.filter { + val isMatchingTitle = it.mediaMetadata.title?.contains(text, true) == true + val isMatchingAlbum = it.mediaMetadata.albumTitle?.contains(text, true) == true + val isMatchingArtist = it.mediaMetadata.artist?.contains(text, true) == true + isMatchingTitle || isMatchingAlbum || isMatchingArtist + } + } override fun onTracksChanged(tracks: Tracks) { if (!tracks.isEmpty && !tracks.isTypeSelected(C.TRACK_TYPE_AUDIO)) { @@ -1214,7 +1502,22 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val result = mediaItems.flatMap { if (it.localConfiguration != null) listOf(it) - else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) + else if (it.mediaId.startsWith("album_")) { + val albumId = it.mediaId.removePrefix("album_").toLongOrNull() + gramophoneApplication.reader.albumListFlow.first().find { a -> a.id == albumId }?.songList ?: emptyList() + } else if (it.mediaId.startsWith("artist_")) { + val artistName = it.mediaId.removePrefix("artist_") + gramophoneApplication.reader.artistListFlow.first().find { a -> a.title == artistName }?.songList?.shuffled() ?: emptyList() + } else if (it.mediaId.startsWith("playlist_")) { + val playlistIdStr = it.mediaId.removePrefix("playlist_") + gramophoneApplication.reader.playlistListFlow.first().find { p -> + when (playlistIdStr) { + "recently_added" -> p is uk.akane.libphonograph.dynamicitem.RecentlyAdded + "favorite" -> p is uk.akane.libphonograph.dynamicitem.Favorite + else -> p.id?.toString() == playlistIdStr + } + }?.songList ?: emptyList() + } else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) gramophoneApplication.reader.songListFlow.first() .filter { m -> m.mediaId == it.mediaId } else if (it.requestMetadata.searchQuery != null) @@ -1222,7 +1525,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis else throw UnsupportedOperationException("can't do anything with $it") } - completion.set(result) + completion.set(result.map { convertItem(it)!! }) } catch (e: UnsupportedOperationException) { completion.setException(e) } From 90045d6f85a51d48c5e518b949f9f04c6bc7a375 Mon Sep 17 00:00:00 2001 From: Nathan Banks Date: Sun, 26 Apr 2026 18:51:04 +0000 Subject: [PATCH 2/5] fix: setPeriodicPositionUpdateEnabled to false to workaround AA platform bug See https://github.com/androidx/media/issues/2192 --- .../gramophone/logic/GramophonePlaybackService.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index 1e4bc7789..82c40827d 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -555,6 +555,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis ) ) .setSystemUiPlaybackResumptionOptIn(Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) + .setPeriodicPositionUpdateEnabled(false) .build() addSession(mediaSession!!) controller = MediaBrowser.Builder(this, mediaSession!!.token).buildAsync().get() @@ -1085,7 +1086,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis gramophoneApplication.reader.songListFlow.first().filter { it.mediaMetadata.artist == artistName } } else if (parentId.startsWith("playlist_")) { val playlistIdStr = parentId.removePrefix("playlist_") - val playlist = gramophoneApplication.reader.playlistListFlow.first().find { + val playlist = gramophoneApplication.reader.playlistListFlow.first().find { when (playlistIdStr) { "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite @@ -1152,7 +1153,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis if (artist != null) createFolderItem("artist_${artist.title}", artist.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = resources.getQuantityString(R.plurals.songs, artist.songList.size, artist.songList.size), artworkUri = artist.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) else null } else if (mediaId.startsWith("playlist_")) { val playlistIdStr = mediaId.removePrefix("playlist_") - val playlist = gramophoneApplication.reader.playlistListFlow.first().find { + val playlist = gramophoneApplication.reader.playlistListFlow.first().find { when (playlistIdStr) { "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite @@ -1510,7 +1511,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis gramophoneApplication.reader.artistListFlow.first().find { a -> a.title == artistName }?.songList?.shuffled() ?: emptyList() } else if (it.mediaId.startsWith("playlist_")) { val playlistIdStr = it.mediaId.removePrefix("playlist_") - gramophoneApplication.reader.playlistListFlow.first().find { p -> + gramophoneApplication.reader.playlistListFlow.first().find { p -> when (playlistIdStr) { "recently_added" -> p is uk.akane.libphonograph.dynamicitem.RecentlyAdded "favorite" -> p is uk.akane.libphonograph.dynamicitem.Favorite From e405f419a7228cb31f63358eecb7d32d6271dc74 Mon Sep 17 00:00:00 2001 From: Nathan Banks Date: Sat, 25 Apr 2026 00:51:00 +0000 Subject: [PATCH 3/5] refactor: add shared art resolver and content provider for android auto artwork --- app/src/main/AndroidManifest.xml | 8 +- .../logic/GramophoneAlbumArtProvider.kt | 171 +++++++++++++++++ .../gramophone/logic/GramophoneApplication.kt | 46 ++--- .../logic/GramophonePlaybackService.kt | 95 +++++----- .../logic/utils/GramophoneArtResolver.kt | 177 ++++++++++++++++++ 5 files changed, 414 insertions(+), 83 deletions(-) create mode 100644 app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt create mode 100644 app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b2a8d370a..bcf01779c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -160,6 +160,12 @@ android:resource="@xml/file_paths" /> + + @@ -235,4 +241,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt new file mode 100644 index 000000000..1b88fcf4e --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt @@ -0,0 +1,171 @@ +package org.akanework.gramophone.logic + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.media3.common.util.Log +import org.akanework.gramophone.logic.utils.GramophoneArtResolver +import java.io.File +import java.security.MessageDigest + +/** + * ContentProvider that serves album artwork to external processes (e.g. Android Auto). + * + * External processes cannot resolve Gramophone's internal URI schemes + * (`gramophoneSongCover://`, `gramophoneAlbumCover://`). This provider acts as a bridge, + * using the shared [GramophoneArtResolver] to locate and serve the artwork over a + * standard `content://` URI. + * + * URI format: `content://org.akanework.gramophone.albumart/{type}/{id}/{encodedPath}` + * where `type` is "song" or "album". + * + * The provider writes artwork to a temporary cache file and returns a read-only + * [ParcelFileDescriptor] for it. Cache files are keyed by a hash of the URI to + * avoid redundant work on repeated requests. + */ +class GramophoneAlbumArtProvider : ContentProvider() { + + private val TAG = "GramoArtProvider" + + companion object { + private const val MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024L // 50MB + } + + override fun onCreate() = true + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + val context = context ?: return null + val segments = uri.pathSegments + + if (segments.size < 3) { + Log.w(TAG, "Invalid URI format, expected 3 path segments: $uri") + return null + } + + val type = segments[0] // "song" or "album" + val id = segments[1] // songId or albumId + val encodedPath = segments[2] + val realPath = Uri.decode(encodedPath) + + val cacheDir = File(context.cacheDir, "albumart") + if (!cacheDir.exists()) cacheDir.mkdirs() + + // Use SHA-256 as cache key to avoid collisions and re-extracting on repeated requests + val cacheFileName = sha256(uri.toString()) + val cacheFile = File(cacheDir, "art_$cacheFileName") + + if (!cacheFile.exists()) { + trimCacheIfNeeded(cacheDir) + + // Write to temp file first to avoid partial reads during write + // Use random suffix to avoid collisions when multiple threads cache same artwork + val randomSuffix = System.nanoTime().toString(36) // Unique per-request suffix + val tempFile = File(cacheDir, "art_${cacheFileName}_${randomSuffix}_tmp") + + val inputStream = when (type) { + "song" -> GramophoneArtResolver.openSongArtwork(context, id, realPath) + "album" -> GramophoneArtResolver.openAlbumArtwork(context, id, realPath) + else -> { + Log.w(TAG, "Unknown artwork type: $type") + null + } + } + + if (inputStream != null) { + try { + inputStream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + output.flush() // Ensure data is written to buffer + output.fd.sync() // Force sync to disk + } + } + // Atomically rename temp file to final cache file + if (!tempFile.renameTo(cacheFile)) { + Log.w(TAG, "Failed to rename temp artwork cache file: $tempFile") + tempFile.delete() + return null + } + } catch (e: Exception) { + Log.w(TAG, "Failed to write artwork cache for $uri", e) + tempFile.delete() + cacheFile.delete() + return null + } + } + } else { + // Update last modified to keep it fresh in LRU + cacheFile.setLastModified(System.currentTimeMillis()) + } + + if (!cacheFile.exists()) return null + + return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) + } + + private fun sha256(input: String): String { + return try { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + bytes.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + // Fallback to hashCode if SHA-256 fails (should not happen) + input.hashCode().toString() + } + } + + private fun trimCacheIfNeeded(cacheDir: File) { + try { + val files = cacheDir.listFiles() ?: return + + // Clean up stale temp files (older than 1 hour) + val oneHourAgo = System.currentTimeMillis() - (60 * 60 * 1000) + for (file in files) { + if (file.name.endsWith("_tmp") && file.lastModified() < oneHourAgo) { + file.delete() + } + } + + var currentSize = files.sumOf { it.length() } + if (currentSize <= MAX_CACHE_SIZE_BYTES) return + + // Sort by last modified (oldest first) + val sortedFiles = files.sortedBy { it.lastModified() } + for (file in sortedFiles) { + val fileSize = file.length() + if (file.delete()) { + currentSize -= fileSize + } + if (currentSize <= MAX_CACHE_SIZE_BYTES * 0.8) break // Trim to 80% to avoid immediate re-trim + } + } catch (e: Exception) { + Log.w(TAG, "Failed to trim cache", e) + } + } + + override fun query( + uri: Uri, + projection: Array?, + selection: String?, + selectionArgs: Array?, + sortOrder: String? + ): Cursor? = null + + override fun getType(uri: Uri): String = "image/jpeg" + + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + + override fun delete( + uri: Uri, + selection: String?, + selectionArgs: Array? + ): Int = 0 + + override fun update( + uri: Uri, + values: ContentValues?, + selection: String?, + selectionArgs: Array? + ): Int = 0 +} diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt index 00159cedd..5077cebaf 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt @@ -20,17 +20,14 @@ package org.akanework.gramophone.logic import android.annotation.SuppressLint import android.app.Application import android.app.NotificationManager -import android.content.ContentUris import android.content.Intent import android.content.SharedPreferences -import android.media.ThumbnailUtils import android.os.Build import android.os.Debug import android.os.StrictMode import android.os.StrictMode.ThreadPolicy import android.os.StrictMode.VmPolicy -import android.provider.MediaStore -import android.util.Size + import android.webkit.MimeTypeMap import androidx.appcompat.app.AppCompatDelegate import androidx.compose.runtime.Composer @@ -70,7 +67,7 @@ import org.akanework.gramophone.logic.utils.Flags import org.akanework.gramophone.ui.LyricWidgetProvider import org.lsposed.hiddenapibypass.LSPass import org.nift4.gramophone.hificore.UacManager -import uk.akane.libphonograph.Constants +import org.akanework.gramophone.logic.utils.GramophoneArtResolver import uk.akane.libphonograph.reader.FlowReader import uk.akane.libphonograph.utils.MiscUtils import java.io.File @@ -82,10 +79,6 @@ class GramophoneApplication : Application(), SingletonImageLoader.Factory, companion object { private const val TAG = "GramophoneApplication" - - // not actually defined in API, but CTS tested - // https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/MediaProvider/src/com/android/providers/media/LocalUriMatcher.java;drc=ddf0d00b2b84b205a2ab3581df8184e756462e8d;l=182 - private const val MEDIA_ALBUM_ART = "albumart" } init { @@ -266,34 +259,20 @@ class GramophoneApplication : Application(), SingletonImageLoader.Factory, if (data.scheme != "gramophoneSongCover") return@Factory null return@Factory Fetcher { val file = File(data.path!!) - val uri = ContentUris.appendId( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon(), - data.authority!!.toLong() - ).appendPath(MEDIA_ALBUM_ART).build() - val bmp = if (options.size.width.pxOrElse { 0 } > 300 - && options.size.height.pxOrElse { 0 } > 300) try { - if (hasScopedStorageV1()) { - ThumbnailUtils.createAudioThumbnail(file, options.size.let { - Size( - it.width.pxOrElse { throw IllegalArgumentException("missing required size") }, - it.height.pxOrElse { throw IllegalArgumentException("missing required size") }) - }, null) - } else null // TODO: fallback for 300 && requestHeight > 300) { + GramophoneArtResolver.extractSongThumbnail( + file, requestWidth, requestHeight ) - throw e - null } else null if (bmp != null) { ImageFetchResult( bmp.asImage(), true, DataSource.DISK ) } else { - if (uri == null) return@Fetcher null val stream = contentResolver.openAssetFileDescriptor(uri, "r") checkNotNull(stream) { "Unable to open '$uri'." } SourceFetchResult( @@ -314,11 +293,8 @@ class GramophoneApplication : Application(), SingletonImageLoader.Factory, return@Factory Fetcher { val cover = MiscUtils.findBestCover(File(data.path!!)) if (cover == null) { - val uri = - ContentUris.withAppendedId( - Constants.baseAlbumCoverUri, - data.authority!!.toLong() - ) + val albumId = data.authority!!.toLong() + val uri = GramophoneArtResolver.buildAlbumCoverUri(albumId) val contentResolver = options.context.contentResolver val afd = contentResolver.openAssetFileDescriptor(uri, "r") checkNotNull(afd) { "Unable to open '$uri'." } diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index 82c40827d..32515d47b 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -118,6 +118,7 @@ import org.akanework.gramophone.logic.utils.LrcUtils.loadAndParseLyricsFile import org.akanework.gramophone.logic.utils.ReplayGainAudioProcessor import org.akanework.gramophone.logic.utils.ReplayGainUtil import org.akanework.gramophone.logic.utils.SemanticLyrics +import org.akanework.gramophone.logic.utils.GramophoneArtResolver import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer import org.akanework.gramophone.logic.utils.exoplayer.GramophoneExtractorsFactory import org.akanework.gramophone.logic.utils.exoplayer.GramophoneMediaSourceFactory @@ -159,28 +160,10 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val endedWorkaroundPlayer get() = internalPlayer - private fun convertMetadata(metadata: MediaMetadata, albumIdFallback: Long? = null): MediaMetadata { + private fun convertMetadata(metadata: MediaMetadata): MediaMetadata { val artworkUri = metadata.artworkUri ?: return metadata - val scheme = artworkUri.scheme - if (scheme == "gramophoneSongCover" || scheme == "gramophoneAlbumCover") { - val albumId = metadata.extras?.getLong(uk.akane.libphonograph.items.EXTRA_ALBUM_ID) - ?: albumIdFallback - ?: (if (scheme == "gramophoneAlbumCover") artworkUri.authority?.toLongOrNull() else null) - if (albumId != null) { - val uri = android.content.ContentUris.withAppendedId(uk.akane.libphonograph.Constants.baseAlbumCoverUri, albumId) - return metadata.buildUpon().setArtworkUri(uri).build() - } else if (scheme == "gramophoneSongCover") { - val songId = artworkUri.authority?.toLongOrNull() - if (songId != null) { - val uri = android.content.ContentUris.appendId( - android.provider.MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon(), - songId - ).appendPath("albumart").build() - return metadata.buildUpon().setArtworkUri(uri).build() - } - } - } - return metadata + val providerUri = GramophoneArtResolver.toProviderUri(artworkUri) ?: return metadata + return metadata.buildUpon().setArtworkUri(providerUri).build() } private fun convertItem(item: MediaItem?): MediaItem? { @@ -433,9 +416,11 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis override fun getCurrentMediaItem(): MediaItem? { return convertItem(super.getCurrentMediaItem()) } + override fun getMediaItemAt(index: Int): MediaItem { + return convertItem(super.getMediaItemAt(index)) ?: super.getMediaItemAt(index) + } override fun getMediaMetadata(): MediaMetadata { - val albumIdFallback = super.getCurrentMediaItem()?.mediaMetadata?.extras?.getLong(uk.akane.libphonograph.items.EXTRA_ALBUM_ID) - return convertMetadata(super.getMediaMetadata(), albumIdFallback) + return convertMetadata(super.getMediaMetadata()) } override fun getCurrentTimeline(): androidx.media3.common.Timeline { val original = super.getCurrentTimeline() @@ -1050,15 +1035,15 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) } listOf( - // createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED), createFolderItem("albums", getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras), createFolderItem("artists", getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras), + createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED), createFolderItem("playlists", getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) ) } - // "songs" -> gramophoneApplication.reader.songListFlow.first() "albums" -> gramophoneApplication.reader.albumListFlow.first().map { createFolderItem("album_${it.id}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = it.albumArtist ?: it.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = it.cover, isPlayable = true, isBrowsable = false) } "artists" -> gramophoneApplication.reader.artistListFlow.first().map { createFolderItem("artist_${it.title}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = resources.getQuantityString(R.plurals.songs, it.songList.size, it.songList.size), artworkUri = it.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) } + "songs" -> gramophoneApplication.reader.songListFlow.first() "playlists" -> gramophoneApplication.reader.playlistListFlow.first().map { val title = when (it) { is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> getString(R.string.recently_added) @@ -1075,7 +1060,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis is uk.akane.libphonograph.dynamicitem.Favorite -> "playlist_favorite" else -> "playlist_${it.id}" } - createFolderItem(id, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon) + createFolderItem(id, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon, isPlayable = true, isBrowsable = false) } else -> { if (parentId.startsWith("album_")) { @@ -1098,13 +1083,13 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } - val startIndex = (page * pageSize).coerceIn(0, list.size) - val endIndex = ((page + 1) * pageSize).coerceIn(0, list.size) - val pagedList = (if (page == 0 && pageSize == Int.MAX_VALUE) list else list.subList(startIndex, endIndex)).map { convertItem(it)!! } + val finalPageSize = pageSize.coerceAtMost(200) + val pagedList = list.drop(page * finalPageSize).take(finalPageSize).map { convertItem(it)!! } - completion.set(LibraryResult.ofItemList(pagedList, params)) + completion.set(LibraryResult.ofItemList(ImmutableList.copyOf(pagedList), params)) } catch (e: Exception) { - completion.setException(e) + Log.w(TAG, "onGetChildren failed for $parentId", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) } } return completion @@ -1127,8 +1112,8 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) .build()) .build() - // } else if (mediaId == "songs") { - // createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + } else if (mediaId == "songs") { + createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) } else if (mediaId == "albums") { val gridExtras = android.os.Bundle().apply { putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) @@ -1183,7 +1168,8 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis completion.set(LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)) } } catch (e: Exception) { - completion.setException(e) + Log.w(TAG, "onGetItem failed for $mediaId", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) } } return completion @@ -1225,7 +1211,8 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis session.notifySearchResultChanged(browser, query, 0, params) completion.set(LibraryResult.ofVoid()) } catch (e: Exception) { - completion.setException(e) + Log.w(TAG, "onSearch failed for $query", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) } } return completion @@ -1243,12 +1230,12 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis lifecycleScope.launch(Dispatchers.Default) { try { val list = searchForMediaItemSync(query) - val startIndex = (page * pageSize).coerceIn(0, list.size) - val endIndex = ((page + 1) * pageSize).coerceIn(0, list.size) - val pagedList = (if (page == 0 && pageSize == Int.MAX_VALUE) list else list.subList(startIndex, endIndex)).map { convertItem(it)!! } - completion.set(LibraryResult.ofItemList(pagedList, params)) + val finalPageSize = pageSize.coerceAtMost(200) + val pagedList = list.drop(page * finalPageSize).take(finalPageSize).map { convertItem(it)!! } + completion.set(LibraryResult.ofItemList(ImmutableList.copyOf(pagedList), params)) } catch (e: Exception) { - completion.setException(e) + Log.w(TAG, "onGetSearchResult failed for $query", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) } } return completion @@ -1495,11 +1482,14 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis controller: MediaSession.ControllerInfo, mediaItems: List ): ListenableFuture> { - if (mediaItems.find { it.localConfiguration == null } == null) // fast path - return Futures.immediateFuture(mediaItems) + if (mediaItems.find { it.localConfiguration == null } == null) + return Futures.immediateFuture(mediaItems.map { convertItem(it)!! }) val completion = SettableFuture.create>() lifecycleScope.launch(Dispatchers.Default) { try { + // Track which item was clicked (used to set starting index) + var clickedItemIndex = 0 + val result = mediaItems.flatMap { if (it.localConfiguration != null) listOf(it) @@ -1508,7 +1498,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis gramophoneApplication.reader.albumListFlow.first().find { a -> a.id == albumId }?.songList ?: emptyList() } else if (it.mediaId.startsWith("artist_")) { val artistName = it.mediaId.removePrefix("artist_") - gramophoneApplication.reader.artistListFlow.first().find { a -> a.title == artistName }?.songList?.shuffled() ?: emptyList() + gramophoneApplication.reader.artistListFlow.first().find { a -> a.title == artistName }?.songList ?: emptyList() } else if (it.mediaId.startsWith("playlist_")) { val playlistIdStr = it.mediaId.removePrefix("playlist_") gramophoneApplication.reader.playlistListFlow.first().find { p -> @@ -1518,15 +1508,26 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis else -> p.id?.toString() == playlistIdStr } }?.songList ?: emptyList() - } else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) - gramophoneApplication.reader.songListFlow.first() - .filter { m -> m.mediaId == it.mediaId } + } else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) { + // Load entire song list, track which item was clicked + val fullSongList = gramophoneApplication.reader.songListFlow.first() + clickedItemIndex = fullSongList.indexOfFirst { m -> m.mediaId == it.mediaId } + fullSongList + } else if (it.requestMetadata.searchQuery != null) searchForMediaItem(it) else throw UnsupportedOperationException("can't do anything with $it") } - completion.set(result.map { convertItem(it)!! }) + val convertedResult = result.map { convertItem(it)!! } + completion.set(convertedResult) + + // Set the clicked item as the starting point (must be done on main thread) + if (clickedItemIndex > 0 && convertedResult.isNotEmpty()) { + withContext(Dispatchers.Main) { + mediaSession.player.setMediaItems(convertedResult, clickedItemIndex, androidx.media3.common.C.TIME_UNSET) + } + } } catch (e: UnsupportedOperationException) { completion.setException(e) } diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt new file mode 100644 index 000000000..5bc7edad6 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt @@ -0,0 +1,177 @@ +package org.akanework.gramophone.logic.utils + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.graphics.Bitmap +import android.media.ThumbnailUtils +import android.net.Uri +import android.os.Build +import android.os.ParcelFileDescriptor +import android.provider.MediaStore +import android.util.Size +import androidx.media3.common.util.Log +import org.akanework.gramophone.BuildConfig +import org.akanework.gramophone.logic.hasScopedStorageV1 +import uk.akane.libphonograph.Constants +import uk.akane.libphonograph.utils.MiscUtils +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * Shared artwork resolution logic used by both the in-process Coil image loader + * and the cross-process [org.akanework.gramophone.logic.GramophoneAlbumArtProvider]. + * + * This avoids duplicating the cover art discovery strategy across multiple components. + * + * URI schemes handled: + * - `gramophoneSongCover:///` — per-song embedded art with MediaStore fallback + * - `gramophoneAlbumCover:///` — folder-based cover art with MediaStore fallback + */ +object GramophoneArtResolver { + + private const val TAG = "GramophoneArtResolver" + + /** Authority for the ContentProvider that serves art to external processes. */ + const val PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.albumart" + + // not actually defined in API, but CTS tested + // https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/MediaProvider/src/com/android/providers/media/LocalUriMatcher.java;drc=ddf0d00b2b84b205a2ab3581df8184e756462e8d;l=182 + private const val MEDIA_ALBUM_ART = "albumart" + + /** + * Builds a `content://` URI pointing to [GramophoneAlbumArtProvider] for the given + * internal artwork URI scheme. This URI is safe to hand to external processes + * (e.g. Android Auto) which cannot resolve our custom schemes. + * + * @param type "song" or "album" + * @param id the song or album ID as a string + * @param path the file/folder path (will be URI-encoded) + */ + fun buildProviderUri(type: String, id: String, path: String): Uri = + Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(PROVIDER_AUTHORITY) + .appendPath(type) + .appendPath(id) + .appendPath(Uri.encode(path)) + .build() + + /** + * Converts a `gramophoneSongCover://` or `gramophoneAlbumCover://` URI into a + * `content://` URI backed by the album art provider, suitable for cross-process use. + * + * Returns `null` if the URI is not one of the custom schemes. + */ + fun toProviderUri(uri: Uri): Uri? { + return when (uri.scheme) { + "gramophoneSongCover" -> buildProviderUri( + "song", + uri.authority ?: "0", + uri.path ?: "" + ) + "gramophoneAlbumCover" -> buildProviderUri( + "album", + uri.authority ?: "0", + uri.path ?: "" + ) + else -> null + } + } + + /** + * Attempts to extract embedded artwork from a song file via [ThumbnailUtils]. + * Only available on Android Q+. + * + * @param file the audio file + * @param width desired thumbnail width (use 0 for default) + * @param height desired thumbnail height (use 0 for default) + * @return the extracted bitmap, or `null` if extraction failed or is unavailable + */ + fun extractSongThumbnail(file: File, width: Int = 512, height: Int = 512): Bitmap? { + if (!hasScopedStorageV1()) return null // ThumbnailUtils.createAudioThumbnail requires Q+ + return try { + ThumbnailUtils.createAudioThumbnail(file, Size(width, height), null) + } catch (e: IOException) { + if (e.message != "No embedded album art found" && + e.message != "No thumbnails in Downloads directories" && + e.message != "No thumbnails in top-level directories" && + e.message != "No album art found" + ) { + Log.w(TAG, "Unexpected IOException extracting song thumbnail", e) + } + null + } + } + + /** + * Returns a MediaStore URI for the song's album art (the `albumart` pseudo-path). + */ + fun buildSongAlbumArtUri(songId: Long): Uri = + ContentUris.appendId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon(), songId + ).appendPath(MEDIA_ALBUM_ART).build() + + /** + * Returns a MediaStore URI for the album's cover art. + */ + fun buildAlbumCoverUri(albumId: Long): Uri = + ContentUris.withAppendedId(Constants.baseAlbumCoverUri, albumId) + + /** + * Opens an [InputStream] for the song's artwork, trying embedded art first + * then falling back to the MediaStore album art URI. + * + * @return an [InputStream] for the artwork, or `null` if no artwork is available + */ + fun openSongArtwork(context: Context, songId: String, filePath: String): InputStream? { + val file = File(filePath) + + // Try extracting embedded thumbnail and writing to a temp bitmap stream + val bmp = extractSongThumbnail(file) + if (bmp != null) { + val stream = java.io.ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bmp.recycle() + return java.io.ByteArrayInputStream(stream.toByteArray()) + } + + // Fallback to MediaStore album art + val mediaStoreUri = buildSongAlbumArtUri(songId.toLong()) + return try { + context.contentResolver.openInputStream(mediaStoreUri) + } catch (e: Exception) { + Log.d(TAG, "Failed to open MediaStore song art for id=$songId", e) + null + } + } + + /** + * Opens an [InputStream] for the album's artwork, trying folder-based cover art first + * (via [MiscUtils.findBestCover]) then falling back to the MediaStore album cover URI. + * + * @return an [InputStream] for the artwork, or `null` if no artwork is available + */ + fun openAlbumArtwork(context: Context, albumId: String, folderPath: String): InputStream? { + // Try folder-based cover art (cover.jpg, albumart.png, etc.) + val cover = MiscUtils.findBestCover(File(folderPath)) + if (cover != null) { + return try { + cover.inputStream() + } catch (e: Exception) { + Log.d(TAG, "Failed to open folder cover at ${cover.path}", e) + null + } + } + + // Fallback to MediaStore album cover + val mediaStoreUri = buildAlbumCoverUri(albumId.toLong()) + return try { + context.contentResolver.openInputStream(mediaStoreUri) + } catch (e: Exception) { + Log.d(TAG, "Failed to open MediaStore album art for id=$albumId", e) + null + } + } +} From 5f40cb634490b3cdfde4ff45c039631acaf3fe75 Mon Sep 17 00:00:00 2001 From: Nathan Banks Date: Sun, 26 Apr 2026 23:27:15 +0000 Subject: [PATCH 4/5] refactor: library management code into separate class --- .../logic/GramophoneLibrarySessionCallback.kt | 397 ++++++++++++++++++ .../logic/GramophonePlaybackService.kt | 336 +-------------- 2 files changed, 413 insertions(+), 320 deletions(-) create mode 100644 app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt new file mode 100644 index 000000000..5bf60027c --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt @@ -0,0 +1,397 @@ +package org.akanework.gramophone.logic + +import android.content.Context +import android.os.Bundle +import androidx.lifecycle.LifecycleCoroutineScope +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.Rating +import androidx.media3.common.util.Log +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaLibraryService.LibraryParams +import androidx.media3.session.MediaLibraryService.MediaLibrarySession +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSession.MediaItemsWithStartPosition +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionError +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.ListenableFuture +import com.google.common.util.concurrent.SettableFuture +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.akanework.gramophone.R +import org.akanework.gramophone.logic.utils.GramophoneArtResolver +import androidx.media3.session.MediaConstants +import com.google.common.util.concurrent.Futures +import uk.akane.libphonograph.items.albumId + +/** + * Handles the media library browsing logic for [GramophonePlaybackService]. + * Extracted from the service to improve maintainability and separate UI/tree-building logic. + */ +class GramophoneLibrarySessionCallback( + private val context: Context, + private val app: GramophoneApplication, + private val lifecycleScope: LifecycleCoroutineScope, + private val convertItem: (MediaItem) -> MediaItem, + private val delegate: SessionDelegate +) : MediaLibrarySession.Callback { + + private val TAG = "GramoLibraryCallback" + + interface SessionDelegate { + fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult + fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) + fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) + fun onSetRating(session: MediaSession, controller: MediaSession.ControllerInfo, mediaId: String, rating: Rating): ListenableFuture + fun onSetRating(session: MediaSession, controller: MediaSession.ControllerInfo, rating: Rating): ListenableFuture + fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle): ListenableFuture + fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, isForPlayback: Boolean): ListenableFuture + } + + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo) = delegate.onConnect(session, controller) + override fun onPostConnect(session: MediaSession, controller: MediaSession.ControllerInfo) = delegate.onPostConnect(session, controller) + override fun onDisconnected(session: MediaSession, controller: MediaSession.ControllerInfo) = delegate.onDisconnected(session, controller) + override fun onSetRating(session: MediaSession, controller: MediaSession.ControllerInfo, mediaId: String, rating: Rating) = delegate.onSetRating(session, controller, mediaId, rating) + override fun onSetRating(session: MediaSession, controller: MediaSession.ControllerInfo, rating: Rating) = delegate.onSetRating(session, controller, rating) + override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo, customCommand: SessionCommand, args: Bundle) = delegate.onCustomCommand(session, controller, customCommand, args) + override fun onPlaybackResumption(mediaSession: MediaSession, controller: MediaSession.ControllerInfo, isForPlayback: Boolean) = delegate.onPlaybackResumption(mediaSession, controller, isForPlayback) + + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams? + ): ListenableFuture> { + val outParams = LibraryParams.Builder() + .setOffline(true) + .setSuggested(false) + .setRecent(false) + .build() + val item = MediaItem.Builder() + .setMediaId("root") + .setMediaMetadata(MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build()) + .build() + return Futures.immediateFuture(LibraryResult.ofItem(item, outParams)) + } + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val completion = SettableFuture.create>>() + lifecycleScope.launch(Dispatchers.Default) { + try { + val list = when (parentId) { + "root" -> { + val gridExtras = Bundle().apply { + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + listOf( + createFolderItem("albums", context.getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras), + createFolderItem("artists", context.getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras), + createFolderItem("songs", context.getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED), + createFolderItem("playlists", context.getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + ) + } + "albums" -> app.reader.albumListFlow.first().map { createFolderItem("album_${it.id}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = it.albumArtist ?: it.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = it.cover, isPlayable = true, isBrowsable = false) } + "artists" -> app.reader.artistListFlow.first().map { createFolderItem("artist_${it.title}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = context.resources.getQuantityString(R.plurals.songs, it.songList.size, it.songList.size), artworkUri = it.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) } + "songs" -> app.reader.songListFlow.first() + "playlists" -> app.reader.playlistListFlow.first().map { + val title = when (it) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> context.getString(R.string.recently_added) + is uk.akane.libphonograph.dynamicitem.Favorite -> context.getString(R.string.playlist_favourite) + else -> it.title ?: "" + } + val icon = when (it) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> android.net.Uri.parse("android.resource://${context.packageName}/${R.drawable.ic_default_cover_playlist_recently}") + is uk.akane.libphonograph.dynamicitem.Favorite -> android.net.Uri.parse("android.resource://${context.packageName}/${R.drawable.ic_default_cover_playlist_favorite}") + else -> null + } + val id = when (it) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> "playlist_recently_added" + is uk.akane.libphonograph.dynamicitem.Favorite -> "playlist_favorite" + else -> "playlist_${it.id}" + } + createFolderItem(id, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon, isPlayable = true, isBrowsable = false) + } + else -> { + if (parentId.startsWith("album_")) { + val albumId = parentId.removePrefix("album_").toLongOrNull() + app.reader.songListFlow.first().filter { it.mediaMetadata.albumId == albumId } + } else if (parentId.startsWith("artist_")) { + val artistName = parentId.removePrefix("artist_") + app.reader.songListFlow.first().filter { it.mediaMetadata.artist == artistName } + } else if (parentId.startsWith("playlist_")) { + val playlistIdStr = parentId.removePrefix("playlist_") + val playlist = app.reader.playlistListFlow.first().find { + when (playlistIdStr) { + "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded + "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite + else -> it.id?.toString() == playlistIdStr + } + } + playlist?.songList ?: emptyList() + } else emptyList() + } + } + + val finalPageSize = pageSize.coerceAtMost(200) + val pagedList = list.drop(page * finalPageSize).take(finalPageSize).map { convertItem(it) } + + completion.set(LibraryResult.ofItemList(ImmutableList.copyOf(pagedList), params)) + } catch (e: Exception) { + Log.w(TAG, "onGetChildren failed for $parentId", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return completion + } + + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String + ): ListenableFuture> { + val completion = SettableFuture.create>() + lifecycleScope.launch(Dispatchers.Default) { + try { + val item = if (mediaId == "root") { + MediaItem.Builder() + .setMediaId("root") + .setMediaMetadata(MediaMetadata.Builder() + .setIsBrowsable(true) + .setIsPlayable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build()) + .build() + } else if (mediaId == "songs") { + createFolderItem("songs", context.getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + } else if (mediaId == "albums") { + val gridExtras = Bundle().apply { + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + createFolderItem("albums", context.getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras) + } else if (mediaId == "artists") { + val gridExtras = Bundle().apply { + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) + } + createFolderItem("artists", context.getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras) + } else if (mediaId == "playlists") { + createFolderItem("playlists", context.getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) + } else if (mediaId.startsWith("album_")) { + val albumId = mediaId.removePrefix("album_").toLongOrNull() + val album = app.reader.albumListFlow.first().find { it.id == albumId } + if (album != null) createFolderItem("album_${album.id}", album.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = album.albumArtist ?: album.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = album.cover, isPlayable = true, isBrowsable = false) else null + } else if (mediaId.startsWith("artist_")) { + val artistName = mediaId.removePrefix("artist_") + val artist = app.reader.artistListFlow.first().find { it.title == artistName } + if (artist != null) createFolderItem("artist_${artist.title}", artist.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = context.resources.getQuantityString(R.plurals.songs, artist.songList.size, artist.songList.size), artworkUri = artist.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) else null + } else if (mediaId.startsWith("playlist_")) { + val playlistIdStr = mediaId.removePrefix("playlist_") + val playlist = app.reader.playlistListFlow.first().find { + when (playlistIdStr) { + "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded + "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite + else -> it.id?.toString() == playlistIdStr + } + } + if (playlist != null) { + val title = when (playlist) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> context.getString(R.string.recently_added) + is uk.akane.libphonograph.dynamicitem.Favorite -> context.getString(R.string.playlist_favourite) + else -> playlist.title ?: "" + } + val icon = when (playlist) { + is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> android.net.Uri.parse("android.resource://${context.packageName}/${R.drawable.ic_default_cover_playlist_recently}") + is uk.akane.libphonograph.dynamicitem.Favorite -> android.net.Uri.parse("android.resource://${context.packageName}/${R.drawable.ic_default_cover_playlist_favorite}") + else -> null + } + createFolderItem(mediaId, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon) + } else null + } else { + app.reader.songListFlow.first().find { it.mediaId == mediaId } + } + + if (item != null) { + completion.set(LibraryResult.ofItem(convertItem(item), null)) + } else { + completion.set(LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)) + } + } catch (e: Exception) { + Log.w(TAG, "onGetItem failed for $mediaId", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return completion + } + + private fun createFolderItem( + id: String, + title: String, + mediaType: @MediaMetadata.MediaType Int, + subtitle: String? = null, + extras: Bundle? = null, + artworkUri: android.net.Uri? = null, + isPlayable: Boolean = false, + isBrowsable: Boolean = true + ): MediaItem { + val metadataBuilder = MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setIsBrowsable(isBrowsable) + .setIsPlayable(isPlayable) + .setMediaType(mediaType) + if (extras != null) metadataBuilder.setExtras(extras) + if (artworkUri != null) metadataBuilder.setArtworkUri(artworkUri) + return MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata(metadataBuilder.build()) + .build() + } + + override fun onSearch( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + params: LibraryParams? + ): ListenableFuture> { + val completion = SettableFuture.create>() + lifecycleScope.launch(Dispatchers.Default) { + try { + session.notifySearchResultChanged(browser, query, 0, params) + completion.set(LibraryResult.ofVoid()) + } catch (e: Exception) { + Log.w(TAG, "onSearch failed for $query", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return completion + } + + override fun onGetSearchResult( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + query: String, + page: Int, + pageSize: Int, + params: LibraryParams? + ): ListenableFuture>> { + val completion = SettableFuture.create>>() + lifecycleScope.launch(Dispatchers.Default) { + try { + val list = searchForMediaItemSync(query) + val finalPageSize = pageSize.coerceAtMost(200) + val pagedList = list.drop(page * finalPageSize).take(finalPageSize).map { convertItem(it) } + completion.set(LibraryResult.ofItemList(ImmutableList.copyOf(pagedList), params)) + } catch (e: Exception) { + Log.w(TAG, "onGetSearchResult failed for $query", e) + completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) + } + } + return completion + } + + private suspend fun searchForMediaItemSync(query: String): List { + val text = query.trim() + val list = app.reader.songListFlow.first() + return if (text == "") list else list.filter { + val isMatchingTitle = it.mediaMetadata.title?.contains(text, true) == true + val isMatchingAlbum = it.mediaMetadata.albumTitle?.contains(text, true) == true + val isMatchingArtist = it.mediaMetadata.artist?.contains(text, true) == true + isMatchingTitle || isMatchingAlbum || isMatchingArtist + } + } + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: List + ): ListenableFuture> { + if (mediaItems.find { it.localConfiguration == null } == null) + return Futures.immediateFuture(mediaItems.map { convertItem(it) }) + val completion = SettableFuture.create>() + lifecycleScope.launch(Dispatchers.Default) { + try { + // Track which item was clicked (used to set starting index) + var clickedItemIndex = 0 + + val result = mediaItems.flatMap { + if (it.localConfiguration != null) + listOf(it) + else if (it.mediaId.startsWith("album_")) { + val albumId = it.mediaId.removePrefix("album_").toLongOrNull() + app.reader.albumListFlow.first().find { a -> a.id == albumId }?.songList ?: emptyList() + } else if (it.mediaId.startsWith("artist_")) { + val artistName = it.mediaId.removePrefix("artist_") + app.reader.artistListFlow.first().find { a -> a.title == artistName }?.songList ?: emptyList() + } else if (it.mediaId.startsWith("playlist_")) { + val playlistIdStr = it.mediaId.removePrefix("playlist_") + app.reader.playlistListFlow.first().find { p -> + when (playlistIdStr) { + "recently_added" -> p is uk.akane.libphonograph.dynamicitem.RecentlyAdded + "favorite" -> p is uk.akane.libphonograph.dynamicitem.Favorite + else -> p.id?.toString() == playlistIdStr + } + }?.songList ?: emptyList() + } else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) { + // Load entire song list, track which item was clicked + val fullSongList = app.reader.songListFlow.first() + clickedItemIndex = fullSongList.indexOfFirst { m -> m.mediaId == it.mediaId } + fullSongList + } + else if (it.requestMetadata.searchQuery != null) + searchForMediaItem(it) + else + throw UnsupportedOperationException("can't do anything with $it") + } + val convertedResult = result.map { convertItem(it) } + completion.set(convertedResult) + + // Set the clicked item as the starting point (must be done on main thread) + if (clickedItemIndex > 0 && convertedResult.isNotEmpty()) { + withContext(Dispatchers.Main) { + mediaSession.player.setMediaItems(convertedResult, clickedItemIndex, androidx.media3.common.C.TIME_UNSET) + } + } + } catch (e: UnsupportedOperationException) { + completion.setException(e) + } catch (e: Exception) { + completion.setException(e) + } + } + return completion + } + + private suspend fun searchForMediaItem(item: MediaItem): List { + val text = item.requestMetadata.searchQuery?.trim() ?: "" + val list = app.reader.songListFlow.first() + // TODO support focus and sub queries (see MainActivity) + return if (text == "") list else list.filter { + // TODO sort results by match quality? (using raw=natural order) + // TODO this is copied directly from SearchFragment, which should probably call into + // here for its search needs instead in the future + val isMatchingTitle = + it.mediaMetadata.title?.contains(text, true) == true + val isMatchingAlbum = + it.mediaMetadata.albumTitle?.contains(text, true) == true + val isMatchingArtist = + it.mediaMetadata.artist?.contains(text, true) == true + isMatchingTitle || isMatchingAlbum || isMatchingArtist + } + } +} diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index 32515d47b..4fc7e6b97 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -134,7 +134,7 @@ import kotlin.random.Random * It's using exoplayer2 as its player backend. */ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Listener, - MediaLibraryService.MediaLibrarySession.Callback, Player.Listener, AnalyticsListener, + GramophoneLibrarySessionCallback.SessionDelegate, Player.Listener, AnalyticsListener, SharedPreferences.OnSharedPreferenceChangeListener { companion object { @@ -160,14 +160,15 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val endedWorkaroundPlayer get() = internalPlayer + private lateinit var libraryCallback: GramophoneLibrarySessionCallback + private fun convertMetadata(metadata: MediaMetadata): MediaMetadata { val artworkUri = metadata.artworkUri ?: return metadata val providerUri = GramophoneArtResolver.toProviderUri(artworkUri) ?: return metadata return metadata.buildUpon().setArtworkUri(providerUri).build() } - private fun convertItem(item: MediaItem?): MediaItem? { - if (item == null) return null + private fun convertItem(item: MediaItem): MediaItem { return item.buildUpon().setMediaMetadata(convertMetadata(item.mediaMetadata)).build() } private var controller: MediaBrowser? = null @@ -414,10 +415,10 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis val sessionPlayer = object : androidx.media3.common.ForwardingPlayer(player) { override fun getCurrentMediaItem(): MediaItem? { - return convertItem(super.getCurrentMediaItem()) + return super.getCurrentMediaItem()?.let { convertItem(it) } } override fun getMediaItemAt(index: Int): MediaItem { - return convertItem(super.getMediaItemAt(index)) ?: super.getMediaItemAt(index) + return convertItem(super.getMediaItemAt(index)) } override fun getMediaMetadata(): MediaMetadata { return convertMetadata(super.getMediaMetadata()) @@ -427,16 +428,24 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis return object : androidx.media3.exoplayer.source.ForwardingTimeline(original) { override fun getWindow(windowIndex: Int, window: androidx.media3.common.Timeline.Window, defaultPositionProjectionUs: Long): androidx.media3.common.Timeline.Window { super.getWindow(windowIndex, window, defaultPositionProjectionUs) - window.mediaItem = convertItem(window.mediaItem) ?: window.mediaItem + window.mediaItem = convertItem(window.mediaItem) return window } } } } + libraryCallback = GramophoneLibrarySessionCallback( + this, + gramophoneApplication, + lifecycleScope, + ::convertItem, + this + ) + mediaSession = MediaLibrarySession - .Builder(this, sessionPlayer, this) + .Builder(this, sessionPlayer, libraryCallback) // CacheBitmapLoader is required for MeiZuLyricsMediaNotificationProvider .setBitmapLoader(CacheBitmapLoader(object : BitmapLoader { // Coil-based bitmap loader to reuse Coil's caching and to make sure we use @@ -996,261 +1005,6 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis return settable } - override fun onGetLibraryRoot( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - params: LibraryParams? - ): ListenableFuture> { - val outParams = LibraryParams.Builder() - .setOffline(true) - .setSuggested(false) - .setRecent(false) - .build() - val item = MediaItem.Builder() - .setMediaId("root") - .setMediaMetadata(MediaMetadata.Builder() - .setIsBrowsable(true) - .setIsPlayable(false) - .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) - .build()) - .build() - return Futures.immediateFuture(LibraryResult.ofItem(item, outParams)) - } - - override fun onGetChildren( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - parentId: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val completion = SettableFuture.create>>() - lifecycleScope.launch(Dispatchers.Default) { - try { - val list = when (parentId) { - "root" -> { - val gridExtras = android.os.Bundle().apply { - putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - } - listOf( - createFolderItem("albums", getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras), - createFolderItem("artists", getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras), - createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED), - createFolderItem("playlists", getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) - ) - } - "albums" -> gramophoneApplication.reader.albumListFlow.first().map { createFolderItem("album_${it.id}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = it.albumArtist ?: it.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = it.cover, isPlayable = true, isBrowsable = false) } - "artists" -> gramophoneApplication.reader.artistListFlow.first().map { createFolderItem("artist_${it.title}", it.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = resources.getQuantityString(R.plurals.songs, it.songList.size, it.songList.size), artworkUri = it.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) } - "songs" -> gramophoneApplication.reader.songListFlow.first() - "playlists" -> gramophoneApplication.reader.playlistListFlow.first().map { - val title = when (it) { - is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> getString(R.string.recently_added) - is uk.akane.libphonograph.dynamicitem.Favorite -> getString(R.string.playlist_favourite) - else -> it.title ?: "" - } - val icon = when (it) { - is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_recently}") - is uk.akane.libphonograph.dynamicitem.Favorite -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_favorite}") - else -> null - } - val id = when (it) { - is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> "playlist_recently_added" - is uk.akane.libphonograph.dynamicitem.Favorite -> "playlist_favorite" - else -> "playlist_${it.id}" - } - createFolderItem(id, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon, isPlayable = true, isBrowsable = false) - } - else -> { - if (parentId.startsWith("album_")) { - val albumId = parentId.removePrefix("album_").toLongOrNull() - gramophoneApplication.reader.songListFlow.first().filter { it.mediaMetadata.albumId == albumId } - } else if (parentId.startsWith("artist_")) { - val artistName = parentId.removePrefix("artist_") - gramophoneApplication.reader.songListFlow.first().filter { it.mediaMetadata.artist == artistName } - } else if (parentId.startsWith("playlist_")) { - val playlistIdStr = parentId.removePrefix("playlist_") - val playlist = gramophoneApplication.reader.playlistListFlow.first().find { - when (playlistIdStr) { - "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded - "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite - else -> it.id?.toString() == playlistIdStr - } - } - playlist?.songList ?: emptyList() - } else emptyList() - } - } - - val finalPageSize = pageSize.coerceAtMost(200) - val pagedList = list.drop(page * finalPageSize).take(finalPageSize).map { convertItem(it)!! } - - completion.set(LibraryResult.ofItemList(ImmutableList.copyOf(pagedList), params)) - } catch (e: Exception) { - Log.w(TAG, "onGetChildren failed for $parentId", e) - completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) - } - } - return completion - } - - override fun onGetItem( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - mediaId: String - ): ListenableFuture> { - val completion = SettableFuture.create>() - lifecycleScope.launch(Dispatchers.Default) { - try { - val item = if (mediaId == "root") { - MediaItem.Builder() - .setMediaId("root") - .setMediaMetadata(MediaMetadata.Builder() - .setIsBrowsable(true) - .setIsPlayable(false) - .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) - .build()) - .build() - } else if (mediaId == "songs") { - createFolderItem("songs", getString(R.string.category_songs), MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) - } else if (mediaId == "albums") { - val gridExtras = android.os.Bundle().apply { - putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - } - createFolderItem("albums", getString(R.string.category_albums), MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS, extras = gridExtras) - } else if (mediaId == "artists") { - val gridExtras = android.os.Bundle().apply { - putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - putInt(MediaConstants.EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, MediaConstants.EXTRAS_VALUE_CONTENT_STYLE_GRID_ITEM) - } - createFolderItem("artists", getString(R.string.category_artists), MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS, extras = gridExtras) - } else if (mediaId == "playlists") { - createFolderItem("playlists", getString(R.string.category_playlists), MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS) - } else if (mediaId.startsWith("album_")) { - val albumId = mediaId.removePrefix("album_").toLongOrNull() - val album = gramophoneApplication.reader.albumListFlow.first().find { it.id == albumId } - if (album != null) createFolderItem("album_${album.id}", album.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = album.albumArtist ?: album.songList.firstOrNull()?.mediaMetadata?.artist?.toString(), artworkUri = album.cover, isPlayable = true, isBrowsable = false) else null - } else if (mediaId.startsWith("artist_")) { - val artistName = mediaId.removePrefix("artist_") - val artist = gramophoneApplication.reader.artistListFlow.first().find { it.title == artistName } - if (artist != null) createFolderItem("artist_${artist.title}", artist.title ?: "", MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, subtitle = resources.getQuantityString(R.plurals.songs, artist.songList.size, artist.songList.size), artworkUri = artist.albumList.firstOrNull()?.cover, isPlayable = true, isBrowsable = false) else null - } else if (mediaId.startsWith("playlist_")) { - val playlistIdStr = mediaId.removePrefix("playlist_") - val playlist = gramophoneApplication.reader.playlistListFlow.first().find { - when (playlistIdStr) { - "recently_added" -> it is uk.akane.libphonograph.dynamicitem.RecentlyAdded - "favorite" -> it is uk.akane.libphonograph.dynamicitem.Favorite - else -> it.id?.toString() == playlistIdStr - } - } - if (playlist != null) { - val title = when (playlist) { - is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> getString(R.string.recently_added) - is uk.akane.libphonograph.dynamicitem.Favorite -> getString(R.string.playlist_favourite) - else -> playlist.title ?: "" - } - val icon = when (playlist) { - is uk.akane.libphonograph.dynamicitem.RecentlyAdded -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_recently}") - is uk.akane.libphonograph.dynamicitem.Favorite -> android.net.Uri.parse("android.resource://$packageName/${R.drawable.ic_default_cover_playlist_favorite}") - else -> null - } - createFolderItem(mediaId, title, MediaMetadata.MEDIA_TYPE_FOLDER_MIXED, artworkUri = icon) - } else null - } else { - gramophoneApplication.reader.songListFlow.first().find { it.mediaId == mediaId } - } - - if (item != null) { - completion.set(LibraryResult.ofItem(convertItem(item)!!, null)) - } else { - completion.set(LibraryResult.ofError(SessionError.ERROR_BAD_VALUE)) - } - } catch (e: Exception) { - Log.w(TAG, "onGetItem failed for $mediaId", e) - completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) - } - } - return completion - } - - private fun createFolderItem( - id: String, - title: String, - mediaType: @MediaMetadata.MediaType Int, - subtitle: String? = null, - extras: android.os.Bundle? = null, - artworkUri: android.net.Uri? = null, - isPlayable: Boolean = false, - isBrowsable: Boolean = true - ): MediaItem { - val metadataBuilder = MediaMetadata.Builder() - .setTitle(title) - .setSubtitle(subtitle) - .setIsBrowsable(isBrowsable) - .setIsPlayable(isPlayable) - .setMediaType(mediaType) - if (extras != null) metadataBuilder.setExtras(extras) - if (artworkUri != null) metadataBuilder.setArtworkUri(artworkUri) - return MediaItem.Builder() - .setMediaId(id) - .setMediaMetadata(metadataBuilder.build()) - .build() - } - - override fun onSearch( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - params: LibraryParams? - ): ListenableFuture> { - val completion = SettableFuture.create>() - lifecycleScope.launch(Dispatchers.Default) { - try { - session.notifySearchResultChanged(browser, query, 0, params) - completion.set(LibraryResult.ofVoid()) - } catch (e: Exception) { - Log.w(TAG, "onSearch failed for $query", e) - completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) - } - } - return completion - } - - override fun onGetSearchResult( - session: MediaLibrarySession, - browser: MediaSession.ControllerInfo, - query: String, - page: Int, - pageSize: Int, - params: LibraryParams? - ): ListenableFuture>> { - val completion = SettableFuture.create>>() - lifecycleScope.launch(Dispatchers.Default) { - try { - val list = searchForMediaItemSync(query) - val finalPageSize = pageSize.coerceAtMost(200) - val pagedList = list.drop(page * finalPageSize).take(finalPageSize).map { convertItem(it)!! } - completion.set(LibraryResult.ofItemList(ImmutableList.copyOf(pagedList), params)) - } catch (e: Exception) { - Log.w(TAG, "onGetSearchResult failed for $query", e) - completion.set(LibraryResult.ofError(SessionError.ERROR_UNKNOWN)) - } - } - return completion - } - - private suspend fun searchForMediaItemSync(query: String): List { - val text = query.trim() - val list = gramophoneApplication.reader.songListFlow.first() - return if (text == "") list else list.filter { - val isMatchingTitle = it.mediaMetadata.title?.contains(text, true) == true - val isMatchingAlbum = it.mediaMetadata.albumTitle?.contains(text, true) == true - val isMatchingArtist = it.mediaMetadata.artist?.contains(text, true) == true - isMatchingTitle || isMatchingAlbum || isMatchingArtist - } - } override fun onTracksChanged(tracks: Tracks) { if (!tracks.isEmpty && !tracks.isTypeSelected(C.TRACK_TYPE_AUDIO)) { @@ -1477,64 +1231,6 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } - override fun onAddMediaItems( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo, - mediaItems: List - ): ListenableFuture> { - if (mediaItems.find { it.localConfiguration == null } == null) - return Futures.immediateFuture(mediaItems.map { convertItem(it)!! }) - val completion = SettableFuture.create>() - lifecycleScope.launch(Dispatchers.Default) { - try { - // Track which item was clicked (used to set starting index) - var clickedItemIndex = 0 - - val result = mediaItems.flatMap { - if (it.localConfiguration != null) - listOf(it) - else if (it.mediaId.startsWith("album_")) { - val albumId = it.mediaId.removePrefix("album_").toLongOrNull() - gramophoneApplication.reader.albumListFlow.first().find { a -> a.id == albumId }?.songList ?: emptyList() - } else if (it.mediaId.startsWith("artist_")) { - val artistName = it.mediaId.removePrefix("artist_") - gramophoneApplication.reader.artistListFlow.first().find { a -> a.title == artistName }?.songList ?: emptyList() - } else if (it.mediaId.startsWith("playlist_")) { - val playlistIdStr = it.mediaId.removePrefix("playlist_") - gramophoneApplication.reader.playlistListFlow.first().find { p -> - when (playlistIdStr) { - "recently_added" -> p is uk.akane.libphonograph.dynamicitem.RecentlyAdded - "favorite" -> p is uk.akane.libphonograph.dynamicitem.Favorite - else -> p.id?.toString() == playlistIdStr - } - }?.songList ?: emptyList() - } else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) { - // Load entire song list, track which item was clicked - val fullSongList = gramophoneApplication.reader.songListFlow.first() - clickedItemIndex = fullSongList.indexOfFirst { m -> m.mediaId == it.mediaId } - fullSongList - } - else if (it.requestMetadata.searchQuery != null) - searchForMediaItem(it) - else - throw UnsupportedOperationException("can't do anything with $it") - } - val convertedResult = result.map { convertItem(it)!! } - completion.set(convertedResult) - - // Set the clicked item as the starting point (must be done on main thread) - if (clickedItemIndex > 0 && convertedResult.isNotEmpty()) { - withContext(Dispatchers.Main) { - mediaSession.player.setMediaItems(convertedResult, clickedItemIndex, androidx.media3.common.C.TIME_UNSET) - } - } - } catch (e: UnsupportedOperationException) { - completion.setException(e) - } - } - return completion - } - private suspend fun searchForMediaItem(item: MediaItem): List { val text = item.requestMetadata.searchQuery?.trim() ?: "" val list = gramophoneApplication.reader.songListFlow.first() From a41575fb0b72ac18b16360f60f04dcb391196d71 Mon Sep 17 00:00:00 2001 From: Nathan Banks Date: Mon, 27 Apr 2026 00:50:16 +0000 Subject: [PATCH 5/5] refactor: introduce ArtCacheManager with artwork resizing and size-based cache separation This refactor centralizes album art caching and loading logic into a new ArtCacheManager. Key changes include: - Rename GramophoneArtResolver to just ArtResolver - New ArtCacheManager for shared in-process and cross-process caching. - Added ArtCacheManager.clearCache() and integrated it into the ViewPagerFragment's quick_refresh action. This ensures that when a user performs a quick library refresh, the album art cache is also purged. - Updated the standard Refresh menu item in ViewPagerFragment to also clear the artwork cache, ensuring consistency with the Quick Refresh behavior. - Updated ArtResource hierarchy in ArtResolver for canonical resolution. - Simplified GramophoneAlbumArtProvider and Coil fetchers. - Improved MIME type preservation via .mime companion files. - Maximize reuse by defaulting songs to folder-based album art. - Implemented image resizing (1024x1024 high-res, 300x300 low-res) and cache key separation. - ArtResolver now resizes folder art and compresses to JPEG 85. - ArtCacheManager handles size-specific cache buckets (300 vs 1024). - GramophoneApplication Coil fetcher chooses cache size based on request dimensions. - Song resolution for 300px thumbnails now follows Folder -> MediaStore chain, skipping embedded art. --- .../logic/GramophoneAlbumArtProvider.kt | 123 +----- .../gramophone/logic/GramophoneApplication.kt | 69 +--- .../logic/GramophoneLibrarySessionCallback.kt | 4 +- .../logic/GramophonePlaybackService.kt | 6 +- .../gramophone/logic/utils/ArtCacheManager.kt | 152 ++++++++ .../gramophone/logic/utils/ArtResolver.kt | 366 ++++++++++++++++++ .../logic/utils/GramophoneArtResolver.kt | 177 --------- .../ui/fragments/ViewPagerFragment.kt | 3 + 8 files changed, 542 insertions(+), 358 deletions(-) create mode 100644 app/src/main/java/org/akanework/gramophone/logic/utils/ArtCacheManager.kt create mode 100644 app/src/main/java/org/akanework/gramophone/logic/utils/ArtResolver.kt delete mode 100644 app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt index 1b88fcf4e..459a35bac 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt @@ -5,143 +5,26 @@ import android.content.ContentValues import android.database.Cursor import android.net.Uri import android.os.ParcelFileDescriptor -import androidx.media3.common.util.Log -import org.akanework.gramophone.logic.utils.GramophoneArtResolver -import java.io.File -import java.security.MessageDigest +import org.akanework.gramophone.logic.utils.ArtCacheManager /** * ContentProvider that serves album artwork to external processes (e.g. Android Auto). * * External processes cannot resolve Gramophone's internal URI schemes * (`gramophoneSongCover://`, `gramophoneAlbumCover://`). This provider acts as a bridge, - * using the shared [GramophoneArtResolver] to locate and serve the artwork over a + * using the shared [ArtCacheManager] to resolve, cache and serve the artwork over a * standard `content://` URI. * * URI format: `content://org.akanework.gramophone.albumart/{type}/{id}/{encodedPath}` * where `type` is "song" or "album". - * - * The provider writes artwork to a temporary cache file and returns a read-only - * [ParcelFileDescriptor] for it. Cache files are keyed by a hash of the URI to - * avoid redundant work on repeated requests. */ class GramophoneAlbumArtProvider : ContentProvider() { - private val TAG = "GramoArtProvider" - - companion object { - private const val MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024L // 50MB - } - override fun onCreate() = true override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { val context = context ?: return null - val segments = uri.pathSegments - - if (segments.size < 3) { - Log.w(TAG, "Invalid URI format, expected 3 path segments: $uri") - return null - } - - val type = segments[0] // "song" or "album" - val id = segments[1] // songId or albumId - val encodedPath = segments[2] - val realPath = Uri.decode(encodedPath) - - val cacheDir = File(context.cacheDir, "albumart") - if (!cacheDir.exists()) cacheDir.mkdirs() - - // Use SHA-256 as cache key to avoid collisions and re-extracting on repeated requests - val cacheFileName = sha256(uri.toString()) - val cacheFile = File(cacheDir, "art_$cacheFileName") - - if (!cacheFile.exists()) { - trimCacheIfNeeded(cacheDir) - - // Write to temp file first to avoid partial reads during write - // Use random suffix to avoid collisions when multiple threads cache same artwork - val randomSuffix = System.nanoTime().toString(36) // Unique per-request suffix - val tempFile = File(cacheDir, "art_${cacheFileName}_${randomSuffix}_tmp") - - val inputStream = when (type) { - "song" -> GramophoneArtResolver.openSongArtwork(context, id, realPath) - "album" -> GramophoneArtResolver.openAlbumArtwork(context, id, realPath) - else -> { - Log.w(TAG, "Unknown artwork type: $type") - null - } - } - - if (inputStream != null) { - try { - inputStream.use { input -> - tempFile.outputStream().use { output -> - input.copyTo(output) - output.flush() // Ensure data is written to buffer - output.fd.sync() // Force sync to disk - } - } - // Atomically rename temp file to final cache file - if (!tempFile.renameTo(cacheFile)) { - Log.w(TAG, "Failed to rename temp artwork cache file: $tempFile") - tempFile.delete() - return null - } - } catch (e: Exception) { - Log.w(TAG, "Failed to write artwork cache for $uri", e) - tempFile.delete() - cacheFile.delete() - return null - } - } - } else { - // Update last modified to keep it fresh in LRU - cacheFile.setLastModified(System.currentTimeMillis()) - } - - if (!cacheFile.exists()) return null - - return ParcelFileDescriptor.open(cacheFile, ParcelFileDescriptor.MODE_READ_ONLY) - } - - private fun sha256(input: String): String { - return try { - val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) - bytes.joinToString("") { "%02x".format(it) } - } catch (e: Exception) { - // Fallback to hashCode if SHA-256 fails (should not happen) - input.hashCode().toString() - } - } - - private fun trimCacheIfNeeded(cacheDir: File) { - try { - val files = cacheDir.listFiles() ?: return - - // Clean up stale temp files (older than 1 hour) - val oneHourAgo = System.currentTimeMillis() - (60 * 60 * 1000) - for (file in files) { - if (file.name.endsWith("_tmp") && file.lastModified() < oneHourAgo) { - file.delete() - } - } - - var currentSize = files.sumOf { it.length() } - if (currentSize <= MAX_CACHE_SIZE_BYTES) return - - // Sort by last modified (oldest first) - val sortedFiles = files.sortedBy { it.lastModified() } - for (file in sortedFiles) { - val fileSize = file.length() - if (file.delete()) { - currentSize -= fileSize - } - if (currentSize <= MAX_CACHE_SIZE_BYTES * 0.8) break // Trim to 80% to avoid immediate re-trim - } - } catch (e: Exception) { - Log.w(TAG, "Failed to trim cache", e) - } + return ArtCacheManager.openFileDescriptor(context, uri) } override fun query( diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt index 5077cebaf..51e213d5b 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneApplication.kt @@ -36,6 +36,7 @@ import androidx.fragment.app.strictmode.FragmentStrictMode import androidx.media3.common.util.Log import androidx.media3.session.DefaultMediaNotificationProvider import androidx.preference.PreferenceManager +import androidx.core.net.toUri import coil3.ImageLoader import coil3.PlatformContext import coil3.SingletonImageLoader @@ -49,6 +50,7 @@ import coil3.fetch.ImageFetchResult import coil3.fetch.SourceFetchResult import coil3.request.NullRequestDataException import coil3.size.pxOrElse +import coil3.size.pxOrElse import coil3.toCoilUri import coil3.util.Logger import kotlinx.coroutines.CoroutineScope @@ -67,7 +69,7 @@ import org.akanework.gramophone.logic.utils.Flags import org.akanework.gramophone.ui.LyricWidgetProvider import org.lsposed.hiddenapibypass.LSPass import org.nift4.gramophone.hificore.UacManager -import org.akanework.gramophone.logic.utils.GramophoneArtResolver +import org.akanework.gramophone.logic.utils.ArtCacheManager import uk.akane.libphonograph.reader.FlowReader import uk.akane.libphonograph.utils.MiscUtils import java.io.File @@ -256,62 +258,21 @@ class GramophoneApplication : Application(), SingletonImageLoader.Factory, .components { add(Fetcher.Factory { data, options, _ -> if (data !is Uri) return@Factory null - if (data.scheme != "gramophoneSongCover") return@Factory null + if (data.scheme != "gramophoneSongCover" && data.scheme != "gramophoneAlbumCover") return@Factory null return@Factory Fetcher { - val file = File(data.path!!) - val songId = data.authority!!.toLong() - val uri = GramophoneArtResolver.buildSongAlbumArtUri(songId) val requestWidth = options.size.width.pxOrElse { 0 } val requestHeight = options.size.height.pxOrElse { 0 } - val bmp = if (requestWidth > 300 && requestHeight > 300) { - GramophoneArtResolver.extractSongThumbnail( - file, requestWidth, requestHeight - ) - } else null - if (bmp != null) { - ImageFetchResult( - bmp.asImage(), true, DataSource.DISK - ) - } else { - val stream = contentResolver.openAssetFileDescriptor(uri, "r") - checkNotNull(stream) { "Unable to open '$uri'." } - SourceFetchResult( - source = ImageSource( - source = stream.createInputStream().source().buffer(), - fileSystem = options.fileSystem, - metadata = ContentMetadata(uri.toCoilUri(), stream), - ), - mimeType = contentResolver.getType(uri), - dataSource = DataSource.DISK, - ) - } - } - }) - add(Fetcher.Factory { data, options, _ -> - if (data !is Uri) return@Factory null - if (data.scheme != "gramophoneAlbumCover") return@Factory null - return@Factory Fetcher { - val cover = MiscUtils.findBestCover(File(data.path!!)) - if (cover == null) { - val albumId = data.authority!!.toLong() - val uri = GramophoneArtResolver.buildAlbumCoverUri(albumId) - val contentResolver = options.context.contentResolver - val afd = contentResolver.openAssetFileDescriptor(uri, "r") - checkNotNull(afd) { "Unable to open '$uri'." } - return@Fetcher SourceFetchResult( - source = ImageSource( - source = afd.createInputStream().source().buffer(), - fileSystem = options.fileSystem, - metadata = ContentMetadata(data, afd), - ), - mimeType = contentResolver.getType(uri), - dataSource = DataSource.DISK, - ) - } - return@Fetcher SourceFetchResult( - ImageSource(cover.toOkioPath(), options.fileSystem, null, null, null), - MimeTypeMap.getSingleton().getMimeTypeFromExtension(cover.extension), - DataSource.DISK + val size = if (requestWidth > 0 && requestWidth <= 300 && requestHeight > 0 && requestHeight <= 300) 300 else 1024 + + val art = ArtCacheManager.getArt(options.context, data.toString().toUri(), size) + checkNotNull(art) { "Unable to open '$data'." } + SourceFetchResult( + source = ImageSource( + source = art.file.inputStream().source().buffer(), + fileSystem = options.fileSystem, + ), + mimeType = art.mimeType, + dataSource = DataSource.DISK, ) } }) diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt index 5bf60027c..dfed91291 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt @@ -8,7 +8,6 @@ import androidx.media3.common.MediaMetadata import androidx.media3.common.Rating import androidx.media3.common.util.Log import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.MediaLibraryService.MediaLibrarySession import androidx.media3.session.MediaSession @@ -24,7 +23,6 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.akanework.gramophone.R -import org.akanework.gramophone.logic.utils.GramophoneArtResolver import androidx.media3.session.MediaConstants import com.google.common.util.concurrent.Futures import uk.akane.libphonograph.items.albumId @@ -41,7 +39,7 @@ class GramophoneLibrarySessionCallback( private val delegate: SessionDelegate ) : MediaLibrarySession.Callback { - private val TAG = "GramoLibraryCallback" + private val TAG = "GramophoneLibrarySessionCallback" interface SessionDelegate { fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult diff --git a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt index 4fc7e6b97..576e31bde 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -81,8 +81,6 @@ import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession.MediaItemsWithStartPosition import androidx.media3.session.MediaSessionService import androidx.media3.session.SessionCommand -import androidx.media3.session.LibraryResult -import androidx.media3.session.MediaLibraryService.LibraryParams import androidx.media3.session.SessionError import androidx.media3.session.SessionResult import androidx.preference.PreferenceManager @@ -118,7 +116,7 @@ import org.akanework.gramophone.logic.utils.LrcUtils.loadAndParseLyricsFile import org.akanework.gramophone.logic.utils.ReplayGainAudioProcessor import org.akanework.gramophone.logic.utils.ReplayGainUtil import org.akanework.gramophone.logic.utils.SemanticLyrics -import org.akanework.gramophone.logic.utils.GramophoneArtResolver +import org.akanework.gramophone.logic.utils.ArtResolver import org.akanework.gramophone.logic.utils.exoplayer.EndedWorkaroundPlayer import org.akanework.gramophone.logic.utils.exoplayer.GramophoneExtractorsFactory import org.akanework.gramophone.logic.utils.exoplayer.GramophoneMediaSourceFactory @@ -164,7 +162,7 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis private fun convertMetadata(metadata: MediaMetadata): MediaMetadata { val artworkUri = metadata.artworkUri ?: return metadata - val providerUri = GramophoneArtResolver.toProviderUri(artworkUri) ?: return metadata + val providerUri = ArtResolver.toProviderUri(artworkUri) ?: return metadata return metadata.buildUpon().setArtworkUri(providerUri).build() } diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/ArtCacheManager.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/ArtCacheManager.kt new file mode 100644 index 000000000..032b886c9 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/ArtCacheManager.kt @@ -0,0 +1,152 @@ +package org.akanework.gramophone.logic.utils + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import androidx.media3.common.util.Log +import java.io.File +import java.io.InputStream +import java.security.MessageDigest + +/** + * Unified entry point for album art caching and materialization. + */ +object ArtCacheManager { + + private const val TAG = "ArtCacheManager" + private const val MAX_CACHE_SIZE_BYTES = 50 * 1024 * 1024L // 50MB + + private fun getCacheDir(context: Context): File { + val dir = File(context.cacheDir, "albumart") + if (!dir.exists()) dir.mkdirs() + return dir + } + + /** + * Opens an [InputStream] for the given URI, using the cache if available. + */ + fun openInputStream(context: Context, uri: Uri, size: Int = 1024): InputStream? { + return getArt(context, uri, size)?.file?.inputStream() + } + + /** + * Opens a [ParcelFileDescriptor] for the given URI, using the cache if available. + */ + fun openFileDescriptor(context: Context, uri: Uri, size: Int = 1024): ParcelFileDescriptor? { + val file = getArt(context, uri, size)?.file ?: return null + return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + } + + /** + * Represents a cached artwork and its MIME type. + */ + data class ArtResult(val file: File, val mimeType: String) + + /** + * Retrieves the cached artwork for the given URI, materializing it if necessary. + */ + fun getArt(context: Context, uri: Uri, size: Int = 1024): ArtResult? { + val candidates = ArtResolver.getResolutionList(uri, size) + for (candidate in candidates) { + val result = getOrMaterialize(context, candidate, size) + if (result != null) return result + } + return null + } + + private fun getOrMaterialize(context: Context, resource: ArtResolver.ArtResource, size: Int): ArtResult? { + val cacheDir = getCacheDir(context) + val key = sha256(resource.toCacheKey(size)) + val cacheFile = File(cacheDir, "art_$key") + val mimeFile = File(cacheDir, "art_$key.mime") + + if (cacheFile.exists()) { + cacheFile.setLastModified(System.currentTimeMillis()) + val mimeType = if (mimeFile.exists()) mimeFile.readText() else "image/jpeg" + return ArtResult(cacheFile, mimeType) + } + + // Materialize + val artStream = ArtResolver.openResourceStream(context, resource, size) ?: return null + + trimCacheIfNeeded(cacheDir) + + val randomSuffix = System.nanoTime().toString(36) + val tempFile = File(cacheDir, "art_${key}_${randomSuffix}_tmp") + + return try { + artStream.stream.use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + output.flush() + output.fd.sync() + } + } + if (tempFile.renameTo(cacheFile)) { + mimeFile.writeText(artStream.mimeType) + ArtResult(cacheFile, artStream.mimeType) + } else { + Log.w(TAG, "Failed to rename temp file to $cacheFile") + tempFile.delete() + null + } + } catch (e: Exception) { + Log.w(TAG, "Failed to materialize artwork for $resource", e) + tempFile.delete() + null + } + } + + private fun sha256(input: String): String { + return try { + val bytes = MessageDigest.getInstance("SHA-256").digest(input.toByteArray()) + bytes.joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + input.hashCode().toString() + } + } + + private fun trimCacheIfNeeded(cacheDir: File) { + try { + val files = cacheDir.listFiles() ?: return + + // Clean up stale temp files + val oneHourAgo = System.currentTimeMillis() - (60 * 60 * 1000) + for (file in files) { + if (file.name.endsWith("_tmp") && file.lastModified() < oneHourAgo) { + file.delete() + } + } + + var currentSize = files.sumOf { it.length() } + if (currentSize <= MAX_CACHE_SIZE_BYTES) return + + val sortedFiles = files.sortedBy { it.lastModified() } + for (file in sortedFiles) { + val fileSize = file.length() + if (file.delete()) { + currentSize -= fileSize + } + if (currentSize <= MAX_CACHE_SIZE_BYTES * 0.8) break + } + } catch (e: Exception) { + Log.w(TAG, "Failed to trim cache", e) + } + } + + /** + * Clears the entire artwork cache. + */ + fun clearCache(context: Context) { + try { + val cacheDir = getCacheDir(context) + val files = cacheDir.listFiles() ?: return + for (file in files) { + file.delete() + } + Log.i(TAG, "Artwork cache cleared") + } catch (e: Exception) { + Log.w(TAG, "Failed to clear artwork cache", e) + } + } +} diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/ArtResolver.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/ArtResolver.kt new file mode 100644 index 000000000..aeb83372a --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/utils/ArtResolver.kt @@ -0,0 +1,366 @@ +package org.akanework.gramophone.logic.utils + +import android.content.ContentResolver +import android.content.ContentUris +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.ThumbnailUtils +import android.net.Uri +import android.provider.MediaStore +import android.util.Size +import android.webkit.MimeTypeMap +import androidx.media3.common.util.Log +import org.akanework.gramophone.BuildConfig +import org.akanework.gramophone.logic.hasScopedStorageV1 +import uk.akane.libphonograph.Constants +import uk.akane.libphonograph.utils.MiscUtils +import java.io.File +import java.io.IOException +import java.io.InputStream + +/** + * Shared artwork resolution logic used by both the in-process Coil image loader + * and the cross-process [org.akanework.gramophone.logic.GramophoneAlbumArtProvider]. + * + * This avoids duplicating the cover art discovery strategy across multiple components. + * + * URI schemes handled: + * - `gramophoneSongCover:///` — per-song embedded art with MediaStore fallback + * - `gramophoneAlbumCover:///` — folder-based cover art with MediaStore fallback + */ +object ArtResolver { + + private const val TAG = "ArtResolver" + + /** + * Represents a canonical artwork source. + */ + sealed class ArtResource { + data class SongEmbedded(val songId: String, val path: String) : ArtResource() + data class AlbumFolder(val folderPath: String) : ArtResource() + data class SongMediaStore(val songId: String) : ArtResource() + data class AlbumMediaStore(val albumId: String) : ArtResource() + + /** + * Returns a stable cache key for this resource. + */ + fun toCacheKey(size: Int): String = when (this) { + is SongEmbedded -> "SongEmbedded:$songId:$size" + is AlbumFolder -> "AlbumFolder:$folderPath:$size" + is SongMediaStore -> "SongMediaStore:$songId:$size" + is AlbumMediaStore -> "AlbumMediaStore:$albumId:$size" + } + } + + /** Authority for the ContentProvider that serves art to external processes. */ + const val PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.albumart" + + // not actually defined in API, but CTS tested + // https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/MediaProvider/src/com/android/providers/media/LocalUriMatcher.java;drc=ddf0d00b2b84b205a2ab3581df8184e756462e8d;l=182 + private const val MEDIA_ALBUM_ART = "albumart" + + /** + * Parses a URI into an ordered list of potential [ArtResource] candidates for a given size. + */ + fun getResolutionList(uri: Uri, size: Int): List { + val scheme = uri.scheme + val authority = uri.authority ?: return emptyList() + val path = uri.path ?: "" + + return when { + scheme == "gramophoneSongCover" -> { + val songId = authority + val filePath = path + val parentPath = File(filePath).parent + val list = mutableListOf() + if (parentPath != null) { + list.add(ArtResource.AlbumFolder(parentPath)) + } + // Skip embedded art for small thumbnails to use folder art or mediastore fallback + if (size > 300) { + list.add(ArtResource.SongEmbedded(songId, filePath)) + } + list.add(ArtResource.SongMediaStore(songId)) + list + } + scheme == "gramophoneAlbumCover" -> { + val albumId = authority + val folderPath = path + listOf( + ArtResource.AlbumFolder(folderPath), + ArtResource.AlbumMediaStore(albumId) + ) + } + scheme == ContentResolver.SCHEME_CONTENT && authority == PROVIDER_AUTHORITY -> { + val segments = uri.pathSegments + if (segments.size < 3) return emptyList() + val type = segments[0] + val id = segments[1] + val realPath = Uri.decode(segments[2]) + + if (type == "song") { + val parentPath = File(realPath).parent + val list = mutableListOf() + if (parentPath != null) { + list.add(ArtResource.AlbumFolder(parentPath)) + } + if (size > 300) { + list.add(ArtResource.SongEmbedded(id, realPath)) + } + list.add(ArtResource.SongMediaStore(id)) + list + } else if (type == "album") { + listOf( + ArtResource.AlbumFolder(realPath), + ArtResource.AlbumMediaStore(id) + ) + } else emptyList() + } + else -> emptyList() + } + } + + /** + * Represents a canonical artwork source and its metadata. + */ + data class ArtStream(val stream: InputStream, val mimeType: String) + + /** + * Opens an [ArtStream] for the specific [ArtResource] and size. + */ + fun openResourceStream(context: Context, resource: ArtResource, size: Int): ArtStream? { + return when (resource) { + is ArtResource.SongEmbedded -> { + val bmp = extractSongThumbnail(File(resource.path), size, size) + if (bmp != null) { + val stream = java.io.ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bmp.recycle() + ArtStream(java.io.ByteArrayInputStream(stream.toByteArray()), "image/jpeg") + } else null + } + is ArtResource.AlbumFolder -> { + val cover = MiscUtils.findBestCover(File(resource.folderPath)) + if (cover != null) { + try { + val bmp = decodeAndResize(cover, size) + if (bmp != null) { + val stream = java.io.ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bmp.recycle() + ArtStream(java.io.ByteArrayInputStream(stream.toByteArray()), "image/jpeg") + } else { + // Should not happen if decodeAndResize is correct + val mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(cover.extension) ?: "image/jpeg" + ArtStream(cover.inputStream(), mimeType) + } + } catch (e: Exception) { + Log.d(TAG, "Failed to open folder cover at ${cover.path}", e) + null + } + } else null + } + is ArtResource.SongMediaStore -> { + val mediaStoreUri = buildSongAlbumArtUri(resource.songId.toLong()) + try { + val stream = context.contentResolver.openInputStream(mediaStoreUri) + val mimeType = context.contentResolver.getType(mediaStoreUri) ?: "image/jpeg" + if (stream != null) ArtStream(stream, mimeType) else null + } catch (e: Exception) { + Log.d(TAG, "Failed to open MediaStore song art for id=${resource.songId}", e) + null + } + } + is ArtResource.AlbumMediaStore -> { + val mediaStoreUri = buildAlbumCoverUri(resource.albumId.toLong()) + try { + val stream = context.contentResolver.openInputStream(mediaStoreUri) + val mimeType = context.contentResolver.getType(mediaStoreUri) ?: "image/jpeg" + if (stream != null) ArtStream(stream, mimeType) else null + } catch (e: Exception) { + Log.d(TAG, "Failed to open MediaStore album art for id=${resource.albumId}", e) + null + } + } + } + } + + /** + * Safely decodes and resizes an image file if it is larger than the specified maxSize. + * Always returns a Bitmap if decoding succeeds, to allow for normalization. + */ + fun decodeAndResize(file: File, maxSize: Int): Bitmap? { + return try { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.absolutePath, options) + + var inSampleSize = 1 + if (options.outHeight > maxSize || options.outWidth > maxSize) { + val halfHeight: Int = options.outHeight / 2 + val halfWidth: Int = options.outWidth / 2 + while (halfHeight / inSampleSize >= maxSize && halfWidth / inSampleSize >= maxSize) { + inSampleSize *= 2 + } + } + + val decodeOptions = BitmapFactory.Options().apply { + this.inSampleSize = inSampleSize + } + val sampledBitmap = BitmapFactory.decodeFile(file.absolutePath, decodeOptions) ?: return null + + if (sampledBitmap.width > maxSize || sampledBitmap.height > maxSize) { + val scale = maxSize.toFloat() / Math.max(sampledBitmap.width, sampledBitmap.height) + val width = (sampledBitmap.width * scale).toInt() + val height = (sampledBitmap.height * scale).toInt() + val resized = Bitmap.createScaledBitmap(sampledBitmap, width, height, true) + if (resized != sampledBitmap) { + sampledBitmap.recycle() + } + resized + } else { + sampledBitmap + } + } catch (e: Exception) { + Log.w(TAG, "Failed to decode/resize ${file.path}", e) + null + } + } + + /** + * Builds a `content://` URI pointing to [GramophoneAlbumArtProvider] for the given + * internal artwork URI scheme. This URI is safe to hand to external processes + * (e.g. Android Auto) which cannot resolve our custom schemes. + * + * @param type "song" or "album" + * @param id the song or album ID as a string + * @param path the file/folder path (will be URI-encoded) + */ + fun buildProviderUri(type: String, id: String, path: String): Uri = + Uri.Builder() + .scheme(ContentResolver.SCHEME_CONTENT) + .authority(PROVIDER_AUTHORITY) + .appendPath(type) + .appendPath(id) + .appendPath(Uri.encode(path)) + .build() + + /** + * Converts a `gramophoneSongCover://` or `gramophoneAlbumCover://` URI into a + * `content://` URI backed by the album art provider, suitable for cross-process use. + * + * Returns `null` if the URI is not one of the custom schemes. + */ + fun toProviderUri(uri: Uri): Uri? { + return when (uri.scheme) { + "gramophoneSongCover" -> buildProviderUri( + "song", + uri.authority ?: "0", + uri.path ?: "" + ) + "gramophoneAlbumCover" -> buildProviderUri( + "album", + uri.authority ?: "0", + uri.path ?: "" + ) + else -> null + } + } + + /** + * Attempts to extract embedded artwork from a song file via [ThumbnailUtils]. + * Only available on Android Q+. + * + * @param file the audio file + * @param width desired thumbnail width (use 0 for default) + * @param height desired thumbnail height (use 0 for default) + * @return the extracted bitmap, or `null` if extraction failed or is unavailable + */ + fun extractSongThumbnail(file: File, width: Int = 512, height: Int = 512): Bitmap? { + if (!hasScopedStorageV1()) return null // ThumbnailUtils.createAudioThumbnail requires Q+ + return try { + ThumbnailUtils.createAudioThumbnail(file, Size(width, height), null) + } catch (e: IOException) { + if (e.message != "No embedded album art found" && + e.message != "No thumbnails in Downloads directories" && + e.message != "No thumbnails in top-level directories" && + e.message != "No album art found" + ) { + Log.w(TAG, "Unexpected IOException extracting song thumbnail", e) + } + null + } + } + + /** + * Returns a MediaStore URI for the song's album art (the `albumart` pseudo-path). + */ + fun buildSongAlbumArtUri(songId: Long): Uri = + ContentUris.appendId( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon(), songId + ).appendPath(MEDIA_ALBUM_ART).build() + + /** + * Returns a MediaStore URI for the album's cover art. + */ + fun buildAlbumCoverUri(albumId: Long): Uri = + ContentUris.withAppendedId(Constants.baseAlbumCoverUri, albumId) + + /** + * Opens an [InputStream] for the song's artwork, trying embedded art first + * then falling back to the MediaStore album art URI. + * + * @return an [InputStream] for the artwork, or `null` if no artwork is available + */ + fun openSongArtwork(context: Context, songId: String, filePath: String): InputStream? { + val file = File(filePath) + + // Try extracting embedded thumbnail and writing to a temp bitmap stream + val bmp = extractSongThumbnail(file) + if (bmp != null) { + val stream = java.io.ByteArrayOutputStream() + bmp.compress(Bitmap.CompressFormat.JPEG, 85, stream) + bmp.recycle() + return java.io.ByteArrayInputStream(stream.toByteArray()) + } + + // Fallback to MediaStore album art + val mediaStoreUri = buildSongAlbumArtUri(songId.toLong()) + return try { + context.contentResolver.openInputStream(mediaStoreUri) + } catch (e: Exception) { + Log.d(TAG, "Failed to open MediaStore song art for id=$songId", e) + null + } + } + + /** + * Opens an [InputStream] for the album's artwork, trying folder-based cover art first + * (via [MiscUtils.findBestCover]) then falling back to the MediaStore album cover URI. + * + * @return an [InputStream] for the artwork, or `null` if no artwork is available + */ + fun openAlbumArtwork(context: Context, albumId: String, folderPath: String): InputStream? { + // Try folder-based cover art (cover.jpg, albumart.png, etc.) + val cover = MiscUtils.findBestCover(File(folderPath)) + if (cover != null) { + return try { + cover.inputStream() + } catch (e: Exception) { + Log.d(TAG, "Failed to open folder cover at ${cover.path}", e) + null + } + } + + // Fallback to MediaStore album cover + val mediaStoreUri = buildAlbumCoverUri(albumId.toLong()) + return try { + context.contentResolver.openInputStream(mediaStoreUri) + } catch (e: Exception) { + Log.d(TAG, "Failed to open MediaStore album art for id=$albumId", e) + null + } + } +} diff --git a/app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt b/app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt deleted file mode 100644 index 5bc7edad6..000000000 --- a/app/src/main/java/org/akanework/gramophone/logic/utils/GramophoneArtResolver.kt +++ /dev/null @@ -1,177 +0,0 @@ -package org.akanework.gramophone.logic.utils - -import android.content.ContentResolver -import android.content.ContentUris -import android.content.Context -import android.graphics.Bitmap -import android.media.ThumbnailUtils -import android.net.Uri -import android.os.Build -import android.os.ParcelFileDescriptor -import android.provider.MediaStore -import android.util.Size -import androidx.media3.common.util.Log -import org.akanework.gramophone.BuildConfig -import org.akanework.gramophone.logic.hasScopedStorageV1 -import uk.akane.libphonograph.Constants -import uk.akane.libphonograph.utils.MiscUtils -import java.io.File -import java.io.IOException -import java.io.InputStream - -/** - * Shared artwork resolution logic used by both the in-process Coil image loader - * and the cross-process [org.akanework.gramophone.logic.GramophoneAlbumArtProvider]. - * - * This avoids duplicating the cover art discovery strategy across multiple components. - * - * URI schemes handled: - * - `gramophoneSongCover:///` — per-song embedded art with MediaStore fallback - * - `gramophoneAlbumCover:///` — folder-based cover art with MediaStore fallback - */ -object GramophoneArtResolver { - - private const val TAG = "GramophoneArtResolver" - - /** Authority for the ContentProvider that serves art to external processes. */ - const val PROVIDER_AUTHORITY = "${BuildConfig.APPLICATION_ID}.albumart" - - // not actually defined in API, but CTS tested - // https://cs.android.com/android/platform/superproject/main/+/main:packages/providers/MediaProvider/src/com/android/providers/media/LocalUriMatcher.java;drc=ddf0d00b2b84b205a2ab3581df8184e756462e8d;l=182 - private const val MEDIA_ALBUM_ART = "albumart" - - /** - * Builds a `content://` URI pointing to [GramophoneAlbumArtProvider] for the given - * internal artwork URI scheme. This URI is safe to hand to external processes - * (e.g. Android Auto) which cannot resolve our custom schemes. - * - * @param type "song" or "album" - * @param id the song or album ID as a string - * @param path the file/folder path (will be URI-encoded) - */ - fun buildProviderUri(type: String, id: String, path: String): Uri = - Uri.Builder() - .scheme(ContentResolver.SCHEME_CONTENT) - .authority(PROVIDER_AUTHORITY) - .appendPath(type) - .appendPath(id) - .appendPath(Uri.encode(path)) - .build() - - /** - * Converts a `gramophoneSongCover://` or `gramophoneAlbumCover://` URI into a - * `content://` URI backed by the album art provider, suitable for cross-process use. - * - * Returns `null` if the URI is not one of the custom schemes. - */ - fun toProviderUri(uri: Uri): Uri? { - return when (uri.scheme) { - "gramophoneSongCover" -> buildProviderUri( - "song", - uri.authority ?: "0", - uri.path ?: "" - ) - "gramophoneAlbumCover" -> buildProviderUri( - "album", - uri.authority ?: "0", - uri.path ?: "" - ) - else -> null - } - } - - /** - * Attempts to extract embedded artwork from a song file via [ThumbnailUtils]. - * Only available on Android Q+. - * - * @param file the audio file - * @param width desired thumbnail width (use 0 for default) - * @param height desired thumbnail height (use 0 for default) - * @return the extracted bitmap, or `null` if extraction failed or is unavailable - */ - fun extractSongThumbnail(file: File, width: Int = 512, height: Int = 512): Bitmap? { - if (!hasScopedStorageV1()) return null // ThumbnailUtils.createAudioThumbnail requires Q+ - return try { - ThumbnailUtils.createAudioThumbnail(file, Size(width, height), null) - } catch (e: IOException) { - if (e.message != "No embedded album art found" && - e.message != "No thumbnails in Downloads directories" && - e.message != "No thumbnails in top-level directories" && - e.message != "No album art found" - ) { - Log.w(TAG, "Unexpected IOException extracting song thumbnail", e) - } - null - } - } - - /** - * Returns a MediaStore URI for the song's album art (the `albumart` pseudo-path). - */ - fun buildSongAlbumArtUri(songId: Long): Uri = - ContentUris.appendId( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon(), songId - ).appendPath(MEDIA_ALBUM_ART).build() - - /** - * Returns a MediaStore URI for the album's cover art. - */ - fun buildAlbumCoverUri(albumId: Long): Uri = - ContentUris.withAppendedId(Constants.baseAlbumCoverUri, albumId) - - /** - * Opens an [InputStream] for the song's artwork, trying embedded art first - * then falling back to the MediaStore album art URI. - * - * @return an [InputStream] for the artwork, or `null` if no artwork is available - */ - fun openSongArtwork(context: Context, songId: String, filePath: String): InputStream? { - val file = File(filePath) - - // Try extracting embedded thumbnail and writing to a temp bitmap stream - val bmp = extractSongThumbnail(file) - if (bmp != null) { - val stream = java.io.ByteArrayOutputStream() - bmp.compress(Bitmap.CompressFormat.JPEG, 85, stream) - bmp.recycle() - return java.io.ByteArrayInputStream(stream.toByteArray()) - } - - // Fallback to MediaStore album art - val mediaStoreUri = buildSongAlbumArtUri(songId.toLong()) - return try { - context.contentResolver.openInputStream(mediaStoreUri) - } catch (e: Exception) { - Log.d(TAG, "Failed to open MediaStore song art for id=$songId", e) - null - } - } - - /** - * Opens an [InputStream] for the album's artwork, trying folder-based cover art first - * (via [MiscUtils.findBestCover]) then falling back to the MediaStore album cover URI. - * - * @return an [InputStream] for the artwork, or `null` if no artwork is available - */ - fun openAlbumArtwork(context: Context, albumId: String, folderPath: String): InputStream? { - // Try folder-based cover art (cover.jpg, albumart.png, etc.) - val cover = MiscUtils.findBestCover(File(folderPath)) - if (cover != null) { - return try { - cover.inputStream() - } catch (e: Exception) { - Log.d(TAG, "Failed to open folder cover at ${cover.path}", e) - null - } - } - - // Fallback to MediaStore album cover - val mediaStoreUri = buildAlbumCoverUri(albumId.toLong()) - return try { - context.contentResolver.openInputStream(mediaStoreUri) - } catch (e: Exception) { - Log.d(TAG, "Failed to open MediaStore album art for id=$albumId", e) - null - } - } -} diff --git a/app/src/main/java/org/akanework/gramophone/ui/fragments/ViewPagerFragment.kt b/app/src/main/java/org/akanework/gramophone/ui/fragments/ViewPagerFragment.kt index 5b5499337..8774f205d 100644 --- a/app/src/main/java/org/akanework/gramophone/ui/fragments/ViewPagerFragment.kt +++ b/app/src/main/java/org/akanework/gramophone/ui/fragments/ViewPagerFragment.kt @@ -49,6 +49,7 @@ import org.akanework.gramophone.logic.clone import org.akanework.gramophone.logic.enableEdgeToEdgePaddingListener import org.akanework.gramophone.logic.needsManualSnackBarInset import org.akanework.gramophone.logic.updateMargin +import org.akanework.gramophone.logic.utils.ArtCacheManager import org.akanework.gramophone.logic.utils.SdScanner import org.akanework.gramophone.ui.MainActivity import org.akanework.gramophone.ui.adapters.ViewPager2Adapter @@ -121,6 +122,7 @@ class ViewPagerFragment : BaseFragment(true) { } R.id.quick_refresh -> { + ArtCacheManager.clearCache(requireContext()) val playerLayout = activity.playerBottomSheet activity.updateLibrary { showRefreshDoneSnackBar( @@ -130,6 +132,7 @@ class ViewPagerFragment : BaseFragment(true) { } R.id.refresh -> { + ArtCacheManager.clearCache(requireContext()) val context = requireContext() val playerLayout = activity.playerBottomSheet MaterialAlertDialogBuilder(context)