music: cleanup implementation

Clean up the indexer implementation even further.
This commit is contained in:
OxygenCobalt 2022-05-29 10:09:49 -06:00
parent 48289ef006
commit f52fa7f338
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 333 additions and 302 deletions

View file

@ -22,8 +22,9 @@ package org.oxycblt.auxio.music
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.music.indexer.id3v2GenreName
import org.oxycblt.auxio.music.indexer.withoutArticle
import org.oxycblt.auxio.ui.Item import org.oxycblt.auxio.ui.Item
import org.oxycblt.auxio.util.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
@ -250,258 +251,8 @@ data class Genre(override val rawName: String?, override val songs: List<Song>)
get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong() get() = (rawName ?: MediaStore.UNKNOWN_STRING).hashCode().toLong()
override val sortName: String? override val sortName: String?
get() = rawName?.genreNameCompat get() = rawName?.id3v2GenreName
override fun resolveName(context: Context) = override fun resolveName(context: Context) =
rawName?.genreNameCompat ?: context.getString(R.string.def_genre) rawName?.id3v2GenreName ?: context.getString(R.string.def_genre)
} }
/**
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
* anglo-centric, but its mostly for MediaStore compat and hopefully shouldn't run with other
* languages.
*/
private val String.withoutArticle: String
get() {
if (length > 5 && startsWith("the ", ignoreCase = true)) {
return slice(4..lastIndex)
}
if (length > 4 && startsWith("an ", ignoreCase = true)) {
return slice(3..lastIndex)
}
if (length > 3 && startsWith("a ", ignoreCase = true)) {
return slice(2..lastIndex)
}
return this
}
/**
* 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()) ?: this
}
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) ?: this
}
}
// Current name is fine.
return this
}
/**
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
*/
private val genreConstantTable =
arrayOf(
// ID3 Standard
"Blues",
"Classic Rock",
"Country",
"Dance",
"Disco",
"Funk",
"Grunge",
"Hip-Hop",
"Jazz",
"Metal",
"New Age",
"Oldies",
"Other",
"Pop",
"R&B",
"Rap",
"Reggae",
"Rock",
"Techno",
"Industrial",
"Alternative",
"Ska",
"Death Metal",
"Pranks",
"Soundtrack",
"Euro-Techno",
"Ambient",
"Trip-Hop",
"Vocal",
"Jazz+Funk",
"Fusion",
"Trance",
"Classical",
"Instrumental",
"Acid",
"House",
"Game",
"Sound Clip",
"Gospel",
"Noise",
"AlternRock",
"Bass",
"Soul",
"Punk",
"Space",
"Meditative",
"Instrumental Pop",
"Instrumental Rock",
"Ethnic",
"Gothic",
"Darkwave",
"Techno-Industrial",
"Electronic",
"Pop-Folk",
"Eurodance",
"Dream",
"Southern Rock",
"Comedy",
"Cult",
"Gangsta",
"Top 40",
"Christian Rap",
"Pop/Funk",
"Jungle",
"Native American",
"Cabaret",
"New Wave",
"Psychadelic",
"Rave",
"Showtunes",
"Trailer",
"Lo-Fi",
"Tribal",
"Acid Punk",
"Acid Jazz",
"Polka",
"Retro",
"Musical",
"Rock & Roll",
"Hard Rock",
// Winamp extensions, more or less a de-facto standard
"Folk",
"Folk-Rock",
"National Folk",
"Swing",
"Fast Fusion",
"Bebob",
"Latin",
"Revival",
"Celtic",
"Bluegrass",
"Avantgarde",
"Gothic Rock",
"Progressive Rock",
"Psychedelic Rock",
"Symphonic Rock",
"Slow Rock",
"Big Band",
"Chorus",
"Easy Listening",
"Acoustic",
"Humour",
"Speech",
"Chanson",
"Opera",
"Chamber Music",
"Sonata",
"Symphony",
"Booty Bass",
"Primus",
"Porn Groove",
"Satire",
"Slow Jam",
"Club",
"Tango",
"Samba",
"Folklore",
"Ballad",
"Power Ballad",
"Rhythmic Soul",
"Freestyle",
"Duet",
"Punk Rock",
"Drum Solo",
"A capella",
"Euro-House",
"Dance Hall",
"Goa",
"Drum & Bass",
"Club-House",
"Hardcore",
"Terror",
"Indie",
"Britpop",
"Negerpunk",
"Polsk Punk",
"Beat",
"Christian Gangsta",
"Heavy Metal",
"Black Metal",
"Crossover",
"Contemporary Christian",
"Christian Rock",
"Merengue",
"Salsa",
"Thrash Metal",
"Anime",
"JPop",
"Synthpop",
// Winamp 5.6+ extensions, also used by EasyTAG.
// I only include this because post-rock is a based genre and deserves a slot.
"Abstract",
"Art Rock",
"Baroque",
"Bhangra",
"Big Beat",
"Breakbeat",
"Chillout",
"Downtempo",
"Dub",
"EBM",
"Eclectic",
"Electro",
"Electroclash",
"Emo",
"Experimental",
"Garage",
"Global",
"IDM",
"Illbient",
"Industro-Goth",
"Jam Band",
"Krautrock",
"Leftfield",
"Lounge",
"Math Rock",
"New Romantic",
"Nu-Breakz",
"Post-Punk",
"Post-Rock",
"Psytrance",
"Shoegaze",
"Space Rock",
"Trop Rock",
"World Music",
"Neoclassical",
"Audiobook",
"Audio Theatre",
"Neue Deutsche Welle",
"Podcast",
"Indie Rock",
"G-Funk",
"Dubstep",
"Garage Rock",
"Psybient")

