music: refactor backends into extractors

Refactor all Backend instances into a new package called extractor and
a new structure called "Layer".

Layers are no longer generalized into an interface. Instead, they build
on eachother in order to produce a correct output of raw songs.

One of these layers is a stub class to eventually implement caching.

This changeset also phases out the "Ignore MediaStore tags" setting, as
it is no longer needed.
This commit is contained in:
Alexander Capehart 2022-09-08 20:25:40 -06:00
parent 2e71342e1c
commit 28d28287fe
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
35 changed files with 239 additions and 336 deletions

View file

@ -11,8 +11,12 @@
audio focus was lost
- Fixed issue where the app would crash if a song menu in the genre UI was opened
#### What's Changed
- Ignore MediaStore tags is now on by default
#### Dev/Meta
- Completed migration to reactive playback system
- Refactor music backends into a unified chain of extractors
## 2.6.3

View file

@ -83,6 +83,7 @@ abstract class DetailAdapter<L : DetailAdapter.Listener>(
return item is Header || item is SortHeader
}
@Suppress("LeakingThis")
protected val differ = AsyncListDiffer(this, diffCallback)
override val currentList: List<Item>

View file

@ -39,9 +39,6 @@ import org.oxycblt.auxio.util.inflater
*/
class GenreDetailAdapter(private val listener: Listener) :
DetailAdapter<DetailAdapter.Listener>(listener, DIFFER) {
private var currentSong: Song? = null
private var isPlaying = false
override fun getItemViewType(position: Int) =
when (differ.currentList[position]) {
is Genre -> GenreDetailViewHolder.VIEW_TYPE

View file

@ -57,7 +57,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
AppCompatImageView(context, attrs, defStyleAttr) {
private val settings = Settings(context)
var cornerRadius = 0f
private var cornerRadius = 0f
set(value) {
field = value
(background as? MaterialShapeDrawable)?.let { bg ->

View file

@ -23,7 +23,6 @@ import android.content.Context
import android.os.Parcelable
import java.security.MessageDigest
import java.util.UUID
import kotlin.experimental.and
import kotlin.math.max
import kotlin.math.min
import kotlin.reflect.KClass
@ -143,7 +142,7 @@ sealed class MusicParent : Music() {
* A song.
* @author OxygenCobalt
*/
class Song constructor(private val raw: Raw) : Music() {
class Song constructor(raw: Raw) : Music() {
override val uid: UID
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" }
@ -279,6 +278,7 @@ class Song constructor(private val raw: Raw) : Music() {
var formatMimeType: String? = null,
var size: Long? = null,
var dateAdded: Long? = null,
var dateModified: Long? = null,
var durationMs: Long? = null,
var track: Int? = null,
var disc: Int? = null,
@ -473,7 +473,7 @@ fun MessageDigest.update(date: Date?) {
update(date.toString().toByteArray())
}
// Note: All methods regarding integer bytemucking must be little-endian
// Note: All methods regarding integer byte-mucking must be little-endian
/**
* Update the digest using the little-endian byte representation of a byte, or do not update if

View file

@ -24,7 +24,6 @@ import android.database.Cursor
import android.net.Uri
import android.provider.MediaStore
import android.text.format.DateUtils
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD
import java.util.UUID

View file

@ -18,11 +18,7 @@
package org.oxycblt.auxio.music
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager

View file

@ -0,0 +1,18 @@
package org.oxycblt.auxio.music.extractor
import org.oxycblt.auxio.music.Song
/**
* TODO: Stub class, not implemented yet
*/
class CacheLayer {
fun init() {
// STUB: Add cache database
}
fun finalize(rawSongs: List<Song.Raw>) {
// STUB: Add cache database
}
fun maybePopulateCachedRaw(raw: Song.Raw) = false
}

View file

@ -1,21 +1,4 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.system
package org.oxycblt.auxio.music.extractor
import android.content.Context
import android.database.Cursor
@ -26,12 +9,18 @@ import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull
import java.io.File
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.directoryCompat
import org.oxycblt.auxio.music.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.queryCursor
import org.oxycblt.auxio.music.storageVolumesCompat
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.getSystemServiceCompat
import org.oxycblt.auxio.util.logD
import java.io.File
/*
* This file acts as the base for most the black magic required to get a remotely sensible music
@ -91,33 +80,41 @@ import org.oxycblt.auxio.util.logD
* I wish I was born in the neolithic.
*/
// TODO: Move duration util to MusicUtil
/**
* Represents a [Indexer.Backend] that loads music from the media database ([MediaStore]). This is
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead.
* The layer that loads music from the MediaStore database. This is an intermediate step in
* the music loading process.
* @author OxygenCobalt
*/
abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend {
abstract class MediaStoreLayer(private val context: Context, private val cacheLayer: CacheLayer) {
private var cursor: Cursor? = null
private var idIndex = -1
private var titleIndex = -1
private var displayNameIndex = -1
private var mimeTypeIndex = -1
private var sizeIndex = -1
private var dateAddedIndex = -1
private var dateModifiedIndex = -1
private var durationIndex = -1
private var yearIndex = -1
private var albumIndex = -1
private var albumIdIndex = -1
private var artistIndex = -1
private var albumArtistIndex = -1
private val settings = Settings(context)
protected val volumes = mutableListOf<StorageVolume>()
override fun query(): Cursor {
private val _volumes = mutableListOf<StorageVolume>()
protected val volumes: List<StorageVolume> get() = _volumes
/**
* Initialize this instance by making a query over the media database.
*/
open fun init(): Cursor {
cacheLayer.init()
val storageManager = context.getSystemServiceCompat(StorageManager::class)
volumes.addAll(storageManager.storageVolumesCompat)
_volumes.addAll(storageManager.storageVolumesCompat)
val dirs = settings.getMusicDirs(storageManager)
val args = mutableListOf<String>()
@ -152,73 +149,70 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
logD("Starting query [proj: ${projection.toList()}, selector: $selector, args: $args]")
return requireNotNull(
val cursor = requireNotNull(
context.contentResolverSafe.queryCursor(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selector,
args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
.also { cursor = it }
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
displayNameIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
dateModifiedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED)
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
return cursor
}
override fun buildSongs(
cursor: Cursor,
emitIndexing: (Indexer.Indexing) -> Unit
): List<Song> {
val rawSongs = mutableListOf<Song.Raw>()
while (cursor.moveToNext()) {
rawSongs.add(buildRawSong(context, cursor))
if (cursor.position % 50 == 0) {
// Only check for a cancellation every 50 songs or so (~20ms).
// While this seems redundant, each call to emitIndexing checks for a
// cancellation of the co-routine this loading task is running on.
emitIndexing(Indexer.Indexing.Indeterminate)
}
/**
* Finalize this instance by closing the cursor and finalizing the cache.
*/
fun finalize(rawSongs: List<Song.Raw>) {
cursor?.close()
cursor = null
cacheLayer.finalize(rawSongs)
}
/**
* Populate a [raw] with whatever the next value in the cursor is.
*
* This returns true if the song could be restored from cache, false if metadata had to be
* re-extracted, and null if the cursor is exhausted.
*/
fun populateRaw(raw: Song.Raw): Boolean? {
val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
if (!cursor.moveToNext()) {
logD("Cursor is exhausted")
return null
}
// The raw song is not actually complete at this point, as we cannot obtain a genre
// through a song query. Instead, we have to do the hack where we iterate through
// every genre and assign it's name to raw songs that match it's child IDs.
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
// Populate the minimum required fields to maybe obtain a cache entry.
raw.mediaStoreId = cursor.getLong(idIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex)
while (genreCursor.moveToNext()) {
// Genre names could theoretically be anything, including null for some reason.
// Null values are junk and should be ignored, but since we cannot assume the
// format a genre was derived from, we have to treat them like they are ID3
// genres, even when they might not be.
val id = genreCursor.getLong(idIndex)
val name = (genreCursor.getStringOrNull(nameIndex) ?: continue)
.parseId3GenreNames(settings)
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.Members.getContentUri(VOLUME_EXTERNAL, id),
arrayOf(MediaStore.Audio.Genres.Members._ID)) { cursor ->
val songIdIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
while (cursor.moveToNext()) {
val songId = cursor.getLong(songIdIndex)
rawSongs
.find { it.mediaStoreId == songId }
?.let { song -> song.genreNames = name }
if (cursor.position % 50 == 0) {
// Only check for a cancellation every 50 songs or so (~20ms).
emitIndexing(Indexer.Indexing.Indeterminate)
}
}
}
}
// Check for a cancellation every time we finish a genre too, in the case that
// the genre has <50 songs.
emitIndexing(Indexer.Indexing.Indeterminate)
if (cacheLayer.maybePopulateCachedRaw(raw)) {
// We found a valid cache entry, no need to build it.
logD("Found cached raw: ${raw.name}")
return true
}
return rawSongs.map { Song(it) }
buildRaw(cursor, raw)
// We had to freshly make this raw, return false
return false
}
/**
@ -235,6 +229,7 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
MediaStore.Audio.AudioColumns.MIME_TYPE,
MediaStore.Audio.AudioColumns.SIZE,
MediaStore.Audio.AudioColumns.DATE_ADDED,
MediaStore.Audio.AudioColumns.DATE_MODIFIED,
MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM,
@ -250,32 +245,11 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
* obtain an upstream [Song.Raw] first, and then populate it with version-specific fields
* outlined in [projection].
*/
open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
// Initialize our cursor indices if we haven't already.
if (idIndex == -1) {
idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
displayNameIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED)
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
}
val raw = Song.Raw()
raw.mediaStoreId = cursor.getLong(idIndex)
protected open fun buildRaw(cursor: Cursor, raw: Song.Raw) {
raw.name = cursor.getString(titleIndex)
raw.extensionMimeType = cursor.getString(mimeTypeIndex)
raw.size = cursor.getLong(sizeIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex)
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
// from the android system.
@ -294,11 +268,12 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
// 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. While we can't natively parse multi-value tags,
// from MediaStore itself, we can still parse by user-defined separators.
// it's easier to handle later.
raw.artistNames =
cursor.getString(artistIndex).run {
if (this != MediaStore.UNKNOWN_STRING) {
// While we can't natively parse multi-value tags,
// from MediaStore itself, we can still parse by user-defined separators.
maybeParseSeparators(settings)
} else {
null
@ -307,8 +282,6 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
// The album artist field is nullable and never has placeholder values.
raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.maybeParseSeparators(settings)
return raw
}
companion object {
@ -321,12 +294,6 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
@Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
/**
* External has existed since at least API 21, but no constant existed for it until API 29.
* This constant is safe to use.
*/
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
@ -336,17 +303,26 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
}
}
// Note: The separation between version-specific backends may not be the cleanest. To preserve
// speed, we only want to add redundancy on known issues, not with possible issues.
/**
* A [MediaStoreBackend] that completes the music loading process in a way compatible from
* A [MediaStoreLayer] that completes the music loading process in a way compatible from
* @author OxygenCobalt
*/
class Api21MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
class Api21MediaStoreLayer(context: Context, cacheLayer: CacheLayer) :
MediaStoreLayer(context, cacheLayer) {
private var trackIndex = -1
private var dataIndex = -1
override fun init(): Cursor {
val cursor = super.init()
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
return cursor
}
override val projection: Array<String>
get() =
super.projection +
@ -361,15 +337,10 @@ class Api21MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
return true
}
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val raw = super.buildRawSong(context, cursor)
// Initialize our indices if we have not already.
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
}
override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw)
// DATA is equivalent to the absolute path of the file.
val data = cursor.getString(dataIndex)
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume
@ -397,21 +368,29 @@ class Api21MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = it }
}
return raw
}
}
/**
* A [MediaStoreBackend] that selects directories and builds paths using the modern volume fields
* A [MediaStoreLayer] that selects directories and builds paths using the modern volume fields
* available from API 29 onwards.
* @author OxygenCobalt
*/
@RequiresApi(Build.VERSION_CODES.Q)
open class BaseApi29MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
open class BaseApi29MediaStoreLayer(context: Context, cacheLayer: CacheLayer) : MediaStoreLayer(context, cacheLayer) {
private var volumeIndex = -1
private var relativePathIndex = -1
override fun init(): Cursor {
val cursor = super.init()
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
relativePathIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
return cursor
}
override val projection: Array<String>
get() =
super.projection +
@ -431,14 +410,8 @@ open class BaseApi29MediaStoreBackend(context: Context) : MediaStoreBackend(cont
return true
}
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val raw = super.buildRawSong(context, cursor)
if (volumeIndex == -1) {
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
relativePathIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
}
override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw)
val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex)
@ -449,30 +422,30 @@ open class BaseApi29MediaStoreBackend(context: Context) : MediaStoreBackend(cont
if (volume != null) {
raw.directory = Directory.from(volume, relativePath)
}
return raw
}
}
/**
* A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
* A [MediaStoreLayer] that completes the music loading process in a way compatible with at least
* API 29.
* @author OxygenCobalt
*/
@RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(context) {
open class Api29MediaStoreLayer(context: Context, cacheLayer: CacheLayer) : BaseApi29MediaStoreLayer(context, cacheLayer) {
private var trackIndex = -1
override fun init(): Cursor {
val cursor = super.init()
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
return cursor
}
override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val raw = super.buildRawSong(context, cursor)
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
}
override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw)
// This backend is volume-aware, but does not support the modern track fields.
// Use the old field instead.
val rawTrack = cursor.getIntOrNull(trackIndex)
@ -480,21 +453,26 @@ open class Api29MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend
rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = it }
}
return raw
}
}
/**
* A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
* A [MediaStoreLayer] that completes the music loading process in a way compatible with at least
* API 30.
* @author OxygenCobalt
*/
@RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(context) {
class Api30MediaStoreLayer(context: Context, cacheLayer: CacheLayer) : BaseApi29MediaStoreLayer(context, cacheLayer) {
private var trackIndex: Int = -1
private var discIndex: Int = -1
override fun init(): Cursor {
val cursor = super.init()
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
return cursor
}
override val projection: Array<String>
get() =
super.projection +
@ -502,14 +480,8 @@ class Api30MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(cont
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw {
val raw = super.buildRawSong(context, cursor)
// Populate our indices if we have not already.
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
}
override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
super.buildRaw(cursor, raw)
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
@ -517,7 +489,5 @@ class Api30MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(cont
// total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
return raw
}
}

View file

@ -1,39 +1,22 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.system
package org.oxycblt.auxio.music.extractor
import android.content.Context
import android.database.Cursor
import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD
import com.google.android.exoplayer2.metadata.Metadata
import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.util.logW
/**
* A [Indexer.Backend] that leverages ExoPlayer's metadata retrieval system to index metadata.
* The layer 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,
@ -45,29 +28,28 @@ import org.oxycblt.auxio.util.logW
*
* @author OxygenCobalt
*/
class ExoPlayerBackend(private val context: Context, private val inner: MediaStoreBackend) : Indexer.Backend {
class MetadataLayer(private val context: Context, private val mediaStoreLayer: MediaStoreLayer) {
private val settings = Settings(context)
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
// No need to implement our own query logic, as this backend is still reliant on
// MediaStore.
override fun query() = inner.query()
/**
* Initialize the sub-layers that this layer relies on.
*/
fun init() = mediaStoreLayer.init().count
override fun buildSongs(
cursor: Cursor,
emitIndexing: (Indexer.Indexing) -> Unit
): List<Song> {
// Metadata retrieval with ExoPlayer is asynchronous, so a callback may at any point
// add a completed song to the list. To prevent a crash in that case, we use the
// concurrent counterpart to a typical mutable list.
val songs = mutableListOf<Song>()
val total = cursor.count
/**
* Finalize the sub-layers that this layer relies on.
*/
fun finalize(rawSongs: List<Song.Raw>) = mediaStoreLayer.finalize(rawSongs)
while (cursor.moveToNext()) {
// Note: This call to buildRawSong does not populate the genre field. This is
// because indexing genres is quite slow with MediaStore, and so keeping the
// field blank on unsupported ExoPlayer formats ends up being preferable.
val raw = inner.buildRawSong(context, cursor)
fun parse(emit: (Song.Raw) -> Unit) {
while (true) {
val raw = Song.Raw()
if (mediaStoreLayer.populateRaw(raw) ?: break) {
// No need to extract metadata that was successfully restored from the cache
emit(raw)
continue
}
// Spin until there is an open slot we can insert a task in. Note that we do
// not add callbacks to our new tasks, as Future callbacks run on a different
@ -78,10 +60,9 @@ class ExoPlayerBackend(private val context: Context, private val inner: MediaSto
val task = taskPool[i]
if (task != null) {
val song = task.get()
if (song != null) {
songs.add(song)
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
val finishedRaw = task.get()
if (finishedRaw != null) {
emit(finishedRaw)
taskPool[i] = Task(context, settings, raw)
break@spin
}
@ -99,19 +80,17 @@ class ExoPlayerBackend(private val context: Context, private val inner: MediaSto
val task = taskPool[i]
if (task != null) {
val song = task.get() ?: continue@spin
songs.add(song)
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
val finishedRaw = task.get() ?: continue@spin
emit(finishedRaw)
taskPool[i] = null
}
}
break
}
return songs
}
companion object {
/** The amount of tasks this backend can run efficiently at once. */
private const val TASK_CAPACITY = 8
@ -132,7 +111,7 @@ class Task(context: Context, private val settings: Settings, private val raw: So
* Get the song that this task is trying to complete. If the task is still busy, this will
* return null. Otherwise, it will return a song.
*/
fun get(): Song? {
fun get(): Song.Raw? {
if (!future.isDone) {
return null
}
@ -148,7 +127,7 @@ class Task(context: Context, private val settings: Settings, private val raw: So
if (format == null) {
logD("Nothing could be extracted for ${raw.name}")
return Song(raw)
return raw
}
// Populate the format mime type if we have one.
@ -161,7 +140,7 @@ class Task(context: Context, private val settings: Settings, private val raw: So
logD("No metadata could be extracted for ${raw.name}")
}
return Song(raw)
return raw
}
private fun completeRawSong(metadata: Metadata) {

View file

@ -1,4 +1,4 @@
package org.oxycblt.auxio.music.system
package org.oxycblt.auxio.music.extractor
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.music.Date

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.system
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.database.Cursor
import android.os.Build
import androidx.core.content.ContextCompat
import kotlinx.coroutines.CancellationException
@ -28,13 +27,16 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.music.*
import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.music.extractor.Api21MediaStoreLayer
import org.oxycblt.auxio.music.extractor.Api29MediaStoreLayer
import org.oxycblt.auxio.music.extractor.Api30MediaStoreLayer
import org.oxycblt.auxio.music.extractor.CacheLayer
import org.oxycblt.auxio.music.extractor.MetadataLayer
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.util.TaskGuard
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.requireBackgroundThread
/**
* Auxio's media indexer.
@ -43,13 +45,13 @@ import org.oxycblt.auxio.util.requireBackgroundThread
* (and hacky garbage) in order to produce the best possible experience. It is split into three
* distinct steps:
*
* 1. Finding a [Backend] to use and then querying the media database with it.
* 2. Using the [Backend] and the media data to create songs
* 1. Creating the chain of layers to extract metadata with
* 2. Running the chain process
* 3. Using the songs to build the library, which primarily involves linking up all data objects
* with their corresponding parents/children.
*
* This class in particular handles 3 primarily. For the code that handles 1 and 2, see the
* [Backend] implementations.
* layer implementations.
*
* This class also fulfills the role of maintaining the current music loading state, which seems
* like a job for [MusicStore] but in practice is only really leveraged by the components that
@ -194,27 +196,23 @@ class Indexer {
private fun indexImpl(context: Context, handle: Long): MusicStore.Library? {
emitIndexing(Indexing.Indeterminate, handle)
// Since we have different needs for each version, we determine a "Backend" to use
// when loading music and then leverage that to create the initial song list.
// This is technically dependency injection. Except it doesn't increase your compile
// times by 3x. Isn't that nice.
// Create the chain of layers. Each layer builds on the previous layer and
// enables version-specific features in order to create the best possible music
// experience. This is technically dependency injection. Except it doesn't increase
// your compile times by 3x. Isn't that nice.
val mediaStoreBackend =
val cacheLayer = CacheLayer()
val mediaStoreLayer =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend(context)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend(context)
else -> Api21MediaStoreBackend(context)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreLayer(context, cacheLayer)
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreLayer(context, cacheLayer)
else -> Api21MediaStoreLayer(context, cacheLayer)
}
val settings = Settings(context)
val backend =
if (settings.useQualityTags) {
ExoPlayerBackend(context, mediaStoreBackend)
} else {
mediaStoreBackend
}
val metadataLayer = MetadataLayer(context, mediaStoreLayer)
val songs = buildSongs(backend, handle)
val songs = buildSongs(metadataLayer, handle)
if (songs.isEmpty()) {
return null
}
@ -236,32 +234,37 @@ class Indexer {
}
/**
* Does the initial query over the song database using [backend]. The songs returned by this
* Does the initial query over the song database using [metadataLayer]. The songs returned by this
* function are **not** well-formed. The companion [buildAlbums], [buildArtists], and
* [buildGenres] functions must be called with the returned list so that all songs are properly
* linked up.
*/
private fun buildSongs(backend: Backend, handle: Long): List<Song> {
private fun buildSongs(metadataLayer: MetadataLayer, handle: Long): List<Song> {
val start = System.currentTimeMillis()
var songs =
backend.query().use { cursor ->
logD(
"Successfully queried media database " +
"in ${System.currentTimeMillis() - start}ms")
// Initialize the extractor chain. This also nets us the projected total
// that we can show when loading.
val total = metadataLayer.init()
backend.buildSongs(cursor) { emitIndexing(it, handle) }
}
// Note: We use a set here so we can eliminate effective duplicates of
// songs (by UID).
val songs = mutableSetOf<Song>()
val rawSongs = mutableListOf<Song.Raw>()
// Deduplicate songs to prevent (most) deformed music clones
songs = songs.distinctBy { it.uid }.toMutableList()
metadataLayer.parse { raw ->
songs.add(Song(raw))
rawSongs.add(raw)
emitIndexing(Indexing.Songs(songs.size, total), handle)
}
// Ensure that sorting order is consistent so that grouping is also consistent.
Sort(Sort.Mode.ByName, true).songsInPlace(songs)
metadataLayer.finalize(rawSongs)
val sorted = Sort(Sort.Mode.ByName, true).songs(songs)
logD("Successfully built ${songs.size} songs in ${System.currentTimeMillis() - start}ms")
return songs
// Ensure that sorting order is consistent so that grouping is also consistent.
return sorted
}
/**
@ -420,18 +423,6 @@ class Indexer {
fun onStartIndexing()
}
/** Represents a backend that metadata can be extracted from. */
interface Backend {
/** Query the media database for a basic cursor. */
fun query(): Cursor
/** Create a list of songs from the [Cursor] queried in [query]. */
fun buildSongs(
cursor: Cursor,
emitIndexing: (Indexing) -> Unit
): List<Song>
}
companion object {
@Volatile private var INSTANCE: Indexer? = null

View file

@ -54,6 +54,7 @@ class IndexingNotification(private val context: Context) :
}
is Indexer.Indexing.Songs -> {
// Only update the notification every 50 songs to prevent excessive updates.
// TODO: Use a timeout instead to handle rapid-fire updates w/o rate limiting
if (indexing.current % 50 == 0) {
logD("Updating state to $indexing")
setContentText(

View file

@ -218,8 +218,7 @@ class IndexerService : Service(), Indexer.Controller, Settings.Callback {
override fun onSettingChanged(key: String) {
when (key) {
getString(R.string.set_key_music_dirs),
getString(R.string.set_key_music_dirs_include),
getString(R.string.set_key_quality_tags) -> onStartIndexing()
getString(R.string.set_key_music_dirs_include)-> onStartIndexing()
getString(R.string.set_key_observing) -> {
if (!indexer.isIndexing) {
updateIdleSession()

View file

@ -20,7 +20,6 @@ package org.oxycblt.auxio.playback
import android.os.Bundle
import android.view.LayoutInflater
import androidx.fragment.app.activityViewModels
import kotlin.math.max
import org.oxycblt.auxio.IntegerTable
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.FragmentPlaybackBarBinding

View file

@ -479,7 +479,6 @@ class PlaybackService :
}
companion object {
private const val POS_POLL_INTERVAL = 100L
private const val REWIND_THRESHOLD = 3000L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP"

View file

@ -189,10 +189,6 @@ class Settings(private val context: Context, private val callback: Callback? = n
val pauseOnRepeat: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false)
/** Whether to parse metadata directly with ExoPlayer. */
val useQualityTags: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_quality_tags), false)
/** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_observing), false)

View file

@ -45,7 +45,7 @@ interface MenuItemListener : ItemClickListener {
}
/**
* Like [AsyncListDiffer], but synchronous. This may seem like it would be inefficient, but in
* Like AsyncListDiffer, but synchronous. This may seem like it would be inefficient, but in
* practice Auxio's lists tend to be small enough to the point where this does not matter, and
* situations that would be inefficient are ruled out with [replaceList].
*/

View file

@ -18,13 +18,11 @@
package org.oxycblt.auxio.util
import android.os.Looper
import android.text.format.DateUtils
import java.lang.reflect.Field
import java.lang.reflect.Method
import java.util.concurrent.CancellationException
import kotlin.reflect.KClass
import org.oxycblt.auxio.BuildConfig
import java.util.*
/** Assert that we are on a background thread. */
fun requireBackgroundThread() {

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="0.24" android:color="?attr/colorOnPrimary" />
</selector>

View file

@ -203,8 +203,6 @@
<string name="lbl_bitrate">Přenosová rychlost</string>
<string name="lbl_sample_rate">Vzorkovací frekvence</string>
<string name="set_dirs">Hudební složky</string>
<string name="set_quality_tags">Ignorovat štítky MediaStore</string>
<string name="set_quality_tags_desc">Zvýší kvalitu štítků, ale má za následek delší načítací čas (experimentální)</string>
<string name="set_restore_state">Obnovit stav přehrávání</string>
<string name="lbl_state_restored">Stav obnoven</string>
<string name="set_restore_desc">Obnovit dříve uložený stav přehrávání (pokud existuje)</string>

View file

@ -196,8 +196,6 @@
<string name="lbl_format">Format</string>
<string name="lbl_size">Größe</string>
<string name="lbl_bitrate">Bitrate</string>
<string name="set_quality_tags">MediaStore-Tags ignorieren</string>
<string name="set_quality_tags_desc">Erhöht Tag-Qualität, benötigt aber längere Ladezeiten (Experimentell)</string>
<string name="lbl_observing">Überwachen der Musikbibliothek</string>
<string name="lbl_indexing">Musik wird geladen</string>
<string name="lng_observing">Musikbibliothek wird auf Änderungen überwacht…</string>

View file

@ -188,10 +188,8 @@
<string name="set_restore_state">Reestablecer el estado de reproducción</string>
<string name="set_restore_desc">Reestablecer el estado de reproducción guardado previamente (si existe)</string>
<string name="set_dirs">Carpetas de música</string>
<string name="set_quality_tags">Ignorar etiquetas MediaStore</string>
<string name="set_dirs_desc">Gestionar de dónde se cargará la música</string>
<string name="set_dirs_mode_include_desc">La música<b>solo</b> se cargará de las carpetas que añadas.</string>
<string name="set_quality_tags_desc">Incrementa la calidad de las etiquetas, pero resulra en tiempos de carga mayores (Experimental)</string>
<string name="def_codec">Formato desconocido</string>
<string name="clr_dynamic">Dinámico</string>
<string name="fmt_disc_no">Disco %d</string>

View file

@ -103,8 +103,6 @@
<string name="set_dirs_mode_exclude_desc">Glazba se <b>neće</b> učitati iz dodanih mapa.</string>
<string name="set_dirs_mode_include">Uključi</string>
<string name="set_dirs_mode_include_desc">Glazba će se učitati <b>samo</b> iz dodanih mapa.</string>
<string name="set_quality_tags">Zanemari MediaStore oznake</string>
<string name="set_quality_tags_desc">Poboljšava kvalitetu oznaka, no može produljiti vrijeme učitavanja (Eksperimentalno)</string>
<string name="set_observing">Automatsko ponovno učitavanje</string>
<string name="set_observing_desc">Ponovo učitaj svoju zbirku glazbe čim se dogode promjene (eksperimentalno)</string>
<string name="err_no_music">Nijedan zvučni zapis nije pronađen</string>

View file

@ -204,8 +204,6 @@
<string name="lbl_state_restored">Stato ripristinato</string>
<string name="lbl_sort_date_added">Data aggiunta</string>
<string name="set_observing">Ricaricamento automatico</string>
<string name="set_quality_tags">Ignora tags MediaStore</string>
<string name="set_quality_tags_desc">Migliora qualità dei tag, ma potrebbe richiedere tempi di carimento più lunghi (sperimentale)</string>
<string name="set_restore_state">Ripristina stato riproduzione</string>
<string name="set_restore_desc">Ripristina lo stato di riproduzione precedentemente salvato (se disponibile)</string>
<string name="err_did_not_restore">Impossibile ripristinare lo stato</string>

View file

@ -168,8 +168,6 @@
<string name="lng_search_library">Ieškokite savo bibliotekoje…</string>
<string name="lbl_equalizer">Ekvalaizeris</string>
<string name="set_dirs_mode">Režimas</string>
<string name="set_quality_tags">Ignoruoti „MediaStore“ žymas</string>
<string name="set_quality_tags_desc">Padidėja žymų kokybė, tačiau dėl to pailgėja krovimo laikas (Eksperimentinis)</string>
<string name="set_observing">Automatinis krovimas</string>
<string name="err_no_music">Muzikos nerasta</string>
</resources>

View file

@ -170,8 +170,6 @@
<string name="set_playback_mode_none">Afspelen vanaf getoond item</string>
<string name="set_restore_state">Afspeelstatus herstellen</string>
<string name="set_restore_desc">Herstel de eerder opgeslagen afspeelstatus (indien aanwezig)</string>
<string name="set_quality_tags">MediaStore tags negeren</string>
<string name="set_quality_tags_desc">Verhoogt tag-kwaliteit, maar vereist langere laadtijden</string>
<string name="err_did_not_restore">Geen staat kan hersteld worden</string>
<string name="desc_clear_queue_item">Verwijder dit wachtrij liedje</string>
<string name="desc_queue_handle">Verplaats dit wachtrij liedje</string>

View file

@ -193,9 +193,7 @@
<string name="set_lib_tabs">Abas da biblioteca</string>
<string name="lbl_genre">Gênero</string>
<string name="set_playback_mode_artist">Reproduzir do artista</string>
<string name="set_quality_tags_desc">Aumenta a qualidade da tag, mas resulta em tempos de carregamento mais longos (Experimental)</string>
<string name="set_restore_desc">Restaurar o estado de reprodução salvo anteriormente (se houver)</string>
<string name="set_quality_tags">Ignorar tags do MediaStore</string>
<string name="set_pre_amp_with">Ajuste com tags</string>
<string name="lbl_state_wiped">Estado liberado</string>
<string name="lbl_state_restored">Estado restaurado</string>

View file

@ -215,10 +215,8 @@
<string name="set_observing_desc">Перезагружать библиотеку при каждом изменении (экспериментально)</string>
<string name="fmt_db_neg">-%.1f дБ</string>
<string name="fmt_lib_genre_count">Жанров загружено: %d</string>
<string name="set_quality_tags">Игнорировать теги MediaStore</string>
<string name="set_restore_desc">Восстановить предыдущее состояние воспроизведения (если есть)</string>
<string name="set_dirs_mode">Режим</string>
<string name="set_quality_tags_desc">Улучшает качество тегов, но приводит к долгому времени загрузки (экспериментально)</string>
<string name="set_dirs_mode_include_desc">Музыка будет загружена <b>только</b> из указанных папок.</string>
<string name="err_did_not_restore">Невозможно восстановить состояние воспроизведения</string>
<string name="def_track">Нет номера трека</string>

View file

@ -193,8 +193,6 @@
<string name="set_restore_desc">Önceden kaydedilmiş oynatma durumunu geri getirir (varsa)</string>
<string name="set_round_mode">Yuvarlak mod</string>
<string name="set_restore_state">Oynatma durumunu eski haline getir</string>
<string name="set_quality_tags">MediaStore etiketlerini yoksay</string>
<string name="set_quality_tags_desc">Etiket kalitesini artırır, ancak daha uzun yükleme süreleri gerektirir</string>
<string name="err_did_not_restore">Hiçbir durum geri getirelemedi</string>
<string name="set_repeat_pause">Tekrarda duraklat</string>
<string name="lbl_indexing">Müzik yükleniyor</string>

View file

@ -200,7 +200,6 @@
<string name="err_did_not_restore">没有可以恢复的状态</string>
<string name="set_restore_state">恢复播放状态</string>
<string name="set_restore_desc">恢复此前保存的播放状态(如果有)</string>
<string name="set_quality_tags_desc">提高标签质量,但会导致加载时间变长(实验性)</string>
<string name="set_observing">自动重载中</string>
<string name="set_observing_desc">发生更改时自动重新加载您的曲库(实验性)</string>
<string name="desc_queue_bar">打开队列</string>
@ -209,7 +208,6 @@
<string name="lng_observing">正在监测您的曲库以查找更改…</string>
<string name="lbl_state_wiped">已清除状态</string>
<string name="lbl_state_restored">已恢复状态</string>
<string name="set_quality_tags">忽略 MediaStore 标签</string>
<string name="lbl_eps">EP 专辑</string>
<string name="lbl_ep">EP 专辑</string>
<string name="lbl_singles">单曲</string>

View file

@ -34,7 +34,6 @@
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
<string name="set_key_separators" translatable="false">auxio_separators</string>
<string name="set_key_observing" translatable="false">auxio_observing</string>
<string name="set_key_quality_tags" translatable="false">auxio_quality_tags</string>
<string name="set_key_search_filter" translatable="false">KEY_SEARCH_FILTER</string>

View file

@ -224,8 +224,6 @@
<!-- Restrict music loading to selected folders -->
<string name="set_dirs_mode_include">Include</string>
<string name="set_dirs_mode_include_desc">Music will <b>only</b> be loaded from the folders you add.</string>
<string name="set_quality_tags">Ignore MediaStore tags</string>
<string name="set_quality_tags_desc">Increases tag quality, but results in longer loading times (Experimental)</string>
<string name="set_observing">Automatic reloading</string>
<string name="set_observing_desc">Reload your music library whenever it changes (Experimental)</string>

View file

@ -70,15 +70,6 @@
<item name="useLargeIcon">true</item>
</style>
<style name="Widget.Auxio.ItemLayout" parent="">
<item name="android:layout_width">match_parent</item>
<item name="android:layout_height">wrap_content</item>
<item name="android:background">?attr/selectableItemBackground</item>
<item name="android:padding">@dimen/spacing_medium</item>
<item name="android:clickable">true</item>
<item name="android:focusable">true</item>
</style>
<style name="Widget.Auxio.RecyclerView.Linear" parent="">
<item name="layoutManager">androidx.recyclerview.widget.LinearLayoutManager</item>
</style>

View file

@ -124,10 +124,6 @@
app:summary="@string/set_repeat_pause_desc"
app:title="@string/set_repeat_pause" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/set_content">
<Preference
app:key="@string/set_key_save_state"
app:summary="@string/set_save_desc"
@ -143,6 +139,10 @@
app:summary="@string/set_restore_desc"
app:title="@string/set_restore_state" />
</PreferenceCategory>
<PreferenceCategory app:title="@string/set_content">
<Preference
app:key="@string/set_key_reindex"
app:summary="@string/set_reindex_desc"
@ -153,12 +153,6 @@
app:summary="@string/set_dirs_desc"
app:title="@string/set_dirs" />
<SwitchPreferenceCompat
app:defaultValue="false"
app:key="@string/set_key_quality_tags"
app:summary="@string/set_quality_tags_desc"
app:title="@string/set_quality_tags" />
<SwitchPreferenceCompat
app:defaultValue="false"
app:key="@string/set_key_observing"