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:
parent
82247775ac
commit
627ab97948
16 changed files with 140 additions and 124 deletions
|
@ -64,11 +64,11 @@ dependencies {
|
||||||
// General
|
// General
|
||||||
implementation "androidx.core:core-ktx:1.7.0"
|
implementation "androidx.core:core-ktx:1.7.0"
|
||||||
implementation "androidx.activity:activity-ktx:1.4.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
|
// UI
|
||||||
implementation "androidx.recyclerview:recyclerview:1.2.1"
|
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.dynamicanimation:dynamicanimation:1.0.0"
|
||||||
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
|
implementation "androidx.viewpager2:viewpager2:1.1.0-beta01"
|
||||||
|
|
||||||
|
@ -95,20 +95,20 @@ dependencies {
|
||||||
// Exoplayer
|
// Exoplayer
|
||||||
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION.
|
// WARNING: THE EXOPLAYER VERSION MUST BE KEPT IN LOCK-STEP WITH THE FLAC EXTENSION.
|
||||||
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
|
// IF NOT, VERY UNFRIENDLY BUILD FAILURES AND CRASHES MAY ENSUE.
|
||||||
def exoplayerVersion = '2.17.0'
|
def exoplayerVersion = "2.17.1"
|
||||||
implementation("com.google.android.exoplayer:exoplayer-core:$exoplayerVersion")
|
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayerVersion"
|
||||||
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
|
implementation fileTree(dir: "libs", include: ["extension-*.aar"])
|
||||||
|
|
||||||
// Image loading
|
// Image loading
|
||||||
implementation 'io.coil-kt:coil:2.0.0-alpha09'
|
implementation "io.coil-kt:coil:2.0.0-rc01"
|
||||||
|
|
||||||
// Material
|
// Material
|
||||||
implementation 'com.google.android.material:material:1.6.0-alpha03'
|
implementation "com.google.android.material:material:1.6.0-alpha03"
|
||||||
|
|
||||||
// --- DEBUG ---
|
// --- DEBUG ---
|
||||||
|
|
||||||
// Lint
|
// Lint
|
||||||
ktlint 'com.pinterest:ktlint:0.44.0'
|
ktlint "com.pinterest:ktlint:0.44.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
task ktlint(type: JavaExec, group: "verification") {
|
task ktlint(type: JavaExec, group: "verification") {
|
||||||
|
|
|
@ -37,7 +37,8 @@ sealed class Item {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [Item] variant that represents a music 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() {
|
sealed class Music : Item() {
|
||||||
/** The raw name of this item. */
|
/** The raw name of this item. */
|
||||||
|
@ -116,8 +117,8 @@ data class Song(
|
||||||
internalMediaStoreArtistName ?: album.artist.resolvedName
|
internalMediaStoreArtistName ?: album.artist.resolvedName
|
||||||
|
|
||||||
/** Internal field. Do not use. */
|
/** Internal field. Do not use. */
|
||||||
val internalGroupingId: Int get() {
|
val internalAlbumGroupingId: Long get() {
|
||||||
var result = internalGroupingArtistName.lowercase().hashCode()
|
var result = internalGroupingArtistName.lowercase().hashCode().toLong()
|
||||||
result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode()
|
result = 31 * result + internalMediaStoreAlbumName.lowercase().hashCode()
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
@ -187,7 +188,11 @@ data class Album(
|
||||||
artist.resolvedName
|
artist.resolvedName
|
||||||
|
|
||||||
/** Internal field. Do not use. */
|
/** 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. */
|
/** Internal method. Do not use. */
|
||||||
fun internalLinkArtist(artist: Artist) {
|
fun internalLinkArtist(artist: Artist) {
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
import android.content.ContentUris
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
@ -8,7 +9,7 @@ import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import androidx.core.text.isDigitsOnly
|
import androidx.core.text.isDigitsOnly
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.excluded.ExcludedDatabase
|
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -112,27 +113,39 @@ class MusicLoader {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun loadSongs(context: Context): List<Song> {
|
/**
|
||||||
var songs = mutableListOf<Song>()
|
* Gets a content resolver in a way that does not mangle metadata on
|
||||||
val blacklistDatabase = ExcludedDatabase.getInstance(context)
|
* certain OEM skins. See https://github.com/OxygenCobalt/Auxio/issues/50
|
||||||
val paths = blacklistDatabase.readPaths()
|
* 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"
|
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
|
||||||
val args = mutableListOf<String>()
|
val args = mutableListOf<String>()
|
||||||
|
|
||||||
// DATA was deprecated on Android 10, but is set to be un-deprecated in Android 12L.
|
// Apply the excluded directories by filtering out specific DATA values.
|
||||||
// The only reason we'd want to change this is to add external partitions support, but
|
// DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
|
||||||
// that's less efficient and there's no demand for that right now.
|
// 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
|
// TODO: Determine if grokking the actual DATA value outside of SQL is more or less
|
||||||
// efficient than the current system
|
// efficient than the current system
|
||||||
for (path in paths) {
|
for (path in blacklistDatabase.readPaths()) {
|
||||||
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
|
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
|
||||||
args += "$path%" // Append % so that the selector properly detects children
|
args += "$path%" // Append % so that the selector properly detects children
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Move all references to contentResolver into a single variable so we can
|
var songs = mutableListOf<Song>()
|
||||||
// avoid accidentally removing the applicationContext fix
|
|
||||||
context.applicationContext.contentResolver.query(
|
context.contentResolverSafe.query(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MediaStore.Audio.AudioColumns._ID,
|
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
|
// 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
|
// 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.
|
// below API 30.
|
||||||
// TODO: Disk number support?
|
// TODO: Disk number support?
|
||||||
val track = cursor.getIntOrNull(trackIndex)?.mod(1000)
|
val track = cursor.getIntOrNull(trackIndex)?.mod(1000)
|
||||||
|
@ -222,19 +235,22 @@ class MusicLoader {
|
||||||
return songs
|
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> {
|
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 albums = mutableListOf<Album>()
|
||||||
val songsByAlbum = songs.groupBy { song ->
|
val songsByAlbum = songs.groupBy { it.internalAlbumGroupingId }
|
||||||
song.internalGroupingId
|
|
||||||
}
|
|
||||||
|
|
||||||
for (entry in songsByAlbum) {
|
for (entry in songsByAlbum) {
|
||||||
val albumSongs = entry.value
|
val albumSongs = entry.value
|
||||||
|
@ -270,43 +286,30 @@ class MusicLoader {
|
||||||
return albums
|
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> {
|
private fun buildArtists(context: Context, albums: List<Album>): List<Artist> {
|
||||||
val artists = mutableListOf<Artist>()
|
val artists = mutableListOf<Artist>()
|
||||||
val albumsByArtist = albums.groupBy { it.internalGroupingArtistName }
|
val albumsByArtist = albums.groupBy { it.internalArtistGroupingId }
|
||||||
|
|
||||||
for (entry in albumsByArtist) {
|
for (entry in albumsByArtist) {
|
||||||
val artistName = entry.key
|
val templateAlbum = entry.value[0]
|
||||||
val resolvedName = when (artistName) {
|
val artistName = templateAlbum.internalGroupingArtistName
|
||||||
|
val resolvedName = when (templateAlbum.internalGroupingArtistName) {
|
||||||
MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist)
|
MediaStore.UNKNOWN_STRING -> context.getString(R.string.def_artist)
|
||||||
else -> artistName
|
else -> artistName
|
||||||
}
|
}
|
||||||
val artistAlbums = entry.value
|
val artistAlbums = entry.value
|
||||||
|
|
||||||
// Album deduplication does not eliminate every case of fragmented artists, do
|
artists.add(
|
||||||
// we deduplicate in the artist creation step as well.
|
Artist(
|
||||||
// Note that we actually don't do this in groupBy. This is generally because using
|
artistName,
|
||||||
// a template song may not result in the best possible artist name in all cases.
|
resolvedName,
|
||||||
val previousArtistIndex = artists.indexOfFirst { artist ->
|
artistAlbums
|
||||||
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
|
|
||||||
)
|
)
|
||||||
} else {
|
)
|
||||||
artists.add(
|
|
||||||
Artist(
|
|
||||||
artistName,
|
|
||||||
resolvedName,
|
|
||||||
artistAlbums
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
logD("Successfully built ${artists.size} artists")
|
logD("Successfully built ${artists.size} artists")
|
||||||
|
@ -314,20 +317,21 @@ class MusicLoader {
|
||||||
return artists
|
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> {
|
private fun readGenres(context: Context, songs: List<Song>): List<Genre> {
|
||||||
val genres = mutableListOf<Genre>()
|
val genres = mutableListOf<Genre>()
|
||||||
|
|
||||||
val genreCursor = context.applicationContext.contentResolver.query(
|
context.contentResolverSafe.query(
|
||||||
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
|
||||||
arrayOf(
|
arrayOf(
|
||||||
MediaStore.Audio.Genres._ID,
|
MediaStore.Audio.Genres._ID,
|
||||||
MediaStore.Audio.Genres.NAME
|
MediaStore.Audio.Genres.NAME
|
||||||
),
|
),
|
||||||
null, null, null
|
null, null, null
|
||||||
)
|
)?.use { cursor ->
|
||||||
|
|
||||||
// And then process those into Genre objects
|
|
||||||
genreCursor?.use { cursor ->
|
|
||||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
|
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
|
||||||
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
|
val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
|
||||||
|
|
||||||
|
@ -351,7 +355,6 @@ class MusicLoader {
|
||||||
}
|
}
|
||||||
|
|
||||||
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
|
val songsWithoutGenres = songs.filter { it.internalIsMissingGenre }
|
||||||
|
|
||||||
if (songsWithoutGenres.isNotEmpty()) {
|
if (songsWithoutGenres.isNotEmpty()) {
|
||||||
// Songs that don't have a genre will be thrown into an unknown genre.
|
// Songs that don't have a genre will be thrown into an unknown genre.
|
||||||
val unknownGenre = Genre(
|
val unknownGenre = Genre(
|
||||||
|
@ -368,17 +371,42 @@ class MusicLoader {
|
||||||
return genres
|
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>? {
|
private fun queryGenreSongs(context: Context, genreId: Long, songs: List<Song>): List<Song>? {
|
||||||
val genreSongs = mutableListOf<Song>()
|
val genreSongs = mutableListOf<Song>()
|
||||||
|
|
||||||
// Don't even bother blacklisting here as useless iterations are less expensive than IO
|
// 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),
|
MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
|
||||||
arrayOf(MediaStore.Audio.Genres.Members._ID),
|
arrayOf(MediaStore.Audio.Genres.Members._ID),
|
||||||
null, null, null
|
null, null, null
|
||||||
)
|
)?.use { cursor ->
|
||||||
|
|
||||||
songCursor?.use { cursor ->
|
|
||||||
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
|
val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
|
||||||
|
|
||||||
while (cursor.moveToNext()) {
|
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 }
|
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 {
|
companion object {
|
||||||
/**
|
/**
|
||||||
* The album_artist MediaStore field has existed since at least API 21, but until API
|
* 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.
|
* warning about using a possibly-unsupported constant.
|
||||||
*/
|
*/
|
||||||
@Suppress("InlinedApi")
|
@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
|
* A complete table of all the constant genre values for ID3(v2), including non-standard
|
||||||
* winamp extensions.
|
* extensions.
|
||||||
*/
|
*/
|
||||||
private val legacyGenreTable = arrayOf(
|
private val genreConstantTable = arrayOf(
|
||||||
// ID3 Standard
|
// ID3 Standard
|
||||||
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
|
"Blues", "Classic Rock", "Country", "Dance", "Disco", "Funk", "Grunge", "Hip-Hop",
|
||||||
"Jazz", "Metal", "New Age", "Oldies", "Other", "Pop", "R&B", "Rap", "Reggae", "Rock",
|
"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",
|
"New Wave", "Psychadelic", "Rave", "Showtunes", "Trailer", "Lo-Fi", "Tribal",
|
||||||
"Acid Punk", "Acid Jazz", "Polka", "Retro", "Musical", "Rock & Roll", "Hard Rock",
|
"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",
|
"Folk", "Folk-Rock", "National Folk", "Swing", "Fast Fusion", "Bebob", "Latin",
|
||||||
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock",
|
"Revival", "Celtic", "Bluegrass", "Avantgarde", "Gothic Rock", "Progressive Rock",
|
||||||
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus",
|
"Psychedelic Rock", "Symphonic Rock", "Slow Rock", "Big Band", "Chorus",
|
||||||
|
@ -454,9 +461,8 @@ class MusicLoader {
|
||||||
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
"Crossover", "Contemporary Christian", "Christian Rock", "Merengue", "Salsa",
|
||||||
"Thrash Metal", "Anime", "JPop", "Synthpop",
|
"Thrash Metal", "Anime", "JPop", "Synthpop",
|
||||||
|
|
||||||
// Winamp 5.6+ extensions, used by EasyTAG and friends
|
// Winamp 5.6+ extensions, also used by EasyTAG.
|
||||||
// The only reason I include this set is because post-rock is a based genre and
|
// I only include this because post-rock is a based genre and deserves a slot.
|
||||||
// deserves a slot.
|
|
||||||
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
|
"Abstract", "Art Rock", "Baroque", "Bhangra", "Big Beat", "Breakbeat", "Chillout",
|
||||||
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
|
"Downtempo", "Dub", "EBM", "Eclectic", "Electro", "Electroclash", "Emo", "Experimental",
|
||||||
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",
|
"Garage", "Global", "IDM", "Illbient", "Industro-Goth", "Jam Band", "Krautrock",
|
||||||
|
|
|
@ -118,7 +118,7 @@ class MusicStore private constructor() {
|
||||||
/**
|
/**
|
||||||
* A response that [MusicStore] returns when loading music.
|
* A response that [MusicStore] returns when loading music.
|
||||||
* And before you ask, yes, I do like rust.
|
* 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 {
|
sealed class Response {
|
||||||
class Ok(val musicStore: MusicStore) : Response()
|
class Ok(val musicStore: MusicStore) : Response()
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.net.Uri
|
||||||
import android.os.Bundle
|
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
|
// 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
|
// Unless I change the system to use the drive/directory system, that is. But there's no
|
||||||
// demand for that.
|
// 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") {
|
if (typeAndPath[0] == "primary") {
|
||||||
return getRootPath() + "/" + typeAndPath.last()
|
return getRootPath() + "/" + typeAndPath.last()
|
||||||
}
|
}
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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.annotation.SuppressLint
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
|
@ -16,7 +16,7 @@
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* 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 android.content.Context
|
||||||
import androidx.lifecycle.LiveData
|
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
|
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal
|
||||||
* of paths. Use [Factory] to instantiate this.
|
* of paths. Use [Factory] to instantiate this.
|
||||||
|
* TODO: Unify with MusicViewModel
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
|
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
|
|
@ -44,6 +44,10 @@ import org.oxycblt.auxio.util.logE
|
||||||
* **PLEASE Use this instead of [PlaybackStateManager], UI's are extremely volatile and this provides
|
* **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.**
|
* an interface that properly sanitizes input and abstracts functions unlike the master class.**
|
||||||
* @author OxygenCobalt
|
* @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 {
|
class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
||||||
// Playback
|
// Playback
|
||||||
|
@ -170,7 +174,6 @@ class PlaybackViewModel : ViewModel(), PlaybackStateManager.Callback {
|
||||||
logD("Playing with uri $uri")
|
logD("Playing with uri $uri")
|
||||||
|
|
||||||
val musicStore = MusicStore.maybeGetInstance() ?: return
|
val musicStore = MusicStore.maybeGetInstance() ?: return
|
||||||
|
|
||||||
musicStore.findSongForUri(uri, context.contentResolver)?.let { song ->
|
musicStore.findSongForUri(uri, context.contentResolver)?.let { song ->
|
||||||
playSong(song)
|
playSong(song)
|
||||||
}
|
}
|
||||||
|
|
|
@ -40,8 +40,6 @@ import org.oxycblt.auxio.util.logE
|
||||||
*
|
*
|
||||||
* All access should be done with [PlaybackStateManager.getInstance].
|
* All access should be done with [PlaybackStateManager.getInstance].
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*
|
|
||||||
* TODO: Rework this to possibly handle gapless playback and more refined queue management.
|
|
||||||
*/
|
*/
|
||||||
class PlaybackStateManager private constructor() {
|
class PlaybackStateManager private constructor() {
|
||||||
// Playback
|
// Playback
|
||||||
|
|
|
@ -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
|
* 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
|
* I fear that it may not work on OEM skins that for whatever reason don't make this
|
||||||
* action fire.
|
* action fire.
|
||||||
|
* TODO: Figure out how players like Retro are able to get autoplay working with
|
||||||
|
* bluetooth headsets
|
||||||
*/
|
*/
|
||||||
private fun maybeResumeFromPlug() {
|
private fun maybeResumeFromPlug() {
|
||||||
if (playbackManager.song != null &&
|
if (playbackManager.song != null &&
|
||||||
|
@ -523,6 +525,8 @@ class PlaybackService : Service(), Player.Listener, PlaybackStateManager.Callbac
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pause from a headset plug.
|
* 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() {
|
private fun pauseFromPlug() {
|
||||||
if (playbackManager.song != null) {
|
if (playbackManager.song != null) {
|
||||||
|
|
|
@ -32,7 +32,7 @@ import androidx.recyclerview.widget.RecyclerView
|
||||||
import coil.Coil
|
import coil.Coil
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.accent.AccentCustomizeDialog
|
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.home.tabs.TabCustomizeDialog
|
||||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||||
import org.oxycblt.auxio.settings.pref.IntListPrefDialog
|
import org.oxycblt.auxio.settings.pref.IntListPrefDialog
|
||||||
|
|
|
@ -27,14 +27,14 @@ import org.oxycblt.auxio.music.Item
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
class DiffCallback<T : Item> : DiffUtil.ItemCallback<T>() {
|
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 {
|
override fun areItemsTheSame(oldItem: T, newItem: T): Boolean {
|
||||||
// Prevent ID collisions from occurring between datatypes.
|
// Prevent ID collisions from occurring between datatypes.
|
||||||
if (oldItem.javaClass != newItem.javaClass) return false
|
if (oldItem.javaClass != newItem.javaClass) return false
|
||||||
|
|
||||||
return oldItem.id == newItem.id
|
return oldItem.id == newItem.id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun areContentsTheSame(oldItem: T, newItem: T): Boolean {
|
||||||
|
return oldItem.hashCode() == newItem.hashCode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -245,11 +245,8 @@ fun Context.hardRestart() {
|
||||||
// Instead of having to do a ton of cleanup and horrible code changes
|
// 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
|
// 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.
|
// one, after all] and then kill the application using exitProcess. Works well enough.
|
||||||
val intent = Intent(applicationContext, MainActivity::class.java).setFlags(
|
val intent = Intent(applicationContext, MainActivity::class.java)
|
||||||
Intent.FLAG_ACTIVITY_CLEAR_TASK
|
.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
|
||||||
)
|
|
||||||
|
|
||||||
startActivity(intent)
|
startActivity(intent)
|
||||||
|
|
||||||
exitProcess(0)
|
exitProcess(0)
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,7 +17,7 @@ org.gradle.jvmargs=-Xmx2048m
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
# Automatically convert third-party libraries to use AndroidX
|
# Automatically convert third-party libraries to use AndroidX
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
# Stop ExoPlayer from mangling AAR libraries with default abstract methods
|
# Stop ExoPlayer AARs from being mangled
|
||||||
android.enableDexingArtifactTransform=false
|
android.enableDexingArtifactTransform=false
|
||||||
# Kotlin code style for this project: "official" or "obsolete":
|
# Kotlin code style for this project: "official" or "obsolete":
|
||||||
kotlin.code.style=official
|
kotlin.code.style=official
|
||||||
|
|
|
@ -17,12 +17,12 @@ org.oxycblt.auxio # Main UIs
|
||||||
├──.coil # Image loading components
|
├──.coil # Image loading components
|
||||||
├──.detail # Album/Artist/Genre detail UIs
|
├──.detail # Album/Artist/Genre detail UIs
|
||||||
│ └──.recycler # RecyclerView components for detail UIs
|
│ └──.recycler # RecyclerView components for detail UIs
|
||||||
├──.excluded # Excluded Directories UI + Systems
|
|
||||||
├──.home # Home UI
|
├──.home # Home UI
|
||||||
│ ├──.fastscroll # Fast scroller UI
|
│ ├──.fastscroll # Fast scroller UI
|
||||||
│ ├──.list # Home item lists
|
│ ├──.list # Home item lists
|
||||||
│ └──.tabs # Home tab customization
|
│ └──.tabs # Home tab customization
|
||||||
├──.music # Music data and loading
|
├──.music # Music data and loading
|
||||||
|
│ └──.excluded # Excluded Directories UI + Systems
|
||||||
├──.playback # Playback UI + Systems
|
├──.playback # Playback UI + Systems
|
||||||
│ ├──.queue # Queue UI
|
│ ├──.queue # Queue UI
|
||||||
│ ├──.state # Playback state backend
|
│ ├──.state # Playback state backend
|
||||||
|
@ -86,10 +86,10 @@ should only be talking to other shared objects. All objects can use the utility
|
||||||
#### Data objects
|
#### Data objects
|
||||||
Auxio represents data in multiple ways.
|
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:
|
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
|
- `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.
|
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,
|
- `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.
|
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`.
|
**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.
|
||||||
|
|
Loading…
Reference in a new issue