From 637bcccd510e6031335904f5d750cac6901d04c6 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Sun, 5 Dec 2021 17:56:21 -0700 Subject: [PATCH] music: clean up loader implementation Clean up the music loader implementation, removing pre-sorting to make it a bit more efficent. Instead, sorting is done on indiviual components. --- .../java/org/oxycblt/auxio/coil/Fetchers.kt | 7 +- .../oxycblt/auxio/excluded/ExcludedDialog.kt | 23 +- .../org/oxycblt/auxio/music/MusicLoader.kt | 261 ++++++++---------- .../org/oxycblt/auxio/music/MusicStore.kt | 16 +- .../oxycblt/auxio/search/SearchViewModel.kt | 12 +- .../org/oxycblt/auxio/util/ContextUtil.kt | 14 + app/src/main/res/values-cs/strings.xml | 2 +- app/src/main/res/values-de/strings.xml | 2 +- app/src/main/res/values-es/strings.xml | 2 +- app/src/main/res/values/strings.xml | 2 +- app/src/main/res/xml/prefs_main.xml | 2 +- info/FAQ.md | 3 +- 12 files changed, 155 insertions(+), 191 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt index 7ba606a7c..418083170 100644 --- a/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt +++ b/app/src/main/java/org/oxycblt/auxio/coil/Fetchers.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Song +import org.oxycblt.auxio.ui.Sort import kotlin.math.min /** @@ -76,7 +77,10 @@ class ArtistImageFetcher private constructor( private val artist: Artist, ) : AuxioFetcher() { override suspend fun fetch(): FetchResult? { - val results = artist.albums.mapAtMost(4) { album -> + val albums = Sort.ByName(true) + .sortAlbums(artist.albums) + + val results = albums.mapAtMost(4) { album -> fetchArt(context, album) } @@ -100,6 +104,7 @@ class GenreImageFetcher private constructor( private val genre: Genre, ) : AuxioFetcher() { override suspend fun fetch(): FetchResult? { + // We don't need to sort here, as the way we val albums = genre.songs.groupBy { it.album }.keys val results = albums.mapAtMost(4) { album -> fetchArt(context, album) diff --git a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt index 2e913ec6f..7e3671af1 100644 --- a/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/excluded/ExcludedDialog.kt @@ -18,7 +18,6 @@ package org.oxycblt.auxio.excluded -import android.content.Intent import android.net.Uri import android.os.Bundle import android.os.Environment @@ -32,14 +31,13 @@ import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.MainActivity import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogExcludedBinding import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.LifecycleDialog +import org.oxycblt.auxio.util.hardRestart import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast -import kotlin.system.exitProcess /** * Dialog that manages the currently excluded directories. @@ -150,25 +148,12 @@ class ExcludedDialog : LifecycleDialog() { private fun saveAndRestart() { excludedModel.save { - playbackModel.savePlaybackState(requireContext(), ::hardRestart) + playbackModel.savePlaybackState(requireContext()) { + requireContext().hardRestart() + } } } - private fun hardRestart() { - logD("Performing hard restart.") - - // 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(requireContext().applicationContext, MainActivity::class.java).setFlags( - Intent.FLAG_ACTIVITY_CLEAR_TASK - ) - - startActivity(intent) - - exitProcess(0) - } - /** * Get *just* the root path, nothing else is really needed. */ 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 947343188..db3f11c2c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicLoader.kt @@ -1,37 +1,14 @@ -/* - * Copyright (c) 2021 Auxio Project - * MusicLoader.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - package org.oxycblt.auxio.music -import android.annotation.SuppressLint import android.content.Context import android.provider.MediaStore -import android.provider.MediaStore.Audio.Genres -import android.provider.MediaStore.Audio.Media import androidx.core.database.getStringOrNull import org.oxycblt.auxio.R import org.oxycblt.auxio.excluded.ExcludedDatabase -import org.oxycblt.auxio.ui.Sort -import org.oxycblt.auxio.util.logD /** - * 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 + * This class acts as the base for most the black magic required to get a remotely sensible music + * indexing system while still optimizing for time. I would recommend you leave this module 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. * @@ -90,111 +67,72 @@ import org.oxycblt.auxio.util.logD * * @author OxygenCobalt */ -class MusicLoader(private val context: Context) { - var genres = mutableListOf() - var albums = mutableListOf() - var artists = mutableListOf() - var songs = mutableListOf() +class MusicLoader { + data class Library( + val genres: List, + val artists: List, + val albums: List, + val songs: List + ) - private val resolver = context.contentResolver + fun load(context: Context): Library? { + val songs = loadSongs(context) + if (songs.isEmpty()) return null - private var selector = "${Media.IS_MUSIC}=1" - private var args = arrayOf() + val albums = buildAlbums(songs) + val artists = buildArtists(context, albums) + val genres = readGenres(context, songs) - /** - * Begin the loading process. - * Resulting models are pushed to [genres], [artists], [albums], and [songs]. - */ - fun load() { - buildSelector() - - loadGenres() - loadSongs() - linkGenres() - - buildAlbums() - buildArtists() + return Library( + genres, + artists, + albums, + songs + ) } - @Suppress("DEPRECATION") - private fun buildSelector() { + private fun loadSongs(context: Context): List { + var songs = mutableListOf() val blacklistDatabase = ExcludedDatabase.getInstance(context) - val paths = blacklistDatabase.readPaths() - // DATA was deprecated on Android Q, but is set to be un-deprecated in Android 12L + var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1" + val args = mutableListOf() + + // 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 efficent and there's no demand for that right now. for (path in paths) { - selector += " AND ${Media.DATA} NOT LIKE ?" + selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?" args += "$path%" // Append % so that the selector properly detects children } - } - private fun loadGenres() { - logD("Starting genre search...") - - // First, get a cursor for every genre in the android system - val genreCursor = resolver.query( - Genres.EXTERNAL_CONTENT_URI, + context.contentResolver.query( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, arrayOf( - Genres._ID, // 0 - Genres.NAME // 1 + 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, ), - null, null, - Genres.DEFAULT_SORT_ORDER - ) - - // And then process those into Genre objects - genreCursor?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(Genres._ID) - val nameIndex = cursor.getColumnIndexOrThrow(Genres.NAME) - - while (cursor.moveToNext()) { - val id = cursor.getLong(idIndex) - // No non-broken genre would be missing a name. - val name = cursor.getStringOrNull(nameIndex) ?: continue - - genres.add(Genre(id, name, name.getGenreNameCompat() ?: name)) - } - } - - logD("Genre search finished with ${genres.size} genres found.") - } - - @SuppressLint("InlinedApi") - private fun loadSongs() { - logD("Starting song search...") - - val songCursor = resolver.query( - Media.EXTERNAL_CONTENT_URI, - arrayOf( - Media._ID, // 0 - Media.TITLE, // 1 - Media.DISPLAY_NAME, // 2 - Media.ALBUM, // 3 - Media.ALBUM_ID, // 4 - Media.ARTIST, // 5 - Media.ALBUM_ARTIST, // 6 - Media.YEAR, // 7 - Media.TRACK, // 8 - Media.DURATION, // 9 - ), - selector, args, - Media.DEFAULT_SORT_ORDER - ) - - songCursor?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(Media._ID) - val titleIndex = cursor.getColumnIndexOrThrow(Media.TITLE) - val fileIndex = cursor.getColumnIndexOrThrow(Media.DISPLAY_NAME) - 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 durationIndex = cursor.getColumnIndexOrThrow(Media.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) while (cursor.moveToNext()) { val id = cursor.getLong(idIndex) @@ -226,20 +164,19 @@ class MusicLoader(private val context: Context) { it.name to it.albumName to it.artistName to it.track to it.duration }.toMutableList() - logD("Song search finished with ${songs.size} found") + return songs } - private fun buildAlbums() { - logD("Linking albums") - + private fun buildAlbums(songs: List): List { // Group up songs by their album ids and then link them with their albums + val albums = mutableListOf() val songsByAlbum = songs.groupBy { it.albumId } songsByAlbum.forEach { entry -> - // 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] + // Use the song with the latest year as our metadata song. + // This allows us to replicate the LAST_YEAR field, which is useful as it means that + // weird years like "0" wont show up if there are alternatives. + val song = requireNotNull(entry.value.maxByOrNull { it.year }) albums.add( Album( @@ -253,14 +190,12 @@ class MusicLoader(private val context: Context) { } albums.removeAll { it.songs.isEmpty() } - albums = Sort.ByName(true).sortAlbums(albums).toMutableList() - logD("Songs successfully linked into ${albums.size} albums") + return albums } - private fun buildArtists() { - logD("Linking artists") - + private fun buildArtists(context: Context, albums: List): List { + val artists = mutableListOf() val albumsByArtist = albums.groupBy { it.artistName } albumsByArtist.forEach { entry -> @@ -270,8 +205,8 @@ class MusicLoader(private val context: Context) { entry.key } - // 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. + // In most cases, MediaStore artist IDs are unreliable or omitted for speed. + // Use the hashCode of the artist name as our ID and move on. artists.add( Artist( id = entry.key.hashCode().toLong(), @@ -282,36 +217,42 @@ class MusicLoader(private val context: Context) { ) } - artists = Sort.ByName(true).sortParents(artists).toMutableList() - - logD("Albums successfully linked into ${artists.size} artists") + return artists } - private fun linkGenres() { - logD("Linking genres") + private fun readGenres(context: Context, songs: List): List { + val genres = mutableListOf() - genres.forEach { genre -> - val songCursor = resolver.query( - Genres.Members.getContentUri("external", genre.id), - arrayOf(Genres.Members._ID), - null, null, null // Dont even bother blacklisting here as useless iters are less expensive than IO - ) + // First, get a cursor for every genre in the android system + val genreCursor = context.contentResolver.query( + MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, + arrayOf( + MediaStore.Audio.Genres._ID, + MediaStore.Audio.Genres.NAME + ), + null, null, null + ) - songCursor?.use { cursor -> - val idIndex = cursor.getColumnIndexOrThrow(Genres.Members._ID) + // And then process those into Genre objects + genreCursor?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID) + val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME) - while (cursor.moveToNext()) { - val id = cursor.getLong(idIndex) + while (cursor.moveToNext()) { + // No non-broken genre would be missing a name. + val id = cursor.getLong(idIndex) + val name = cursor.getStringOrNull(nameIndex) ?: continue - songs.find { it.id == id }?.let { song -> - genre.linkSong(song) - } - } + val genre = Genre( + id, name, name.getGenreNameCompat() ?: name + ) + + linkGenre(context, genre, songs) + genres.add(genre) } } // Songs that don't have a genre will be thrown into an unknown genre. - val unknownGenre = Genre( id = Long.MIN_VALUE, name = MediaStore.UNKNOWN_STRING, @@ -327,5 +268,27 @@ class MusicLoader(private val context: Context) { if (unknownGenre.songs.isNotEmpty()) { genres.add(unknownGenre) } + + return genres + } + + private fun linkGenre(context: Context, genre: Genre, songs: List) { + val songCursor = context.contentResolver.query( + MediaStore.Audio.Genres.Members.getContentUri("external", genre.id), + arrayOf(MediaStore.Audio.Genres.Members._ID), + null, null, null // Dont even bother blacklisting here as useless iters are less expensive than IO + ) + + songCursor?.use { cursor -> + val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID) + + while (cursor.moveToNext()) { + val id = cursor.getLong(idIndex) + + songs.find { it.id == id }?.let { song -> + genre.linkSong(song) + } + } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index a03d89156..be38b942a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -68,17 +68,13 @@ class MusicStore private constructor() { try { val start = System.currentTimeMillis() - val loader = MusicLoader(context) - loader.load() + val loader = MusicLoader() + val library = loader.load(context) ?: return Response.Err(ErrorKind.NO_MUSIC) - if (loader.songs.isEmpty()) { - return Response.Err(ErrorKind.NO_MUSIC) - } - - mSongs = loader.songs - mAlbums = loader.albums - mArtists = loader.artists - mGenres = loader.genres + mSongs = library.songs + mAlbums = library.albums + mArtists = library.artists + mGenres = library.genres logD("Music load completed successfully in ${System.currentTimeMillis() - start}ms.") } catch (e: Exception) { diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index ba55d8b9a..61e7dda93 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -33,6 +33,7 @@ import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.MusicStore import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.DisplayMode +import org.oxycblt.auxio.ui.Sort import java.text.Normalizer /** @@ -77,6 +78,7 @@ class SearchViewModel : ViewModel() { // Searching can be quite expensive, so hop on a co-routine viewModelScope.launch { + val sort = Sort.ByName(true) val results = mutableListOf() // A filter mode of null means to not filter at all. @@ -84,28 +86,28 @@ class SearchViewModel : ViewModel() { if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ARTISTS) { musicStore.artists.filterByOrNull(query)?.let { artists -> results.add(Header(-1, HeaderString.Single(R.string.lbl_artists))) - results.addAll(artists) + results.addAll(sort.sortParents(artists)) } } if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_ALBUMS) { musicStore.albums.filterByOrNull(query)?.let { albums -> results.add(Header(-2, HeaderString.Single(R.string.lbl_albums))) - results.addAll(albums) + results.addAll(sort.sortAlbums(albums)) } } if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_GENRES) { musicStore.genres.filterByOrNull(query)?.let { genres -> results.add(Header(-3, HeaderString.Single(R.string.lbl_genres))) - results.addAll(genres) + results.addAll(sort.sortParents(genres)) } } if (mFilterMode == null || mFilterMode == DisplayMode.SHOW_SONGS) { musicStore.songs.filterByOrNull(query)?.let { songs -> results.add(Header(-4, HeaderString.Single(R.string.lbl_songs))) - results.addAll(songs) + results.addAll(sort.sortSongs(songs)) } } @@ -136,7 +138,7 @@ class SearchViewModel : ViewModel() { * Shortcut that will run a ignoreCase filter on a list and only return * a value if the resulting list is empty. */ - private fun List.filterByOrNull(value: String): List? { + private fun List.filterByOrNull(value: String): List? { val filtered = filter { // Ensure the name we match with is correct. val name = if (it is MusicParent) { diff --git a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt index d49c8fb3a..ddbdcd723 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/ContextUtil.kt @@ -30,6 +30,7 @@ import androidx.annotation.StringRes import androidx.core.content.ContextCompat import org.oxycblt.auxio.MainActivity import kotlin.reflect.KClass +import kotlin.system.exitProcess const val INTENT_REQUEST_CODE = 0xA0A0 @@ -83,6 +84,19 @@ fun Context.newMainIntent(): PendingIntent { ) } +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 + ) + + startActivity(intent) + + exitProcess(0) +} + /** * Create a toast using the provided string resource. */ diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9b89d707f..83b5f1c1f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -55,7 +55,7 @@ "Tmavé" "Barva" "Černé téma" - "Použít kompletně černé tmavé téma" + "Použít kompletně černé tmavé téma" "Zobrazení" "Zobrazit barvy alba" "Vypněte pro ušetření paměti" diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3bf2862c2..94f08a5b7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -154,7 +154,7 @@ Album Jahr Schwarzes Thema - Ein schwarzes Thema für das dunkle verwenden + Ein schwarzes Thema für das dunkle verwenden Pause bei Wiederholung Pausiert, wenn ein Song wiederholt wird Zufällig an- oder ausschalten diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4b2ee8607..070efaed8 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -60,7 +60,7 @@ Oscuro Acento Tema negro - Usar tema negro puro + Usar tema negro puro Pantalla Mostrar carátula de álbum diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15ea48b4c..69f56711c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -65,7 +65,7 @@ Dark Color scheme Black theme - Use a pure-black dark theme + Use a pure-black dark theme Display Library tabs diff --git a/app/src/main/res/xml/prefs_main.xml b/app/src/main/res/xml/prefs_main.xml index 4d4ccf6a5..f7f66db90 100644 --- a/app/src/main/res/xml/prefs_main.xml +++ b/app/src/main/res/xml/prefs_main.xml @@ -24,7 +24,7 @@ app:defaultValue="false" app:iconSpaceReserved="false" app:key="KEY_BLACK_THEME" - app:summary="@string/setting_black_mode_desc" + app:summary="@string/set_black_mode_desc" app:title="@string/set_black_mode" /> diff --git a/info/FAQ.md b/info/FAQ.md index 294022ee3..103d3c7fe 100644 --- a/info/FAQ.md +++ b/info/FAQ.md @@ -16,8 +16,7 @@ This is probably caused by one of two reasons: #### I have a large library and Auxio takes really long to load it! -This is expected since reading media takes awhile, especially with libraries containing 10k songs or more. -I hope to mitigate this in the future by allowing one to customize the music loader to optimize for speed instead of accuracy. +This is expected since reading from the audio database takes awhile, especially with libraries containing 10k songs or more. #### Why ExoPlayer?