diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e6b3fad..4723ee80e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt index 639687f45..02b8fc909 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/Detail.kt @@ -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. diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt index 34ad14729..7f3d9f773 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailAppBarLayout.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index 4e6e60005..d6a3666c7 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -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.* diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 8d76972c5..095bae072 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 713163cbc..8baabb71c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -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.. diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt index 6b56f8c70..72177d409 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/ExtractionResult.kt @@ -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 } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt index 48a5bb165..cc65b8b7f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt @@ -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 @@ -584,4 +584,4 @@ private fun Int.unpackTrackNo() = mod(1000).nonZeroOrNull() * disc number is the 4th+ digit. * @return The disc number extracted from the combined integer field, or null if the value was zero. */ -private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull() \ No newline at end of file +private fun Int.unpackDiscNo() = div(1000).nonZeroOrNull() diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt index aae57924e..8aad9fab7 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt @@ -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>() - val vorbisTags = mutableMapOf>() - - // 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>) { // 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>) { // 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()) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt b/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt new file mode 100644 index 000000000..87af3fef6 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/extractor/Tags.kt @@ -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 . + */ + +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>() + /** The ID3v2 text identification frames found in the file. Can have more than one value. */ + val id3v2: Map> + get() = _id3v2 + + private val _vorbis = mutableMapOf>() + /** The vorbis comments found in the file. Can have more than one value. */ + val vorbis: Map> + 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()) +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt similarity index 98% rename from app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt index dcdedc0ef..5531d08ca 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/DirectoryAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/DirectoryAdapter.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.filesystem import android.view.View import android.view.ViewGroup diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt index 00a22deaf..b48e19c11 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/Storage.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/Filesystem.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.filesystem import android.content.Context import android.media.MediaFormat diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt index 60bc797e9..8302aaf24 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/FilesystemUtil.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.storage +package org.oxycblt.auxio.music.filesystem import android.annotation.SuppressLint import android.content.ContentResolver diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt rename to app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt index 6d4244d07..441cb7cbe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/storage/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/filesystem/MusicDirsDialog.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt index 24ffe2bf1..95f193971 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/ParsingUtil.kt @@ -31,7 +31,7 @@ import org.oxycblt.auxio.util.nonZeroOrNull */ fun List.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.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" diff --git a/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt index ddfdf838e..c270f6d1d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/parsing/Separators.kt @@ -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 . + */ + package org.oxycblt.auxio.music.parsing /** @@ -10,4 +27,4 @@ object Separators { const val SLASH = '/' const val PLUS = '+' const val AND = '&' -} \ No newline at end of file +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index 50548c430..428e125b2 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt index 6ff100fba..98450a763 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/replaygain/ReplayGainAudioProcessor.kt @@ -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() - - 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.-]") } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index d62053211..c615a27ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -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) { diff --git a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt index aabb07823..2412b5ee9 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/Settings.kt @@ -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 diff --git a/app/src/main/res/navigation/nav_main.xml b/app/src/main/res/navigation/nav_main.xml index d5c6c0b8d..ab64b289e 100644 --- a/app/src/main/res/navigation/nav_main.xml +++ b/app/src/main/res/navigation/nav_main.xml @@ -106,7 +106,7 @@ tools:layout="@layout/dialog_pre_amp" />