View file

@ -53,11 +53,17 @@ class MusicStore private constructor() {
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
/**
* Add a callback to this instance. Make sure to remove it when done.
*/
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
response?.let(callback::onMusicUpdate) response?.let(callback::onMusicUpdate)
callbacks.add(callback) callbacks.add(callback)
} }
/**
* Remove a callback from this instance.
*/
fun removeCallback(callback: Callback) { fun removeCallback(callback: Callback) {
callbacks.remove(callback) callbacks.remove(callback)
} }

View file

@ -17,10 +17,8 @@
package org.oxycblt.auxio.music.indexer package org.oxycblt.auxio.music.indexer
import android.content.ContentUris
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
import android.provider.MediaStore
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata import com.google.android.exoplayer2.metadata.Metadata
@ -29,7 +27,6 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import com.google.android.exoplayer2.source.TrackGroupArray import com.google.android.exoplayer2.source.TrackGroupArray
import com.google.common.util.concurrent.FutureCallback import com.google.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.Future import java.util.concurrent.Future
@ -39,11 +36,20 @@ import org.oxycblt.auxio.util.logW
/** /**
* A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata. * A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
* *
* Normally, leveraging ExoPlayer's metadata system would be a terrible idea, as it is horrifically
* slow. However, if we parallelize it, we can get similar throughput to other metadata extractors,
* which is nice as it means we don't have to bundle a redundant metadata library like JAudioTagger.
*
* Now, ExoPlayer's metadata API is not the best. It's opaque, undocumented, and prone to weird
* pitfalls given ExoPlayer's cozy relationship with native code. However, this backend should do
* enough to eliminate such issues.
*
* TODO: This class is currently not used, as there are a number of technical improvements that must * TODO: This class is currently not used, as there are a number of technical improvements that must
* be made first before it is practical. * be made first before it can be integrated.
* *
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@Suppress("UNUSED")
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend { class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private val runningTasks: Array<Future<TrackGroupArray>?> = arrayOfNulls(TASK_CAPACITY) private val runningTasks: Array<Future<TrackGroupArray>?> = arrayOfNulls(TASK_CAPACITY)
@ -58,21 +64,21 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
val audio = inner.buildAudio(context, cursor) val audio = inner.buildAudio(context, cursor)
val audioUri = val audioUri = requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(audio.id))
while (true) { while (true) {
// Spin until a task slot opens up, then start trying to parse metadata. // Spin until a task slot opens up, then start trying to parse metadata.
val idx = runningTasks.indexOfFirst { it == null } val index = runningTasks.indexOfFirst { it == null }
if (idx != -1) { if (index != -1) {
val task = val task =
MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(audioUri)) MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(audioUri))
Futures.addCallback( Futures.addCallback(
task, AudioCallback(audio, songs, idx), Executors.newSingleThreadExecutor()) task,
AudioCallback(audio, songs, index),
Executors.newSingleThreadExecutor())
runningTasks[idx] = task runningTasks[index] = task
break break
} }
@ -89,7 +95,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private inner class AudioCallback( private inner class AudioCallback(
private val audio: MediaStoreBackend.Audio, private val audio: MediaStoreBackend.Audio,
private val dest: ConcurrentLinkedQueue<Song>, private val dest: ConcurrentLinkedQueue<Song>,
private val taskIdx: Int private val taskIndex: Int
) : FutureCallback<TrackGroupArray> { ) : FutureCallback<TrackGroupArray> {
override fun onSuccess(result: TrackGroupArray) { override fun onSuccess(result: TrackGroupArray) {
val metadata = result[0].getFormat(0).metadata val metadata = result[0].getFormat(0).metadata
@ -100,30 +106,33 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
} }
dest.add(audio.toSong()) dest.add(audio.toSong())
runningTasks[taskIdx] = null runningTasks[taskIndex] = null
} }
override fun onFailure(t: Throwable) { override fun onFailure(t: Throwable) {
logW("Unable to extract metadata for ${audio.title}") logW("Unable to extract metadata for ${audio.title}")
logW(t.stackTraceToString()) logW(t.stackTraceToString())
dest.add(audio.toSong()) dest.add(audio.toSong())
runningTasks[taskIdx] = null runningTasks[taskIndex] = null
} }
} }
private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) { private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) {
for (i in 0 until metadata.length()) { for (i in 0 until metadata.length()) {
// We only support two formats as it stands:
// - ID3v2 text frames
// - Vorbis comments
// This should be enough to cover the vast, vast majority of audio formats.
// It is also assumed that a file only has either ID3v2 text frames or vorbis
// comments.
when (val tag = metadata.get(i)) { when (val tag = metadata.get(i)) {
// ID3v2 text information frame.
is TextInformationFrame -> is TextInformationFrame ->
if (tag.value.isNotEmpty()) { if (tag.value.isNotEmpty()) {
handleId3TextFrame(tag.id, tag.value.sanitize(), audio) handleId3v2TextFrame(tag.id.sanitize(), tag.value.sanitize(), audio)
} }
// Vorbis comment. It is assumed that a Metadata can only have vorbis
// comments/pictures or ID3 frames, never both.
is VorbisComment -> is VorbisComment ->
if (tag.value.isNotEmpty()) { if (tag.value.isNotEmpty()) {
handleVorbisComment(tag.key, tag.value.sanitize(), audio) handleVorbisComment(tag.key.sanitize(), tag.value.sanitize(), audio)
} }
} }
} }
@ -138,12 +147,12 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
* *
* This function mitigates it by first encoding the string as UTF-8 bytes (replacing malformed * This function mitigates it by first encoding the string as UTF-8 bytes (replacing malformed
* characters with the replacement in the process), and then re-interpreting it as a new string, * characters with the replacement in the process), and then re-interpreting it as a new string,
* which hopefully fixes encoding sanity while also copying the string out of dodgy native * which hopefully fixes encoding insanity while also copying the string out of dodgy native
* memory. * memory.
*/ */
private fun String.sanitize() = String(encodeToByteArray(), StandardCharsets.UTF_8) private fun String.sanitize() = String(encodeToByteArray())
private fun handleId3TextFrame(id: String, value: String, audio: MediaStoreBackend.Audio) { private fun handleId3v2TextFrame(id: String, value: String, audio: MediaStoreBackend.Audio) {
// It's assumed that duplicate frames are eliminated by ExoPlayer's metadata parser. // It's assumed that duplicate frames are eliminated by ExoPlayer's metadata parser.
when (id) { when (id) {
"TIT2" -> audio.title = value // Title "TIT2" -> audio.title = value // Title

View file

@ -66,6 +66,7 @@ object Indexer {
if (songs.isEmpty()) return null if (songs.isEmpty()) return null
val buildStart = System.currentTimeMillis() val buildStart = System.currentTimeMillis()
val albums = buildAlbums(songs) val albums = buildAlbums(songs)
val artists = buildArtists(albums) val artists = buildArtists(albums)
val genres = buildGenres(songs) val genres = buildGenres(songs)
@ -206,7 +207,7 @@ object Indexer {
/** Represents a backend that metadata can be extracted from. */ /** Represents a backend that metadata can be extracted from. */
interface Backend { interface Backend {
/** Query the media database for an initial cursor. */ /** Query the media database for a basic cursor. */
fun query(context: Context): Cursor fun query(context: Context): Cursor
/** Create a list of songs from the [Cursor] queried in [query]. */ /** Create a list of songs from the [Cursor] queried in [query]. */

View file

@ -18,8 +18,11 @@
package org.oxycblt.auxio.music.indexer package org.oxycblt.auxio.music.indexer
import android.content.ContentResolver import android.content.ContentResolver
import android.content.ContentUris
import android.database.Cursor import android.database.Cursor
import android.net.Uri import android.net.Uri
import android.provider.MediaStore
import androidx.core.text.isDigitsOnly
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */ /** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
fun ContentResolver.queryCursor( fun ContentResolver.queryCursor(
@ -38,6 +41,12 @@ fun <R> ContentResolver.useQuery(
block: (Cursor) -> R block: (Cursor) -> R
): R? = queryCursor(uri, projection, selector, args)?.use(block) ): R? = queryCursor(uri, projection, selector, args)?.use(block)
/** Converts a [Long] Audio ID into a URI to that particular audio file. */
val Long.audioUri: Uri
get() =
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(this))
/** /**
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and * Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
* CD_TRACK_NUMBER. * CD_TRACK_NUMBER.
@ -45,6 +54,265 @@ fun <R> ContentResolver.useQuery(
val String.no: Int? val String.no: Int?
get() = split('/', limit = 2).getOrNull(0)?.toIntOrNull() get() = split('/', limit = 2).getOrNull(0)?.toIntOrNull()
/** Parse out the year field from a (presumably) ISO-8601-like date. */ /**
* Parse out the year field from a (presumably) ISO-8601-like date. This differs across tag formats
* and has no real consistent, but it's assumed that most will format granular dates as YYYY-MM-DD
* (...) and thus we can parse the year out by splitting at the first -.
*/
val String.iso8601year: Int? val String.iso8601year: Int?
get() = split(":", limit = 2).getOrNull(0)?.toIntOrNull() get() = split('-', limit = 2).getOrNull(0)?.toIntOrNull()
/**
* Slice a string so that any preceding articles like The/A(n) are truncated. This is hilariously
* anglo-centric, but it's also a bit of an expected feature in music players, so we implement it
* anyway.
*/
val String.withoutArticle: String
get() {
if (length > 5 && startsWith("the ", ignoreCase = true)) {
return slice(4..lastIndex)
}
if (length > 4 && startsWith("an ", ignoreCase = true)) {
return slice(3..lastIndex)
}
if (length > 3 && startsWith("a ", ignoreCase = true)) {
return slice(2..lastIndex)
}
return this
}
/**
* Decodes the genre name from an ID3(v2) constant. See [genreConstantTable] for the genre constant
* map that Auxio uses.
*/
val String.id3v2GenreName: String
get() {
if (isDigitsOnly()) {
// ID3v1, just parse as an integer
return genreConstantTable.getOrNull(toInt()) ?: this
}
if (startsWith('(') && endsWith(')')) {
// ID3v2.3/ID3v2.4, parse out the parentheses and get the integer
// Any genres formatted as "(CHARS)" will be ignored.
// TODO: Technically, the spec for genres is far more complex here. Perhaps we
// should copy mutagen's implementation?
// https://github.com/quodlibet/mutagen/blob/master/mutagen/id3/_frames.py
val genreInt = substring(1 until lastIndex).toIntOrNull()
if (genreInt != null) {
return genreConstantTable.getOrNull(genreInt) ?: this
}
}
// Current name is fine.
return this
}
/**
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
*/
private val genreConstantTable =
arrayOf(
// ID3 Standard
"Blues",
"Classic Rock",
"Country",
"Dance",
"Disco",
"Funk",
"Grunge",
"Hip-Hop",
"Jazz",
"Metal",
"New Age",
"Oldies",
"Other",
"Pop",
"R&B",
"Rap",
"Reggae",
"Rock",
"Techno",
"Industrial",
"Alternative",
"Ska",
"Death Metal",
"Pranks",
"Soundtrack",
"Euro-Techno",
"Ambient",
"Trip-Hop",
"Vocal",
"Jazz+Funk",
"Fusion",
"Trance",
"Classical",
"Instrumental",
"Acid",
"House",
"Game",
"Sound Clip",
"Gospel",
"Noise",
"AlternRock",
"Bass",
"Soul",
"Punk",
"Space",
"Meditative",
"Instrumental Pop",
"Instrumental Rock",
"Ethnic",
"Gothic",
"Darkwave",
"Techno-Industrial",
"Electronic",
"Pop-Folk",
"Eurodance",
"Dream",
"Southern Rock",
"Comedy",
"Cult",
"Gangsta",
"Top 40",
"Christian Rap",
"Pop/Funk",
"Jungle",
"Native American",
"Cabaret",
"New Wave",
"Psychadelic",
"Rave",
"Showtunes",
"Trailer",
"Lo-Fi",
"Tribal",
"Acid Punk",
"Acid Jazz",
"Polka",
"Retro",
"Musical",
"Rock & Roll",
"Hard Rock",
// Winamp extensions, more or less a de-facto standard
"Folk",
"Folk-Rock",
"National Folk",
"Swing",
"Fast Fusion",
"Bebob",
"Latin",
"Revival",
"Celtic",
"Bluegrass",
"Avantgarde",
"Gothic Rock",
"Progressive Rock",
"Psychedelic Rock",
"Symphonic Rock",
"Slow Rock",
"Big Band",
"Chorus",
"Easy Listening",
"Acoustic",
"Humour",
"Speech",
"Chanson",
"Opera",
"Chamber Music",
"Sonata",
"Symphony",
"Booty Bass",
"Primus",
"Porn Groove",
"Satire",
"Slow Jam",
"Club",
"Tango",
"Samba",
"Folklore",
"Ballad",
"Power Ballad",
"Rhythmic Soul",
"Freestyle",
"Duet",
"Punk Rock",
"Drum Solo",
"A capella",
"Euro-House",
"Dance Hall",
"Goa",
"Drum & Bass",
"Club-House",
"Hardcore",
"Terror",
"Indie",
"Britpop",
"Negerpunk",
"Polsk Punk",
"Beat",
"Christian Gangsta",
"Heavy Metal",
"Black Metal",
"Crossover",
"Contemporary Christian",
"Christian Rock",
"Merengue",
"Salsa",
"Thrash Metal",
"Anime",
"JPop",
"Synthpop",
// Winamp 5.6+ extensions, also used by EasyTAG.
// I only include this because post-rock is a based genre and deserves a slot.
"Abstract",
"Art Rock",
"Baroque",
"Bhangra",
"Big Beat",
"Breakbeat",
"Chillout",
"Downtempo",
"Dub",
"EBM",
"Eclectic",
"Electro",
"Electroclash",
"Emo",
"Experimental",
"Garage",
"Global",
"IDM",
"Illbient",
"Industro-Goth",
"Jam Band",
"Krautrock",
"Leftfield",
"Lounge",
"Math Rock",
"New Romantic",
"Nu-Breakz",
"Post-Punk",
"Post-Rock",
"Psytrance",
"Shoegaze",
"Space Rock",
"Trop Rock",
"World Music",
"Neoclassical",
"Audiobook",
"Audio Theatre",
"Neue Deutsche Welle",
"Podcast",
"Indie Rock",
"G-Funk",
"Dubstep",
"Garage Rock",
"Psybient")

View file

@ -73,14 +73,12 @@ import org.oxycblt.auxio.util.contentResolverSafe
* Is there anything we can do about it? No. Google has routinely shut down issues that begged * Is there anything we can do about it? No. Google has routinely shut down issues that begged
* google to fix glaring issues with MediaStore or to just take the API behind the woodshed and * google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
* shoot it. Largely because they have zero incentive to improve it given how "obscure" local music * shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
* listening is. As a result, some players like Vanilla and VLC just hack their own * listening is. As a result, Auxio exposes an option to use an internal parser based on ExoPlayer
* pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for * that at least tries to correct the insane metadata that this API returns, but not only is that
* Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much * system horrifically slow and bug-prone, it also faces the even larger issue of how google keeps
* harder it is to manage a database of your own media that mirrors the filesystem perfectly. And * trying to kill the filesystem and force you into their ContentResolver API. In the future
* even if I set aside those crippling issues and changed my indexer to that, it would face the even * MediaStore could be the only system we have, which is also the day that greenland melts and
* larger problem of how google keeps trying to kill the filesystem and force you into their * birthdays stop happening forever.
* 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 * 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. * probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
@ -181,7 +179,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
} }
/** /**
* The projection to use when querying media. Add version-specific columns here in our * The projection to use when querying media. Add version-specific columns here in an
* implementation. * implementation.
*/ */
open val projection: Array<String> open val projection: Array<String>
@ -230,8 +228,10 @@ abstract class MediaStoreBackend : Indexer.Backend {
audio.album = cursor.getStringOrNull(albumIndex) audio.album = cursor.getStringOrNull(albumIndex)
audio.albumId = cursor.getLong(albumIdIndex) audio.albumId = cursor.getLong(albumIdIndex)
// If the artist field is <unknown>, make it null. This makes handling the // Android does not make a non-existent artist tag null, it instead fills it in
// insanity of the artist field easier later on. // as <unknown>, which makes absolutely no sense given how other fields default
// to null if they are not present. If this field is <unknown>, null it so that
// it's easier to handle later.
audio.artist = audio.artist =
cursor.getStringOrNull(artistIndex)?.run { cursor.getStringOrNull(artistIndex)?.run {
if (this != MediaStore.UNKNOWN_STRING) { if (this != MediaStore.UNKNOWN_STRING) {
@ -267,21 +267,20 @@ abstract class MediaStoreBackend : Indexer.Backend {
) { ) {
fun toSong(): Song = fun toSong(): Song =
Song( Song(
// Assert that the fields that should exist are present. I can't confirm that
// every device provides these fields, but it seems likely that they do.
rawName = requireNotNull(title) { "Malformed audio: No title" }, rawName = requireNotNull(title) { "Malformed audio: No title" },
fileName = requireNotNull(displayName) { "Malformed audio: No file name" }, fileName = requireNotNull(displayName) { "Malformed audio: No file name" },
uri = uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
requireNotNull(id) { "Malformed audio: No song id" }),
durationMs = requireNotNull(duration) { "Malformed audio: No duration" }, durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track, track = track,
disc = disc, disc = disc,
_year = year, _year = year,
_albumName = requireNotNull(album) { "Malformed song: No album name" }, _albumName = requireNotNull(album) { "Malformed audio: No album name" },
_albumCoverUri = _albumCoverUri =
ContentUris.withAppendedId( ContentUris.withAppendedId(
EXTERNAL_ALBUM_ART_URI, EXTERNAL_ALBUM_ART_URI,
requireNotNull(albumId) { "Malformed song: No album id" }), requireNotNull(albumId) { "Malformed audio: No album id" }),
_artistName = artist, _artistName = artist,
_albumArtistName = albumArtist, _albumArtistName = albumArtist,
_genreName = genre) _genreName = genre)

View file

@ -40,6 +40,8 @@ import org.oxycblt.auxio.util.textSafe
* Instead, we wrap it in a safe class that hopefully implements enough safety to not crash the app * Instead, we wrap it in a safe class that hopefully implements enough safety to not crash the app
* or result in blatantly janky behavior. Mostly. * or result in blatantly janky behavior. Mostly.
* *
* TODO: Add smooth seeking
*
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class StyledSeekBar class StyledSeekBar

View file

@ -71,8 +71,7 @@ class PlaybackStateManager private constructor() {
notifyPlayingChanged() notifyPlayingChanged()
} }
/** The current playback progress */ /** The current playback progress */
var positionMs = 0L private var positionMs = 0L
private set
/** The current [RepeatMode] */ /** The current [RepeatMode] */
var repeatMode = RepeatMode.NONE var repeatMode = RepeatMode.NONE
set(value) { set(value) {
@ -92,8 +91,7 @@ class PlaybackStateManager private constructor() {
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()
/** /**
* Add a [PlaybackStateManager.Callback] to this instance. Make sure to remove the callback with * Add a callback to this instance. Make sure to remove it when done.
* [removeCallback] when done.
*/ */
fun addCallback(callback: Callback) { fun addCallback(callback: Callback) {
if (isInitialized) { if (isInitialized) {

View file

@ -44,9 +44,6 @@ fun <T> unlikelyToBeNull(value: T?): T {
/** Shortcut to clamp an integer between [min] and [max] */ /** Shortcut to clamp an integer between [min] and [max] */
fun Int.clamp(min: Int, max: Int): Int = MathUtils.clamp(this, min, max) fun Int.clamp(min: Int, max: Int): Int = MathUtils.clamp(this, min, max)
/** Shortcut to clamp an integer between [min] and [max] */
fun Long.clamp(min: Long, max: Long): Long = MathUtils.clamp(this, min, max)
/** /**
* Convert a [Long] of seconds into a string duration. * Convert a [Long] of seconds into a string duration.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:-- * @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--