music: refactor music loading

Refactor music loading to be based off of songs entirely. This reduces
efficency but enables some nices fixes, notably:
1. Album artists now have basic support [You won't be able to see
specific artists, but they won't be fragmented anymore]
2. Samsung devices probably shouldn't get confused about artist names
anymore, like in #40

This should hopefully be the last time I need to refactor this horrible
system. Thank god.
This commit is contained in:
OxygenCobalt 2021-09-26 15:33:31 -06:00
parent 276f991b2b
commit 3ab425839c
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 202 additions and 202 deletions

View file

@ -30,6 +30,7 @@ import coil.size.Size
import okio.buffer import okio.buffer
import okio.source import okio.source
import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.toAlbumArtURI
import org.oxycblt.auxio.music.toURI import org.oxycblt.auxio.music.toURI
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import java.io.ByteArrayInputStream import java.io.ByteArrayInputStream
@ -56,14 +57,15 @@ class AlbumArtFetcher(private val context: Context) : Fetcher<Album> {
} }
private fun loadMediaStoreCovers(data: Album): SourceResult { private fun loadMediaStoreCovers(data: Album): SourceResult {
val stream = context.contentResolver.openInputStream(data.coverUri) val uri = data.id.toAlbumArtURI()
val stream = context.contentResolver.openInputStream(uri)
if (stream != null) { if (stream != null) {
// Don't close the stream here as it will cause an error later from an attempted read. // Don't close the stream here as it will cause an error later from an attempted read.
// This stream still seems to close itself at some point, so its fine. // This stream still seems to close itself at some point, so its fine.
return SourceResult( return SourceResult(
source = stream.source().buffer(), source = stream.source().buffer(),
mimeType = context.contentResolver.getType(data.coverUri), mimeType = context.contentResolver.getType(uri),
dataSource = DataSource.DISK dataSource = DataSource.DISK
) )
} }
@ -97,5 +99,5 @@ class AlbumArtFetcher(private val context: Context) : Fetcher<Album> {
return loadMediaStoreCovers(data) return loadMediaStoreCovers(data)
} }
override fun key(data: Album) = data.coverUri.toString() override fun key(data: Album) = data.id.toString()
} }

View file

