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.net.Uri
import android.provider.MediaStore
import androidx.core.text.isDigitsOnly
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.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()
override val sortName: String?
get() = rawName?.genreNameCompat
get() = rawName?.id3v2GenreName
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>()
/**
* Add a callback to this instance. Make sure to remove it when done.
*/
fun addCallback(callback: Callback) {
response?.let(callback::onMusicUpdate)
callbacks.add(callback)
}
/**
* Remove a callback from this instance.
*/
fun removeCallback(callback: Callback) {
callbacks.remove(callback)
}

View file

@ -17,10 +17,8 @@
package org.oxycblt.auxio.music.indexer
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.provider.MediaStore
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
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.common.util.concurrent.FutureCallback
import com.google.common.util.concurrent.Futures
import java.nio.charset.StandardCharsets
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.Executors
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.
*
* 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
* be made first before it is practical.
* be made first before it can be integrated.
*
* @author OxygenCobalt
*/
@Suppress("UNUSED")
class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private val runningTasks: Array<Future<TrackGroupArray>?> = arrayOfNulls(TASK_CAPACITY)
@ -58,21 +64,21 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
while (cursor.moveToNext()) {
val audio = inner.buildAudio(context, cursor)
val audioUri =
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, requireNotNull(audio.id))
val audioUri = requireNotNull(audio.id) { "Malformed audio: No id" }.audioUri
while (true) {
// Spin until a task slot opens up, then start trying to parse metadata.
val idx = runningTasks.indexOfFirst { it == null }
if (idx != -1) {
val index = runningTasks.indexOfFirst { it == null }
if (index != -1) {
val task =
MetadataRetriever.retrieveMetadata(context, MediaItem.fromUri(audioUri))
Futures.addCallback(
task, AudioCallback(audio, songs, idx), Executors.newSingleThreadExecutor())
task,
AudioCallback(audio, songs, index),
Executors.newSingleThreadExecutor())
runningTasks[idx] = task
runningTasks[index] = task
break
}
@ -89,7 +95,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private inner class AudioCallback(
private val audio: MediaStoreBackend.Audio,
private val dest: ConcurrentLinkedQueue<Song>,
private val taskIdx: Int
private val taskIndex: Int
) : FutureCallback<TrackGroupArray> {
override fun onSuccess(result: TrackGroupArray) {
val metadata = result[0].getFormat(0).metadata
@ -100,30 +106,33 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
}
dest.add(audio.toSong())
runningTasks[taskIdx] = null
runningTasks[taskIndex] = null
}
override fun onFailure(t: Throwable) {
logW("Unable to extract metadata for ${audio.title}")
logW(t.stackTraceToString())
dest.add(audio.toSong())
runningTasks[taskIdx] = null
runningTasks[taskIndex] = null
}
}
private fun completeAudio(audio: MediaStoreBackend.Audio, metadata: Metadata) {
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)) {
// ID3v2 text information frame.
is TextInformationFrame ->
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 ->
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
* 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.
*/
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.
when (id) {
"TIT2" -> audio.title = value // Title

View file

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

View file

@ -18,8 +18,11 @@
package org.oxycblt.auxio.music.indexer
import android.content.ContentResolver
import android.content.ContentUris
import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import androidx.core.text.isDigitsOnly
/** Shortcut for making a [ContentResolver] query with less superfluous arguments. */
fun ContentResolver.queryCursor(
@ -38,6 +41,12 @@ fun <R> ContentResolver.useQuery(
block: (Cursor) -> R
): 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
* CD_TRACK_NUMBER.
@ -45,6 +54,265 @@ fun <R> ContentResolver.useQuery(
val String.no: Int?
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?
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
* 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
* listening is. As a result, some players like Vanilla and VLC just hack their own
* pseudo-MediaStore implementation from their own (better) 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 my 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.
* listening is. As a result, Auxio exposes an option to use an internal parser based on ExoPlayer
* that at least tries to correct the insane metadata that this API returns, but not only is that
* system horrifically slow and bug-prone, it also faces the even larger issue 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.
@ -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.
*/
open val projection: Array<String>
@ -230,8 +228,10 @@ abstract class MediaStoreBackend : Indexer.Backend {
audio.album = cursor.getStringOrNull(albumIndex)
audio.albumId = cursor.getLong(albumIdIndex)
// If the artist field is <unknown>, make it null. This makes handling the
// insanity of the artist field easier later on.
// Android does not make a non-existent artist tag null, it instead fills it in
// 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 =
cursor.getStringOrNull(artistIndex)?.run {
if (this != MediaStore.UNKNOWN_STRING) {
@ -267,21 +267,20 @@ abstract class MediaStoreBackend : Indexer.Backend {
) {
fun toSong(): 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" },
fileName = requireNotNull(displayName) { "Malformed audio: No file name" },
uri =
ContentUris.withAppendedId(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
requireNotNull(id) { "Malformed audio: No song id" }),
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track,
disc = disc,
_year = year,
_albumName = requireNotNull(album) { "Malformed song: No album name" },
_albumName = requireNotNull(album) { "Malformed audio: No album name" },
_albumCoverUri =
ContentUris.withAppendedId(
EXTERNAL_ALBUM_ART_URI,
requireNotNull(albumId) { "Malformed song: No album id" }),
requireNotNull(albumId) { "Malformed audio: No album id" }),
_artistName = artist,
_albumArtistName = albumArtist,
_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
* or result in blatantly janky behavior. Mostly.
*
* TODO: Add smooth seeking
*
* @author OxygenCobalt
*/
class StyledSeekBar

View file

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

View file

@ -44,9 +44,6 @@ fun <T> unlikelyToBeNull(value: T?): T {
/** Shortcut to clamp an integer between [min] and [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.
* @param isElapsed Whether this duration is represents elapsed time. If this is false, then --:--