all: cleanup

A bunch of small changes that have accrued over the last week due to
having more or less no time to work on Auxio.
This commit is contained in:
OxygenCobalt 2022-03-13 16:42:03 -06:00
parent 82247775ac
commit 627ab97948
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 140 additions and 124 deletions

View file

@ -64,11 +64,11 @@ dependencies {
// General
implementation "androidx.core:core-ktx:1.7.0"
implementation "androidx.activity:activity-ktx:1.4.0"
implementation 'androidx.fragment:fragment-ktx:1.4.1'
implementation "androidx.fragment:fragment-ktx:1.4.1"
// UI
implementation "androidx.recyclerview:recyclerview:1.2.1"
implementation 'androidx.constraintlayout:constraintlayout:2.1.3'
implementation "androidx.constraintlayout:constraintlayout:2.1.3"
implementation "androidx.dynamicanimation:dynamicanimation:1.0.0"
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
@ -95,20 +95,20 @@ dependencies {
// Exoplayer
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION.
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
def exoplayerVersion = '2.17.0'
implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
def exoplayerVersion = "2.17.1"
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayerVersion"
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
// Image loading
implementation 'io.coil-kt:coil:2.0.0-alpha09'
implementation "io.coil-kt:coil:2.0.0-rc01"
// Material
implementation 'com.google.android.material:material:1.6.0-alpha03'
implementation "com.google.android.material:material:1.6.0-alpha03"
// --- DEBUG ---
// Lint
ktlint 'com.pinterest:ktlint:0.44.0'
ktlint "com.pinterest:ktlint:0.44.0"
}
task ktlint(type: JavaExec, group: "verification") {

View file

@ -37,7 +37,8 @@ sealed class Item {
/**
* [Item] variant that represents a music item.
* @property name
* TODO: Make name the actual display name and move raw names (including file names) to a new
* field called rawName.
*/
sealed class Music : Item() {
/** The raw name of this item. */
@ -116,8 +117,8 @@ data class Song(
internalMediaStoreArtistName ?: album.artist.resolvedName
/** Internal field. Do not use. */
val internalGroupingId: Int get() {
var result = internalGroupingArtistName.lowercase().hashCode()
val internalAlbumGroupingId: Long get() {
var result = internalGroupingArtistName.lowercase().hashCode().toLong()
result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode()
return result
}
@ -187,7 +188,11 @@ data class Album(
artist.resolvedName
/** Internal field. Do not use. */
val internalIsMissingArtist: Boolean = mArtist != null
val internalArtistGroupingId: Long get() =
internalGroupingArtistName.lowercase().hashCode().toLong()
/** Internal field. Do not use. */
val internalIsMissingArtist: Boolean get() = mArtist == null
/** Internal method. Do not use. */
fun internalLinkArtist(artist: Artist) {

View file

@ -1,5 +1,6 @@
package org.oxycblt.auxio.music
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.net.Uri
@ -8,7 +9,7 @@ import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.excluded.ExcludedDatabase
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.util.logD
/**
@ -112,27 +113,39 @@ class MusicLoader {
)
}
private fun loadSongs(context: Context): List<Song> {
var songs = mutableListOf<Song>()
val blacklistDatabase = ExcludedDatabase.getInstance(context)
val paths = blacklistDatabase.readPaths()
/**
* Gets a content resolver in a way that does not mangle metadata on
* certain OEM skins. See https://github.com/OxygenCobalt/Auxio/issues/50
* for more info.
*/
private val Context.contentResolverSafe: ContentResolver get() =
applicationContext.contentResolver
/**
* Does the initial query over the song database, including excluded directory
* checks. The songs returned by this function are **not** well-formed. The
* companion [buildAlbums], [buildArtists], and [readGenres] functions must be
* called with the returned list so that all songs are properly linked up.
*/
private fun loadSongs(context: Context): List<Song> {
val blacklistDatabase = ExcludedDatabase.getInstance(context)
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
val args = mutableListOf<String>()
// DATA was deprecated on Android 10, but is set to be un-deprecated in Android 12L.
// The only reason we'd want to change this is to add external partitions support, but
// that's less efficient and there's no demand for that right now.
// Apply the excluded directories by filtering out specific DATA values.
// DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
// so it's probably okay to use it. The only reason we would want to use
// another method is for external partitions support, but there is no demand for that.
// TODO: Determine if grokking the actual DATA value outside of SQL is more or less
// efficient than the current system
for (path in paths) {
for (path in blacklistDatabase.readPaths()) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
// TODO: Move all references to contentResolver into a single variable so we can
// avoid accidentally removing the applicationContext fix
context.applicationContext.contentResolver.query(
var songs = mutableListOf<Song>()
context.contentResolverSafe.query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.AudioColumns._ID,
@ -166,7 +179,7 @@ class MusicLoader {
// The TRACK field is for some reason formatted as DTTT, where D is the disk
// and T is the track. This is dumb and insane and forces me to mangle track
// numbers above 1000 but there is nothing we can do that won't break the app
// numbers above 1000, but there is nothing we can do that won't break the app
// below API 30.
// TODO: Disk number support?
val track = cursor.getIntOrNull(trackIndex)?.mod(1000)
@ -222,19 +235,22 @@ class MusicLoader {
return songs
}
/**
* Group songs up into their respective albums. Instead of using the unreliable album or
* artist databases, we instead group up songs by their *lowercase* artist and album name
* to create albums. This serves two purposes:
* 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN".
* This makes sure both of those are resolved into a single artist called "Rammstein"
* 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This
* ensures that all songs are unified under a single album.
*
* This does come with some costs, it's far slower than using the album ID itself, and
* it may result in an unrelated album art being selected depending on the song chosen
* as the template, but it seems to work pretty well.
*/
private fun buildAlbums(songs: List<Song>): List<Album> {
// Group up songs by their lowercase artist and album name. This serves two purposes:
// 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN".
// This makes sure both of those are resolved into a single artist called "Rammstein"
// 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This
// ensures that all songs are unified under a single album.
// This does come with some costs, it's far slower than using the album ID itself, and it
// may result in an unrelated album art being selected depending on the song chosen as
// the template, but it seems to work pretty well.
val albums = mutableListOf<Album>()
val songsByAlbum = songs.groupBy { song ->
song.internalGroupingId
}
val songsByAlbum = songs.groupBy { it.internalAlbumGroupingId }
for (entry in songsByAlbum) {
val albumSongs = entry.value
@ -270,43 +286,30 @@ class MusicLoader {
return albums
}
/**
* Group up albums into artists. This also requires a de-duplication step due to some
* edge cases where [buildAlbums] could not detect duplicates.
*/
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
val artists = mutableListOf<Artist>()
val albumsByArtist = albums.groupBy { it.internalGroupingArtistName }
val albumsByArtist = albums.groupBy { it.internalArtistGroupingId }
for (entry in albumsByArtist) {
val artistName = entry.key
val resolvedName = when (artistName) {
val templateAlbum = entry.value[0]
val artistName = templateAlbum.internalGroupingArtistName
val resolvedName = when (templateAlbum.internalGroupingArtistName) {
MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist)
else -> artistName
}
val artistAlbums = entry.value
// Album deduplication does not eliminate every case of fragmented artists, do
// we deduplicate in the artist creation step as well.
// Note that we actually don't do this in groupBy. This is generally because using
// a template song may not result in the best possible artist name in all cases.
val previousArtistIndex = artists.indexOfFirst { artist ->
artist.name.lowercase() == artistName.lowercase()
}
if (previousArtistIndex > -1) {
val previousArtist = artists[previousArtistIndex]
logD("Merging duplicate artist into pre-existing artist ${previousArtist.name}")
artists[previousArtistIndex] = Artist(
previousArtist.name,
previousArtist.resolvedName,
previousArtist.albums + artistAlbums
artists.add(
Artist(
artistName,
resolvedName,
artistAlbums
)
} else {
artists.add(
Artist(
artistName,
resolvedName,
artistAlbums
)
)
}
)
}
logD("Successfully built ${artists.size} artists")
@ -314,20 +317,21 @@ class MusicLoader {
return artists
}
/**
* Read all genres and link them up to the given songs. This is the code that
* requires me to make dozens of useless queries just to link genres up.
*/
private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
val genres = mutableListOf<Genre>()
val genreCursor = context.applicationContext.contentResolver.query(
context.contentResolverSafe.query(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(
MediaStore.Audio.Genres._ID,
MediaStore.Audio.Genres.NAME
),
null, null, null
)
// And then process those into Genre objects
genreCursor?.use { cursor ->
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
@ -351,7 +355,6 @@ class MusicLoader {
}
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
if (songsWithoutGenres.isNotEmpty()) {
// Songs that don't have a genre will be thrown into an unknown genre.
val unknownGenre = Genre(
@ -368,17 +371,42 @@ class MusicLoader {
return genres
}
/**
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the
* genre constant map that Auxio uses.
*/
private val String.genreNameCompat: String? get() {
if (isDigitsOnly()) {
// ID3v1, just parse as an integer
return genreConstantTable.getOrNull(toInt())
}
if (startsWith('(') && endsWith(')')) {
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
// Any genres formatted as "(CHARS)" will be ignored.
val genreInt = substring(1 until lastIndex).toIntOrNull()
if (genreInt != null) {
return genreConstantTable.getOrNull(genreInt)
}
}
// Current name is fine.
return null
}
/**
* Queries the genre songs for [genreId]. Some genres are insane and don't contain songs
* for some reason, so if that's the case then this function will return null.
*/
private fun queryGenreSongs(context: Context, genreId: Long, songs: List<Song>): List<Song>? {
val genreSongs = mutableListOf<Song>()
// Don't even bother blacklisting here as useless iterations are less expensive than IO
val songCursor = context.applicationContext.contentResolver.query(
context.contentResolverSafe.query(
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
arrayOf(MediaStore.Audio.Genres.Members._ID),
null, null, null
)
songCursor?.use { cursor ->
)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
@ -389,30 +417,9 @@ class MusicLoader {
}
}
// Some genres might be empty due to MediaStore insanity.
// If that is the case, we drop them.
return genreSongs.ifEmpty { null }
}
private val String.genreNameCompat: String? get() {
if (isDigitsOnly()) {
// ID3v1, just parse as an integer
return legacyGenreTable.getOrNull(toInt())
}
if (startsWith('(') && endsWith(')')) {
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
// Any genres formatted as "(CHARS)" will be ignored.
val genreInt = substring(1 until lastIndex).toIntOrNull()
if (genreInt != null) {
return legacyGenreTable.getOrNull(genreInt)
}
}
// Current name is fine.
return null
}
companion object {
/**
* The album_artist MediaStore field has existed since at least API 21, but until API
@ -421,13 +428,13 @@ class MusicLoader {
* warning about using a possibly-unsupported constant.
*/
@Suppress("InlinedApi")
const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
/**
* A complete array of all the hardcoded genre values for ID3(v2), contains standard genres and
* winamp extensions.
* A complete table of all the constant genre values for ID3(v2), including non-standard
* extensions.
*/
private val legacyGenreTable = arrayOf(
private val genreConstantTable = arrayOf(
// ID3 Standard
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock",
@ -441,7 +448,7 @@ class MusicLoader {
"New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
// Winamp Extensions
// Winamp extensions, more or less a de-facto standard
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin",
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock",
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus",
@ -454,9 +461,8 @@ class MusicLoader {
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
"Thrash Metal", "Anime", "JPop", "Synthpop",
// Winamp 5.6+ extensions, used by EasyTAG and friends
// The only reason I include this set is because post-rock is a based genre and
// deserves a slot.
// Winamp 5.6+ extensions, also used by EasyTAG.
// I only include this because post-rock is a based genre and deserves a slot.
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",

View file

@ -118,7 +118,7 @@ class MusicStore private constructor() {
/**
* A response that [MusicStore] returns when loading music.
* And before you ask, yes, I do like rust.
* TODO: Replace this with the kotlin builtin
* TODO: Add the exception to the "FAILED" ErrorKind
*/
sealed class Response {
class Ok(val musicStore: MusicStore) : Response()

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.excluded
package org.oxycblt.auxio.music.excluded
import android.content.ContentValues
import android.content.Context

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.excluded
package org.oxycblt.auxio.music.excluded
import android.net.Uri
import android.os.Bundle
@ -141,6 +141,8 @@ class ExcludedDialog : LifecycleDialog() {
// Only the main drive is supported, since that's all we can get from MediaColumns.DATA
// Unless I change the system to use the drive/directory system, that is. But there's no
// demand for that.
// TODO: You are going to split the queries into pre-Q and post-Q versions, so perhaps
// you should try to add external partition support again.
if (typeAndPath[0] == "primary") {
return getRootPath() + "/" + typeAndPath.last()
}

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.excluded
package org.oxycblt.auxio.music.excluded
import android.annotation.SuppressLint
import android.view.ViewGroup

View file

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.excluded
package org.oxycblt.auxio.music.excluded
import android.content.Context
import androidx.lifecycle.LiveData
@ -32,6 +32,7 @@ import org.oxycblt.auxio.util.logD
/**
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal
* of paths. Use [Factory] to instantiate this.
* TODO: Unify with MusicViewModel
* @author OxygenCobalt
*/
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {

View file

@ -44,6 +44,10 @@ import org.oxycblt.auxio.util.logE
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this provides
* an interface that properly sanitizes input and abstracts functions unlike the master class.**
* @author OxygenCobalt
*
* TODO: Completely rework this module to support the new music rescan system,
* proper android auto and external exposing, and so on.
* - DO NOT REWRITE IT! THAT'S BAD AND WILL PROBABLY RE-INTRODUCE A TON OF BUGS.
*/
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
// Playback
@ -170,7 +174,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
logD("Playing with uri $uri")
val musicStore = MusicStore.maybeGetInstance() ?: return
musicStore.findSongForUri(uri, context.contentResolver)?.let { song ->
playSong(song)
}

View file

@ -40,8 +40,6 @@ import org.oxycblt.auxio.util.logE
*
* All access should be done with [PlaybackStateManager.getInstance].
* @author OxygenCobalt
*
* TODO: Rework this to possibly handle gapless playback and more refined queue management.
*/
class PlaybackStateManager private constructor() {
// Playback

View file

@ -510,6 +510,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
* due to AudioManager.ACTION_HEADSET_PLUG always firing on startup. This is fixed, but
* I fear that it may not work on OEM skins that for whatever reason don't make this
* action fire.
* TODO: Figure out how players like Retro are able to get autoplay working with
* bluetooth headsets
*/
private fun maybeResumeFromPlug() {
if (playbackManager.song != null &&
@ -523,6 +525,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
/**
* Pause from a headset plug.
* TODO: Find a way to centralize this stuff into a single BroadcastReciever instead
* of the weird disjointed arrangement between MediaSession and this.
*/
private fun pauseFromPlug() {
if (playbackManager.song != null) {

View file

@ -32,7 +32,7 @@ import androidx.recyclerview.widget.RecyclerView
import coil.Coil
import org.oxycblt.auxio.R
import org.oxycblt.auxio.accent.AccentCustomizeDialog
import org.oxycblt.auxio.excluded.ExcludedDialog
import org.oxycblt.auxio.music.excluded.ExcludedDialog
import org.oxycblt.auxio.home.tabs.TabCustomizeDialog
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.pref.IntListPrefDialog

View file

@ -27,14 +27,14 @@ import org.oxycblt.auxio.music.Item
* @author OxygenCobalt
*/
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
// Prevent ID collisions from occurring between datatypes.
if (oldItem.javaClass != newItem.javaClass) return false
return oldItem.id == newItem.id
}
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
return oldItem.hashCode() == newItem.hashCode()
}
}

View file

@ -245,11 +245,8 @@ fun Context.hardRestart() {
// Instead of having to do a ton of cleanup and horrible code changes
// to restart this application non-destructively, I just restart the UI task [There is only
// one, after all] and then kill the application using exitProcess. Works well enough.
val intent = Intent(applicationContext, MainActivity::class.java).setFlags(
Intent.FLAG_ACTIVITY_CLEAR_TASK
)
val intent = Intent(applicationContext, MainActivity::class.java)
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
startActivity(intent)
exitProcess(0)
}

View file

@ -17,7 +17,7 @@ org.gradle.jvmargs=-Xmx2048m
android.useAndroidX=true
# Automatically convert third-party libraries to use AndroidX
android.enableJetifier=true
# Stop ExoPlayer from mangling AAR libraries with default abstract methods
# Stop ExoPlayer AARs from being mangled
android.enableDexingArtifactTransform=false
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official

View file

@ -17,12 +17,12 @@ org.oxycblt.auxio # Main UIs
├──.coil # Image loading components
├──.detail # Album/Artist/Genre detail UIs
│ └──.recycler # RecyclerView components for detail UIs
├──.excluded # Excluded Directories UI + Systems
├──.home # Home UI
│ ├──.fastscroll # Fast scroller UI
│ ├──.list # Home item lists
│ └──.tabs # Home tab customization
├──.music # Music data and loading
│ └──.excluded # Excluded Directories UI + Systems
├──.playback # Playback UI + Systems
│ ├──.queue # Queue UI
│ ├──.state # Playback state backend
@ -86,10 +86,10 @@ should only be talking to other shared objects. All objects can use the utility
#### Data objects
Auxio represents data in multiple ways.
`BaseModel` is the base class for most music and UI data in Auxio, with a single ID field meant to mark it as unique.
`Item` is the base class for most music and UI data in Auxio, with a single ID field meant to mark it as unique.
It has the following implementations:
- `Music` is a `BaseModel` that represents music. It adds a `name` field that represents the raw name of the music (from `MediaStore`).
- `Music` is a `Item` that represents music. It adds a `name` field that represents the raw name of the music (from `MediaStore`).
- `MusicParent` is a type of `Music` that contains children. It adds a `resolveName` field that converts the raw `MediaStore` name
to a name that can be used in UIs.
- `Header` and `ActionHeader` are UI data objects that represent a header item. `Header` corresponds to a simple header with no action,
@ -301,4 +301,4 @@ an immutable version of the playback state that negates some of the problems wit
a layout [e.g "Form"] depending on its current dimensions and applies the `WidgetState` object to that.
**Note:** The AppWidget implementation violates UI conventions by directly interfacing with coil and `PlaybackStateManager`.
This is required due to `RemoteView` limitations.
This is required due to `RemoteView` limitations.