diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 763732ee5..61ec47e0c 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -2,9 +2,9 @@ name: Android CI on: push: - branches: [ "dev" ] + branches: [] pull_request: - branches: [ "dev" ] + branches: [] jobs: build: diff --git a/CHANGELOG.md b/CHANGELOG.md index 3907a19ae..32e65b5de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,16 @@ #### What's Improved - Album covers are now loaded on a per-song basis -- Correctly interpret MP4 sort tags +- MP4 sort tags are now correctly interpreted +- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly +- M3U paths are now interpreted both as relative and absolute regardless of the format +- Added support for M3U paths starting with /storage/ #### What's Fixed - Fixed repeat mode not restoring on startup +- Fixed rewinding not occuring when skipping back at the beginning of the queue if +rewind before skipping was turned off +- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used #### What's Changed - For the time being, the media notification will not follow Album Covers or 1:1 Covers settings @@ -20,6 +26,11 @@ #### dev -> dev1 changes - Re-added ability to open app from clicking on notification - Removed tasker plugin +- Support multi-value MP4 tags with multiple `data` sub-atoms are parsed correctly +- M3U paths are now interpreted both as relative and absolute regardless of the format +- Added support for M3U paths starting with /storage/ +- Fixed artist duplication when inconsistent MusicBrainz ID tag naming was used +- Made album cover keying more efficient at the cost of resillients ## 3.4.3 diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt index d28979b6b..f1be38db3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/CoverExtractor.kt @@ -157,8 +157,8 @@ constructor( } private suspend fun extractQualityCover(cover: Cover.Embedded) = - extractAospMetadataCover(cover) - ?: extractExoplayerCover(cover) ?: extractMediaStoreCover(cover) + extractExoplayerCover(cover) + ?: extractAospMetadataCover(cover) ?: extractMediaStoreCover(cover) private fun extractAospMetadataCover(cover: Cover.Embedded): InputStream? = MediaMetadataRetriever().run { diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt index 0b0949efd..1e7809606 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/DHash.kt @@ -26,6 +26,7 @@ import android.graphics.ColorMatrixColorFilter import android.graphics.Paint import java.math.BigInteger +@Suppress("UNUSED") fun Bitmap.dHash(hashSize: Int = 16): String { // Step 1: Resize the bitmap to a fixed size val resizedBitmap = Bitmap.createScaledBitmap(this, hashSize + 1, hashSize, true) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 4c45dfc84..99e3fee4d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -164,7 +164,10 @@ constructor( } val deviceLibrary = musicRepository.deviceLibrary ?: return@launch - val songs = importedPlaylist.paths.mapNotNull(deviceLibrary::findSongByPath) + val songs = + importedPlaylist.paths.mapNotNull { + it.firstNotNullOfOrNull(deviceLibrary::findSongByPath) + } if (songs.isEmpty()) { logE("No songs found") diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 00f8eb43b..2a7113066 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -32,7 +32,7 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 45, exportSchema = false) +@Database(entities = [CachedSong::class], version = 46, exportSchema = false) abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao } diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt index 1cf0b0810..a1171efee 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/ExternalPlaylistManager.kt @@ -76,7 +76,9 @@ data class ExportConfig(val absolute: Boolean, val windowsPaths: Boolean) * @see ExternalPlaylistManager * @see M3U */ -data class ImportedPlaylist(val name: String?, val paths: List) +data class ImportedPlaylist(val name: String?, val paths: List) + +typealias PossiblePaths = List class ExternalPlaylistManagerImpl @Inject diff --git a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt index 11ce3dea1..211f6cea6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/external/M3U.kt @@ -29,9 +29,12 @@ import javax.inject.Inject import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.fs.Components import org.oxycblt.auxio.music.fs.Path +import org.oxycblt.auxio.music.fs.Volume +import org.oxycblt.auxio.music.fs.VolumeManager import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.unlikelyToBeNull /** * Minimal M3U file format implementation. @@ -72,10 +75,16 @@ interface M3U { } } -class M3UImpl @Inject constructor(@ApplicationContext private val context: Context) : M3U { +class M3UImpl +@Inject +constructor( + @ApplicationContext private val context: Context, + private val volumeManager: VolumeManager +) : M3U { override fun read(stream: InputStream, workingDirectory: Path): ImportedPlaylist? { + val volumes = volumeManager.getVolumes() val reader = BufferedReader(InputStreamReader(stream)) - val paths = mutableListOf() + val paths = mutableListOf() var name: String? = null consumeFile@ while (true) { @@ -112,39 +121,13 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } // There is basically no formal specification of file paths in M3U, and it differs - // based on the US that generated it. These are the paths though that I assume most - // programs will generate. - val components = - when { - path.startsWith('/') -> { - // Unix absolute path. Note that we still assume this absolute path is in - // the same volume as the M3U file. There's no sane way to map the volume - // to the phone's volumes, so this is the only thing we can do. - Components.parseUnix(path) - } - path.startsWith("./") -> { - // Unix relative path, resolve it - Components.parseUnix(path).absoluteTo(workingDirectory.components) - } - path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> { - // Windows absolute path, we should get rid of the volume prefix, but - // otherwise - // the rest should be fine. Again, we have to disregard what the volume - // actually - // is since there's no sane way to map it to the phone's volumes. - Components.parseWindows(path.substring(2)) - } - path.startsWith(".\\") -> { - // Windows relative path, we need to remove the .\\ prefix - Components.parseWindows(path).absoluteTo(workingDirectory.components) - } - else -> { - // No clue, parse by all separators and assume it's relative. - Components.parseAny(path).absoluteTo(workingDirectory.components) - } - } + // based on the programs that generated it. I more or less have to consider any possible + // interpretation as valid. + val interpretations = interpretPath(path) + val possibilities = + interpretations.flatMap { expandInterpretation(it, workingDirectory, volumes) } - paths.add(Path(workingDirectory.volume, components)) + paths.add(possibilities) } return if (paths.isNotEmpty()) { @@ -155,6 +138,44 @@ class M3UImpl @Inject constructor(@ApplicationContext private val context: Conte } } + private data class InterpretedPath(val components: Components, val likelyAbsolute: Boolean) + + private fun interpretPath(path: String): List = + when { + path.startsWith('/') -> listOf(InterpretedPath(Components.parseUnix(path), true)) + path.startsWith("./") -> listOf(InterpretedPath(Components.parseUnix(path), false)) + path.matches(WINDOWS_VOLUME_PREFIX_REGEX) -> + listOf(InterpretedPath(Components.parseWindows(path.substring(2)), true)) + path.startsWith("\\") -> listOf(InterpretedPath(Components.parseWindows(path), true)) + path.startsWith(".\\") -> listOf(InterpretedPath(Components.parseWindows(path), false)) + else -> + listOf( + InterpretedPath(Components.parseUnix(path), false), + InterpretedPath(Components.parseWindows(path), true)) + } + + private fun expandInterpretation( + path: InterpretedPath, + workingDirectory: Path, + volumes: List + ): List { + val absoluteInterpretation = Path(workingDirectory.volume, path.components) + val relativeInterpretation = + Path(workingDirectory.volume, path.components.absoluteTo(workingDirectory.components)) + val volumeExactMatch = volumes.find { it.components?.contains(path.components) == true } + val volumeInterpretation = + volumeExactMatch?.let { + val components = + unlikelyToBeNull(volumeExactMatch.components).containing(path.components) + Path(volumeExactMatch, components) + } + return if (path.likelyAbsolute) { + listOfNotNull(volumeInterpretation, absoluteInterpretation, relativeInterpretation) + } else { + listOfNotNull(relativeInterpretation, volumeInterpretation, absoluteInterpretation) + } + } + override fun write( playlist: Playlist, outputStream: OutputStream, diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt index 2639ec207..9f3cbff3b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/Fs.kt @@ -158,6 +158,8 @@ value class Components private constructor(val components: List) { return components == other.components.take(components.size) } + fun containing(other: Components) = Components(other.components.drop(components.size)) + companion object { /** * Parses a path string into a [Components] instance by the unix path separator (/). diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index 202f364df..d30e5324e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.music.metadata -import android.graphics.BitmapFactory import androidx.core.text.isDigitsOnly import androidx.media3.common.MediaItem import androidx.media3.exoplayer.MetadataRetriever @@ -26,8 +25,8 @@ import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.source.TrackGroupArray import java.util.concurrent.Future import javax.inject.Inject +import kotlin.math.min import org.oxycblt.auxio.image.extractor.CoverExtractor -import org.oxycblt.auxio.image.extractor.dHash import org.oxycblt.auxio.music.device.RawSong import org.oxycblt.auxio.music.fs.toAudioUri import org.oxycblt.auxio.music.info.Date @@ -106,10 +105,24 @@ private class TagWorkerImpl( populateWithId3v2(textTags.id3v2) populateWithVorbis(textTags.vorbis) - val coverInputStream = coverExtractor.findCoverDataInMetadata(metadata) - val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } - rawSong.coverPerceptualHash = bitmap?.dHash() - bitmap?.recycle() + coverExtractor.findCoverDataInMetadata(metadata)?.use { + val available = it.available() + val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong()) + it.skip(skip) + val bytes = ByteArray(COVER_KEY_SAMPLE) + it.read(bytes) + + @OptIn(ExperimentalStdlibApi::class) val byteString = bytes.toHexString() + + rawSong.coverPerceptualHash = byteString + } + + // OPTIONAL: Nicer cover art keying using an actual perceptual hash + // Really bad idea if you have big cover arts. Okay idea if you have different + // formats for the same cover art. + // val bitmap = coverInputStream?.use { BitmapFactory.decodeStream(it) } + // rawSong.coverPerceptualHash = bitmap?.dHash() + // bitmap?.recycle() // OPUS base gain interpretation code: This is likely not needed, as the media player // should be using the base gain already. Uncomment if that's not the case. @@ -140,7 +153,9 @@ private class TagWorkerImpl( private fun populateWithId3v2(textFrames: Map>) { // Song - textFrames["TXXX:musicbrainz release track id"]?.let { rawSong.musicBrainzId = it.first() } + (textFrames["TXXX:musicbrainz release track id"] + ?: textFrames["TXXX:musicbrainz_releasetrackid"]) + ?.let { rawSong.musicBrainzId = it.first() } textFrames["TIT2"]?.let { rawSong.name = it.first() } textFrames["TSOT"]?.let { rawSong.sortName = it.first() } @@ -170,7 +185,9 @@ private class TagWorkerImpl( ?.let { rawSong.date = it } // Album - textFrames["TXXX:musicbrainz album id"]?.let { rawSong.albumMusicBrainzId = it.first() } + (textFrames["TXXX:musicbrainz album id"] ?: textFrames["TXXX:musicbrainz_albumid"])?.let { + rawSong.albumMusicBrainzId = it.first() + } textFrames["TALB"]?.let { rawSong.albumName = it.first() } textFrames["TSOA"]?.let { rawSong.albumSortName = it.first() } (textFrames["TXXX:musicbrainz album type"] @@ -180,7 +197,9 @@ private class TagWorkerImpl( ?.let { rawSong.releaseTypes = it } // Artist - textFrames["TXXX:musicbrainz artist id"]?.let { rawSong.artistMusicBrainzIds = it } + (textFrames["TXXX:musicbrainz artist id"] ?: textFrames["TXXX:musicbrainz_artistid"])?.let { + rawSong.artistMusicBrainzIds = it + } (textFrames["TXXX:artists"] ?: textFrames["TPE1"])?.let { rawSong.artistNames = it } (textFrames["TXXX:artistssort"] ?: textFrames["TXXX:artists_sort"] ?: textFrames["TXXX:artists sort"] @@ -188,9 +207,9 @@ private class TagWorkerImpl( ?.let { rawSong.artistSortNames = it } // Album artist - textFrames["TXXX:musicbrainz album artist id"]?.let { - rawSong.albumArtistMusicBrainzIds = it - } + (textFrames["TXXX:musicbrainz album artist id"] + ?: textFrames["TXXX:musicbrainz_albumartistid"]) + ?.let { rawSong.albumArtistMusicBrainzIds = it } (textFrames["TXXX:albumartists"] ?: textFrames["TXXX:album_artists"] ?: textFrames["TXXX:album artists"] ?: textFrames["TPE2"]) @@ -261,7 +280,9 @@ private class TagWorkerImpl( private fun populateWithVorbis(comments: Map>) { // Song - comments["musicbrainz_releasetrackid"]?.let { rawSong.musicBrainzId = it.first() } + (comments["musicbrainz_releasetrackid"] ?: comments["musicbrainz release track id"])?.let { + rawSong.musicBrainzId = it.first() + } comments["title"]?.let { rawSong.name = it.first() } comments["titlesort"]?.let { rawSong.sortName = it.first() } @@ -290,20 +311,28 @@ private class TagWorkerImpl( ?.let { rawSong.date = it } // Album - comments["musicbrainz_albumid"]?.let { rawSong.albumMusicBrainzId = it.first() } + (comments["musicbrainz_albumid"] ?: comments["musicbrainz album id"])?.let { + rawSong.albumMusicBrainzId = it.first() + } comments["album"]?.let { rawSong.albumName = it.first() } comments["albumsort"]?.let { rawSong.albumSortName = it.first() } - comments["releasetype"]?.let { rawSong.releaseTypes = it } + (comments["releasetype"] ?: comments["musicbrainz album type"])?.let { + rawSong.releaseTypes = it + } // Artist - comments["musicbrainz_artistid"]?.let { rawSong.artistMusicBrainzIds = it } + (comments["musicbrainz_artistid"] ?: comments["musicbrainz artist id"])?.let { + rawSong.artistMusicBrainzIds = it + } (comments["artists"] ?: comments["artist"])?.let { rawSong.artistNames = it } (comments["artistssort"] ?: comments["artists_sort"] ?: comments["artists sort"] ?: comments["artistsort"]) ?.let { rawSong.artistSortNames = it } // Album artist - comments["musicbrainz_albumartistid"]?.let { rawSong.albumArtistMusicBrainzIds = it } + (comments["musicbrainz_albumartistid"] ?: comments["musicbrainz album artist id"])?.let { + rawSong.albumArtistMusicBrainzIds = it + } (comments["albumartists"] ?: comments["album_artists"] ?: comments["album artists"] ?: comments["albumartist"]) @@ -360,6 +389,8 @@ private class TagWorkerImpl( first().replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()?.nonZeroOrNull() private companion object { + val COVER_KEY_SAMPLE = 32 + val COMPILATION_ALBUM_ARTISTS = listOf("Various Artists") val COMPILATION_RELEASE_TYPES = listOf("compilation") diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt index 63b68925d..93841a63f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemBrowser.kt @@ -19,6 +19,9 @@ package org.oxycblt.auxio.music.service import android.content.Context +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants import androidx.media3.common.MediaItem import androidx.media3.session.MediaSession.ControllerInfo import dagger.hilt.android.qualifiers.ApplicationContext @@ -29,6 +32,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import org.oxycblt.auxio.R import org.oxycblt.auxio.list.ListSettings import org.oxycblt.auxio.list.sort.Sort import org.oxycblt.auxio.music.Album @@ -57,8 +61,6 @@ constructor( private var invalidator: Invalidator? = null interface Invalidator { - data class ParentId(val id: String, val itemCount: Int) - fun invalidate(ids: Map) fun invalidate(controller: ControllerInfo, query: String, itemCount: Int) @@ -213,27 +215,41 @@ constructor( return when (val item = musicRepository.find(uid)) { is Album -> { val songs = listSettings.albumSongSort.songs(item.songs) - songs.map { it.toMediaItem(context, item) } + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } } is Artist -> { val albums = ARTIST_ALBUMS_SORT.albums(item.explicitAlbums + item.implicitAlbums) val songs = listSettings.artistSongSort.songs(item.songs) - albums.map { it.toMediaItem(context) } + songs.map { it.toMediaItem(context, item) } + albums.map { it.toMediaItem(context).withHeader(R.string.lbl_albums) } + + songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } } is Genre -> { val artists = GENRE_ARTISTS_SORT.artists(item.artists) val songs = listSettings.genreSongSort.songs(item.songs) - artists.map { it.toMediaItem(context) } + - songs.map { it.toMediaItem(context, null) } + artists.map { it.toMediaItem(context).withHeader(R.string.lbl_artists) } + + songs.map { it.toMediaItem(context, null).withHeader(R.string.lbl_songs) } } is Playlist -> { - item.songs.map { it.toMediaItem(context, item) } + item.songs.map { it.toMediaItem(context, item).withHeader(R.string.lbl_songs) } } is Song, null -> return null } } + private fun MediaItem.withHeader(@StringRes res: Int): MediaItem { + val oldExtras = mediaMetadata.extras ?: Bundle() + val newExtras = + Bundle(oldExtras).apply { + putString( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, + context.getString(res)) + } + return buildUpon() + .setMediaMetadata(mediaMetadata.buildUpon().setExtras(newExtras).build()) + .build() + } + private fun getCategorySize( category: MediaSessionUID.Category, musicRepository: MusicRepository diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt index bde25c1b5..9a5bb53c2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/MediaItemTranslation.kt @@ -19,10 +19,15 @@ package org.oxycblt.auxio.music.service import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.os.Bundle +import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.media.utils.MediaConstants import androidx.media3.common.MediaItem import androidx.media3.common.MediaMetadata +import java.io.ByteArrayOutputStream import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.music.Album @@ -37,14 +42,27 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.util.getPlural fun MediaSessionUID.Category.toMediaItem(context: Context): MediaItem { + // TODO: Make custom overflow menu for compat + val style = + Bundle().apply { + putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_SINGLE_ITEM, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_CATEGORY_LIST_ITEM) + } val metadata = MediaMetadata.Builder() .setTitle(context.getString(nameRes)) .setIsPlayable(false) .setIsBrowsable(true) .setMediaType(mediaType) - .build() - return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata).build() + .setExtras(style) + if (bitmapRes != null) { + val data = ByteArrayOutputStream() + BitmapFactory.decodeResource(context.resources, bitmapRes) + .compress(Bitmap.CompressFormat.PNG, 100, data) + metadata.setArtworkData(data.toByteArray(), MediaMetadata.PICTURE_TYPE_FILE_ICON) + } + return MediaItem.Builder().setMediaId(toString()).setMediaMetadata(metadata.build()).build() } fun Song.toMediaItem(context: Context, parent: MusicParent?): MediaItem { @@ -103,7 +121,7 @@ fun Album.toMediaItem(context: Context): MediaItem { .setReleaseMonth(dates?.min?.month) .setReleaseDay(dates?.min?.day) .setMediaType(MediaMetadata.MEDIA_TYPE_ALBUM) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) @@ -133,7 +151,7 @@ fun Artist.toMediaItem(context: Context): MediaItem { context.getString(R.string.def_song_count) })) .setMediaType(MediaMetadata.MEDIA_TYPE_ARTIST) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setGenre(genres.resolveNames(context)) .setArtworkUri(cover.single.mediaStoreCoverUri) @@ -157,7 +175,7 @@ fun Genre.toMediaItem(context: Context): MediaItem { context.getString(R.string.def_song_count) }) .setMediaType(MediaMetadata.MEDIA_TYPE_GENRE) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setArtworkUri(cover.single.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) @@ -180,7 +198,7 @@ fun Playlist.toMediaItem(context: Context): MediaItem { context.getString(R.string.def_song_count) }) .setMediaType(MediaMetadata.MEDIA_TYPE_PLAYLIST) - .setIsPlayable(true) + .setIsPlayable(false) .setIsBrowsable(true) .setArtworkUri(cover?.single?.mediaStoreCoverUri) .setExtras(Bundle().apply { putString("uid", mediaSessionUID.toString()) }) @@ -205,14 +223,38 @@ fun MediaItem.toSong(deviceLibrary: DeviceLibrary): Song? { } sealed interface MediaSessionUID { - enum class Category(val id: String, @StringRes val nameRes: Int, val mediaType: Int?) : - MediaSessionUID { - ROOT("root", R.string.info_app_name, null), - SONGS("songs", R.string.lbl_songs, MediaMetadata.MEDIA_TYPE_MUSIC), - ALBUMS("albums", R.string.lbl_albums, MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), - ARTISTS("artists", R.string.lbl_artists, MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), - GENRES("genres", R.string.lbl_genres, MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), - PLAYLISTS("playlists", R.string.lbl_playlists, MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); + enum class Category( + val id: String, + @StringRes val nameRes: Int, + @DrawableRes val bitmapRes: Int?, + val mediaType: Int? + ) : MediaSessionUID { + ROOT("root", R.string.info_app_name, null, null), + SONGS( + "songs", + R.string.lbl_songs, + R.drawable.ic_song_bitmap_24, + MediaMetadata.MEDIA_TYPE_MUSIC), + ALBUMS( + "albums", + R.string.lbl_albums, + R.drawable.ic_album_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS), + ARTISTS( + "artists", + R.string.lbl_artists, + R.drawable.ic_artist_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS), + GENRES( + "genres", + R.string.lbl_genres, + R.drawable.ic_genre_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_GENRES), + PLAYLISTS( + "playlists", + R.string.lbl_playlists, + R.drawable.ic_playlist_bitmap_24, + MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS); override fun toString() = "$ID_CATEGORY:$id" diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt index 8413738b9..058b33403 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/ExoPlaybackStateHolder.kt @@ -270,8 +270,10 @@ class ExoPlaybackStateHolder( override fun prev() { if (playbackSettings.rewindWithPrev) { player.seekToPrevious() - } else { + } else if (player.hasPreviousMediaItem()) { player.seekToPreviousMediaItem() + } else { + player.seekTo(0) } if (!playbackSettings.rememberPause) { player.play() @@ -365,17 +367,28 @@ class ExoPlaybackStateHolder( rawQueue: RawQueue, ack: StateAck.NewPlayback? ) { - this.parent = parent - player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) - if (rawQueue.isShuffled) { - player.shuffleModeEnabled = true - player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) - } else { - player.shuffleModeEnabled = false + logD("Applying saved state") + var sendEvent = false + if (this.parent != parent) { + this.parent = parent + sendEvent = true + } + if (rawQueue != resolveQueue()) { + player.setMediaItems(rawQueue.heap.map { it.toMediaItem(context, null) }) + if (rawQueue.isShuffled) { + player.shuffleModeEnabled = true + player.setShuffleOrder(BetterShuffleOrder(rawQueue.shuffledMapping.toIntArray())) + } else { + player.shuffleModeEnabled = false + } + player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) + player.prepare() + player.pause() + sendEvent = true + } + if (sendEvent) { + ack?.let { playbackManager.ack(this, it) } } - player.seekTo(rawQueue.heapIndex, C.TIME_UNSET) - player.prepare() - ack?.let { playbackManager.ack(this, it) } } override fun endSession() { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt index 6b56d334a..f5ea4215c 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionPlayer.kt @@ -173,6 +173,15 @@ class MediaSessionPlayer( playbackManager.repeatMode(appRepeatMode) } + override fun seekToDefaultPosition(mediaItemIndex: Int) { + val indices = unscrambleQueueIndices() + val fakeIndex = indices.indexOf(mediaItemIndex) + if (fakeIndex < 0) { + return + } + playbackManager.goto(fakeIndex) + } + override fun seekToNext() = playbackManager.next() override fun seekToNextMediaItem() = playbackManager.next() @@ -183,18 +192,9 @@ class MediaSessionPlayer( override fun seekTo(positionMs: Long) = playbackManager.seekTo(positionMs) - override fun seekTo(mediaItemIndex: Int, positionMs: Long) { - val indices = unscrambleQueueIndices() - val fakeIndex = indices.indexOf(mediaItemIndex) - if (fakeIndex < 0) { - return - } - playbackManager.goto(fakeIndex) - if (positionMs == C.TIME_UNSET) { - return - } - playbackManager.seekTo(positionMs) - } + override fun seekTo(mediaItemIndex: Int, positionMs: Long) = notAllowed() + + override fun seekToDefaultPosition() = notAllowed() override fun addMediaItems(index: Int, mediaItems: MutableList) { val deviceLibrary = musicRepository.deviceLibrary ?: return @@ -278,10 +278,6 @@ class MediaSessionPlayer( override fun setPlaybackSpeed(speed: Float) = notAllowed() - override fun seekToDefaultPosition() = notAllowed() - - override fun seekToDefaultPosition(mediaItemIndex: Int) = notAllowed() - override fun seekForward() = notAllowed() override fun seekBack() = notAllowed() diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt index fb884cee3..69f2aab6d 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/MediaSessionServiceFragment.kt @@ -129,7 +129,7 @@ constructor( fun release() { waitJob.cancel() - mediaSession.release() + mediaItemBrowser.release() actionHandler.release() exoHolder.release() playbackManager.removeListener(this) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt index 8597770de..6d0c1fbeb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/service/PlaybackActionHandler.kt @@ -49,13 +49,15 @@ constructor( @ApplicationContext private val context: Context, private val playbackManager: PlaybackStateManager, private val playbackSettings: PlaybackSettings, - private val systemReceiver: SystemPlaybackReceiver + private val widgetComponent: WidgetComponent ) : PlaybackStateManager.Listener, PlaybackSettings.Listener { interface Callback { fun onCustomLayoutChanged(layout: List) } + private val systemReceiver = + SystemPlaybackReceiver(playbackManager, playbackSettings, widgetComponent) private var callback: Callback? = null fun attach(callback: Callback) { @@ -71,6 +73,7 @@ constructor( playbackManager.removeListener(this) playbackSettings.unregisterListener(this) context.unregisterReceiver(systemReceiver) + widgetComponent.release() } fun withCommands(commands: SessionCommands) = @@ -180,12 +183,10 @@ object PlaybackActions { * A [BroadcastReceiver] for receiving playback-specific [Intent]s from the system that require an * active [IntentFilter] to be registered. */ -class SystemPlaybackReceiver -@Inject -constructor( - val playbackManager: PlaybackStateManager, - val playbackSettings: PlaybackSettings, - val widgetComponent: WidgetComponent +class SystemPlaybackReceiver( + private val playbackManager: PlaybackStateManager, + private val playbackSettings: PlaybackSettings, + private val widgetComponent: WidgetComponent ) : BroadcastReceiver() { private var initialHeadsetPlugEventHandled = false diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt index 82685f051..f53745632 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackCommand.kt @@ -91,7 +91,8 @@ constructor( override val shuffled: Boolean ) : PlaybackCommand - override fun song(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle) + override fun song(song: Song, shuffle: ShuffleMode) = + newCommand(song, null, listOf(song), shuffle) override fun songFromAll(song: Song, shuffle: ShuffleMode) = newCommand(song, shuffle) @@ -105,7 +106,7 @@ constructor( newCommand(song, genre, song.genres, listSettings.genreSongSort, shuffle) override fun songFromPlaylist(song: Song, playlist: Playlist, shuffle: ShuffleMode) = - newCommand(song, playlist, playlist.songs, listSettings.playlistSort, shuffle) + newCommand(song, playlist, playlist.songs, shuffle) override fun all(shuffle: ShuffleMode) = newCommand(null, shuffle) diff --git a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt index 347b099ca..494ab2c0e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/state/PlaybackStateManager.kt @@ -795,15 +795,8 @@ class PlaybackStateManagerImpl @Inject constructor() : PlaybackStateManager { index }) - // Valid state where something needs to be played, direct the stateholder to apply - // this new state. - val oldStateMirror = stateMirror - if (oldStateMirror.rawQueue != rawQueue) { - logD("Queue changed, must reload player") - stateHolder.playing(false) - stateHolder.applySavedState(parent, rawQueue, StateAck.NewPlayback) - stateHolder.seekTo(savedState.positionMs) - } + stateHolder.applySavedState(savedState.parent, rawQueue, StateAck.NewPlayback) + stateHolder.seekTo(savedState.positionMs) stateHolder.repeatMode(savedState.repeatMode) isInitialized = true diff --git a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt index d723dd5e3..71927c70c 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/StateUtil.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import org.oxycblt.auxio.BuildConfig /** * A wrapper around [StateFlow] exposing a one-time consumable event. @@ -166,7 +167,13 @@ suspend fun SendChannel.sendWithTimeout(element: E, timeout: Long = DEFAU try { withTimeout(timeout) { send(element) } } catch (e: TimeoutCancellationException) { - throw TimeoutException("Timed out sending element $element to channel: $e") + logE("Failed to send element to channel $e in ${timeout}ms.") + if (BuildConfig.DEBUG) { + throw TimeoutException("Timed out sending element to channel: $e") + } else { + logE(e.stackTraceToString()) + send(element) + } } } @@ -203,7 +210,13 @@ suspend fun ReceiveChannel.forEachWithTimeout( subsequent = true } } catch (e: TimeoutCancellationException) { - throw TimeoutException("Timed out receiving element from channel: $e") + logE("Failed to send element to channel $e in ${timeout}ms.") + if (BuildConfig.DEBUG) { + throw TimeoutException("Timed out sending element to channel: $e") + } else { + logE(e.stackTraceToString()) + handler() + } } } } diff --git a/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png new file mode 100644 index 000000000..c25d1465b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_album_bitmap_24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png new file mode 100644 index 000000000..7a4ec1d24 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_artist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png new file mode 100644 index 000000000..0002c9c87 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_genre_bitmap_24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png new file mode 100644 index 000000000..844eabebf Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_playlist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png new file mode 100644 index 000000000..9eb67934b Binary files /dev/null and b/app/src/main/res/drawable-hdpi/ic_song_bitmap_24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png new file mode 100644 index 000000000..1047e230c Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_album_bitmap_24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png new file mode 100644 index 000000000..3f3936e36 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_artist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png new file mode 100644 index 000000000..d5034c0d8 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_genre_bitmap_24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png new file mode 100644 index 000000000..931d33f30 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_playlist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png new file mode 100644 index 000000000..cbaa75491 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/ic_song_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png new file mode 100644 index 000000000..f1ea82745 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_album_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png new file mode 100644 index 000000000..880afde64 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_artist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png new file mode 100644 index 000000000..7ac11d34d Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_genre_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png new file mode 100644 index 000000000..d88b412c9 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_playlist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png new file mode 100644 index 000000000..5a4c995f6 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/ic_song_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png new file mode 100644 index 000000000..68d136f59 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_album_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png new file mode 100644 index 000000000..5481634b0 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_artist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png new file mode 100644 index 000000000..e7b7ff04c Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_genre_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png new file mode 100644 index 000000000..1ac463441 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_playlist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png new file mode 100644 index 000000000..55709e1e2 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/ic_song_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png new file mode 100644 index 000000000..08f1eae7c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_album_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png new file mode 100644 index 000000000..e515a4134 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_artist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png new file mode 100644 index 000000000..f211f6b60 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_genre_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png new file mode 100644 index 000000000..b0a08a8b3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_playlist_bitmap_24.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png b/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png new file mode 100644 index 000000000..c0869bc1d Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/ic_song_bitmap_24.png differ