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 audio focus was lost
- Fixed issue where the app would crash if a song menu in the genre UI was opened - 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 #### Dev/Meta
- Completed migration to reactive playback system - Completed migration to reactive playback system
- Refactor music backends into a unified chain of extractors
## 2.6.3 ## 2.6.3

View file

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

View file

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

View file

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

View file

@ -23,7 +23,6 @@ import android.content.Context
import android.os.Parcelable import android.os.Parcelable
import java.security.MessageDigest import java.security.MessageDigest
import java.util.UUID import java.util.UUID
import kotlin.experimental.and
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.reflect.KClass import kotlin.reflect.KClass
@ -143,7 +142,7 @@ sealed class MusicParent : Music() {
* A song. * A song.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class Song constructor(private val raw: Raw) : Music() { class Song constructor(raw: Raw) : Music() {
override val uid: UID override val uid: UID
override val rawName = requireNotNull(raw.name) { "Invalid raw: No title" } 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 formatMimeType: String? = null,
var size: Long? = null, var size: Long? = null,
var dateAdded: Long? = null, var dateAdded: Long? = null,
var dateModified: Long? = null,
var durationMs: Long? = null, var durationMs: Long? = null,
var track: Int? = null, var track: Int? = null,
var disc: Int? = null, var disc: Int? = null,
@ -473,7 +473,7 @@ fun MessageDigest.update(date: Date?) {
update(date.toString().toByteArray()) 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 * 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.net.Uri
import android.provider.MediaStore import android.provider.MediaStore
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import java.util.UUID import java.util.UUID

View file

@ -18,11 +18,7 @@
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

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 @@
/* package org.oxycblt.auxio.music.extractor
* 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
import android.content.Context import android.content.Context
import android.database.Cursor import android.database.Cursor
@ -26,12 +9,18 @@ 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 java.io.File import org.oxycblt.auxio.music.Date
import org.oxycblt.auxio.music.* 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.settings.Settings
import org.oxycblt.auxio.util.contentResolverSafe import org.oxycblt.auxio.util.contentResolverSafe
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 java.io.File
/* /*
* This file acts as the base for most the black magic required to get a remotely sensible music * This file acts as the base for most the black magic required to get a remotely sensible music
@ -91,20 +80,21 @@ import org.oxycblt.auxio.util.logD
* I wish I was born in the neolithic. * 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 * The layer that loads music from the MediaStore database. This is an intermediate step in
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead. * the music loading process.
* @author OxygenCobalt * @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 idIndex = -1
private var titleIndex = -1 private var titleIndex = -1
private var displayNameIndex = -1 private var displayNameIndex = -1
private var mimeTypeIndex = -1 private var mimeTypeIndex = -1
private var sizeIndex = -1 private var sizeIndex = -1
private var dateAddedIndex = -1 private var dateAddedIndex = -1
private var dateModifiedIndex = -1
private var durationIndex = -1 private var durationIndex = -1
private var yearIndex = -1 private var yearIndex = -1
private var albumIndex = -1 private var albumIndex = -1
@ -113,11 +103,18 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
private var albumArtistIndex = -1 private var albumArtistIndex = -1
private val settings = Settings(context) 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) val storageManager = context.getSystemServiceCompat(StorageManager::class)
volumes.addAll(storageManager.storageVolumesCompat) _volumes.addAll(storageManager.storageVolumesCompat)
val dirs = settings.getMusicDirs(storageManager) val dirs = settings.getMusicDirs(storageManager)
val args = mutableListOf<String>() 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]") logD("Starting query [proj: ${projection.toList()}, selector: $selector, args: $args]")
return requireNotNull( val cursor = requireNotNull(
context.contentResolverSafe.queryCursor( context.contentResolverSafe.queryCursor(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection, projection,
selector, selector,
args.toTypedArray())) { "Content resolver failure: No Cursor returned" } 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, * Finalize this instance by closing the cursor and finalizing the cache.
emitIndexing: (Indexer.Indexing) -> Unit */
): List<Song> { fun finalize(rawSongs: List<Song.Raw>) {
val rawSongs = mutableListOf<Song.Raw>() cursor?.close()
while (cursor.moveToNext()) { cursor = null
rawSongs.add(buildRawSong(context, cursor))
if (cursor.position % 50 == 0) { cacheLayer.finalize(rawSongs)
// 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)
}
} }
// 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 * Populate a [raw] with whatever the next value in the cursor is.
// every genre and assign it's name to raw songs that match it's child IDs. *
context.contentResolverSafe.useQuery( * This returns true if the song could be restored from cache, false if metadata had to be
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, * re-extracted, and null if the cursor is exhausted.
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor -> */
val idIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID) fun populateRaw(raw: Song.Raw): Boolean? {
val nameIndex = genreCursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME) val cursor = requireNotNull(cursor) { "MediaStoreLayer is not properly initialized" }
if (!cursor.moveToNext()) {
while (genreCursor.moveToNext()) { logD("Cursor is exhausted")
// Genre names could theoretically be anything, including null for some reason. return null
// 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 // Populate the minimum required fields to maybe obtain a cache entry.
// the genre has <50 songs. raw.mediaStoreId = cursor.getLong(idIndex)
emitIndexing(Indexer.Indexing.Indeterminate) raw.dateAdded = cursor.getLong(dateAddedIndex)
raw.dateModified = cursor.getLong(dateAddedIndex)
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.MIME_TYPE,
MediaStore.Audio.AudioColumns.SIZE, MediaStore.Audio.AudioColumns.SIZE,
MediaStore.Audio.AudioColumns.DATE_ADDED, MediaStore.Audio.AudioColumns.DATE_ADDED,
MediaStore.Audio.AudioColumns.DATE_MODIFIED,
MediaStore.Audio.AudioColumns.DURATION, MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.YEAR, MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM, 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 * obtain an upstream [Song.Raw] first, and then populate it with version-specific fields
* outlined in [projection]. * outlined in [projection].
*/ */
open fun buildRawSong(context: Context, cursor: Cursor): Song.Raw { protected open fun buildRaw(cursor: Cursor, raw: 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)
raw.name = cursor.getString(titleIndex) raw.name = cursor.getString(titleIndex)
raw.extensionMimeType = cursor.getString(mimeTypeIndex) raw.extensionMimeType = cursor.getString(mimeTypeIndex)
raw.size = cursor.getLong(sizeIndex) raw.size = cursor.getLong(sizeIndex)
raw.dateAdded = cursor.getLong(dateAddedIndex)
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name // Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
// from the android system. // 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 // 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 // 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. While we can't natively parse multi-value tags, // it's easier to handle later.
// from MediaStore itself, we can still parse by user-defined separators.
raw.artistNames = raw.artistNames =
cursor.getString(artistIndex).run { cursor.getString(artistIndex).run {
if (this != MediaStore.UNKNOWN_STRING) { 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) maybeParseSeparators(settings)
} else { } else {
null 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. // The album artist field is nullable and never has placeholder values.
raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.maybeParseSeparators(settings) raw.albumArtistNames = cursor.getStringOrNull(albumArtistIndex)?.maybeParseSeparators(settings)
return raw
} }
companion object { companion object {
@ -321,12 +294,6 @@ abstract class MediaStoreBackend(private val context: Context) : Indexer.Backend
@Suppress("InlinedApi") @Suppress("InlinedApi")
private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST 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 * The base selector that works across all versions of android. Does not exclude
* directories. * 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 // 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. // 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 * @author OxygenCobalt
*/ */
class Api21MediaStoreBackend(context: Context) : MediaStoreBackend(context) { class Api21MediaStoreLayer(context: Context, cacheLayer: CacheLayer) :
MediaStoreLayer(context, cacheLayer) {
private var trackIndex = -1 private var trackIndex = -1
private var dataIndex = -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> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
@ -361,15 +337,10 @@ class Api21MediaStoreBackend(context: Context) : MediaStoreBackend(context) {
return true return true
} }
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw { override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
val raw = super.buildRawSong(context, cursor) super.buildRaw(cursor, raw)
// 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)
}
// DATA is equivalent to the absolute path of the file.
val data = cursor.getString(dataIndex) val data = cursor.getString(dataIndex)
// On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume // 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.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = 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. * available from API 29 onwards.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @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 volumeIndex = -1
private var relativePathIndex = -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> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
@ -431,14 +410,8 @@ open class BaseApi29MediaStoreBackend(context: Context) : MediaStoreBackend(cont
return true return true
} }
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw { override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
val raw = super.buildRawSong(context, cursor) super.buildRaw(cursor, raw)
if (volumeIndex == -1) {
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
relativePathIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
}
val volumeName = cursor.getString(volumeIndex) val volumeName = cursor.getString(volumeIndex)
val relativePath = cursor.getString(relativePathIndex) val relativePath = cursor.getString(relativePathIndex)
@ -449,29 +422,29 @@ open class BaseApi29MediaStoreBackend(context: Context) : MediaStoreBackend(cont
if (volume != null) { if (volume != null) {
raw.directory = Directory.from(volume, relativePath) 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. * API 29.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @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 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> override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw { override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
val raw = super.buildRawSong(context, cursor) super.buildRaw(cursor, raw)
if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
}
// This backend is volume-aware, but does not support the modern track fields. // This backend is volume-aware, but does not support the modern track fields.
// Use the old field instead. // Use the old field instead.
@ -480,21 +453,26 @@ open class Api29MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend
rawTrack.unpackTrackNo()?.let { raw.track = it } rawTrack.unpackTrackNo()?.let { raw.track = it }
rawTrack.unpackDiscNo()?.let { raw.disc = 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. * API 30.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.R) @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 trackIndex: Int = -1
private var discIndex: 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> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
@ -502,14 +480,8 @@ class Api30MediaStoreBackend(context: Context) : BaseApi29MediaStoreBackend(cont
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
MediaStore.Audio.AudioColumns.DISC_NUMBER) MediaStore.Audio.AudioColumns.DISC_NUMBER)
override fun buildRawSong(context: Context, cursor: Cursor): Song.Raw { override fun buildRaw(cursor: Cursor, raw: Song.Raw) {
val raw = super.buildRawSong(context, cursor) super.buildRaw(cursor, raw)
// 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)
}
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in // 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 // 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. // total, as we have no use for it.
cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it } cursor.getStringOrNull(trackIndex)?.parsePositionNum()?.let { raw.track = it }
cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it } cursor.getStringOrNull(discIndex)?.parsePositionNum()?.let { raw.disc = it }
return raw
} }
} }

View file

@ -1,39 +1,22 @@
/* package org.oxycblt.auxio.music.extractor
* 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
import android.content.Context import android.content.Context
import android.database.Cursor
import androidx.core.text.isDigitsOnly import androidx.core.text.isDigitsOnly
import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MediaItem
import com.google.android.exoplayer2.MetadataRetriever import com.google.android.exoplayer2.MetadataRetriever
import com.google.android.exoplayer2.metadata.Metadata
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame 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.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.audioUri import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.settings.Settings import org.oxycblt.auxio.settings.Settings
import org.oxycblt.auxio.util.logD 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 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 * 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, * 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 * @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 settings = Settings(context)
private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY) private val taskPool: Array<Task?> = arrayOfNulls(TASK_CAPACITY)
// No need to implement our own query logic, as this backend is still reliant on /**
// MediaStore. * Initialize the sub-layers that this layer relies on.
override fun query() = inner.query() */
fun init() = mediaStoreLayer.init().count
override fun buildSongs( /**
cursor: Cursor, * Finalize the sub-layers that this layer relies on.
emitIndexing: (Indexer.Indexing) -> Unit */
): List<Song> { fun finalize(rawSongs: List<Song.Raw>) = mediaStoreLayer.finalize(rawSongs)
// 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
while (cursor.moveToNext()) { fun parse(emit: (Song.Raw) -> Unit) {
// Note: This call to buildRawSong does not populate the genre field. This is while (true) {
// because indexing genres is quite slow with MediaStore, and so keeping the val raw = Song.Raw()
// field blank on unsupported ExoPlayer formats ends up being preferable. if (mediaStoreLayer.populateRaw(raw) ?: break) {
val raw = inner.buildRawSong(context, cursor) // 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 // 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 // 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] val task = taskPool[i]
if (task != null) { if (task != null) {
val song = task.get() val finishedRaw = task.get()
if (song != null) { if (finishedRaw != null) {
songs.add(song) emit(finishedRaw)
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
taskPool[i] = Task(context, settings, raw) taskPool[i] = Task(context, settings, raw)
break@spin break@spin
} }
@ -99,19 +80,17 @@ class ExoPlayerBackend(private val context: Context, private val inner: MediaSto
val task = taskPool[i] val task = taskPool[i]
if (task != null) { if (task != null) {
val song = task.get() ?: continue@spin val finishedRaw = task.get() ?: continue@spin
songs.add(song) emit(finishedRaw)
emitIndexing(Indexer.Indexing.Songs(songs.size, total))
taskPool[i] = null taskPool[i] = null
} }
} }
break break
} }
return songs
} }
companion object { companion object {
/** The amount of tasks this backend can run efficiently at once. */ /** The amount of tasks this backend can run efficiently at once. */
private const val TASK_CAPACITY = 8 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 * 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. * return null. Otherwise, it will return a song.
*/ */
fun get(): Song? { fun get(): Song.Raw? {
if (!future.isDone) { if (!future.isDone) {
return null return null
} }
@ -148,7 +127,7 @@ class Task(context: Context, private val settings: Settings, private val raw: So
if (format == null) { if (format == null) {
logD("Nothing could be extracted for ${raw.name}") logD("Nothing could be extracted for ${raw.name}")
return Song(raw) return raw
} }
// Populate the format mime type if we have one. // 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}") logD("No metadata could be extracted for ${raw.name}")
} }
return Song(raw) return raw
} }
private fun completeRawSong(metadata: Metadata) { 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 androidx.core.text.isDigitsOnly
import org.oxycblt.auxio.music.Date import org.oxycblt.auxio.music.Date

View file

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

View file

@ -54,6 +54,7 @@ class IndexingNotification(private val context: Context) :
} }
is Indexer.Indexing.Songs -> { is Indexer.Indexing.Songs -> {
// Only update the notification every 50 songs to prevent excessive updates. // 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) { if (indexing.current % 50 == 0) {
logD("Updating state to $indexing") logD("Updating state to $indexing")
setContentText( setContentText(

View file

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

View file

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

View file

@ -479,7 +479,6 @@ class PlaybackService :
} }
companion object { companion object {
private const val POS_POLL_INTERVAL = 100L
private const val REWIND_THRESHOLD = 3000L private const val REWIND_THRESHOLD = 3000L
const val ACTION_INC_REPEAT_MODE = BuildConfig.APPLICATION_ID + ".action.LOOP" 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 val pauseOnRepeat: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_repeat_pause), false) 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. */ /** Whether to be actively watching for changes in the music library. */
val shouldBeObserving: Boolean val shouldBeObserving: Boolean
get() = inner.getBoolean(context.getString(R.string.set_key_observing), false) 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 * 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]. * situations that would be inefficient are ruled out with [replaceList].
*/ */

View file

@ -18,13 +18,11 @@
package org.oxycblt.auxio.util package org.oxycblt.auxio.util
import android.os.Looper import android.os.Looper
import android.text.format.DateUtils
import java.lang.reflect.Field import java.lang.reflect.Field
import java.lang.reflect.Method 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() {

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_bitrate">Přenosová rychlost</string>
<string name="lbl_sample_rate">Vzorkovací frekvence</string> <string name="lbl_sample_rate">Vzorkovací frekvence</string>
<string name="set_dirs">Hudební složky</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="set_restore_state">Obnovit stav přehrávání</string>
<string name="lbl_state_restored">Stav obnoven</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> <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_format">Format</string>
<string name="lbl_size">Größe</string> <string name="lbl_size">Größe</string>
<string name="lbl_bitrate">Bitrate</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_observing">Überwachen der Musikbibliothek</string>
<string name="lbl_indexing">Musik wird geladen</string> <string name="lbl_indexing">Musik wird geladen</string>
<string name="lng_observing">Musikbibliothek wird auf Änderungen überwacht…</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_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_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_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_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_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="def_codec">Formato desconocido</string>
<string name="clr_dynamic">Dinámico</string> <string name="clr_dynamic">Dinámico</string>
<string name="fmt_disc_no">Disco %d</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_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">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_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">Automatsko ponovno učitavanje</string>
<string name="set_observing_desc">Ponovo učitaj svoju zbirku glazbe čim se dogode promjene (eksperimentalno)</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> <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_state_restored">Stato ripristinato</string>
<string name="lbl_sort_date_added">Data aggiunta</string> <string name="lbl_sort_date_added">Data aggiunta</string>
<string name="set_observing">Ricaricamento automatico</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_state">Ripristina stato riproduzione</string>
<string name="set_restore_desc">Ripristina lo stato di riproduzione precedentemente salvato (se disponibile)</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> <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="lng_search_library">Ieškokite savo bibliotekoje…</string>
<string name="lbl_equalizer">Ekvalaizeris</string> <string name="lbl_equalizer">Ekvalaizeris</string>
<string name="set_dirs_mode">Režimas</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="set_observing">Automatinis krovimas</string>
<string name="err_no_music">Muzikos nerasta</string> <string name="err_no_music">Muzikos nerasta</string>
</resources> </resources>

View file

@ -170,8 +170,6 @@
<string name="set_playback_mode_none">Afspelen vanaf getoond item</string> <string name="set_playback_mode_none">Afspelen vanaf getoond item</string>
<string name="set_restore_state">Afspeelstatus herstellen</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_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="err_did_not_restore">Geen staat kan hersteld worden</string>
<string name="desc_clear_queue_item">Verwijder dit wachtrij liedje</string> <string name="desc_clear_queue_item">Verwijder dit wachtrij liedje</string>
<string name="desc_queue_handle">Verplaats 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="set_lib_tabs">Abas da biblioteca</string>
<string name="lbl_genre">Gênero</string> <string name="lbl_genre">Gênero</string>
<string name="set_playback_mode_artist">Reproduzir do artista</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_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="set_pre_amp_with">Ajuste com tags</string>
<string name="lbl_state_wiped">Estado liberado</string> <string name="lbl_state_wiped">Estado liberado</string>
<string name="lbl_state_restored">Estado restaurado</string> <string name="lbl_state_restored">Estado restaurado</string>

View file

@ -215,10 +215,8 @@
<string name="set_observing_desc">Перезагружать библиотеку при каждом изменении (экспериментально)</string> <string name="set_observing_desc">Перезагружать библиотеку при каждом изменении (экспериментально)</string>
<string name="fmt_db_neg">-%.1f дБ</string> <string name="fmt_db_neg">-%.1f дБ</string>
<string name="fmt_lib_genre_count">Жанров загружено: %d</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_restore_desc">Восстановить предыдущее состояние воспроизведения (если есть)</string>
<string name="set_dirs_mode">Режим</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="set_dirs_mode_include_desc">Музыка будет загружена <b>только</b> из указанных папок.</string>
<string name="err_did_not_restore">Невозможно восстановить состояние воспроизведения</string> <string name="err_did_not_restore">Невозможно восстановить состояние воспроизведения</string>
<string name="def_track">Нет номера трека</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_restore_desc">Önceden kaydedilmiş oynatma durumunu geri getirir (varsa)</string>
<string name="set_round_mode">Yuvarlak mod</string> <string name="set_round_mode">Yuvarlak mod</string>
<string name="set_restore_state">Oynatma durumunu eski haline getir</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="err_did_not_restore">Hiçbir durum geri getirelemedi</string>
<string name="set_repeat_pause">Tekrarda duraklat</string> <string name="set_repeat_pause">Tekrarda duraklat</string>
<string name="lbl_indexing">Müzik yükleniyor</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="err_did_not_restore">没有可以恢复的状态</string>
<string name="set_restore_state">恢复播放状态</string> <string name="set_restore_state">恢复播放状态</string>
<string name="set_restore_desc">恢复此前保存的播放状态(如果有)</string> <string name="set_restore_desc">恢复此前保存的播放状态(如果有)</string>
<string name="set_quality_tags_desc">提高标签质量,但会导致加载时间变长(实验性)</string>
<string name="set_observing">自动重载中</string> <string name="set_observing">自动重载中</string>
<string name="set_observing_desc">发生更改时自动重新加载您的曲库(实验性)</string> <string name="set_observing_desc">发生更改时自动重新加载您的曲库(实验性)</string>
<string name="desc_queue_bar">打开队列</string> <string name="desc_queue_bar">打开队列</string>
@ -209,7 +208,6 @@
<string name="lng_observing">正在监测您的曲库以查找更改…</string> <string name="lng_observing">正在监测您的曲库以查找更改…</string>
<string name="lbl_state_wiped">已清除状态</string> <string name="lbl_state_wiped">已清除状态</string>
<string name="lbl_state_restored">已恢复状态</string> <string name="lbl_state_restored">已恢复状态</string>
<string name="set_quality_tags">忽略 MediaStore 标签</string>
<string name="lbl_eps">EP 专辑</string> <string name="lbl_eps">EP 专辑</string>
<string name="lbl_ep">EP 专辑</string> <string name="lbl_ep">EP 专辑</string>
<string name="lbl_singles">单曲</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_music_dirs_include" translatable="false">auxio_include_dirs</string>
<string name="set_key_separators" translatable="false">auxio_separators</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_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> <string name="set_key_search_filter" translatable="false">KEY_SEARCH_FILTER</string>

View file

@ -224,8 +224,6 @@
<!-- Restrict music loading to selected folders --> <!-- Restrict music loading to selected folders -->
<string name="set_dirs_mode_include">Include</string> <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_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">Automatic reloading</string>
<string name="set_observing_desc">Reload your music library whenever it changes (Experimental)</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> <item name="useLargeIcon">true</item>
</style> </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=""> <style name="Widget.Auxio.RecyclerView.Linear" parent="">
<item name="layoutManager">androidx.recyclerview.widget.LinearLayoutManager</item> <item name="layoutManager">androidx.recyclerview.widget.LinearLayoutManager</item>
</style> </style>

View file

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