@ -38,6 +38,7 @@ import org.oxycblt.auxio.music.Album
import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.Parent import org.oxycblt.auxio.music.Parent
import org.oxycblt.auxio.music.toAlbumArtURI
import java.io.Closeable import java.io.Closeable
import java.io.InputStream import java.io.InputStream
@ -57,11 +58,11 @@ class MosaicFetcher(private val context: Context) : Fetcher<Parent> {
when (data) { when (data) {
is Artist -> data.albums.forEachIndexed { index, album -> is Artist -> data.albums.forEachIndexed { index, album ->
if (index < 4) { uris.add(album.coverUri) } if (index < 4) { uris.add(album.id.toAlbumArtURI()) }
} }
is Genre -> data.songs.groupBy { it.album.coverUri }.keys.forEachIndexed { index, uri -> is Genre -> data.songs.groupBy { it.album.id }.keys.forEachIndexed { index, id ->
if (index < 4) { uris.add(uri) } if (index < 4) { uris.add(id.toAlbumArtURI()) }
} }
else -> {} else -> {}

View file

@ -163,13 +163,13 @@ class HomeFragment : Fragment() {
homeModel.curTab.observe(viewLifecycleOwner) { tab -> homeModel.curTab.observe(viewLifecycleOwner) { tab ->
binding.homeAppbar.liftOnScrollTargetViewId = when (requireNotNull(tab)) { binding.homeAppbar.liftOnScrollTargetViewId = when (requireNotNull(tab)) {
DisplayMode.SHOW_SONGS -> { DisplayMode.SHOW_SONGS -> {
updateSortMenu(sortItem, homeModel.songSortMode) updateSortMenu(sortItem, tab)
R.id.home_song_list R.id.home_song_list
} }
DisplayMode.SHOW_ALBUMS -> { DisplayMode.SHOW_ALBUMS -> {
updateSortMenu(sortItem, homeModel.albumSortMode) { id -> updateSortMenu(sortItem, tab) { id ->
id != R.id.option_sort_album id != R.id.option_sort_album
} }
@ -177,7 +177,7 @@ class HomeFragment : Fragment() {
} }
DisplayMode.SHOW_ARTISTS -> { DisplayMode.SHOW_ARTISTS -> {
updateSortMenu(sortItem, homeModel.artistSortMode) { id -> updateSortMenu(sortItem, tab) { id ->
id == R.id.option_sort_asc || id == R.id.option_sort_dsc id == R.id.option_sort_asc || id == R.id.option_sort_dsc
} }
@ -185,7 +185,7 @@ class HomeFragment : Fragment() {
} }
DisplayMode.SHOW_GENRES -> { DisplayMode.SHOW_GENRES -> {
updateSortMenu(sortItem, homeModel.genreSortMode) { id -> updateSortMenu(sortItem, tab) { id ->
id == R.id.option_sort_asc || id == R.id.option_sort_dsc id == R.id.option_sort_asc || id == R.id.option_sort_dsc
} }
@ -228,9 +228,11 @@ class HomeFragment : Fragment() {
private fun updateSortMenu( private fun updateSortMenu(
item: MenuItem, item: MenuItem,
toHighlight: SortMode, displayMode: DisplayMode,
isVisible: (Int) -> Boolean = { true } isVisible: (Int) -> Boolean = { true }
) { ) {
val toHighlight = homeModel.getSortForDisplay(displayMode)
for (option in item.subMenu) { for (option in item.subMenu) {
if (option.itemId == toHighlight.itemId) { if (option.itemId == toHighlight.itemId) {
option.isChecked = true option.isChecked = true

View file

@ -26,6 +26,7 @@ import org.oxycblt.auxio.music.Artist
import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Genre
import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.music.MusicStore
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.SortMode import org.oxycblt.auxio.ui.SortMode
@ -56,19 +57,13 @@ class HomeViewModel : ViewModel() {
private val mCurTab = MutableLiveData(mTabs.value!![0]) private val mCurTab = MutableLiveData(mTabs.value!![0])
val curTab: LiveData<DisplayMode> = mCurTab val curTab: LiveData<DisplayMode> = mCurTab
var genreSortMode = SortMode.ASCENDING private var genreSortMode = SortMode.ASCENDING
private set private var artistSortMode = SortMode.ASCENDING
private var albumSortMode = SortMode.ASCENDING
var artistSortMode = SortMode.ASCENDING private var songSortMode = SortMode.ASCENDING
private set
var albumSortMode = SortMode.ASCENDING
private set
var songSortMode = SortMode.ASCENDING
private set
private val musicStore = MusicStore.getInstance() private val musicStore = MusicStore.getInstance()
private val settingsManager = SettingsManager.getInstance()
init { init {
mSongs.value = songSortMode.sortSongs(musicStore.songs) mSongs.value = songSortMode.sortSongs(musicStore.songs)
@ -86,6 +81,15 @@ class HomeViewModel : ViewModel() {
mCurTab.value = mode mCurTab.value = mode
} }
fun getSortForDisplay(displayMode: DisplayMode): SortMode {
return when (displayMode) {
DisplayMode.SHOW_GENRES -> genreSortMode
DisplayMode.SHOW_ARTISTS -> artistSortMode
DisplayMode.SHOW_ALBUMS -> albumSortMode
DisplayMode.SHOW_SONGS -> songSortMode
}
}
/** /**
* Update the currently displayed item's [SortMode]. * Update the currently displayed item's [SortMode].
*/ */

View file

@ -18,7 +18,6 @@
package org.oxycblt.auxio.music package org.oxycblt.auxio.music
import android.net.Uri
import android.view.View import android.view.View
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.annotation.StringRes import androidx.annotation.StringRes
@ -87,7 +86,10 @@ data class Song(
override val id: Long, override val id: Long,
override val name: String, override val name: String,
val fileName: String, val fileName: String,
val albumName: String,
val albumId: Long, val albumId: Long,
val artistName: String,
val year: Int,
val track: Int, val track: Int,
val duration: Long val duration: Long
) : BaseModel(), Hashable { ) : BaseModel(), Hashable {
@ -97,8 +99,8 @@ data class Song(
val genre: Genre? get() = mGenre val genre: Genre? get() = mGenre
val album: Album get() = requireNotNull(mAlbum) val album: Album get() = requireNotNull(mAlbum)
val seconds = duration / 1000 val seconds: Long get() = duration / 1000
val formattedDuration = seconds.toDuration() val formattedDuration: String get() = (duration / 1000).toDuration()
override val hash: Int get() { override val hash: Int get() {
var result = name.hashCode() var result = name.hashCode()
@ -108,22 +110,17 @@ data class Song(
} }
fun linkAlbum(album: Album) { fun linkAlbum(album: Album) {
if (mAlbum == null) {
mAlbum = album mAlbum = album
} }
}
fun linkGenre(genre: Genre) { fun linkGenre(genre: Genre) {
if (mGenre == null) {
mGenre = genre mGenre = genre
} }
}
} }
/** /**
* The data object for an album. Inherits [Parent]. * The data object for an album. Inherits [Parent].
* @property artistName The name of the parent artist. Do not use this outside of creating the artist from albums * @property artistName The name of the parent artist. Do not use this outside of creating the artist from albums
* @property coverUri The [Uri] for the album's cover. **Load this using Coil.**
* @property year The year this album was released. 0 if there is none in the metadata. * @property year The year this album was released. 0 if there is none in the metadata.
* @property artist The Album's parent [Artist]. use this instead of [artistName] * @property artist The Album's parent [Artist]. use this instead of [artistName]
* @property songs The Album's child [Song]s. * @property songs The Album's child [Song]s.
@ -133,15 +130,18 @@ data class Album(
override val id: Long, override val id: Long,
override val name: String, override val name: String,
val artistName: String, val artistName: String,
val coverUri: Uri, val year: Int,
val year: Int val songs: List<Song>
) : Parent() { ) : Parent() {
init {
songs.forEach { song ->
song.linkAlbum(this)
}
}
private var mArtist: Artist? = null private var mArtist: Artist? = null
val artist: Artist get() = requireNotNull(mArtist) val artist: Artist get() = requireNotNull(mArtist)
private val mSongs = mutableListOf<Song>()
val songs: List<Song> get() = mSongs
val totalDuration: String get() = val totalDuration: String get() =
songs.sumOf { it.seconds }.toDuration() songs.sumOf { it.seconds }.toDuration()
@ -155,13 +155,6 @@ data class Album(
fun linkArtist(artist: Artist) { fun linkArtist(artist: Artist) {
mArtist = artist mArtist = artist
} }
fun linkSongs(songs: List<Song>) {
for (song in songs) {
song.linkAlbum(this)
mSongs.add(song)
}
}
} }
/** /**
@ -175,6 +168,12 @@ data class Artist(
override val name: String, override val name: String,
val albums: List<Album> val albums: List<Album>
) : Parent() { ) : Parent() {
init {
albums.forEach { album ->
album.linkArtist(this)
}
}
val genre: Genre? by lazy { val genre: Genre? by lazy {
// Get the genre that corresponds to the most songs in this artist, which would be // Get the genre that corresponds to the most songs in this artist, which would be
// the most "Prominent" genre. // the most "Prominent" genre.
@ -186,12 +185,6 @@ data class Artist(
} }
override val hash = name.hashCode() override val hash = name.hashCode()
init {
albums.forEach { album ->
album.linkArtist(this)
}
}
} }
/** /**

View file

@ -20,18 +20,74 @@ package org.oxycblt.auxio.music
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.content.Context import android.content.Context
import android.net.Uri
import android.provider.MediaStore
import android.provider.MediaStore.Audio.Albums
import android.provider.MediaStore.Audio.Genres import android.provider.MediaStore.Audio.Genres
import android.provider.MediaStore.Audio.Media import android.provider.MediaStore.Audio.Media
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.excluded.ExcludedDatabase import org.oxycblt.auxio.excluded.ExcludedDatabase
import org.oxycblt.auxio.ui.SortMode
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
/** /**
* Class that loads/constructs [Genre]s, [Artist]s, [Album]s, and [Song] objects from the filesystem * This class does pretty much all the black magic required to get a remotely sensible music
* indexing system while still optimizing for time. I would recommend you leave this file now
* before you lose your sanity trying to understand the hoops I had to jump through for this
* system, but if you really want to stay, here's a debrief on why this code is so awful.
*
* MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
* other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a
* crime against humanity and probably a way to summon Zalgo if you tried hard enough.
*
* 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
* to straightfoward for this platform. 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 in MediaStore's documentation, 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 busted, 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!
*
* 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?
* I sure didn't, until I decided to upgrade my music collection to ID3v2.4 and Xiph only to see
* that their metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or
* 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 is
* 21 years old. It can drink now. All 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 efficent "make a big query on all the songs lol" method
* so that songs don't end up fragmented across artists. Pretty much every OEM has added some
* extension or quirk to MediaStore that I cannot determine, with some OEMs (COUGHSAMSUNGCOUGH)
* crippling the normal tables so that you're railroaded into their ad-infested 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
* Genre's will just break for no reason, sometimes this plate of spaghetti just completely breaks
* down and is unable to get any metadata. Everything is broken in it's own special unique way and
* I absolutely hate it.
*
* Is there anything we can do about it? No. Google has routinely shut down issues that beg 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, especially for such obscure things
* as indexing music. 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 me 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.
* Because go **** yourself for wanting to listen to music you own. Be a good consoomer and listen
* to your AlgoMix MusikStream.
*
* I hate this platform so much.
*
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicLoader(private val context: Context) { class MusicLoader(private val context: Context) {
@ -53,12 +109,11 @@ class MusicLoader(private val context: Context) {
buildSelector() buildSelector()
loadGenres() loadGenres()
loadAlbums()
loadSongs() loadSongs()
linkAlbums()
buildArtists()
linkGenres() linkGenres()
buildAlbums()
buildArtists()
} }
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@ -95,7 +150,8 @@ class MusicLoader(private val context: Context) {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val name = cursor.getStringOrNull(nameIndex) ?: continue // No non-broken genre would be missing a name // No non-broken genre would be missing a name.
val name = cursor.getStringOrNull(nameIndex) ?: continue
genres.add(Genre(id, name)) genres.add(Genre(id, name))
} }
@ -104,53 +160,6 @@ class MusicLoader(private val context: Context) {
logD("Genre search finished with ${genres.size} genres found.") logD("Genre search finished with ${genres.size} genres found.")
} }
private fun loadAlbums() {
logD("Starting album search...")
val albumCursor = resolver.query(
Albums.EXTERNAL_CONTENT_URI,
arrayOf(
Albums._ID, // 0
Albums.ALBUM, // 1
Albums.ARTIST, // 2
Albums.LAST_YEAR, // 4
),
null, null,
Albums.DEFAULT_SORT_ORDER
)
val albumPlaceholder = context.getString(R.string.def_album)
val artistPlaceholder = context.getString(R.string.def_artist)
albumCursor?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(Albums._ID)
val nameIndex = cursor.getColumnIndexOrThrow(Albums.ALBUM)
val artistNameIndex = cursor.getColumnIndexOrThrow(Albums.ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(Albums.LAST_YEAR)
while (cursor.moveToNext()) {
val id = cursor.getLong(idIndex)
val name = cursor.getString(nameIndex) ?: albumPlaceholder
var artistName = cursor.getString(artistNameIndex) ?: artistPlaceholder
val year = cursor.getInt(yearIndex)
val coverUri = id.toAlbumArtURI()
// Correct any artist names to a nicer "Unknown Artist" label
if (artistName == MediaStore.UNKNOWN_STRING) {
artistName = artistPlaceholder
}
albums.add(Album(id, name, artistName, coverUri, year))
}
}
albums = albums.distinctBy {
it.name to it.artistName to it.year
}.toMutableList()
logD("Album search finished with ${albums.size} albums found")
}
@SuppressLint("InlinedApi") @SuppressLint("InlinedApi")
private fun loadSongs() { private fun loadSongs() {
logD("Starting song search...") logD("Starting song search...")
@ -159,11 +168,15 @@ class MusicLoader(private val context: Context) {
Media.EXTERNAL_CONTENT_URI, Media.EXTERNAL_CONTENT_URI,
arrayOf( arrayOf(
Media._ID, // 0 Media._ID, // 0
Media.DISPLAY_NAME, // 1 Media.TITLE, // 1
Media.TITLE, // 2 Media.DISPLAY_NAME, // 2
Media.ALBUM_ID, // 3 Media.ALBUM, // 3
Media.TRACK, // 4 Media.ALBUM_ID, // 4
Media.DURATION, // 5 Media.ARTIST, // 5
Media.ALBUM_ARTIST, // 6
Media.YEAR, // 7
Media.TRACK, // 8
Media.DURATION, // 9
), ),
selector, args, selector, args,
Media.DEFAULT_SORT_ORDER Media.DEFAULT_SORT_ORDER
@ -173,7 +186,11 @@ class MusicLoader(private val context: Context) {
val idIndex = cursor.getColumnIndexOrThrow(Media._ID) val idIndex = cursor.getColumnIndexOrThrow(Media._ID)
val titleIndex = cursor.getColumnIndexOrThrow(Media.TITLE) val titleIndex = cursor.getColumnIndexOrThrow(Media.TITLE)
val fileIndex = cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME) val fileIndex = cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME)
val albumIndex = cursor.getColumnIndexOrThrow(Media.ALBUM_ID) val albumIndex = cursor.getColumnIndexOrThrow(Media.ALBUM)
val albumIdIndex = cursor.getColumnIndexOrThrow(Media.ALBUM_ID)
val artistIndex = cursor.getColumnIndexOrThrow(Media.ARTIST)
val albumArtistIndex = cursor.getColumnIndexOrThrow(Media.ALBUM_ARTIST)
val yearIndex = cursor.getColumnIndexOrThrow(Media.YEAR)
val trackIndex = cursor.getColumnIndexOrThrow(Media.TRACK) val trackIndex = cursor.getColumnIndexOrThrow(Media.TRACK)
val durationIndex = cursor.getColumnIndexOrThrow(Media.DURATION) val durationIndex = cursor.getColumnIndexOrThrow(Media.DURATION)
@ -181,111 +198,89 @@ class MusicLoader(private val context: Context) {
val id = cursor.getLong(idIndex) val id = cursor.getLong(idIndex)
val fileName = cursor.getString(fileIndex) val fileName = cursor.getString(fileIndex)
val title = cursor.getString(titleIndex) ?: fileName val title = cursor.getString(titleIndex) ?: fileName
val albumId = cursor.getLong(albumIndex) val album = cursor.getString(albumIndex)
val albumId = cursor.getLong(albumIdIndex)
// MediaStore does not have support for artists in the album field, so we have to
// detect it on a song-by-song basis. This is another massive bottleneck in the music
// loader since we have to do a massive query to get what we want, but theres not
// a lot I can do that doesn't degrade UX.
val artist = cursor.getStringOrNull(albumArtistIndex)
?: cursor.getString(artistIndex)
val year = cursor.getInt(yearIndex)
val track = cursor.getInt(trackIndex) val track = cursor.getInt(trackIndex)
val duration = cursor.getLong(durationIndex) val duration = cursor.getLong(durationIndex)
songs.add(Song(id, title, fileName, albumId, track, duration)) songs.add(
Song(
id, title, fileName, album, albumId, artist, year, track, duration
)
)
} }
} }
songs = songs.distinctBy { songs = songs.distinctBy {
it.name to it.albumId to it.track to it.duration it.name to it.albumId to it.artistName to it.track to it.duration
}.toMutableList() }.toMutableList()
logD("Song search finished with ${songs.size} found") logD("Song search finished with ${songs.size} found")
} }
private fun linkAlbums() { private fun buildAlbums() {
logD("Linking albums") logD("Linking albums")
// Group up songs by their album ids and then link them with their albums // Group up songs by their album ids and then link them with their albums
val songsByAlbum = songs.groupBy { it.albumId } val songsByAlbum = songs.groupBy { it.albumId }
val unknownAlbum = Album(
id = -1,
name = context.getString(R.string.def_album),
artistName = context.getString(R.string.def_artist),
coverUri = Uri.EMPTY,
year = 0
)
songsByAlbum.forEach { entry -> songsByAlbum.forEach { entry ->
(albums.find { it.id == entry.key } ?: unknownAlbum).linkSongs(entry.value) // Rely on the first song in this list for album information.
// Note: This might result in a bad year being used for an album if an album's songs
// have multiple years. This is fixable but is currently omitted for speed.
val song = entry.value[0]
albums.add(
Album(
id = entry.key,
name = song.albumName,
artistName = song.artistName,
songs = entry.value,
year = song.year
)
)
} }
albums.removeAll { it.songs.isEmpty() } albums.removeAll { it.songs.isEmpty() }
albums = SortMode.ASCENDING.sortAlbums(albums).toMutableList()
// If something goes horribly wrong and somehow songs are still not linked up by the logD("Songs successfully linked into ${albums.size} albums")
// album id, just throw them into an unknown album.
if (unknownAlbum.songs.isNotEmpty()) {
albums.add(unknownAlbum)
}
} }
private fun buildArtists() { private fun buildArtists() {
logD("Linking artists") logD("Linking artists")
// Group albums up by their artist name, should not result in any null-artist issues
val albumsByArtist = albums.groupBy { it.artistName } val albumsByArtist = albums.groupBy { it.artistName }
albumsByArtist.forEach { entry -> albumsByArtist.forEach { entry ->
// Because of our hacky album artist system, MediaStore artist IDs are unreliable.
// Therefore we just use the hashCode of the artist name as our ID and move on.
artists.add( artists.add(
// IDs are incremented from the minimum int value so that they remain unique.
Artist( Artist(
id = (Int.MIN_VALUE + artists.size).toLong(), id = entry.key.hashCode().toLong(),
name = entry.key, name = entry.key,
albums = entry.value albums = entry.value
) )
) )
} }
artists = SortMode.ASCENDING.sortModels(artists).toMutableList()
logD("Albums successfully linked into ${artists.size} artists") logD("Albums successfully linked into ${artists.size} artists")
} }
private fun linkGenres() { private fun linkGenres() {
logD("Linking genres") logD("Linking genres")
/*
* Okay, I'm going to go on a bit of a tangent here because this bit of code infuriates me.
*
* In any reasonable platform, you think that you could just query a media database for the
* "genre" field and get the genre for a song, right? But not with android! No. Thats too
* normal for this dysfunctional SDK that was dropped on it's head as a baby. Instead, we
* have to iterate through EACH GENRE, QUERY THE MEDIA DATABASE FOR THE SONGS OF THAT GENRE,
* AND THEN ITERATE THROUGH THAT LIST TO LINK EVERY SONG WITH THE GENRE. This O(mom im scared)
* algorithm single-handedly DOUBLES the amount of time it takes Auxio to load music, but
* apparently this is the only way you can have a remotely sensible genre system on this
* busted OS. Why is it this way? Nobody knows! Now this quirk is immortalized and has to
* be replicated in all future versions of this API, because god forbid you break some
* app that's probably older than some of the people reading this by now.
*
* Another fun fact, did you know that you can only get the date from ID3v2.3 MPEG files?
* I sure didn't, until I decided to update my music collection to ID3v2.4 and Xiph only
* to see that android apparently has a brain aneurysm the moment it sees a dreaded TDRC
* or DATE tag. This bug is similarly baked into the platform and hasnt been fixed, even
* in android TWELVE. ID3v2.4 has existed for TWENTY-ONE YEARS. IT CAN DRINK NOW. At least
* you could replace your ancient dinosaur parser with Taglib or something, but again,
* google cant bear even slighting the gods of backwards compat.
*
* Is there anything we can do about this system? No. Google's issue tracker, in classic
* google fashion, requires a google account to even view. Even if I were to set aside my
* Torvalds-esqe convictions and make an account, MediaStore is such an obscure part of the
* platform that it basically receives no attention compared to the advertising APIs or the
* nineteenth UI rework, and its not like the many music player developers are going to band
* together to beg google to take this API behind the woodshed and shoot it. So instead,
* players like VLC and Vanilla just hack their own pidgin version of MediaStore off of
* their own media parsers, but even this becomes increasingly impossible as google
* continues to kill the filesystem ala iOS. In the future MediaStore could be the only
* system we have, which is also the day greenland melts and birthdays stop happening
* forever. I'm pretty sure that at this point nothing is going to happen, google will
* continue to neglect MediaStore, and all the people who just want to listen to their music
* collections on their phone will continue to get screwed. But hey, at least some dev at
* google got a cushy managerial position where they can tweet about politics all day for
* shipping the brand new androidx.FooBarBlasterView, yay!
*
* I hate this platform so much.
*/
genres.forEach { genre -> genres.forEach { genre ->
val songCursor = resolver.query( val songCursor = resolver.query(
Genres.Members.getContentUri("external", genre.id), Genres.Members.getContentUri("external", genre.id),
@ -306,22 +301,21 @@ class MusicLoader(private val context: Context) {
} }
} }
// Any songs without genres will be thrown into an unknown genre // Songs that don't have a genre will be thrown into an unknown genre.
val songsWithoutGenres = songs.filter { it.genre == null }
if (songsWithoutGenres.isNotEmpty()) {
val unknownGenre = Genre( val unknownGenre = Genre(
id = -2, id = -2,
name = context.getString(R.string.def_genre) name = context.getString(R.string.def_genre)
) )
songsWithoutGenres.forEach { song -> songs.forEach { song ->
if (song.genre == null) {
unknownGenre.linkSong(song) unknownGenre.linkSong(song)
} }
}
if (unknownGenre.songs.isEmpty()) {
genres.add(unknownGenre) genres.add(unknownGenre)
} }
genres.removeAll { it.songs.isEmpty() }
} }
} }

View file

@ -41,17 +41,13 @@ enum class LoopMode {
* @return The int constant for this mode * @return The int constant for this mode
*/ */
fun toInt(): Int { fun toInt(): Int {
return when (this) { return CONST_NONE + ordinal
NONE -> CONST_NONE
ALL -> CONST_ALL
TRACK -> CONST_TRACK
}
} }
companion object { companion object {
const val CONST_NONE = 0xA100 private const val CONST_NONE = 0xA100
const val CONST_ALL = 0xA101 private const val CONST_ALL = 0xA101
const val CONST_TRACK = 0xA102 private const val CONST_TRACK = 0xA102
/** /**
* Convert an int [constant] into a LoopMode, or null if it isnt valid. * Convert an int [constant] into a LoopMode, or null if it isnt valid.

View file

@ -37,19 +37,14 @@ enum class PlaybackMode {
* @return The constant for this mode, * @return The constant for this mode,
*/ */
fun toInt(): Int { fun toInt(): Int {
return when (this) { return CONST_IN_ARTIST + ordinal
IN_ARTIST -> CONST_IN_ARTIST
IN_GENRE -> CONST_IN_GENRE
IN_ALBUM -> CONST_IN_ALBUM
ALL_SONGS -> CONST_ALL_SONGS
}
} }
companion object { companion object {
const val CONST_IN_GENRE = 0xA103 private const val CONST_IN_GENRE = 0xA103
const val CONST_IN_ARTIST = 0xA104 private const val CONST_IN_ARTIST = 0xA104
const val CONST_IN_ALBUM = 0xA105 private const val CONST_IN_ALBUM = 0xA105
const val CONST_ALL_SONGS = 0xA106 private const val CONST_ALL_SONGS = 0xA106
/** /**
* Get a [PlaybackMode] for an int [constant] * Get a [PlaybackMode] for an int [constant]

View file

@ -32,6 +32,9 @@ class DiffCallback<T : BaseModel> : DiffUtil.ItemCallback<T>() {
} }
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean { 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 return oldItem.id == newItem.id
} }
} }

View file

@ -35,6 +35,11 @@ enum class DisplayMode {
private const val CONST_SHOW_ALBUMS = 0xA10A private const val CONST_SHOW_ALBUMS = 0xA10A
private const val CONST_SHOW_SONGS = 0xA10B private const val CONST_SHOW_SONGS = 0xA10B
/**
* Convert this enum into an integer for filtering.
* In this context, a null value means to filter nothing.
* @return An integer constant for that display mode, or a constant for a null [DisplayMode]
*/
fun toFilterInt(value: DisplayMode?): Int { fun toFilterInt(value: DisplayMode?): Int {
return when (value) { return when (value) {
SHOW_SONGS -> CONST_SHOW_SONGS SHOW_SONGS -> CONST_SHOW_SONGS
@ -45,6 +50,11 @@ enum class DisplayMode {
} }
} }
/**
* Convert a filtering integer to a [DisplayMode].
* In this context, a null value means to filter nothing.
* @return A [DisplayMode] for this constant (including null)
*/
fun fromFilterInt(value: Int): DisplayMode? { fun fromFilterInt(value: Int): DisplayMode? {
return when (value) { return when (value) {
CONST_SHOW_SONGS -> SHOW_SONGS CONST_SHOW_SONGS -> SHOW_SONGS