diff --git a/README.md b/README.md index 8f7bb28cf..664048797 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ I primarily built Auxio for myself, but you can use it too, I guess. - Customizable UI & Behavior - Genres/Artists/Albums/Songs indexing - Reliable playback state persistence -- ReplayGain support (On MP3, FLAC, OGG, and OPUS) +- ReplayGain support (On MP3, MP4, FLAC, OGG, and OPUS) - Material You (Android 12+ only) - Edge-to-edge - Embedded covers support diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt index 488db065d..b085c946f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailFragment.kt @@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail import androidx.annotation.MenuRes import androidx.appcompat.widget.PopupMenu -import androidx.core.view.forEach +import androidx.core.view.children import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -125,7 +125,7 @@ abstract class DetailFragment : Fragment() { } if (showItem != null) { - menu.forEach { item -> + for (item in menu.children) { item.isVisible = showItem(item.itemId) } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt index ba6a8918c..a4166a1ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/tabs/Tab.kt @@ -64,7 +64,7 @@ sealed class Tab(open val mode: DisplayMode) { var sequence = 0b0100 var shift = SEQUENCE_LEN * 4 - distinct.forEach { tab -> + for (tab in distinct) { val bin = when (tab) { is Visible -> 1.shl(3) or tab.mode.ordinal is Invisible -> tab.mode.ordinal @@ -107,10 +107,9 @@ sealed class Tab(open val mode: DisplayMode) { // Make sure there are no duplicate tabs val distinct = tabs.distinctBy { it.mode } - // For safety, use the default configuration if something went wrong - // and we have an empty or larger-than-expected tab array. + // For safety, return null if we have an empty or larger-than-expected tab array. if (distinct.isEmpty() || distinct.size < SEQUENCE_LEN) { - logE("Sequence size was ${distinct.size}, which is invalid. Using defaults instead") + logE("Sequence size was ${distinct.size}, which is invalid.") return null } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Models.kt b/app/src/main/java/org/oxycblt/auxio/music/Models.kt index 6d5f8b7e5..78fefe345 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Models.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Models.kt @@ -137,7 +137,7 @@ data class Album( val _mediaStoreArtistName: String, ) : MusicParent() { init { - songs.forEach { song -> + for (song in songs) { song.mediaStoreLinkAlbum(this) } } @@ -181,7 +181,7 @@ data class Artist( val albums: List ) : MusicParent() { init { - albums.forEach { album -> + for (album in albums) { album.mediaStoreLinkArtist(this) } } @@ -198,23 +198,19 @@ data class Artist( data class Genre( override val name: String, override val resolvedName: String, - /** Internal field. Do not use. */ - val _mediaStoreId: Long + val songs: List ) : MusicParent() { + init { + for (song in songs) { + song.mediaStoreLinkGenre(this) + } + } + override val id = name.hashCode().toLong() /** The formatted total duration of this genre */ val totalDuration: String get() = songs.sumOf { it.seconds }.toDuration(false) - - private val mSongs = mutableListOf() - val songs: List get() = mSongs - - /** Internal method. Do not use. */ - fun linkSong(song: Song) { - mSongs.add(song) - song.mediaStoreLinkGenre(this) - } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt index 47d25b07d..d978a4a71 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt @@ -22,13 +22,14 @@ import java.lang.Exception * * You think that if you wanted to query a song's genre from a media database, you could just * put "genre" in the query and it would return it, right? But not with MediaStore! No, that's - * too straightforward for this class that was dropped on it's head as a baby. So instead, you + * too straightforward for this contract that was dropped on it's head as a baby. So instead, you * have to query for each genre, query all the songs in each genre, and then iterate through those * songs to link every song with their genre. This is not documented anywhere, and the * O(mom im scared) algorithm you have to run to get it working single-handedly DOUBLES Auxio's * loading times. At no point have the devs considered that this column is absolutely insane, and * instead focused on adding infuriat- I mean nice proprietary extensions to MediaStore for their - * own Google Play Music, and we all know how great that worked out! + * own Google Play Music, and of course every Google Play Music user knew how great that turned + * out! * * It's not even ergonomics that makes this API bad. It's base implementation is completely borked * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? @@ -37,7 +38,7 @@ import java.lang.Exception * DATE tag. Once again, this is because internally android uses an ancient in-house metadata * parser to get everything indexed, and so far they have not bothered to modernize this parser * or even switch it to something more powerful like Taglib, not even in Android 12. ID3v2.4 has - * been around for 21 years. It can drink now. All of my what. + * been around for *21 years.* *It can drink now.* All of my what. * * Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums * table, so we have to go for the less efficient "make a big query on all the songs lol" method @@ -46,21 +47,21 @@ import java.lang.Exception * crippling the normal tables so that you're railroaded into their music app. The way I do * blacklisting relies on a deprecated method, and the supposedly "modern" method is SLOWER and * causes even more problems since I have to manage databases across version boundaries. Sometimes - * music will have a deformed clone that I can't filter out, sometimes Genres will just break for no - * reason, and sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 - * to Shift JIS WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY + * music will have a deformed clone that I can't filter out, sometimes Genres will just break for + * no reason, and sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to + * Latin-1 to *Shift JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY * * Is there anything we can do about it? No. Google has routinely shut down issues that begged google * to fix glaring issues with MediaStore or to just take the API behind the woodshed and shoot it. - * Largely because they have zero incentive to improve it given how "obscure" music listening is. - * As a result, some players like Vanilla and VLC just hack their own pidgin version of MediaStore - * from their own parsers, but this is both infeasible for Auxio due to how incredibly slow it is - * to get a file handle from the android sandbox AND how much harder it is to manage a database of - * your own media that mirrors the filesystem perfectly. And even if I set aside those crippling - * issues and changed my indexer to that, it would face the even larger problem of how google keeps - * trying to kill the filesystem and force you into their ContentResolver API. In the future - * MediaStore could be the only system we have, which is also the day that greenland melts and - * birthdays stop happening forever. + * Largely because they have zero incentive to improve it given how "obscure" local music listening + * is. As a result, some players like Vanilla and VLC just hack their own pseudo-MediaStore + * implementation from their own (better) parsers, but this is both infeasible for Auxio due to how + * incredibly slow it is to get a file handle from the android sandbox AND how much harder it is to + * manage a database of your own media that mirrors the filesystem perfectly. And even if I set + * aside those crippling issues and changed my indexer to that, it would face the even larger + * problem of how google keeps trying to kill the filesystem and force you into their + * ContentResolver API. In the future MediaStore could be the only system we have, which is also the + * day that greenland melts and birthdays stop happening forever. * * I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and * probably deprecated eventually for a "new" API that just coincidentally excludes music indexing. @@ -122,32 +123,32 @@ class MusicLoader { args += "$path%" // Append % so that the selector properly detects children } - context.contentResolver.query( + context.applicationContext.contentResolver.query( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, arrayOf( - MediaStore.Audio.Media._ID, - MediaStore.Audio.Media.TITLE, - MediaStore.Audio.Media.DISPLAY_NAME, - MediaStore.Audio.Media.ALBUM, - MediaStore.Audio.Media.ALBUM_ID, - MediaStore.Audio.Media.ARTIST, - MediaStore.Audio.Media.ALBUM_ARTIST, - MediaStore.Audio.Media.YEAR, - MediaStore.Audio.Media.TRACK, - MediaStore.Audio.Media.DURATION, + MediaStore.Audio.AudioColumns._ID, + MediaStore.Audio.AudioColumns.TITLE, + MediaStore.Audio.AudioColumns.DISPLAY_NAME, + MediaStore.Audio.AudioColumns.ALBUM, + MediaStore.Audio.AudioColumns.ALBUM_ID, + MediaStore.Audio.AudioColumns.ARTIST, + MediaStore.Audio.AudioColumns.ALBUM_ARTIST, + MediaStore.Audio.AudioColumns.YEAR, + MediaStore.Audio.AudioColumns.TRACK, + MediaStore.Audio.AudioColumns.DURATION, ), selector, args.toTypedArray(), null )?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID) - val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TITLE) - val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME) - val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM) - val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ID) - val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ARTIST) - val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.ALBUM_ARTIST) - val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.YEAR) - val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.TRACK) - val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION) + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) + val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) + val fileIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) + val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) + val albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID) + val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) + val albumArtistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ARTIST) + val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR) + val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) + val durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION) while (cursor.moveToNext()) { val id = cursor.getLong(idIndex) @@ -284,7 +285,6 @@ class MusicLoader { private fun readGenres(context: Context, songs: List): List { val genres = mutableListOf() - // First, get a cursor for every genre in the android system val genreCursor = context.contentResolver.query( MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, arrayOf( @@ -300,45 +300,46 @@ class MusicLoader { val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME) while (cursor.moveToNext()) { + // Genre names can be a normal name, an ID3v2 constant, or null. Normal names are + // resolved as usual, but null values don't make sense and are often junk anyway, + // so we skip genres that have them. val id = cursor.getLong(idIndex) - // No non-broken genre would be missing a name. val name = cursor.getStringOrNull(nameIndex) ?: continue - val resolvedName = when (name) { - MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_genre) - else -> name.getGenreNameCompat() ?: name - } + val resolvedName = name.getGenreNameCompat() ?: name + val genreSongs = queryGenreSongs(context, id, songs) ?: continue - val genre = Genre(name, resolvedName, id) - - linkGenre(context, genre, songs) - genres.add(genre) + genres.add( + Genre( + name, + resolvedName, + genreSongs + ) + ) } } - // Songs that don't have a genre will be thrown into an unknown genre. - val unknownGenre = Genre( - name = MediaStore.UNKNOWN_STRING, - resolvedName = context.getString(R.string.def_genre), - Long.MIN_VALUE - ) + val songsWithoutGenres = songs.filter { it.genre == null } - songs.forEach { song -> - if (song.genre == null) { - unknownGenre.linkSong(song) - } - } + if (songsWithoutGenres.isNotEmpty()) { + // Songs that don't have a genre will be thrown into an unknown genre. + val unknownGenre = Genre( + name = MediaStore.UNKNOWN_STRING, + resolvedName = context.getString(R.string.def_genre), + songsWithoutGenres + ) - if (unknownGenre.songs.isNotEmpty()) { genres.add(unknownGenre) } return genres } - private fun linkGenre(context: Context, genre: Genre, songs: List) { + private fun queryGenreSongs(context: Context, genreId: Long, songs: List): List? { + val genreSongs = mutableListOf() + // Don't even bother blacklisting here as useless iterations are less expensive than IO val songCursor = context.contentResolver.query( - MediaStore.Audio.Genres.Members.getContentUri("external", genre._mediaStoreId), + MediaStore.Audio.Genres.Members.getContentUri("external", genreId), arrayOf(MediaStore.Audio.Genres.Members._ID), null, null, null ) @@ -350,9 +351,13 @@ class MusicLoader { val id = cursor.getLong(idIndex) songs.find { it._mediaStoreId == id }?.let { song -> - genre.linkSong(song) + genreSongs.add(song) } } } + + // Some genres might be empty due to MediaStore empty. + // If that is the case, we drop them. + return genreSongs.ifEmpty { null } } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt index a5d3222fd..057ff16ee 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/AudioReactor.kt @@ -98,6 +98,8 @@ class AudioReactor( return } + logD("$metadata") + // ReplayGain is configurable, so determine what to do based off of the mode. val useAlbumGain: (Gain) -> Boolean = when (settingsManager.replayGainMode) { ReplayGainMode.OFF -> { @@ -144,8 +146,8 @@ class AudioReactor( // Final adjustment along the volume curve. // Ensure this is clamped to 0 or 1 so that it can be used as a volume. - // TODO: Support positive ReplayGain values. They're more obscure but still exist. - // It will likely require moving functionality from this class to an AudioProcessor + // While positive ReplayGain values *could* be theoretically added, it's such + // a niche use-case that to be worth the effort required. Maybe if someone requests it. volume = MathUtils.clamp((10f.pow((adjust / 20f))), 0f, 1f) } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt index 855c4dcb2..21006df6e 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsListFragment.kt @@ -55,11 +55,8 @@ class SettingsListFragment : PreferenceFragmentCompat() { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - preferenceScreen.children.forEach { pref -> - recursivelyHandleChildren(pref) - } - preferenceManager.onDisplayPreferenceDialogListener = this + preferenceScreen.children.forEach(::recursivelyHandlePreference) view.findViewById(androidx.preference.R.id.recycler_view).apply { clipToPadding = false @@ -87,28 +84,18 @@ class SettingsListFragment : PreferenceFragmentCompat() { } /** - * Recursively call [handlePreference] on a preference. + * Recursively handle a preference, doing any specific actions on it. */ - private fun recursivelyHandleChildren(preference: Preference) { - if (!preference.isVisible) { - return - } + private fun recursivelyHandlePreference(preference: Preference) { + if (!preference.isVisible) return if (preference is PreferenceCategory) { - // If this preference is a category of its own, handle its own children - preference.children.forEach { pref -> - recursivelyHandleChildren(pref) + for (child in preference.children) { + recursivelyHandlePreference(child) } - } else { - handlePreference(preference) } - } - /** - * Handle a preference, doing any specific actions on it. - */ - private fun handlePreference(pref: Preference) { - pref.apply { + preference.apply { when (key) { SettingsManager.KEY_THEME -> { setIcon(AppCompatDelegate.getDefaultNightMode().toThemeIcon()) diff --git a/app/src/main/res/layout/item_album_song.xml b/app/src/main/res/layout/item_album_song.xml index 6a4b54234..2493bbaac 100644 --- a/app/src/main/res/layout/item_album_song.xml +++ b/app/src/main/res/layout/item_album_song.xml @@ -24,7 +24,7 @@ android:text="@{String.valueOf(song.track)}" android:textAlignment="center" android:textAppearance="@style/TextAppearance.Auxio.TitleMedium" - android:textSize="20sp" + android:textSize="@dimen/text_size_ext_title_mid_larger" android:textColor="@color/sel_accented_secondary" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 2344c2bd7..68866574c 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -22,6 +22,10 @@ 32dp 32dp + 16sp + 18sp + 20sp + 2dp 4dp diff --git a/app/src/main/res/values/typography.xml b/app/src/main/res/values/typography.xml index af8b19c69..88447d7fa 100644 --- a/app/src/main/res/values/typography.xml +++ b/app/src/main/res/values/typography.xml @@ -4,7 +4,7 @@ @@ -97,19 +97,14 @@ 0.03 - \ No newline at end of file diff --git a/build.gradle b/build.gradle index ddb0cff35..4219249e5 100644 --- a/build.gradle +++ b/build.gradle @@ -9,7 +9,7 @@ buildscript { } dependencies { - classpath 'com.android.tools.build:gradle:7.1.0' + classpath 'com.android.tools.build:gradle:7.1.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:$navigation_version" diff --git a/info/ADDITIONS.md b/info/ADDITIONS.md index 83ab0f60d..d0a6cdecf 100644 --- a/info/ADDITIONS.md +++ b/info/ADDITIONS.md @@ -29,4 +29,5 @@ Feel free to fork Auxio to add your own feature set however. - Tag editing [#33] (Out of scope) - Gapless Playback [#35] (Technical issues) - Reduce leading instrument [#45] (Technical issues, Out of scope) -- Opening music through a provider [#30] (Out of scope) \ No newline at end of file +- Disabling track numbers [#73] (Out of scope) +- Opening music through a provider [#30] (Out of scope) diff --git a/info/FAQ.md b/info/FAQ.md index 1085ec962..16cccec4f 100644 --- a/info/FAQ.md +++ b/info/FAQ.md @@ -38,8 +38,7 @@ I hope to make the app rescan music on the fly eventually. #### ReplayGain isn't working on my music! This is for a couple reason: -- Auxio doesn't extract ReplayGain tags for your format. This is the case with MP4 files since there's no -defined ReplayGain standard for those. +- Auxio doesn't extract ReplayGain tags for your format. - Auxio doesn't recognize your ReplayGain tags. This is usually because of a non-standard tag like ID3v2's `RVAD` or an unrecognized name. - Your tags use a ReplayGain value higher than 0. Due to technical limitations, Auxio does not support this right now.