music: refactor separator parsing
Add separator parsing back, albeit in a much different manner. Now Auxio will try to parse by , ; / + and &. This will be disabled by default in the future and available as a setting. When parsing by separator, whitespace is also trimmed. This occurs nowhere else though, as there is no demand.
This commit is contained in:
parent
2033e2cb1f
commit
81ca021ce7
9 changed files with 386 additions and 354 deletions
|
@ -54,7 +54,14 @@ sealed class Music : Item {
|
||||||
* fast-scrolling.
|
* fast-scrolling.
|
||||||
*/
|
*/
|
||||||
val sortName: String?
|
val sortName: String?
|
||||||
get() = rawSortName ?: rawName?.parseSortName()
|
get() = rawSortName ?: rawName?.run {
|
||||||
|
when {
|
||||||
|
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
|
||||||
|
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
|
||||||
|
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
|
||||||
|
else -> this
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would
|
* Resolve a name from it's raw form to a form suitable to be shown in a ui. Ex. "unknown" would
|
||||||
|
@ -79,7 +86,7 @@ sealed class Music : Item {
|
||||||
* external sources, as it can persist across app restarts and does not need to encode useless
|
* external sources, as it can persist across app restarts and does not need to encode useless
|
||||||
* information about the relationships between items.
|
* information about the relationships between items.
|
||||||
*
|
*
|
||||||
* TODO: MusizBrainz tags
|
* TODO: MusicBrainz tags
|
||||||
*
|
*
|
||||||
* @author OxygenCobalt
|
* @author OxygenCobalt
|
||||||
*/
|
*/
|
||||||
|
@ -186,19 +193,24 @@ class Song constructor(private val raw: Raw) : Music() {
|
||||||
// TODO: Multi-artist support
|
// TODO: Multi-artist support
|
||||||
// private val _artists: MutableList<Artist> = mutableListOf()
|
// private val _artists: MutableList<Artist> = mutableListOf()
|
||||||
|
|
||||||
|
private val artistName = raw.artistNames?.joinToString()
|
||||||
|
private val albumArtistName = raw.albumArtistNames?.joinToString()
|
||||||
|
private val artistSortName = raw.artistSortNames?.joinToString()
|
||||||
|
private val albumArtistSortName = raw.albumArtistSortNames?.joinToString()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The raw artist name for this song in particular. First uses the artist tag, and then falls
|
* The raw artist name for this song in particular. First uses the artist tag, and then falls
|
||||||
* back to the album artist tag (i.e parent artist name). Null if name is unknown.
|
* back to the album artist tag (i.e parent artist name). Null if name is unknown.
|
||||||
*/
|
*/
|
||||||
val individualArtistRawName: String?
|
val individualArtistRawName: String?
|
||||||
get() = raw.artistName ?: album.artist.rawName
|
get() = artistName ?: album.artist.rawName
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolve the artist name for this song in particular. First uses the artist tag, and then
|
* Resolve the artist name for this song in particular. First uses the artist tag, and then
|
||||||
* falls back to the album artist tag (i.e parent artist name)
|
* falls back to the album artist tag (i.e parent artist name)
|
||||||
*/
|
*/
|
||||||
fun resolveIndividualArtistName(context: Context) =
|
fun resolveIndividualArtistName(context: Context) =
|
||||||
raw.artistName ?: album.artist.resolveName(context)
|
artistName ?: album.artist.resolveName(context)
|
||||||
|
|
||||||
private val _genres: MutableList<Genre> = mutableListOf()
|
private val _genres: MutableList<Genre> = mutableListOf()
|
||||||
/**
|
/**
|
||||||
|
@ -218,10 +230,10 @@ class Song constructor(private val raw: Raw) : Music() {
|
||||||
date = raw.date,
|
date = raw.date,
|
||||||
releaseType = raw.albumReleaseType,
|
releaseType = raw.albumReleaseType,
|
||||||
rawArtist =
|
rawArtist =
|
||||||
if (raw.albumArtistName != null) {
|
if (albumArtistName != null) {
|
||||||
Artist.Raw(raw.albumArtistName, raw.albumArtistSortName)
|
Artist.Raw(albumArtistName, albumArtistSortName)
|
||||||
} else {
|
} else {
|
||||||
Artist.Raw(raw.artistName, raw.artistSortName)
|
Artist.Raw(artistName, artistSortName)
|
||||||
})
|
})
|
||||||
|
|
||||||
val _rawGenres = raw.genreNames?.map { Genre.Raw(it) } ?: listOf(Genre.Raw(null))
|
val _rawGenres = raw.genreNames?.map { Genre.Raw(it) } ?: listOf(Genre.Raw(null))
|
||||||
|
@ -248,8 +260,8 @@ class Song constructor(private val raw: Raw) : Music() {
|
||||||
update(_rawAlbum.name.lowercase())
|
update(_rawAlbum.name.lowercase())
|
||||||
update(_rawAlbum.date)
|
update(_rawAlbum.date)
|
||||||
|
|
||||||
update(raw.artistName)
|
update(artistName)
|
||||||
update(raw.albumArtistName)
|
update(albumArtistName)
|
||||||
|
|
||||||
update(track)
|
update(track)
|
||||||
update(disc)
|
update(disc)
|
||||||
|
@ -275,10 +287,10 @@ class Song constructor(private val raw: Raw) : Music() {
|
||||||
var albumName: String? = null,
|
var albumName: String? = null,
|
||||||
var albumSortName: String? = null,
|
var albumSortName: String? = null,
|
||||||
var albumReleaseType: ReleaseType? = null,
|
var albumReleaseType: ReleaseType? = null,
|
||||||
var artistName: String? = null,
|
var artistNames: List<String>? = null,
|
||||||
var artistSortName: String? = null,
|
var artistSortNames: List<String>? = null,
|
||||||
var albumArtistName: String? = null,
|
var albumArtistNames: List<String>? = null,
|
||||||
var albumArtistSortName: String? = null,
|
var albumArtistSortNames: List<String>? = null,
|
||||||
var genreNames: List<String>? = null
|
var genreNames: List<String>? = null
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
@ -729,7 +741,7 @@ sealed class ReleaseType {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun parse(types: List<String>): ReleaseType {
|
fun parse(types: List<String>): ReleaseType {
|
||||||
val primary = types[0].trim()
|
val primary = types[0]
|
||||||
|
|
||||||
// Primary types should be the first one in sequence. The spec makes no mention of
|
// Primary types should be the first one in sequence. The spec makes no mention of
|
||||||
// whether primary types are a pre-requisite for secondary types, so we assume that
|
// whether primary types are a pre-requisite for secondary types, so we assume that
|
||||||
|
@ -747,7 +759,7 @@ sealed class ReleaseType {
|
||||||
secondaryIdx: Int,
|
secondaryIdx: Int,
|
||||||
target: (Refinement?) -> ReleaseType
|
target: (Refinement?) -> ReleaseType
|
||||||
): ReleaseType {
|
): ReleaseType {
|
||||||
val secondary = (getOrNull(secondaryIdx) ?: return target(null)).trim()
|
val secondary = (getOrNull(secondaryIdx) ?: return target(null))
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
// Compilation is the only weird secondary release type, as it could
|
// Compilation is the only weird secondary release type, as it could
|
||||||
|
|
|
@ -28,6 +28,13 @@ import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
|
||||||
|
fun Date?.resolveYear(context: Context) =
|
||||||
|
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
||||||
|
|
||||||
|
/** Converts this string to a UUID, or returns null if it is not valid. */
|
||||||
|
fun String.toUuid() = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { null }
|
||||||
|
|
||||||
/** 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(
|
||||||
uri: Uri,
|
uri: Uri,
|
||||||
|
@ -58,320 +65,3 @@ val Long.audioUri: Uri
|
||||||
/** Converts a [Long] Album ID into a URI pointing to MediaStore-cached album art. */
|
/** Converts a [Long] Album ID into a URI pointing to MediaStore-cached album art. */
|
||||||
val Long.albumCoverUri: Uri
|
val Long.albumCoverUri: Uri
|
||||||
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
|
get() = ContentUris.withAppendedId(EXTERNAL_ALBUM_ART_URI, this)
|
||||||
|
|
||||||
fun String.toUuid() = try { UUID.fromString(this) } catch (e: IllegalArgumentException) { null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse out the track number field as if the given Int is formatted as DTTT, where D Is the disc
|
|
||||||
* and T is the track number. Values of zero will be ignored under the assumption that they are
|
|
||||||
* invalid.
|
|
||||||
*/
|
|
||||||
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse out the disc number field as if the given Int is formatted as DTTT, where D Is the disc and
|
|
||||||
* T is the track number. Values of zero will be ignored under the assumption that they are invalid.
|
|
||||||
*/
|
|
||||||
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
|
||||||
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
|
|
||||||
*/
|
|
||||||
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
|
||||||
|
|
||||||
/** Parse a plain year from the field into a [Date]. */
|
|
||||||
fun String.parseYear() = toIntOrNull()?.let(Date::from)
|
|
||||||
|
|
||||||
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
|
|
||||||
fun String.parseTimestamp() = Date.from(this)
|
|
||||||
|
|
||||||
/** Shortcut to resolve a year from a nullable date. Will return "No Date" if it is null. */
|
|
||||||
fun Date?.resolveYear(context: Context) =
|
|
||||||
this?.resolveYear(context) ?: context.getString(R.string.def_date)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
fun String.parseSortName() =
|
|
||||||
when {
|
|
||||||
length > 5 && startsWith("the ", ignoreCase = true) -> substring(4)
|
|
||||||
length > 4 && startsWith("an ", ignoreCase = true) -> substring(3)
|
|
||||||
length > 3 && startsWith("a ", ignoreCase = true) -> substring(2)
|
|
||||||
else -> this
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shortcut to parse a [ReleaseType] from a list of strings */
|
|
||||||
fun List<String>.parseReleaseType() = ReleaseType.parse(this)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes the genre name from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
|
|
||||||
* that Auxio uses.
|
|
||||||
*/
|
|
||||||
fun String.parseId3GenreName() = parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: listOf(this)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Decodes the genre names from an ID3(v2) constant. See [GENRE_TABLE] for the genre constant map
|
|
||||||
* that Auxio uses.
|
|
||||||
*/
|
|
||||||
fun List<String>.parseId3GenreName() = flatMap { it.parseId3GenreName() }
|
|
||||||
|
|
||||||
|
|
||||||
private fun String.parseId3v1Genre(): String? =
|
|
||||||
when {
|
|
||||||
// ID3v1 genres are a plain integer value without formatting, so in that case
|
|
||||||
// try to index the genre table with such.
|
|
||||||
isDigitsOnly() -> GENRE_TABLE.getOrNull(toInt())
|
|
||||||
|
|
||||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
|
||||||
this == "CR" -> "Cover"
|
|
||||||
this == "RX" -> "Remix"
|
|
||||||
|
|
||||||
// Current name is fine.
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun String.parseId3v2Genre(): List<String>? {
|
|
||||||
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
|
|
||||||
val genres = mutableSetOf<String>()
|
|
||||||
|
|
||||||
// ID3v2 genres are far more complex and require string grokking to properly implement.
|
|
||||||
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
|
|
||||||
// This implementation in particular is based off Mutagen's genre parser.
|
|
||||||
|
|
||||||
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
|
||||||
// ID3v1 tags.
|
|
||||||
val genreIds = groups.getOrNull(1)
|
|
||||||
if (genreIds != null && genreIds.isNotEmpty()) {
|
|
||||||
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
|
||||||
for (id in ids) {
|
|
||||||
id.parseId3v1Genre()?.let(genres::add)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
|
||||||
// escaped strings formatted as ((genre).
|
|
||||||
val genreName = groups.getOrNull(3)
|
|
||||||
if (genreName != null && genreName.isNotEmpty()) {
|
|
||||||
if (genreName.startsWith("((")) {
|
|
||||||
genres.add(genreName.substring(1))
|
|
||||||
} else {
|
|
||||||
genres.add(genreName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return genres.toList()
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Regex that implements matching for ID3v2's genre format. */
|
|
||||||
private val GENRE_RE = Regex("((?:\\(([0-9]+|RX|CR)\\))*)(.+)?")
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
|
||||||
* Note that we do not translate these, as that greatly increases technical complexity.
|
|
||||||
*/
|
|
||||||
private val GENRE_TABLE =
|
|
||||||
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",
|
|
||||||
|
|
||||||
// Auxio's extensions (Future garage is also based and deserves a slot)
|
|
||||||
"Future Garage")
|
|
||||||
|
|
|
@ -18,7 +18,11 @@
|
||||||
package org.oxycblt.auxio.music
|
package org.oxycblt.auxio.music
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
|
@ -31,6 +35,7 @@ import java.lang.reflect.Method
|
||||||
import org.oxycblt.auxio.R
|
import org.oxycblt.auxio.R
|
||||||
import org.oxycblt.auxio.util.lazyReflectedMethod
|
import org.oxycblt.auxio.util.lazyReflectedMethod
|
||||||
|
|
||||||
|
|
||||||
/** A path to a file. [name] is the stripped file name, [parent] is the parent path. */
|
/** A path to a file. [name] is the stripped file name, [parent] is the parent path. */
|
||||||
data class Path(val name: String, val parent: Directory)
|
data class Path(val name: String, val parent: Directory)
|
||||||
|
|
||||||
|
|
|
@ -28,11 +28,6 @@ import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||||
import org.oxycblt.auxio.music.Date
|
import org.oxycblt.auxio.music.Date
|
||||||
import org.oxycblt.auxio.music.Song
|
import org.oxycblt.auxio.music.Song
|
||||||
import org.oxycblt.auxio.music.audioUri
|
import org.oxycblt.auxio.music.audioUri
|
||||||
import org.oxycblt.auxio.music.parseId3GenreName
|
|
||||||
import org.oxycblt.auxio.music.parsePositionNum
|
|
||||||
import org.oxycblt.auxio.music.parseReleaseType
|
|
||||||
import org.oxycblt.auxio.music.parseTimestamp
|
|
||||||
import org.oxycblt.auxio.music.parseYear
|
|
||||||
import org.oxycblt.auxio.util.logD
|
import org.oxycblt.auxio.util.logD
|
||||||
import org.oxycblt.auxio.util.logW
|
import org.oxycblt.auxio.util.logW
|
||||||
|
|
||||||
|
@ -238,15 +233,15 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
tags["TSOA"]?.let { raw.albumSortName = it[0] }
|
tags["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||||
|
|
||||||
// (Sort) Artist
|
// (Sort) Artist
|
||||||
tags["TPE1"]?.let { raw.artistName = it.joinToString() }
|
tags["TPE1"]?.let { raw.artistNames = it.parseMultiValue() }
|
||||||
tags["TSOP"]?.let { raw.artistSortName = it.joinToString() }
|
tags["TSOP"]?.let { raw.artistSortNames = it.parseMultiValue() }
|
||||||
|
|
||||||
// (Sort) Album artist
|
// (Sort) Album artist
|
||||||
tags["TPE2"]?.let { raw.albumArtistName = it.joinToString() }
|
tags["TPE2"]?.let { raw.albumArtistNames = it.parseMultiValue() }
|
||||||
tags["TSO2"]?.let { raw.albumArtistSortName = it.joinToString() }
|
tags["TSO2"]?.let { raw.albumArtistSortNames = it.parseMultiValue() }
|
||||||
|
|
||||||
// Genre, with the weird ID3 rules.
|
// Genre, with the weird ID3 rules.
|
||||||
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreName() }
|
tags["TCON"]?.let { raw.genreNames = it.parseId3GenreNames() }
|
||||||
|
|
||||||
// Release type (GRP1 is sometimes used for this, so fall back to it)
|
// Release type (GRP1 is sometimes used for this, so fall back to it)
|
||||||
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
|
(tags["TXXX:MusicBrainz Album Type"] ?: tags["GRP1"])?.parseReleaseType()?.let {
|
||||||
|
@ -302,15 +297,15 @@ class Task(context: Context, private val raw: Song.Raw) {
|
||||||
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
tags["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
||||||
|
|
||||||
// (Sort) Artist
|
// (Sort) Artist
|
||||||
tags["ARTIST"]?.let { raw.artistName = it.joinToString() }
|
tags["ARTIST"]?.let { raw.artistNames = it.parseMultiValue() }
|
||||||
tags["ARTISTSORT"]?.let { raw.artistSortName = it.joinToString() }
|
tags["ARTISTSORT"]?.let { raw.artistSortNames = it.parseMultiValue() }
|
||||||
|
|
||||||
// (Sort) Album artist
|
// (Sort) Album artist
|
||||||
tags["ALBUMARTIST"]?.let { raw.albumArtistName = it.joinToString() }
|
tags["ALBUMARTIST"]?.let { raw.albumArtistNames = it.parseMultiValue() }
|
||||||
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortName = it.joinToString() }
|
tags["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it.parseMultiValue() }
|
||||||
|
|
||||||
// Genre, no ID3 rules here
|
// Genre, no ID3 rules here
|
||||||
tags["GENRE"]?.let { raw.genreNames = it }
|
tags["GENRE"]?.let { raw.genreNames = it.parseMultiValue() }
|
||||||
|
|
||||||
// Release type
|
// Release type
|
||||||
tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }
|
tags["RELEASETYPE"]?.parseReleaseType()?.let { raw.albumReleaseType = it }
|
||||||
|
|
|
@ -189,7 +189,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
// format a genre was derived from, we have to treat them like they are ID3
|
// format a genre was derived from, we have to treat them like they are ID3
|
||||||
// genres, even when they might not be.
|
// genres, even when they might not be.
|
||||||
val id = genreCursor.getLong(idIndex)
|
val id = genreCursor.getLong(idIndex)
|
||||||
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreName()
|
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue).parseId3GenreNames()
|
||||||
|
|
||||||
context.contentResolverSafe.useQuery(
|
context.contentResolverSafe.useQuery(
|
||||||
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
|
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
|
||||||
|
@ -293,13 +293,13 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
||||||
// as <unknown>, which makes absolutely no sense given how other fields default
|
// 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
|
// to null if they are not present. If this field is <unknown>, null it so that
|
||||||
// it's easier to handle later.
|
// it's easier to handle later.
|
||||||
raw.artistName =
|
raw.artistNames =
|
||||||
cursor.getString(artistIndex).run {
|
cursor.getString(artistIndex).run {
|
||||||
if (this != MediaStore.UNKNOWN_STRING) this else null
|
if (this != MediaStore.UNKNOWN_STRING) listOf(this) else null
|
||||||
}
|
}
|
||||||
|
|
||||||
// The album artist field is nullable and never has placeholder values.
|
// The album artist field is nullable and never has placeholder values.
|
||||||
raw.albumArtistName = cursor.getStringOrNull(albumArtistIndex)
|
raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.let { listOf(it) }
|
||||||
|
|
||||||
return raw
|
return raw
|
||||||
}
|
}
|
||||||
|
|
329
app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt
Normal file
329
app/src/main/java/org/oxycblt/auxio/music/system/ParsingUtil.kt
Normal file
|
@ -0,0 +1,329 @@
|
||||||
|
package org.oxycblt.auxio.music.system
|
||||||
|
|
||||||
|
import androidx.core.text.isDigitsOnly
|
||||||
|
import org.oxycblt.auxio.music.Date
|
||||||
|
import org.oxycblt.auxio.music.ReleaseType
|
||||||
|
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse out the track number field as if the given Int is formatted as DTTT, where D Is the disc
|
||||||
|
* and T is the track number. Values of zero will be ignored under the assumption that they are
|
||||||
|
* invalid.
|
||||||
|
*/
|
||||||
|
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse out the disc number field as if the given Int is formatted as DTTT, where D Is the disc and
|
||||||
|
* T is the track number. Values of zero will be ignored under the assumption that they are invalid.
|
||||||
|
*/
|
||||||
|
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse out the number field from an NN/TT string that is typically found in DISC_NUMBER and
|
||||||
|
* CD_TRACK_NUMBER. Values of zero will be ignored under the assumption that they are invalid.
|
||||||
|
*/
|
||||||
|
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
|
||||||
|
|
||||||
|
/** Parse a plain year from the field into a [Date]. */
|
||||||
|
fun String.parseYear() = toIntOrNull()?.let(Date::from)
|
||||||
|
|
||||||
|
/** Parse an ISO-8601 time-stamp from this field into a [Date]. */
|
||||||
|
fun String.parseTimestamp() = Date.from(this)
|
||||||
|
|
||||||
|
private val SEPARATOR_REGEX = Regex("[^\\\\][\\[,;/+&]")
|
||||||
|
private val ESCAPED_REGEX = Regex("\\\\[\\[,;/+&]")
|
||||||
|
|
||||||
|
fun List<String>.parseMultiValue() =
|
||||||
|
if (size == 1) {
|
||||||
|
get(0).parseSeparatorsImpl()
|
||||||
|
} else {
|
||||||
|
this
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.parseSeparatorsImpl() =
|
||||||
|
// First split by non-escaped separators (No preceding \), and then split by escaped
|
||||||
|
// separators.
|
||||||
|
SEPARATOR_REGEX.split(this).map {
|
||||||
|
ESCAPED_REGEX.replace(it) { match -> match.value.substring(1) }.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<String>.parseReleaseType() =
|
||||||
|
if (size == 1) {
|
||||||
|
ReleaseType.parse(get(0).parseSeparatorsImpl())
|
||||||
|
} else {
|
||||||
|
ReleaseType.parse(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun List<String>.parseId3GenreNames() =
|
||||||
|
if (size == 1) {
|
||||||
|
get(0).parseId3GenreNames()
|
||||||
|
} else {
|
||||||
|
map { it.parseId3v1Genre() ?: it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun String.parseId3GenreNames() =
|
||||||
|
parseId3v1Genre()?.let { listOf(it) } ?:
|
||||||
|
parseId3v2Genre() ?:
|
||||||
|
parseSeparatorsImpl()
|
||||||
|
|
||||||
|
private fun String.parseId3v1Genre(): String? =
|
||||||
|
when {
|
||||||
|
// ID3v1 genres are a plain integer value without formatting, so in that case
|
||||||
|
// try to index the genre table with such.
|
||||||
|
isDigitsOnly() -> GENRE_TABLE.getOrNull(toInt())
|
||||||
|
|
||||||
|
// CR and RX are not technically ID3v1, but are formatted similarly to a plain number.
|
||||||
|
this == "CR" -> "Cover"
|
||||||
|
this == "RX" -> "Remix"
|
||||||
|
|
||||||
|
// Current name is fine.
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun String.parseId3v2Genre(): List<String>? {
|
||||||
|
val groups = (GENRE_RE.matchEntire(this) ?: return null).groupValues
|
||||||
|
val genres = mutableSetOf<String>()
|
||||||
|
|
||||||
|
// ID3v2 genres are far more complex and require string grokking to properly implement.
|
||||||
|
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
|
||||||
|
// This implementation in particular is based off Mutagen's genre parser.
|
||||||
|
|
||||||
|
// Case 1: Genre IDs in the format (INT|RX|CR). If these exist, parse them as
|
||||||
|
// ID3v1 tags.
|
||||||
|
val genreIds = groups.getOrNull(1)
|
||||||
|
if (genreIds != null && genreIds.isNotEmpty()) {
|
||||||
|
val ids = genreIds.substring(1, genreIds.lastIndex).split(")(")
|
||||||
|
for (id in ids) {
|
||||||
|
id.parseId3v1Genre()?.let(genres::add)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Case 2: Genre names as a normal string. The only case we have to look out for are
|
||||||
|
// escaped strings formatted as ((genre).
|
||||||
|
val genreName = groups.getOrNull(3)
|
||||||
|
if (genreName != null && genreName.isNotEmpty()) {
|
||||||
|
if (genreName.startsWith("((")) {
|
||||||
|
genres.add(genreName.substring(1))
|
||||||
|
} else {
|
||||||
|
genres.add(genreName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If this parsing task didn't change anything, move on.
|
||||||
|
if (genres.size == 1 && genres.first() == this) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return genres.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regex that implements matching for ID3v2's genre format. */
|
||||||
|
private val GENRE_RE = Regex("((?:\\((\\d+|RX|CR)\\))*)(.+)?")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A complete table of all the constant genre values for ID3(v2), including non-standard extensions.
|
||||||
|
* Note that we do not translate these, as that greatly increases technical complexity.
|
||||||
|
*/
|
||||||
|
private val GENRE_TABLE =
|
||||||
|
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",
|
||||||
|
|
||||||
|
// Auxio's extensions (Future garage is also based and deserves a slot)
|
||||||
|
"Future Garage")
|
|
@ -130,7 +130,7 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||||
// capitalization is consistent before continuing.
|
// capitalization is consistent before continuing.
|
||||||
is TextInformationFrame -> {
|
is TextInformationFrame -> {
|
||||||
key = entry.description?.uppercase()
|
key = entry.description?.uppercase()
|
||||||
value = entry.value
|
value = entry.values[0]
|
||||||
}
|
}
|
||||||
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
||||||
// skipped.
|
// skipped.
|
||||||
|
|
|
@ -74,7 +74,6 @@ abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
error("Unexpected menu item selected")
|
error("Unexpected menu item selected")
|
||||||
return@musicMenuImpl false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -142,7 +141,6 @@ abstract class MenuFragment<T : ViewBinding> : ViewBindingFragment<T>() {
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
error("Unexpected menu item selected")
|
error("Unexpected menu item selected")
|
||||||
return@musicMenuImpl false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -24,6 +24,7 @@ import java.lang.reflect.Method
|
||||||
import java.util.concurrent.CancellationException
|
import java.util.concurrent.CancellationException
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
import org.oxycblt.auxio.BuildConfig
|
import org.oxycblt.auxio.BuildConfig
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
/** Assert that we are on a background thread. */
|
/** Assert that we are on a background thread. */
|
||||||
fun requireBackgroundThread() {
|
fun requireBackgroundThread() {
|
||||||
|
@ -43,8 +44,10 @@ fun <T> unlikelyToBeNull(value: T?) =
|
||||||
value!!
|
value!!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Returns null if this value is 0. */
|
||||||
fun Int.nonZeroOrNull() = if (this > 0) this else null
|
fun Int.nonZeroOrNull() = if (this > 0) this else null
|
||||||
|
|
||||||
|
/** Returns null if this value is not in [range]. */
|
||||||
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
|
fun Int.inRangeOrNull(range: IntRange) = if (range.contains(this)) this else null
|
||||||
|
|
||||||
/** Converts a long in milliseconds to a long in deci-seconds */
|
/** Converts a long in milliseconds to a long in deci-seconds */
|
||||||
|
|
Loading…
Reference in a new issue