music: split off extractor parsing

Split off parsing-related components from extractor into a new parsing
module.

A lot of these methods are used in non-extractor code, so it makes more
sense for them to not be part of the extractors.

The code that is really extractor-specific can remain within the
extractor files.
This commit is contained in:
Alexander Capehart 2022-12-31 19:50:54 -07:00
parent dc46c49f07
commit 7721e64096
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
20 changed files with 143 additions and 155 deletions

View file

@ -116,7 +116,7 @@
<!-- </intent-filter>--> <!-- </intent-filter>-->
<!-- </receiver>--> <!-- </receiver>-->
<!-- "Now Playing" widget.. --> <!-- "Now Playing" widget. -->
<receiver <receiver
android:name=".widgets.WidgetProvider" android:name=".widgets.WidgetProvider"
android:exported="false" android:exported="false"

View file

@ -216,7 +216,7 @@ class MainFragment :
lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio } lastInsets?.let { translationY = it.systemBarInsetsCompat.top * halfOutRatio }
} }
// Prevent interactions when the playback panell fully fades out. // Prevent interactions when the playback panel fully fades out.
binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f binding.playbackPanelFragment.isInvisible = binding.playbackPanelFragment.alpha == 0f
binding.queueSheet.apply { binding.queueSheet.apply {

View file

@ -31,12 +31,12 @@ import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import java.lang.reflect.Field import java.lang.reflect.Field
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.ui.AuxioAppBarLayout import org.oxycblt.auxio.ui.CoordinatorAppBarLayout
import org.oxycblt.auxio.util.getInteger import org.oxycblt.auxio.util.getInteger
import org.oxycblt.auxio.util.lazyReflectedField import org.oxycblt.auxio.util.lazyReflectedField
/** /**
* An [AuxioAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes * An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
* beyond it's first item. * beyond it's first item.
* *
* This is intended for the detail views, in which the first item is the album/artist/genre header, * This is intended for the detail views, in which the first item is the album/artist/genre header,
@ -51,7 +51,7 @@ import org.oxycblt.auxio.util.lazyReflectedField
class DetailAppBarLayout class DetailAppBarLayout
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AuxioAppBarLayout(context, attrs, defStyleAttr) { CoordinatorAppBarLayout(context, attrs, defStyleAttr) {
private var titleView: TextView? = null private var titleView: TextView? = null
private var recycler: RecyclerView? = null private var recycler: RecyclerView? = null

View file

@ -345,7 +345,6 @@ class HomeFragment :
is Indexer.Response.Err -> { is Indexer.Response.Err -> {
logD("Updating UI to Response.Err state") logD("Updating UI to Response.Err state")
binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed)
// Configure the action to act as a reload trigger. // Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
@ -354,10 +353,9 @@ class HomeFragment :
} }
} }
is Indexer.Response.NoMusic -> { is Indexer.Response.NoMusic -> {
// TODO: Move this state to the list fragments (makes life easier) // TODO: Move this state to the list fragments (quality of life)
logD("Updating UI to Response.NoMusic state") logD("Updating UI to Response.NoMusic state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) binding.homeIndexingStatus.text = context.getString(R.string.err_no_music)
// Configure the action to act as a reload trigger. // Configure the action to act as a reload trigger.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE
@ -368,7 +366,6 @@ class HomeFragment :
is Indexer.Response.NoPerms -> { is Indexer.Response.NoPerms -> {
logD("Updating UI to Response.NoPerms state") logD("Updating UI to Response.NoPerms state")
binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms)
// Configure the action to act as a permission launcher. // Configure the action to act as a permission launcher.
binding.homeIndexingAction.apply { binding.homeIndexingAction.apply {
visibility = View.VISIBLE visibility = View.VISIBLE

View file

@ -65,7 +65,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
private val cornerRadius: Float private val cornerRadius: Float
init { init {
// Obtain some StyledImageView attributes to use later when theming the cusotm view. // Obtain some StyledImageView attributes to use later when theming the custom view.
@SuppressLint("CustomViewStyleable") @SuppressLint("CustomViewStyleable")
val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView) val styledAttrs = context.obtainStyledAttributes(attrs, R.styleable.StyledImageView)
// Keep track of our corner radius so that we can apply the same attributes to the custom // Keep track of our corner radius so that we can apply the same attributes to the custom

View file

@ -30,9 +30,8 @@ import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Item
import org.oxycblt.auxio.music.extractor.parseId3GenreNames import org.oxycblt.auxio.music.parsing.parseId3GenreNames
import org.oxycblt.auxio.music.extractor.parseMultiValue import org.oxycblt.auxio.music.parsing.parseMultiValue
import org.oxycblt.auxio.music.extractor.toUuidOrNull
import org.oxycblt.auxio.music.storage.* import org.oxycblt.auxio.music.storage.*
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
@ -1213,6 +1212,18 @@ class Genre constructor(private val raw: Raw, override val songs: List<Song>) :
// --- MUSIC UID CREATION UTILITIES --- // --- MUSIC UID CREATION UTILITIES ---
/**
* Convert a [String] to a [UUID].
* @return A [UUID] converted from the [String] value, or null if the value was not valid.
* @see UUID.fromString
*/
fun String.toUuidOrNull(): UUID? =
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}
/** /**
* Update a [MessageDigest] with a lowercase [String]. * Update a [MessageDigest] with a lowercase [String].
* @param string The [String] to hash. If null, it will not be hashed. * @param string The [String] to hash. If null, it will not be hashed.

View file

@ -23,7 +23,10 @@ import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.parsing.correctWhitespace
import org.oxycblt.auxio.music.parsing.splitEscaped
import org.oxycblt.auxio.util.* import org.oxycblt.auxio.util.*
/** /**
@ -278,7 +281,7 @@ private class CacheDatabase(context: Context) :
raw.track = cursor.getIntOrNull(trackIndex) raw.track = cursor.getIntOrNull(trackIndex)
raw.disc = cursor.getIntOrNull(discIndex) raw.disc = cursor.getIntOrNull(discIndex)
raw.date = cursor.getStringOrNull(dateIndex)?.parseTimestamp() raw.date = cursor.getStringOrNull(dateIndex)?.let(Date::from)
raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex) raw.albumMusicBrainzId = cursor.getStringOrNull(albumMusicBrainzIdIndex)
raw.albumName = cursor.getString(albumNameIndex) raw.albumName = cursor.getString(albumNameIndex)

View file

@ -26,8 +26,10 @@ import android.provider.MediaStore
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import org.oxycblt.auxio.music.Date
import java.io.File import java.io.File
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.Directory import org.oxycblt.auxio.music.storage.Directory
import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.music.storage.contentResolverSafe
import org.oxycblt.auxio.music.storage.directoryCompat import org.oxycblt.auxio.music.storage.directoryCompat
@ -38,6 +40,7 @@ import org.oxycblt.auxio.music.storage.useQuery
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.nonZeroOrNull
/** /**
* The layer that loads music from the [MediaStore] database. This is an intermediate step in the * The layer that loads music from the [MediaStore] database. This is an intermediate step in the
@ -302,7 +305,7 @@ abstract class MediaStoreExtractor(
// MediaStore only exposes the year value of a file. This is actually worse than it // MediaStore only exposes the year value of a file. This is actually worse than it
// seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments. // seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments.
// This is one of the major weaknesses of using MediaStore, hence the redundancy layers. // This is one of the major weaknesses of using MediaStore, hence the redundancy layers.
raw.date = cursor.getIntOrNull(yearIndex)?.toDate() raw.date = cursor.getIntOrNull(yearIndex)?.let(Date::from)
// A non-existent album name should theoretically be the name of the folder it contained // A non-existent album name should theoretically be the name of the folder it contained
// in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the
// file is not actually in the root internal storage directory. We can't do anything to // file is not actually in the root internal storage directory. We can't do anything to
@ -561,7 +564,24 @@ class Api30MediaStoreExtractor(context: Context, cacheExtractor: CacheExtractor)
// the tag itself, which is to say that it is formatted as NN/TT tracks, where // the tag itself, which is to say that it is formatted as NN/TT tracks, where
// N is the number and T is the total. Parse the number while ignoring the // N is the number and T is the total. Parse the number while ignoring the
// total, as we have no use for it. // total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it } cursor.getStringOrNull(trackIndex)?.parseId3v2Position()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it } cursor.getStringOrNull(discIndex)?.parseId3v2Position()?.let { raw.disc = it }
} }
} }
/**
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
* @return The track number extracted from the combined integer value, or null if the value was
* zero.
*/
private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
/**
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
* @return The disc number extracted from the combined integer field, or null if the value was zero.
*/
private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull()

View file

@ -26,6 +26,8 @@ import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment 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.parsing.correctWhitespace
import org.oxycblt.auxio.music.parsing.parseId3v2Position
import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.music.storage.toAudioUri
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.logW
@ -128,7 +130,6 @@ class MetadataExtractor(
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Task(context: Context, private val raw: Song.Raw) { class Task(context: Context, private val raw: Song.Raw) {
// TODO: Unify with MetadataExtractor
// Note that we do not leverage future callbacks. This is because errors in the // Note that we do not leverage future callbacks. This is because errors in the
// (highly fallible) extraction process will not bubble up to Indexer when a // (highly fallible) extraction process will not bubble up to Indexer when a
// listener is used, instead crashing the app entirely. // listener is used, instead crashing the app entirely.
@ -144,6 +145,7 @@ class Task(context: Context, private val raw: Song.Raw) {
*/ */
fun get(): Song.Raw? { fun get(): Song.Raw? {
if (!future.isDone) { if (!future.isDone) {
// Not done yet, nothing to do.
return null return null
} }
@ -227,10 +229,10 @@ class Task(context: Context, private val raw: Song.Raw) {
textFrames["TSOT"]?.let { raw.sortName = it[0] } textFrames["TSOT"]?.let { raw.sortName = it[0] }
// Track. Only parse out the track number and ignore the total tracks value. // Track. Only parse out the track number and ignore the total tracks value.
textFrames["TRCK"]?.run { get(0).parsePositionNum() }?.let { raw.track = it } textFrames["TRCK"]?.run { get(0).parseId3v2Position() }?.let { raw.track = it }
// Disc. Only parse out the disc number and ignore the total discs value. // Disc. Only parse out the disc number and ignore the total discs value.
textFrames["TPOS"]?.run { get(0).parsePositionNum() }?.let { raw.disc = it } textFrames["TPOS"]?.run { get(0).parseId3v2Position() }?.let { raw.disc = it }
// Dates are somewhat complicated, as not only did their semantics change from a flat year // Dates are somewhat complicated, as not only did their semantics change from a flat year
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of // value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
@ -241,9 +243,9 @@ class Task(context: Context, private val raw: Song.Raw) {
// 3. ID3v2.4 Release Date, as it is the second most common date type // 3. ID3v2.4 Release Date, as it is the second most common date type
// 4. ID3v2.3 Original Date, as it is like #1 // 4. ID3v2.3 Original Date, as it is like #1
// 5. ID3v2.3 Release Year, as it is the most common date type // 5. ID3v2.3 Release Year, as it is the most common date type
(textFrames["TDOR"]?.run { get(0).parseTimestamp() } (textFrames["TDOR"]?.run { Date.from(get(0)) }
?: textFrames["TDRC"]?.run { get(0).parseTimestamp() } ?: textFrames["TDRC"]?.run { Date.from(get(0)) }
?: textFrames["TDRL"]?.run { get(0).parseTimestamp() } ?: textFrames["TDRL"]?.run { Date.from(get(0)) }
?: parseId3v23Date(textFrames)) ?: parseId3v23Date(textFrames))
?.let { raw.date = it } ?.let { raw.date = it }
@ -335,9 +337,9 @@ class Task(context: Context, private val raw: Song.Raw) {
// 2. Date, as it is the most common date type // 2. Date, as it is the most common date type
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only // 3. Year, as old vorbis tags tended to use this (I know this because it's the only
// date tag that android supports, so it must be 15 years old or more!) // date tag that android supports, so it must be 15 years old or more!)
(comments["ORIGINALDATE"]?.run { get(0).parseTimestamp() } (comments["ORIGINALDATE"]?.run { Date.from(get(0)) }
?: comments["DATE"]?.run { get(0).parseTimestamp() } ?: comments["DATE"]?.run { Date.from(get(0)) }
?: comments["YEAR"]?.run { get(0).parseYear() }) ?: comments["YEAR"]?.run { get(0).toIntOrNull()?.let(Date::from) })
?.let { raw.date = it } ?.let { raw.date = it }
// Album // Album

View file

@ -15,59 +15,27 @@
* 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.music.extractor package org.oxycblt.auxio.music.parsing
import java.util.UUID
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.nonZeroOrNull import org.oxycblt.auxio.util.nonZeroOrNull
/** /// --- GENERIC PARSING ---
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
* disc number is the 4th+ digit.
* @return The track number extracted from the combined integer value, or null if the value was
* zero.
*/
fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull()
/** /**
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within * Parse a multi-value tag based on the user configuration. If the value is already composed of more
* MediaStore's TRACK column, and combine the track and disc value into a single field where the * than one value, nothing is done. Otherwise, this function will attempt to split it based on the
* disc number is the 4th+ digit. * user's separator preferences.
* @return The disc number extracted from the combined integer field, or null if the value was zero. * @param settings [Settings] required to obtain user separator configuration.
* @return A new list of one or more [String]s.
*/ */
fun Int.unpackDiscNo() = div(1000).nonZeroOrNull() fun List<String>.parseMultiValue(settings: Settings) =
if (size == 1) {
/** get(0).maybeParseBySeparators(settings)
* Parse the number out of a combined number + total position [String] field. These fields often } else {
* appear in ID3v2 files, and consist of a number and an (optional) total value delimited by a /. // Nothing to do.
* @return The number value extracted from the string field, or null if the value could not be this
* parsed or if the value was zero. }
*/
fun String.parsePositionNum() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
/**
* Transform an [Int] year field into a [Date].
* @return A [Date] consisting of the year value, or null if the value was zero.
* @see Date.from
*/
fun Int.toDate() = Date.from(this)
/**
* Parse an integer year field from a [String] and transform it into a [Date].
* @return A [Date] consisting of the year value, or null if the value could not be parsed or if the
* value was zero.
* @see Date.from
*/
fun String.parseYear() = toIntOrNull()?.toDate()
/**
* Parse an ISO-8601 timestamp [String] into a [Date].
* @return A [Date] consisting of the year value plus one or more refinement values (ex. month,
* day), or null if the timestamp was not valid.
*/
fun String.parseTimestamp() = Date.from(this)
/** /**
* Split a [String] by the given selector, automatically handling escaped characters that satisfy * Split a [String] by the given selector, automatically handling escaped characters that satisfy
@ -126,43 +94,26 @@ fun String.correctWhitespace() = trim().ifBlank { null }
*/ */
fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() } fun List<String>.correctWhitespace() = mapNotNull { it.correctWhitespace() }
/**
* Parse a multi-value tag based on the user configuration. If the value is already composed of more
* than one value, nothing is done. Otherwise, this function will attempt to split it based on the
* user's separator preferences.
* @param settings [Settings] required to obtain user separator configuration.
* @return A new list of one or more [String]s.
*/
fun List<String>.parseMultiValue(settings: Settings) =
if (size == 1) {
get(0).maybeParseSeparators(settings)
} else {
// Nothing to do.
this
}
/** /**
* Attempt to parse a string by the user's separator preferences. * Attempt to parse a string by the user's separator preferences.
* @param settings [Settings] required to obtain user separator configuration. * @param settings [Settings] required to obtain user separator configuration.
* @return A list of one or more [String]s that were split up by the user-defined separators. * @return A list of one or more [String]s that were split up by the user-defined separators.
*/ */
fun String.maybeParseSeparators(settings: Settings): List<String> { private fun String.maybeParseBySeparators(settings: Settings): List<String> {
// Get the separators the user desires. If null, there's nothing to do. // Get the separators the user desires. If null, there's nothing to do.
val separators = settings.musicSeparators ?: return listOf(this) val separators = settings.musicSeparators ?: return listOf(this)
return splitEscaped { separators.contains(it) }.correctWhitespace() return splitEscaped { separators.contains(it) }.correctWhitespace()
} }
/// --- ID3v2 PARSING ---
/** /**
* Convert a [String] to a [UUID]. * Parse the number out of a ID3v2-style number + total position [String] field. These fields
* @return A [UUID] converted from the [String] value, or null if the value was not valid. * consist of a number and an (optional) total value delimited by a /.
* @see UUID.fromString * @return The number value extracted from the string field, or null if the value could not be
* parsed or if the value was zero.
*/ */
fun String.toUuidOrNull(): UUID? = fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZeroOrNull()
try {
UUID.fromString(this)
} catch (e: IllegalArgumentException) {
null
}
/** /**
* Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer * Parse a multi-value genre name using ID3 rules. This will convert any ID3v1 integer
@ -173,7 +124,7 @@ fun String.toUuidOrNull(): UUID? =
*/ */
fun List<String>.parseId3GenreNames(settings: Settings) = fun List<String>.parseId3GenreNames(settings: Settings) =
if (size == 1) { if (size == 1) {
get(0).parseId3GenreNames(settings) get(0).parseId3MultiValueGenre(settings)
} else { } else {
// Nothing to split, just map any ID3v1 genres to their name counterparts. // Nothing to split, just map any ID3v1 genres to their name counterparts.
map { it.parseId3v1Genre() ?: it } map { it.parseId3v1Genre() ?: it }
@ -183,8 +134,8 @@ fun List<String>.parseId3GenreNames(settings: Settings) =
* Parse a single ID3v1/ID3v2 integer genre field into their named representations. * Parse a single ID3v1/ID3v2 integer genre field into their named representations.
* @return A list of one or more genre names. * @return A list of one or more genre names.
*/ */
fun String.parseId3GenreNames(settings: Settings) = private fun String.parseId3MultiValueGenre(settings: Settings) =
parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseSeparators(settings) parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings)
/** /**
* Parse an ID3v1 integer genre field. * Parse an ID3v1 integer genre field.
@ -193,10 +144,10 @@ fun String.parseId3GenreNames(settings: Settings) =
*/ */
private fun String.parseId3v1Genre(): String? { private fun String.parseId3v1Genre(): String? {
// ID3v1 genres are a plain integer value without formatting, so in that case // ID3v1 genres are a plain integer value without formatting, so in that case
// try to index the genre table with such. If this fails, then try to compare it // try to index the genre table with such.
// to some other hard-coded values.
val numeric = val numeric =
toIntOrNull() toIntOrNull()
// Not a numeric value, try some other fixed values.
?: return when (this) { ?: return when (this) {
// CR and RX are not technically ID3v1, but are formatted similarly to a plain // CR and RX are not technically ID3v1, but are formatted similarly to a plain
// number. // number.
@ -204,7 +155,6 @@ private fun String.parseId3v1Genre(): String? {
"RX" -> "Remix" "RX" -> "Remix"
else -> null else -> null
} }
return GENRE_TABLE.getOrNull(numeric) return GENRE_TABLE.getOrNull(numeric)
} }

View file

@ -0,0 +1,13 @@
package org.oxycblt.auxio.music.parsing
/**
* Defines the allowed separator characters that can be used to delimit multi-value tags.
* @author Alexander Capehart (OxygenCobalt)
*/
object Separators {
const val COMMA = ','
const val SEMICOLON = ';'
const val SLASH = '/'
const val PLUS = '+'
const val AND = '&'
}

View file

@ -15,7 +15,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.music.extractor package org.oxycblt.auxio.music.parsing
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -62,11 +62,11 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
?: Settings(requireContext()).musicSeparators) ?: Settings(requireContext()).musicSeparators)
?.forEach { ?.forEach {
when (it) { when (it) {
SEPARATOR_COMMA -> binding.separatorComma.isChecked = true Separators.COMMA -> binding.separatorComma.isChecked = true
SEPARATOR_SEMICOLON -> binding.separatorSemicolon.isChecked = true Separators.SEMICOLON -> binding.separatorSemicolon.isChecked = true
SEPARATOR_SLASH -> binding.separatorSlash.isChecked = true Separators.SLASH -> binding.separatorSlash.isChecked = true
SEPARATOR_PLUS -> binding.separatorPlus.isChecked = true Separators.PLUS -> binding.separatorPlus.isChecked = true
SEPARATOR_AND -> binding.separatorAnd.isChecked = true Separators.AND -> binding.separatorAnd.isChecked = true
else -> error("Unexpected separator in settings data") else -> error("Unexpected separator in settings data")
} }
} }
@ -84,21 +84,15 @@ class SeparatorsDialog : ViewBindingDialogFragment<DialogSeparatorsBinding>() {
// of use a mapping that could feasibly drift from the actual layout. // of use a mapping that could feasibly drift from the actual layout.
var separators = "" var separators = ""
val binding = requireBinding() val binding = requireBinding()
if (binding.separatorComma.isChecked) separators += SEPARATOR_COMMA if (binding.separatorComma.isChecked) separators += Separators.COMMA
if (binding.separatorSemicolon.isChecked) separators += SEPARATOR_SEMICOLON if (binding.separatorSemicolon.isChecked) separators += Separators.SEMICOLON
if (binding.separatorSlash.isChecked) separators += SEPARATOR_SLASH if (binding.separatorSlash.isChecked) separators += Separators.SLASH
if (binding.separatorPlus.isChecked) separators += SEPARATOR_PLUS if (binding.separatorPlus.isChecked) separators += Separators.PLUS
if (binding.separatorAnd.isChecked) separators += SEPARATOR_AND if (binding.separatorAnd.isChecked) separators += Separators.AND
return separators return separators
} }
private companion object { private companion object {
val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS" const val KEY_PENDING_SEPARATORS = BuildConfig.APPLICATION_ID + ".key.PENDING_SEPARATORS"
// TODO: Move these to a more "Correct" location?
const val SEPARATOR_COMMA = ','
const val SEPARATOR_SEMICOLON = ';'
const val SEPARATOR_SLASH = '/'
const val SEPARATOR_PLUS = '+'
const val SEPARATOR_AND = '&'
} }
} }

View file

@ -133,7 +133,7 @@ class PlaybackPanelFragment :
// Launch the system equalizer app, if possible. // Launch the system equalizer app, if possible.
val equalizerIntent = val equalizerIntent =
Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL) Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL)
// Provide audio session ID so equalizer can show options for this app // Provide audio session ID so the equalizer can show options for this app
// in particular. // in particular.
.putExtra( .putExtra(
AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId) AudioEffect.EXTRA_AUDIO_SESSION, playbackModel.currentAudioSessionId)

View file

@ -104,46 +104,44 @@ class PreferenceFragment : PreferenceFragmentCompat() {
} }
override fun onPreferenceTreeClick(preference: Preference): Boolean { override fun onPreferenceTreeClick(preference: Preference): Boolean {
val context = requireContext()
// Hook generic preferences to their specified preferences // Hook generic preferences to their specified preferences
// TODO: These seem like good things to put into a side navigation view, if I choose to // TODO: These seem like good things to put into a side navigation view, if I choose to
// do one. // do one.
when (preference.key) { when (preference.key) {
context.getString(R.string.set_key_save_state) -> { getString(R.string.set_key_save_state) -> {
playbackModel.savePlaybackState { saved -> playbackModel.savePlaybackState { saved ->
// Use the nullable context, as we could try to show a toast when this // Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached. // fragment is no longer attached.
if (saved) { if (saved) {
this.context?.showToast(R.string.lbl_state_saved) context?.showToast(R.string.lbl_state_saved)
} else { } else {
this.context?.showToast(R.string.err_did_not_save) context?.showToast(R.string.err_did_not_save)
} }
} }
} }
context.getString(R.string.set_key_wipe_state) -> { getString(R.string.set_key_wipe_state) -> {
playbackModel.wipePlaybackState { wiped -> playbackModel.wipePlaybackState { wiped ->
if (wiped) { if (wiped) {
// Use the nullable context, as we could try to show a toast when this // Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached. // fragment is no longer attached.
this.context?.showToast(R.string.lbl_state_wiped) context?.showToast(R.string.lbl_state_wiped)
} else { } else {
this.context?.showToast(R.string.err_did_not_wipe) context?.showToast(R.string.err_did_not_wipe)
} }
} }
} }
context.getString(R.string.set_key_restore_state) -> getString(R.string.set_key_restore_state) ->
playbackModel.tryRestorePlaybackState { restored -> playbackModel.tryRestorePlaybackState { restored ->
if (restored) { if (restored) {
// Use the nullable context, as we could try to show a toast when this // Use the nullable context, as we could try to show a toast when this
// fragment is no longer attached. // fragment is no longer attached.
this.context?.showToast(R.string.lbl_state_restored) context?.showToast(R.string.lbl_state_restored)
} else { } else {
this.context?.showToast(R.string.err_did_not_restore) context?.showToast(R.string.err_did_not_restore)
} }
} }
context.getString(R.string.set_key_reindex) -> musicModel.refresh() getString(R.string.set_key_reindex) -> musicModel.refresh()
context.getString(R.string.set_key_rescan) -> musicModel.rescan() getString(R.string.set_key_rescan) -> musicModel.rescan()
else -> return super.onPreferenceTreeClick(preference) else -> return super.onPreferenceTreeClick(preference)
} }
@ -151,8 +149,7 @@ class PreferenceFragment : PreferenceFragmentCompat() {
} }
private fun setupPreference(preference: Preference) { private fun setupPreference(preference: Preference) {
val context = requireActivity() val settings = Settings(requireContext())
val settings = Settings(context)
if (!preference.isVisible) { if (!preference.isVisible) {
// Nothing to do. // Nothing to do.
@ -165,30 +162,31 @@ class PreferenceFragment : PreferenceFragmentCompat() {
} }
when (preference.key) { when (preference.key) {
context.getString(R.string.set_key_theme) -> { getString(R.string.set_key_theme) -> {
preference.onPreferenceChangeListener = preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, value -> Preference.OnPreferenceChangeListener { _, value ->
AppCompatDelegate.setDefaultNightMode(value as Int) AppCompatDelegate.setDefaultNightMode(value as Int)
true true
} }
} }
context.getString(R.string.set_key_accent) -> { getString(R.string.set_key_accent) -> {
preference.summary = context.getString(settings.accent.name) preference.summary = getString(settings.accent.name)
} }
context.getString(R.string.set_key_black_theme) -> { getString(R.string.set_key_black_theme) -> {
preference.onPreferenceChangeListener = preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ -> Preference.OnPreferenceChangeListener { _, _ ->
if (context.isNight) { val activity = requireActivity()
context.recreate() if (activity.isNight) {
activity.recreate()
} }
true true
} }
} }
context.getString(R.string.set_key_cover_mode) -> { getString(R.string.set_key_cover_mode) -> {
preference.onPreferenceChangeListener = preference.onPreferenceChangeListener =
Preference.OnPreferenceChangeListener { _, _ -> Preference.OnPreferenceChangeListener { _, _ ->
Coil.imageLoader(context).memoryCache?.clear() Coil.imageLoader(requireContext()).memoryCache?.clear()
true true
} }
} }

View file

@ -42,7 +42,7 @@ import org.oxycblt.auxio.util.coordinatorLayoutBehavior
* *
* @author Hai Zhang, Alexander Capehart (OxygenCobalt) * @author Hai Zhang, Alexander Capehart (OxygenCobalt)
*/ */
open class AuxioAppBarLayout open class CoordinatorAppBarLayout
@JvmOverloads @JvmOverloads
constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) : constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr: Int = 0) :
AppBarLayout(context, attrs, defStyleAttr) { AppBarLayout(context, attrs, defStyleAttr) {

View file

@ -9,7 +9,7 @@
android:transitionGroup="true" android:transitionGroup="true"
tools:context=".settings.AboutFragment"> tools:context=".settings.AboutFragment">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/about_appbar" android:id="@+id/about_appbar"
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
app:liftOnScroll="true"> app:liftOnScroll="true">
@ -21,7 +21,7 @@
app:navigationIcon="@drawable/ic_back_24" app:navigationIcon="@drawable/ic_back_24"
app:title="@string/lbl_about" /> app:title="@string/lbl_about" />
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<androidx.core.widget.NestedScrollView <androidx.core.widget.NestedScrollView
android:id="@+id/about_contents" android:id="@+id/about_contents"

View file

@ -8,7 +8,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/home_appbar" android:id="@+id/home_appbar"
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
android:fitsSystemWindows="true"> android:fitsSystemWindows="true">
@ -37,7 +37,7 @@
app:tabGravity="start" app:tabGravity="start"
app:tabMode="scrollable" /> app:tabMode="scrollable" />
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<FrameLayout <FrameLayout

View file

@ -7,7 +7,7 @@
android:background="?attr/colorSurface" android:background="?attr/colorSurface"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
app:liftOnScroll="true" app:liftOnScroll="true"
app:liftOnScrollTargetViewId="@id/search_recycler"> app:liftOnScrollTargetViewId="@id/search_recycler">
@ -51,7 +51,7 @@
</org.oxycblt.auxio.list.selection.SelectionToolbarOverlay> </org.oxycblt.auxio.list.selection.SelectionToolbarOverlay>
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<org.oxycblt.auxio.list.recycler.AuxioRecyclerView <org.oxycblt.auxio.list.recycler.AuxioRecyclerView
android:id="@+id/search_recycler" android:id="@+id/search_recycler"

View file

@ -8,7 +8,7 @@
android:orientation="vertical" android:orientation="vertical"
android:transitionGroup="true"> android:transitionGroup="true">
<org.oxycblt.auxio.ui.AuxioAppBarLayout <org.oxycblt.auxio.ui.CoordinatorAppBarLayout
android:id="@+id/settings_appbar" android:id="@+id/settings_appbar"
style="@style/Widget.Auxio.AppBarLayout" style="@style/Widget.Auxio.AppBarLayout"
android:clickable="true" android:clickable="true"
@ -22,7 +22,7 @@
app:navigationIcon="@drawable/ic_back_24" app:navigationIcon="@drawable/ic_back_24"
app:title="@string/set_title" /> app:title="@string/set_title" />
</org.oxycblt.auxio.ui.AuxioAppBarLayout> </org.oxycblt.auxio.ui.CoordinatorAppBarLayout>
<androidx.fragment.app.FragmentContainerView <androidx.fragment.app.FragmentContainerView
android:id="@+id/settings_list_fragment" android:id="@+id/settings_list_fragment"

View file

@ -111,7 +111,7 @@
tools:layout="@layout/dialog_music_dirs" /> tools:layout="@layout/dialog_music_dirs" />
<dialog <dialog
android:id="@+id/separators_dialog" android:id="@+id/separators_dialog"
android:name="org.oxycblt.auxio.music.extractor.SeparatorsDialog" android:name="org.oxycblt.auxio.music.parsing.SeparatorsDialog"
android:label="music_dirs_dialog" android:label="music_dirs_dialog"
tools:layout="@layout/dialog_separators" /> tools:layout="@layout/dialog_separators" />