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)