diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4b634b874..bcf01779c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -69,11 +69,9 @@ - + + @@ -237,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..459a35bac --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneAlbumArtProvider.kt @@ -0,0 +1,54 @@ +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 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 [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". + */ +class GramophoneAlbumArtProvider : ContentProvider() { + + override fun onCreate() = true + + override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { + val context = context ?: return null + return ArtCacheManager.openFileDescriptor(context, uri) + } + + 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..51e213d5b 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 @@ -39,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 @@ -52,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 @@ -70,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 uk.akane.libphonograph.Constants +import org.akanework.gramophone.logic.utils.ArtCacheManager import uk.akane.libphonograph.reader.FlowReader import uk.akane.libphonograph.utils.MiscUtils import java.io.File @@ -82,10 +81,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 { @@ -263,79 +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 - 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 - if (data !is Uri) return@Factory null - if (data.scheme != "gramophoneAlbumCover") return@Factory null + if (data.scheme != "gramophoneSongCover" && data.scheme != "gramophoneAlbumCover") return@Factory null return@Factory Fetcher { - val cover = MiscUtils.findBestCover(File(data.path!!)) - if (cover == null) { - val uri = - ContentUris.withAppendedId( - Constants.baseAlbumCoverUri, - data.authority!!.toLong() - ) - 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 requestWidth = options.size.width.pxOrElse { 0 } + val requestHeight = options.size.height.pxOrElse { 0 } + 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 new file mode 100644 index 000000000..dfed91291 --- /dev/null +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophoneLibrarySessionCallback.kt @@ -0,0 +1,395 @@ +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.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 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 = "GramophoneLibrarySessionCallback" + + 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 0e6d8c033..576e31bde 100644 --- a/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt +++ b/app/src/main/java/org/akanework/gramophone/logic/GramophonePlaybackService.kt @@ -116,6 +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.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 @@ -131,7 +132,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 { @@ -153,8 +154,21 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis private val internalPlaybackThread = HandlerThread("ExoPlayer:Playback", Process.THREAD_PRIORITY_AUDIO) private var mediaSession: MediaLibrarySession? = null + private var internalPlayer: EndedWorkaroundPlayer? = null val endedWorkaroundPlayer - get() = mediaSession?.player as EndedWorkaroundPlayer? + get() = internalPlayer + + private lateinit var libraryCallback: GramophoneLibrarySessionCallback + + private fun convertMetadata(metadata: MediaMetadata): MediaMetadata { + val artworkUri = metadata.artworkUri ?: return metadata + val providerUri = ArtResolver.toProviderUri(artworkUri) ?: return metadata + return metadata.buildUpon().setArtworkUri(providerUri).build() + } + + private fun convertItem(item: MediaItem): MediaItem { + return item.buildUpon().setMediaMetadata(convertMetadata(item.mediaMetadata)).build() + } private var controller: MediaBrowser? = null private val sendLyrics = Runnable { scheduleSendingLyrics(false) } var lyrics: SemanticLyrics? = null @@ -394,10 +408,42 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis player.exoPlayer.setShuffleOrder(CircularShuffleOrder(player, 0, 0, Random.nextLong())) lastPlayedManager = LastPlayedManager(this, player) lastPlayedManager.allowSavingState = false + internalPlayer = player + + val sessionPlayer = object : androidx.media3.common.ForwardingPlayer(player) { + + override fun getCurrentMediaItem(): MediaItem? { + return super.getCurrentMediaItem()?.let { convertItem(it) } + } + override fun getMediaItemAt(index: Int): MediaItem { + return convertItem(super.getMediaItemAt(index)) + } + override fun getMediaMetadata(): MediaMetadata { + return convertMetadata(super.getMediaMetadata()) + } + override fun getCurrentTimeline(): androidx.media3.common.Timeline { + val original = super.getCurrentTimeline() + 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) + return window + } + } + } + } + + libraryCallback = GramophoneLibrarySessionCallback( + this, + gramophoneApplication, + lifecycleScope, + ::convertItem, + this + ) mediaSession = MediaLibrarySession - .Builder(this, player, 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 @@ -501,6 +547,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() @@ -956,25 +1003,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) - .build()) - .build() - return Futures.immediateFuture(LibraryResult.ofItem(item, outParams)) - }*/ override fun onTracksChanged(tracks: Tracks) { if (!tracks.isEmpty && !tracks.isTypeSelected(C.TRACK_TYPE_AUDIO)) { @@ -1201,35 +1229,6 @@ class GramophonePlaybackService : MediaLibraryService(), MediaSessionService.Lis } } - override fun onAddMediaItems( - mediaSession: MediaSession, - controller: MediaSession.ControllerInfo, - mediaItems: List - ): ListenableFuture> { - if (mediaItems.find { it.localConfiguration == null } == null) // fast path - return Futures.immediateFuture(mediaItems) - val completion = SettableFuture.create>() - lifecycleScope.launch(Dispatchers.Default) { - try { - val result = mediaItems.flatMap { - if (it.localConfiguration != null) - listOf(it) - else if (it.mediaId != MediaItem.DEFAULT_MEDIA_ID) - gramophoneApplication.reader.songListFlow.first() - .filter { m -> m.mediaId == it.mediaId } - else if (it.requestMetadata.searchQuery != null) - searchForMediaItem(it) - else - throw UnsupportedOperationException("can't do anything with $it") - } - completion.set(result) - } 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() 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/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)