music: refactor metadata extractor
Refactor the internal tag management portion of MetadataExtractor into a new "Tags" object that can now be re-used in the ReplayGain system. This also does a minor rework to the ReplayGain object to make it totally self-sufficient.
This commit is contained in:
parent
7721e64096
commit
1f5594fb33
21 changed files with 292 additions and 271 deletions
|
@ -10,6 +10,9 @@
|
|||
- Value lists are now properly localized
|
||||
- Queue no longer primarily shows previous songs when opened
|
||||
|
||||
#### What's Changed
|
||||
- R128 Gain tags are now only used when playing OPUS files
|
||||
|
||||
#### What's Fixed
|
||||
- Fixed mangled multi-value ID3v2 tags when UTF-16 is used
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.auxio.detail
|
|||
import androidx.annotation.StringRes
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
import org.oxycblt.auxio.music.filesystem.MimeType
|
||||
|
||||
/**
|
||||
* A header variation that displays a button to open a sort menu.
|
||||
|
|
|
@ -36,8 +36,8 @@ import org.oxycblt.auxio.util.getInteger
|
|||
import org.oxycblt.auxio.util.lazyReflectedField
|
||||
|
||||
/**
|
||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling view goes
|
||||
* beyond it's first item.
|
||||
* An [CoordinatorAppBarLayout] that displays the title of a hidden [Toolbar] when the scrolling
|
||||
* view goes beyond it's first item.
|
||||
*
|
||||
* This is intended for the detail views, in which the first item is the album/artist/genre header,
|
||||
* and thus scrolling past them should make the toolbar show the name in order to give context on
|
||||
|
|
|
@ -39,7 +39,7 @@ import org.oxycblt.auxio.music.Music
|
|||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.storage.MimeType
|
||||
import org.oxycblt.auxio.music.filesystem.MimeType
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.*
|
||||
|
||||
|
|
|
@ -30,9 +30,9 @@ import kotlinx.parcelize.IgnoredOnParcel
|
|||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.list.Item
|
||||
import org.oxycblt.auxio.music.filesystem.*
|
||||
import org.oxycblt.auxio.music.parsing.parseId3GenreNames
|
||||
import org.oxycblt.auxio.music.parsing.parseMultiValue
|
||||
import org.oxycblt.auxio.music.storage.*
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.nonZeroOrNull
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
|
|
@ -20,8 +20,8 @@ package org.oxycblt.auxio.music
|
|||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.useQuery
|
||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||
|
||||
/**
|
||||
* A repository granting access to the music library..
|
||||
|
|
|
@ -24,10 +24,8 @@ package org.oxycblt.auxio.music.extractor
|
|||
enum class ExtractionResult {
|
||||
/** A raw song was successfully extracted from the cache. */
|
||||
CACHED,
|
||||
|
||||
/** A raw song was successfully extracted from parsing it's file. */
|
||||
PARSED,
|
||||
|
||||
/** A raw song could not be parsed. */
|
||||
NONE
|
||||
}
|
||||
|
|
|
@ -26,17 +26,17 @@ import android.provider.MediaStore
|
|||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.filesystem.Directory
|
||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.filesystem.directoryCompat
|
||||
import org.oxycblt.auxio.music.filesystem.mediaStoreVolumeNameCompat
|
||||
import org.oxycblt.auxio.music.filesystem.safeQuery
|
||||
import org.oxycblt.auxio.music.filesystem.storageVolumesCompat
|
||||
import org.oxycblt.auxio.music.filesystem.useQuery
|
||||
import org.oxycblt.auxio.music.parsing.parseId3v2Position
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.storage.directoryCompat
|
||||
import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat
|
||||
import org.oxycblt.auxio.music.storage.safeQuery
|
||||
import org.oxycblt.auxio.music.storage.storageVolumesCompat
|
||||
import org.oxycblt.auxio.music.storage.useQuery
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
|
|
|
@ -21,14 +21,10 @@ import android.content.Context
|
|||
import androidx.core.text.isDigitsOnly
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MetadataRetriever
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import org.oxycblt.auxio.music.Date
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.parsing.correctWhitespace
|
||||
import org.oxycblt.auxio.music.filesystem.toAudioUri
|
||||
import org.oxycblt.auxio.music.parsing.parseId3v2Position
|
||||
import org.oxycblt.auxio.music.storage.toAudioUri
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
|
@ -164,7 +160,9 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
|
||||
val metadata = format.metadata
|
||||
if (metadata != null) {
|
||||
populateWithMetadata(metadata)
|
||||
val tags = Tags(metadata)
|
||||
populateWithId3v2(tags.id3v2)
|
||||
populateWithVorbis(tags.vorbis)
|
||||
} else {
|
||||
logD("No metadata could be extracted for ${raw.name}")
|
||||
}
|
||||
|
@ -172,51 +170,6 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with the newly extracted [Metadata].
|
||||
* @param metadata The [Metadata] to complete the [Song.Raw] with.
|
||||
*/
|
||||
private fun populateWithMetadata(metadata: Metadata) {
|
||||
val id3v2Tags = mutableMapOf<String, List<String>>()
|
||||
val vorbisTags = mutableMapOf<String, MutableList<String>>()
|
||||
|
||||
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
|
||||
// of audio formats. Load both of these types of tags into separate maps, letting the
|
||||
// "source of truth" be the last of a particular tag in a file.
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val tag = metadata[i]) {
|
||||
is TextInformationFrame -> {
|
||||
// Map TXXX frames differently so we can specifically index by their
|
||||
// descriptions.
|
||||
val id = tag.description?.let { "TXXX:${it.sanitize()}" } ?: tag.id.sanitize()
|
||||
val values = tag.values.map { it.sanitize() }.correctWhitespace()
|
||||
if (values.isNotEmpty()) {
|
||||
id3v2Tags[id] = values
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||
val id = tag.key.sanitize().uppercase()
|
||||
val value = tag.value.sanitize().correctWhitespace()
|
||||
if (value != null) {
|
||||
vorbisTags.getOrPut(id) { mutableListOf() }.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when {
|
||||
vorbisTags.isEmpty() -> populateWithId3v2(id3v2Tags)
|
||||
id3v2Tags.isEmpty() -> populateWithVorbis(vorbisTags)
|
||||
else -> {
|
||||
// Some formats (like FLAC) can contain both ID3v2 and Vorbis, so we apply
|
||||
// them both with priority given to vorbis.
|
||||
populateWithId3v2(id3v2Tags)
|
||||
populateWithVorbis(vorbisTags)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete this instance's [Song.Raw] with ID3v2 Text Identification Frames.
|
||||
* @param textFrames A mapping between ID3v2 Text Identification Frame IDs and one or more
|
||||
|
@ -224,15 +177,15 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
*/
|
||||
private fun populateWithId3v2(textFrames: Map<String, List<String>>) {
|
||||
// Song
|
||||
textFrames["TXXX:MusicBrainz Release Track Id"]?.let { raw.musicBrainzId = it[0] }
|
||||
textFrames["TXXX:musicbrainz release track id"]?.let { raw.musicBrainzId = it[0] }
|
||||
textFrames["TIT2"]?.let { raw.name = it[0] }
|
||||
textFrames["TSOT"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track. Only parse out the track number and ignore the total tracks value.
|
||||
textFrames["TRCK"]?.run { get(0).parseId3v2Position() }?.let { raw.track = it }
|
||||
textFrames["TRCK"]?.run { first().parseId3v2Position() }?.let { raw.track = it }
|
||||
|
||||
// Disc. Only parse out the disc number and ignore the total discs value.
|
||||
textFrames["TPOS"]?.run { get(0).parseId3v2Position() }?.let { raw.disc = it }
|
||||
textFrames["TPOS"]?.run { first().parseId3v2Position() }?.let { raw.disc = it }
|
||||
|
||||
// Dates are somewhat complicated, as not only did their semantics change from a flat year
|
||||
// value in ID3v2.3 to a full ISO-8601 date in ID3v2.4, but there are also a variety of
|
||||
|
@ -243,27 +196,27 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
// 3. ID3v2.4 Release Date, as it is the second most common date type
|
||||
// 4. ID3v2.3 Original Date, as it is like #1
|
||||
// 5. ID3v2.3 Release Year, as it is the most common date type
|
||||
(textFrames["TDOR"]?.run { Date.from(get(0)) }
|
||||
?: textFrames["TDRC"]?.run { Date.from(get(0)) }
|
||||
?: textFrames["TDRL"]?.run { Date.from(get(0)) }
|
||||
(textFrames["TDOR"]?.run { Date.from(first()) }
|
||||
?: textFrames["TDRC"]?.run { Date.from(first()) }
|
||||
?: textFrames["TDRL"]?.run { Date.from(first()) }
|
||||
?: parseId3v23Date(textFrames))
|
||||
?.let { raw.date = it }
|
||||
|
||||
// Album
|
||||
textFrames["TXXX:MusicBrainz Album Id"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
textFrames["TXXX:musicbrainz album id"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
textFrames["TALB"]?.let { raw.albumName = it[0] }
|
||||
textFrames["TSOA"]?.let { raw.albumSortName = it[0] }
|
||||
(textFrames["TXXX:MusicBrainz Album Type"] ?: textFrames["GRP1"])?.let {
|
||||
(textFrames["TXXX:musicbrainz album type"] ?: textFrames["GRP1"])?.let {
|
||||
raw.albumTypes = it
|
||||
}
|
||||
|
||||
// Artist
|
||||
textFrames["TXXX:MusicBrainz Artist Id"]?.let { raw.artistMusicBrainzIds = it }
|
||||
textFrames["TXXX:musicbrainz artist id"]?.let { raw.artistMusicBrainzIds = it }
|
||||
textFrames["TPE1"]?.let { raw.artistNames = it }
|
||||
textFrames["TSOP"]?.let { raw.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
textFrames["TXXX:MusicBrainz Album Artist Id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
textFrames["TXXX:musicbrainz album artist id"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
textFrames["TPE2"]?.let { raw.albumArtistNames = it }
|
||||
textFrames["TSO2"]?.let { raw.albumArtistSortNames = it }
|
||||
|
||||
|
@ -284,8 +237,8 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
// Assume that TDAT/TIME can refer to TYER or TORY depending on if TORY
|
||||
// is present.
|
||||
val year =
|
||||
textFrames["TORY"]?.run { get(0).toIntOrNull() }
|
||||
?: textFrames["TYER"]?.run { get(0).toIntOrNull() } ?: return null
|
||||
textFrames["TORY"]?.run { first().toIntOrNull() }
|
||||
?: textFrames["TYER"]?.run { first().toIntOrNull() } ?: return null
|
||||
|
||||
val tdat = textFrames["TDAT"]
|
||||
return if (tdat != null && tdat[0].length == 4 && tdat[0].isDigitsOnly()) {
|
||||
|
@ -319,17 +272,17 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
*/
|
||||
private fun populateWithVorbis(comments: Map<String, List<String>>) {
|
||||
// Song
|
||||
comments["MUSICBRAINZ_RELEASETRACKID"]?.let { raw.musicBrainzId = it[0] }
|
||||
comments["TITLE"]?.let { raw.name = it[0] }
|
||||
comments["TITLESORT"]?.let { raw.sortName = it[0] }
|
||||
comments["musicbrainz_releasetrackid"]?.let { raw.musicBrainzId = it[0] }
|
||||
comments["title"]?.let { raw.name = it[0] }
|
||||
comments["titlesort"]?.let { raw.sortName = it[0] }
|
||||
|
||||
// Track. The total tracks value is in a different comment, so we can just
|
||||
// convert the entirety of this comment into a number.
|
||||
comments["TRACKNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.track = it }
|
||||
comments["tracknumber"]?.run { first().toIntOrNull() }?.let { raw.track = it }
|
||||
|
||||
// Disc. The total discs value is in a different comment, so we can just
|
||||
// convert the entirety of this comment into a number.
|
||||
comments["DISCNUMBER"]?.run { get(0).toIntOrNull() }?.let { raw.disc = it }
|
||||
comments["discnumber"]?.run { first().toIntOrNull() }?.let { raw.disc = it }
|
||||
|
||||
// Vorbis dates are less complicated, but there are still several types
|
||||
// Our hierarchy for dates is as such:
|
||||
|
@ -337,35 +290,28 @@ class Task(context: Context, private val raw: Song.Raw) {
|
|||
// 2. Date, as it is the most common date type
|
||||
// 3. Year, as old vorbis tags tended to use this (I know this because it's the only
|
||||
// date tag that android supports, so it must be 15 years old or more!)
|
||||
(comments["ORIGINALDATE"]?.run { Date.from(get(0)) }
|
||||
?: comments["DATE"]?.run { Date.from(get(0)) }
|
||||
?: comments["YEAR"]?.run { get(0).toIntOrNull()?.let(Date::from) })
|
||||
(comments["originaldate"]?.run { Date.from(first()) }
|
||||
?: comments["date"]?.run { Date.from(first()) }
|
||||
?: comments["year"]?.run { first().toIntOrNull()?.let(Date::from) })
|
||||
?.let { raw.date = it }
|
||||
|
||||
// Album
|
||||
comments["MUSICBRAINZ_ALBUMID"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
comments["ALBUM"]?.let { raw.albumName = it[0] }
|
||||
comments["ALBUMSORT"]?.let { raw.albumSortName = it[0] }
|
||||
comments["RELEASETYPE"]?.let { raw.albumTypes = it }
|
||||
comments["musicbrainz_albumid"]?.let { raw.albumMusicBrainzId = it[0] }
|
||||
comments["album"]?.let { raw.albumName = it[0] }
|
||||
comments["albumsort"]?.let { raw.albumSortName = it[0] }
|
||||
comments["releasetype"]?.let { raw.albumTypes = it }
|
||||
|
||||
// Artist
|
||||
comments["MUSICBRAINZ_ARTISTID"]?.let { raw.artistMusicBrainzIds = it }
|
||||
comments["ARTIST"]?.let { raw.artistNames = it }
|
||||
comments["ARTISTSORT"]?.let { raw.artistSortNames = it }
|
||||
comments["musicbrainz_artistid"]?.let { raw.artistMusicBrainzIds = it }
|
||||
comments["artist"]?.let { raw.artistNames = it }
|
||||
comments["artistsort"]?.let { raw.artistSortNames = it }
|
||||
|
||||
// Album artist
|
||||
comments["MUSICBRAINZ_ALBUMARTISTID"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
comments["ALBUMARTIST"]?.let { raw.albumArtistNames = it }
|
||||
comments["ALBUMARTISTSORT"]?.let { raw.albumArtistSortNames = it }
|
||||
comments["musicbrainz_albumartistid"]?.let { raw.albumArtistMusicBrainzIds = it }
|
||||
comments["albumartist"]?.let { raw.albumArtistNames = it }
|
||||
comments["albumartistsort"]?.let { raw.albumArtistSortNames = it }
|
||||
|
||||
// Genre
|
||||
comments["GENRE"]?.let { raw.genreNames = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies and sanitizes a possibly native/non-UTF-8 string.
|
||||
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
|
||||
* the Unicode replacement byte sequence.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
||||
|
|
86
app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
Normal file
86
app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt
Normal file
|
@ -0,0 +1,86 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.extractor
|
||||
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import org.oxycblt.auxio.music.parsing.correctWhitespace
|
||||
|
||||
/**
|
||||
* Processing wrapper for [Metadata] that allows access to more organized metadata.
|
||||
* @param metadata The [Metadata] to wrap.
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class Tags(metadata: Metadata) {
|
||||
private val _id3v2 = mutableMapOf<String, List<String>>()
|
||||
/** The ID3v2 text identification frames found in the file. Can have more than one value. */
|
||||
val id3v2: Map<String, List<String>>
|
||||
get() = _id3v2
|
||||
|
||||
private val _vorbis = mutableMapOf<String, MutableList<String>>()
|
||||
/** The vorbis comments found in the file. Can have more than one value. */
|
||||
val vorbis: Map<String, List<String>>
|
||||
get() = _vorbis
|
||||
|
||||
init {
|
||||
// ExoPlayer only exposes ID3v2 and Vorbis metadata, which constitutes the vast majority
|
||||
// of audio formats. Load both of these types of tags into separate maps, letting the
|
||||
// "source of truth" be the last of a particular tag in a file.
|
||||
for (i in 0 until metadata.length()) {
|
||||
when (val tag = metadata[i]) {
|
||||
is TextInformationFrame -> {
|
||||
// Map TXXX frames differently so we can specifically index by their
|
||||
// descriptions.
|
||||
val id =
|
||||
tag.description?.let { "TXXX:${it.sanitize().lowercase()}" }
|
||||
?: tag.id.sanitize()
|
||||
val values = tag.values.map { it.sanitize() }.correctWhitespace()
|
||||
if (values.isNotEmpty()) {
|
||||
_id3v2[id] = values
|
||||
}
|
||||
}
|
||||
is InternalFrame -> {
|
||||
// Most MP4 metadata atoms map to ID3v2 text frames, except for the ---- atom,
|
||||
// which has it's own frame. Map this to TXXX, it's rough ID3v2 equivalent.
|
||||
val id = "TXXX:${tag.description.sanitize().lowercase()}"
|
||||
val value = tag.text
|
||||
if (value.isNotEmpty()) {
|
||||
_id3v2[id] = listOf(value)
|
||||
}
|
||||
}
|
||||
is VorbisComment -> {
|
||||
// Vorbis comment keys can be in any case, make them uppercase for simplicity.
|
||||
val id = tag.key.sanitize().lowercase()
|
||||
val value = tag.value.sanitize().correctWhitespace()
|
||||
if (value != null) {
|
||||
_vorbis.getOrPut(id) { mutableListOf() }.add(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies and sanitizes a possibly invalid string outputted from ExoPlayer.
|
||||
* @return A new string allocated in a memory-safe manner with any UTF-8 errors replaced with
|
||||
* the Unicode replacement byte sequence.
|
||||
*/
|
||||
private fun String.sanitize() = String(encodeToByteArray())
|
||||
}
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaFormat
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.ContentResolver
|
|
@ -15,7 +15,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.storage
|
||||
package org.oxycblt.auxio.music.filesystem
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
|
@ -31,7 +31,6 @@ import org.oxycblt.auxio.R
|
|||
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.getSystemServiceCompat
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.showToast
|
|
@ -31,7 +31,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull
|
|||
*/
|
||||
fun List<String>.parseMultiValue(settings: Settings) =
|
||||
if (size == 1) {
|
||||
get(0).maybeParseBySeparators(settings)
|
||||
first().maybeParseBySeparators(settings)
|
||||
} else {
|
||||
// Nothing to do.
|
||||
this
|
||||
|
@ -124,7 +124,7 @@ fun String.parseId3v2Position() = split('/', limit = 2)[0].toIntOrNull()?.nonZer
|
|||
*/
|
||||
fun List<String>.parseId3GenreNames(settings: Settings) =
|
||||
if (size == 1) {
|
||||
get(0).parseId3MultiValueGenre(settings)
|
||||
first().parseId3MultiValueGenre(settings)
|
||||
} else {
|
||||
// Nothing to split, just map any ID3v1 genres to their name counterparts.
|
||||
map { it.parseId3v1Genre() ?: it }
|
||||
|
@ -147,8 +147,8 @@ private fun String.parseId3v1Genre(): String? {
|
|||
// try to index the genre table with such.
|
||||
val numeric =
|
||||
toIntOrNull()
|
||||
// Not a numeric value, try some other fixed values.
|
||||
?: return when (this) {
|
||||
// Not a numeric value, try some other fixed values.
|
||||
?: return when (this) {
|
||||
// CR and RX are not technically ID3v1, but are formatted similarly to a plain
|
||||
// number.
|
||||
"CR" -> "Cover"
|
||||
|
|
|
@ -1,3 +1,20 @@
|
|||
/*
|
||||
* Copyright (c) 2023 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.parsing
|
||||
|
||||
/**
|
||||
|
|
|
@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.storage.contentResolverSafe
|
||||
import org.oxycblt.auxio.music.filesystem.contentResolverSafe
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.service.ForegroundManager
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
|
|
|
@ -18,33 +18,38 @@
|
|||
package org.oxycblt.auxio.playback.replaygain
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.google.android.exoplayer2.C
|
||||
import com.google.android.exoplayer2.Format
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.Tracks
|
||||
import com.google.android.exoplayer2.audio.AudioProcessor
|
||||
import com.google.android.exoplayer2.audio.BaseAudioProcessor
|
||||
import com.google.android.exoplayer2.metadata.Metadata
|
||||
import com.google.android.exoplayer2.metadata.id3.InternalFrame
|
||||
import com.google.android.exoplayer2.metadata.id3.TextInformationFrame
|
||||
import com.google.android.exoplayer2.metadata.vorbis.VorbisComment
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.nio.ByteBuffer
|
||||
import kotlin.math.pow
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.Album
|
||||
import org.oxycblt.auxio.music.extractor.Tags
|
||||
import org.oxycblt.auxio.playback.state.PlaybackStateManager
|
||||
import org.oxycblt.auxio.settings.Settings
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.unlikelyToBeNull
|
||||
|
||||
/**
|
||||
* An [AudioProcessor] that handles ReplayGain values and their amplification of the audio stream.
|
||||
* Instead of leveraging the volume attribute like other implementations, this system manipulates
|
||||
* the bitstream itself to modify the volume, which allows the use of positive ReplayGain values.
|
||||
*
|
||||
* Note: This instance must be updated with a new [Metadata] every time the active track chamges.
|
||||
* Note: This audio processor must be attached to a respective [Player] instance as a
|
||||
* [Player.Listener] to function properly.
|
||||
*
|
||||
* @author Alexander Capehart (OxygenCobalt)
|
||||
*/
|
||||
class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
||||
class ReplayGainAudioProcessor(private val context: Context) :
|
||||
BaseAudioProcessor(), Player.Listener, SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
private val playbackManager = PlaybackStateManager.getInstance()
|
||||
private val settings = Settings(context)
|
||||
private var lastFormat: Format? = null
|
||||
|
||||
private var volume = 1f
|
||||
set(value) {
|
||||
|
@ -53,20 +58,69 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
flush()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add this instance to the components required for it to function correctly.
|
||||
* @param player The [Player] to attach to. Should already have this instance as an audio
|
||||
* processor.
|
||||
*/
|
||||
fun addToListeners(player: Player) {
|
||||
player.addListener(this)
|
||||
settings.addListener(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove this instance from the components required for it to function correctly.
|
||||
* @param player The [Player] to detach from. Should already have this instance as an audio
|
||||
* processor.
|
||||
*/
|
||||
fun releaseFromListeners(player: Player) {
|
||||
player.removeListener(this)
|
||||
settings.removeListener(this)
|
||||
}
|
||||
|
||||
// --- OVERRIDES ---
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
super.onTracksChanged(tracks)
|
||||
// Try to find the currently playing track so we can update the ReplayGain adjustment
|
||||
// based on it.
|
||||
for (group in tracks.groups) {
|
||||
if (group.isSelected) {
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSelected(i)) {
|
||||
applyReplayGain(group.getTrackFormat(i))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nothing selected, apply nothing
|
||||
applyReplayGain(null)
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (key == context.getString(R.string.set_key_replay_gain) ||
|
||||
key == context.getString(R.string.set_key_pre_amp_with) ||
|
||||
key == context.getString(R.string.set_key_pre_amp_without)) {
|
||||
// ReplayGain changed, we need to set it up again.
|
||||
applyReplayGain(lastFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// --- REPLAYGAIN PARSING ---
|
||||
|
||||
/**
|
||||
* Updates the volume adjustment based on the given [Metadata].
|
||||
* @param metadata The [Metadata] of the currently playing track, or null if the track has no
|
||||
* [Metadata].
|
||||
* Updates the volume adjustment based on the given [Format].
|
||||
* @param format The [Format] of the currently playing track, or null if nothing is playing.
|
||||
*/
|
||||
fun applyReplayGain(metadata: Metadata?) {
|
||||
// TODO: Allow this to automatically obtain it's own [Metadata].
|
||||
val gain = metadata?.let(::parseReplayGain)
|
||||
private fun applyReplayGain(format: Format?) {
|
||||
lastFormat = format
|
||||
val gain = parseReplayGain(format ?: return)
|
||||
val preAmp = settings.replayGainPreAmp
|
||||
|
||||
val adjust =
|
||||
if (gain != null) {
|
||||
logD("Found ReplayGain adjustment $gain")
|
||||
// ReplayGain is configurable, so determine what to do based off of the mode.
|
||||
val useAlbumGain =
|
||||
when (settings.replayGainMode) {
|
||||
|
@ -109,104 +163,58 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Parse ReplayGain information from the given [Metadata].
|
||||
* @param metadata The [Metadata] to parse.
|
||||
* @return A [Gain] adjustment, or null if there was no adjustments to parse.
|
||||
* Parse ReplayGain information from the given [Format].
|
||||
* @param format The [Format] to parse.
|
||||
* @return A [Adjustment] adjustment, or null if there were no valid adjustments.
|
||||
*/
|
||||
private fun parseReplayGain(metadata: Metadata): Gain? {
|
||||
// TODO: Unify this parser with the music parser? They both grok Metadata.
|
||||
|
||||
private fun parseReplayGain(format: Format): Adjustment? {
|
||||
val tags = Tags(format.metadata ?: return null)
|
||||
var trackGain = 0f
|
||||
var albumGain = 0f
|
||||
var found = false
|
||||
|
||||
val tags = mutableListOf<GainTag>()
|
||||
|
||||
for (i in 0 until metadata.length()) {
|
||||
val entry = metadata.get(i)
|
||||
|
||||
val key: String?
|
||||
val value: String
|
||||
|
||||
when (entry) {
|
||||
// ID3v2 text information frame, usually these are formatted in lowercase
|
||||
// (like "replaygain_track_gain"), but can also be uppercase. Make sure that
|
||||
// capitalization is consistent before continuing.
|
||||
is TextInformationFrame -> {
|
||||
key = entry.description
|
||||
value = entry.values[0]
|
||||
}
|
||||
// Internal Frame. This is actually MP4's "----" atom, but mapped to an ID3v2
|
||||
// frame by ExoPlayer (presumably to reduce duplication).
|
||||
is InternalFrame -> {
|
||||
key = entry.description
|
||||
value = entry.text
|
||||
}
|
||||
// Vorbis comment. These are nearly always uppercase, so a check for such is
|
||||
// skipped.
|
||||
is VorbisComment -> {
|
||||
key = entry.key
|
||||
value = entry.value
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
|
||||
if (key in REPLAY_GAIN_TAGS) {
|
||||
// Grok a float from a ReplayGain tag by removing everything that is not 0-9, ,
|
||||
// or -.
|
||||
// Derived from vanilla music: https://github.com/vanilla-music/vanilla
|
||||
val gainValue =
|
||||
try {
|
||||
value.replace(Regex("[^\\d.-]"), "").toFloat()
|
||||
} catch (e: Exception) {
|
||||
0f
|
||||
}
|
||||
|
||||
tags.add(GainTag(unlikelyToBeNull(key), gainValue))
|
||||
}
|
||||
// Most ReplayGain tags are formatted as a simple decibel adjustment in a custom
|
||||
// replaygain_*_gain tag.
|
||||
if (format.sampleMimeType != MimeTypes.AUDIO_OPUS) {
|
||||
tags.id3v2["TXXX:$TAG_RG_TRACK_GAIN"]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { trackGain = it }
|
||||
tags.id3v2["TXXX:$TAG_RG_ALBUM_GAIN"]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it }
|
||||
tags.vorbis[TAG_RG_ALBUM_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { trackGain = it }
|
||||
tags.vorbis[TAG_RG_TRACK_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it }
|
||||
} else {
|
||||
// Opus has it's own "r128_*_gain" ReplayGain specification, which requires dividing the
|
||||
// adjustment by 256 to get the gain. This is used alongside the base adjustment
|
||||
// intrinsic to the format to create the normalized adjustment. That base adjustment
|
||||
// is already handled by the media framework, so we just need to apply the more
|
||||
// specific adjustments.
|
||||
tags.vorbis[TAG_R128_TRACK_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { trackGain = it / 256f }
|
||||
tags.vorbis[TAG_R128_ALBUM_GAIN]
|
||||
?.run { first().parseReplayGainAdjustment() }
|
||||
?.let { albumGain = it / 256f }
|
||||
}
|
||||
|
||||
// Case 1: Normal ReplayGain, most commonly found on MPEG files.
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(TAG_RG_TRACK, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
trackGain = tag.value
|
||||
found = true
|
||||
}
|
||||
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(TAG_RG_ALBUM, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
albumGain = tag.value
|
||||
found = true
|
||||
}
|
||||
|
||||
// Case 2: R128 ReplayGain, most commonly found on FLAC files and other lossless
|
||||
// encodings to increase precision in volume adjustments.
|
||||
// While technically there is the R128 base gain in Opus files, that is automatically
|
||||
// applied by the media framework [which ExoPlayer relies on]. The only reason we would
|
||||
// want to read it is to zero previous ReplayGain values for being invalid, however there
|
||||
// is no demand to fix that edge case right now.
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(R128_TRACK, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
trackGain += tag.value / 256f
|
||||
found = true
|
||||
}
|
||||
|
||||
tags
|
||||
.findLast { tag -> tag.key.equals(R128_ALBUM, ignoreCase = true) }
|
||||
?.let { tag ->
|
||||
albumGain += tag.value / 256f
|
||||
found = true
|
||||
}
|
||||
|
||||
return if (found) {
|
||||
Gain(trackGain, albumGain)
|
||||
return if (trackGain != 0f || albumGain != 0f) {
|
||||
Adjustment(trackGain, albumGain)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ReplayGain adjustment into a float value.
|
||||
* @return A parsed adjustment float, or null if the adjustment had invalid formatting.
|
||||
*/
|
||||
private fun String.parseReplayGainAdjustment() =
|
||||
replace(REPLAYGAIN_ADJUSTMENT_FILTER_REGEX, "").toFloatOrNull()
|
||||
|
||||
// --- AUDIO PROCESSOR IMPLEMENTATION ---
|
||||
|
||||
override fun onConfigure(
|
||||
|
@ -271,21 +279,18 @@ class ReplayGainAudioProcessor(context: Context) : BaseAudioProcessor() {
|
|||
* @param track The track adjustment (in dB), or 0 if it is not present.
|
||||
* @param album The album adjustment (in dB), or 0 if it is not present.
|
||||
*/
|
||||
private data class Gain(val track: Float, val album: Float)
|
||||
|
||||
/**
|
||||
* A raw ReplayGain adjustment.
|
||||
* @param key The tag's key.
|
||||
* @param value The tag's adjustment, in dB.
|
||||
*/
|
||||
private data class GainTag(val key: String, val value: Float)
|
||||
// TODO: Try to phase this out
|
||||
private data class Adjustment(val track: Float, val album: Float)
|
||||
|
||||
private companion object {
|
||||
const val TAG_RG_TRACK = "replaygain_track_gain"
|
||||
const val TAG_RG_ALBUM = "replaygain_album_gain"
|
||||
const val R128_TRACK = "r128_track_gain"
|
||||
const val R128_ALBUM = "r128_album_gain"
|
||||
val REPLAY_GAIN_TAGS = arrayOf(TAG_RG_TRACK, TAG_RG_ALBUM, R128_ALBUM, R128_TRACK)
|
||||
const val TAG_RG_TRACK_GAIN = "replaygain_track_gain"
|
||||
const val TAG_RG_ALBUM_GAIN = "replaygain_album_gain"
|
||||
const val TAG_R128_TRACK_GAIN = "r128_track_gain"
|
||||
const val TAG_R128_ALBUM_GAIN = "r128_album_gain"
|
||||
|
||||
/**
|
||||
* Matches non-float information from ReplayGain adjustments. Derived from vanilla music:
|
||||
* https://github.com/vanilla-music/vanilla
|
||||
*/
|
||||
val REPLAYGAIN_ADJUSTMENT_FILTER_REGEX = Regex("[^\\d.-]")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,6 @@ import android.content.BroadcastReceiver
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.SharedPreferences
|
||||
import android.media.AudioManager
|
||||
import android.media.audiofx.AudioEffect
|
||||
import android.os.IBinder
|
||||
|
@ -32,7 +31,6 @@ import com.google.android.exoplayer2.MediaItem
|
|||
import com.google.android.exoplayer2.PlaybackException
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.RenderersFactory
|
||||
import com.google.android.exoplayer2.Tracks
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.audio.AudioCapabilities
|
||||
import com.google.android.exoplayer2.audio.MediaCodecAudioRenderer
|
||||
|
@ -45,7 +43,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.music.MusicStore
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor
|
||||
|
@ -81,8 +78,7 @@ class PlaybackService :
|
|||
Player.Listener,
|
||||
InternalPlayer,
|
||||
MediaSessionComponent.Listener,
|
||||
MusicStore.Listener,
|
||||
SharedPreferences.OnSharedPreferenceChangeListener {
|
||||
MusicStore.Listener {
|
||||
// Player components
|
||||
private lateinit var player: ExoPlayer
|
||||
private lateinit var replayGainProcessor: ReplayGainAudioProcessor
|
||||
|
@ -144,9 +140,9 @@ class PlaybackService :
|
|||
true)
|
||||
.build()
|
||||
.also { it.addListener(this) }
|
||||
replayGainProcessor.addToListeners(player)
|
||||
// Initialize the core service components
|
||||
settings = Settings(this)
|
||||
settings.addListener(this)
|
||||
foregroundManager = ForegroundManager(this)
|
||||
// Initialize any listener-dependent components last as we wouldn't want a listener race
|
||||
// condition to cause us to load music before we were fully initialize.
|
||||
|
@ -187,7 +183,6 @@ class PlaybackService :
|
|||
super.onDestroy()
|
||||
|
||||
foregroundManager.release()
|
||||
settings.removeListener(this)
|
||||
|
||||
// Pause just in case this destruction was unexpected.
|
||||
playbackManager.setPlaying(false)
|
||||
|
@ -200,6 +195,7 @@ class PlaybackService :
|
|||
widgetComponent.release()
|
||||
mediaSessionComponent.release()
|
||||
|
||||
replayGainProcessor.releaseFromListeners(player)
|
||||
player.release()
|
||||
if (openAudioEffectSession) {
|
||||
// Make sure to close the audio session when we release the player.
|
||||
|
@ -304,24 +300,6 @@ class PlaybackService :
|
|||
playbackManager.next()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: Tracks) {
|
||||
super.onTracksChanged(tracks)
|
||||
// Try to find the currently playing track so we can update ReplayGainAudioProcessor
|
||||
// with it.
|
||||
for (group in tracks.groups) {
|
||||
if (group.isSelected) {
|
||||
for (i in 0 until group.length) {
|
||||
if (group.isTrackSelected(i)) {
|
||||
replayGainProcessor.applyReplayGain(group.getTrackFormat(i).metadata)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- MUSICSTORE OVERRIDES ---
|
||||
|
||||
override fun onLibraryChanged(library: MusicStore.Library?) {
|
||||
|
@ -331,17 +309,6 @@ class PlaybackService :
|
|||
}
|
||||
}
|
||||
|
||||
// --- SETTINGS OVERRIDES ---
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) {
|
||||
if (key == getString(R.string.set_key_replay_gain) ||
|
||||
key == getString(R.string.set_key_pre_amp_with) ||
|
||||
key == getString(R.string.set_key_pre_amp_without)) {
|
||||
// ReplayGain changed, we need to set it up again.
|
||||
onTracksChanged(player.currentTracks)
|
||||
}
|
||||
}
|
||||
|
||||
// --- OTHER FUNCTIONS ---
|
||||
|
||||
private fun broadcastAudioEffectAction(event: String) {
|
||||
|
|
|
@ -31,8 +31,8 @@ import org.oxycblt.auxio.home.tabs.Tab
|
|||
import org.oxycblt.auxio.image.CoverMode
|
||||
import org.oxycblt.auxio.music.MusicMode
|
||||
import org.oxycblt.auxio.music.Sort
|
||||
import org.oxycblt.auxio.music.storage.Directory
|
||||
import org.oxycblt.auxio.music.storage.MusicDirectories
|
||||
import org.oxycblt.auxio.music.filesystem.Directory
|
||||
import org.oxycblt.auxio.music.filesystem.MusicDirectories
|
||||
import org.oxycblt.auxio.playback.ActionMode
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
||||
|
|
|
@ -106,7 +106,7 @@
|
|||
tools:layout="@layout/dialog_pre_amp" />
|
||||
<dialog
|
||||
android:id="@+id/music_dirs_dialog"
|
||||
android:name="org.oxycblt.auxio.music.storage.MusicDirsDialog"
|
||||
android:name="org.oxycblt.auxio.music.filesystem.MusicDirsDialog"
|
||||
android:label="music_dirs_dialog"
|
||||
tools:layout="@layout/dialog_music_dirs" />
|
||||
<dialog
|
||||
|
|
Loading…
Reference in a new issue