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:
parent
2e71342e1c
commit
28d28287fe
35 changed files with 239 additions and 336 deletions
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 ->
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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) {
|
|
@ -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
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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].
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue