diff --git a/.gitignore b/.gitignore index d461864a6..a6dda7e2f 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ captures/ *.iml .cxx .kotlin +.aider* +.env diff --git a/CHANGELOG.md b/CHANGELOG.md index 4792b87bd..659f7d6c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## 4.0.2 + +#### What's New +- Added back in support for cover art from cover.png/cover.jpg +- Added "As is" cover art setting +- Option to include hidden files or not (off by default) + +#### What's Improved +- Reduced elevation contrast in black theme + +#### What's Fixed +- Fixed incorrect extension stripping on some files +- Fixed various errors in new branding +- Fixed MTE segfault from improper string handling + +#### What's Changed +- Hidden files no longer loaded by default + ## 4.0.1 #### What's Fixed diff --git a/README.md b/README.md index 07052404a..f74c258cf 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index fdc1e0eab..d444f70c9 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -18,8 +18,8 @@ android { defaultConfig { applicationId namespace - versionName "4.0.1" - versionCode 60 + versionName "4.0.2" + versionCode 61 minSdk min_sdk targetSdk target_sdk diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt index 1da5824b9..55907bb0d 100644 --- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt +++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt @@ -141,4 +141,6 @@ object IntegerTable { const val PLAY_SONG_BY_ITSELF = 0xA124 /** CoverMode.SaveSpace */ const val COVER_MODE_SAVE_SPACE = 0xA125 + /** CoverMode.AsIs */ + const val COVER_MODE_AS_IS = 0xA126 } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt index cad4a2ab2..a0cf8fe83 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverMode.kt @@ -29,7 +29,8 @@ enum class CoverMode { OFF, SAVE_SPACE, BALANCED, - HIGH_QUALITY; + HIGH_QUALITY, + AS_IS; /** * The integer representation of this instance. @@ -43,6 +44,7 @@ enum class CoverMode { SAVE_SPACE -> IntegerTable.COVER_MODE_SAVE_SPACE BALANCED -> IntegerTable.COVER_MODE_BALANCED HIGH_QUALITY -> IntegerTable.COVER_MODE_HIGH_QUALITY + AS_IS -> IntegerTable.COVER_MODE_AS_IS } companion object { @@ -59,6 +61,7 @@ enum class CoverMode { IntegerTable.COVER_MODE_SAVE_SPACE -> SAVE_SPACE IntegerTable.COVER_MODE_BALANCED -> BALANCED IntegerTable.COVER_MODE_HIGH_QUALITY -> HIGH_QUALITY + IntegerTable.COVER_MODE_AS_IS -> AS_IS else -> null } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt index 976fc64e4..5c5bd78d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverProvider.kt @@ -27,11 +27,10 @@ import android.net.Uri import android.os.ParcelFileDescriptor import kotlinx.coroutines.runBlocking import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.image.covers.SiloedCoverId -import org.oxycblt.auxio.image.covers.SiloedCovers -import org.oxycblt.musikr.cover.ObtainResult +import org.oxycblt.auxio.image.covers.SettingCovers +import org.oxycblt.musikr.cover.CoverResult -class CoverProvider : ContentProvider() { +class CoverProvider() : ContentProvider() { override fun onCreate(): Boolean = true override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? { @@ -39,12 +38,10 @@ class CoverProvider : ContentProvider() { return null } val id = uri.lastPathSegment ?: return null - val coverId = SiloedCoverId.parse(id) ?: return null return runBlocking { - val siloedCovers = SiloedCovers.from(requireNotNull(context), coverId.silo) - when (val res = siloedCovers.obtain(id)) { - is ObtainResult.Hit -> res.cover.fd() - is ObtainResult.Miss -> null + when (val result = SettingCovers.immutable(requireNotNull(context)).obtain(id)) { + is CoverResult.Hit -> result.cover.fd() + else -> null } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt index eb402545c..03f731618 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/CoverView.kt @@ -409,7 +409,7 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr @Px val iconSize: Int? ) : Drawable() { init { - // Re-tint the drawable to use the analogous "on surfaceg" color for + // Re-tint the drawable to use the analogous "on surface" color for // StyledImageView. DrawableCompat.setTintList(inner, context.getColorCompat(R.color.sel_on_cover_bg)) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt index 9d7413f65..50ffa61d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/CoverSilo.kt @@ -21,17 +21,23 @@ package org.oxycblt.auxio.image.covers import java.util.UUID import org.oxycblt.musikr.cover.CoverParams -data class CoverSilo(val revision: UUID, val params: CoverParams) { - override fun toString() = "${revision}.${params.resolution}.${params.quality}" +data class CoverSilo(val revision: UUID, val params: CoverParams?) { + override fun toString() = + "${revision}${params?.let { ".${params.resolution}.${params.quality}" } ?: "" }" companion object { fun parse(silo: String): CoverSilo? { val parts = silo.split('.') - if (parts.size != 3) return null + if (parts.size != 1 && parts.size != 3) { + return null + } val revision = parts[0].toUuidOrNull() ?: return null - val resolution = parts[1].toIntOrNull() ?: return null - val quality = parts[2].toIntOrNull() ?: return null - return CoverSilo(revision, CoverParams.of(resolution, quality)) + if (parts.size > 1) { + val resolution = parts[1].toIntOrNull() ?: return null + val quality = parts[2].toIntOrNull() ?: return null + return CoverSilo(revision, CoverParams.of(resolution, quality)) + } + return CoverSilo(revision, null) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt index 7aeb4da5d..cffa626d1 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/NullCovers.kt @@ -20,13 +20,15 @@ package org.oxycblt.auxio.image.covers import android.content.Context import org.oxycblt.musikr.cover.Cover +import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.cover.MutableCovers -import org.oxycblt.musikr.cover.ObtainResult +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Metadata -class NullCovers(private val context: Context) : MutableCovers { - override suspend fun obtain(id: String) = ObtainResult.Hit(NullCover) +class NullCovers(private val context: Context) : MutableCovers { + override suspend fun obtain(id: String) = CoverResult.Hit(NullCover) - override suspend fun write(data: ByteArray): Cover = NullCover + override suspend fun create(file: DeviceFile, metadata: Metadata) = CoverResult.Hit(NullCover) override suspend fun cleanup(excluding: Collection) { context.coversDir().listFiles()?.forEach { it.deleteRecursively() } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt index 63e1dfd8d..e00cfdc2f 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt @@ -23,26 +23,39 @@ import java.util.UUID import javax.inject.Inject import org.oxycblt.auxio.image.CoverMode import org.oxycblt.auxio.image.ImageSettings +import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.CoverIdentifier import org.oxycblt.musikr.cover.CoverParams +import org.oxycblt.musikr.cover.Covers +import org.oxycblt.musikr.cover.FileCover +import org.oxycblt.musikr.cover.FolderCovers import org.oxycblt.musikr.cover.MutableCovers +import org.oxycblt.musikr.cover.MutableFolderCovers interface SettingCovers { - suspend fun create(context: Context, revision: UUID): MutableCovers + suspend fun mutate(context: Context, revision: UUID): MutableCovers + + companion object { + fun immutable(context: Context): Covers = + Covers.chain(BaseSiloedCovers(context), FolderCovers(context)) + } } class SettingCoversImpl @Inject constructor(private val imageSettings: ImageSettings, private val identifier: CoverIdentifier) : SettingCovers { - override suspend fun create(context: Context, revision: UUID): MutableCovers = + override suspend fun mutate(context: Context, revision: UUID): MutableCovers = when (imageSettings.coverMode) { CoverMode.OFF -> NullCovers(context) CoverMode.SAVE_SPACE -> siloedCovers(context, revision, CoverParams.of(500, 70)) CoverMode.BALANCED -> siloedCovers(context, revision, CoverParams.of(750, 85)) CoverMode.HIGH_QUALITY -> siloedCovers(context, revision, CoverParams.of(1000, 100)) + CoverMode.AS_IS -> siloedCovers(context, revision, null) } - private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams) = - MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier) + private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams?) = + MutableCovers.chain( + MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier), + MutableFolderCovers(context)) } diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt index 253d0dacf..856239698 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt @@ -25,21 +25,36 @@ import kotlinx.coroutines.withContext import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.CoverFormat import org.oxycblt.musikr.cover.CoverIdentifier +import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.cover.Covers import org.oxycblt.musikr.cover.FileCover import org.oxycblt.musikr.cover.FileCovers import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.cover.MutableFileCovers -import org.oxycblt.musikr.cover.ObtainResult import org.oxycblt.musikr.fs.app.AppFiles +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Metadata -open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: FileCovers) : Covers { - override suspend fun obtain(id: String): ObtainResult { - val coverId = SiloedCoverId.parse(id) ?: return ObtainResult.Miss() - if (coverId.silo != silo) return ObtainResult.Miss() +class BaseSiloedCovers(private val context: Context) : Covers { + override suspend fun obtain(id: String): CoverResult { + val siloedId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() + val core = SiloCore.from(context, siloedId.silo) + val fileCovers = FileCovers(core.files, core.format) + return when (val result = fileCovers.obtain(siloedId.id)) { + is CoverResult.Hit -> CoverResult.Hit(SiloedCover(siloedId.silo, result.cover)) + is CoverResult.Miss -> CoverResult.Miss() + } + } +} + +open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: FileCovers) : + Covers { + override suspend fun obtain(id: String): CoverResult { + val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss() + if (silo != coverId.silo) return CoverResult.Miss() return when (val result = fileCovers.obtain(coverId.id)) { - is ObtainResult.Hit -> ObtainResult.Hit(SiloedCover(silo, result.cover)) - is ObtainResult.Miss -> ObtainResult.Miss() + is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover)) + is CoverResult.Miss -> CoverResult.Miss() } } @@ -56,8 +71,12 @@ private constructor( private val rootDir: File, private val silo: CoverSilo, private val fileCovers: MutableFileCovers -) : SiloedCovers(silo, fileCovers), MutableCovers { - override suspend fun write(data: ByteArray) = SiloedCover(silo, fileCovers.write(data)) +) : SiloedCovers(silo, fileCovers), MutableCovers { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult = + when (val result = fileCovers.create(file, metadata)) { + is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover)) + is CoverResult.Miss -> CoverResult.Miss() + } override suspend fun cleanup(excluding: Collection) { fileCovers.cleanup(excluding.filterIsInstance().map { it.innerCover }) @@ -111,7 +130,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format: revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() } } val files = AppFiles.at(revisionDir) - val format = CoverFormat.jpeg(silo.params) + val format = silo.params?.let(CoverFormat::jpeg) ?: CoverFormat.asIs() return SiloCore(rootDir, files, format) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 3c7d614c0..1af17e660 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -384,13 +384,14 @@ constructor( Naming.simple() } val locations = musicSettings.musicLocations + val ignoreHidden = !musicSettings.withHidden val currentRevision = musicSettings.revision val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID() val cache = if (withCache) storedCache.visible() else storedCache.invisible() - val covers = settingCovers.create(context, newRevision) + val covers = settingCovers.mutate(context, newRevision) val storage = Storage(cache, covers, storedPlaylists) - val interpretation = Interpretation(nameFactory, separators) + val interpretation = Interpretation(nameFactory, separators, ignoreHidden) val result = Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index be518bfa2..2fae8d3ba 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -40,6 +40,8 @@ interface MusicSettings : Settings { var musicLocations: List /** Whether to exclude non-music audio files from the music library. */ val excludeNonMusic: Boolean + /** Whether to ignore hidden files and directories during music loading. */ + val withHidden: Boolean /** Whether to be actively watching for changes in the music library. */ val shouldBeObserving: Boolean /** A [String] of characters representing the desired characters to denote multi-value tags. */ @@ -90,6 +92,9 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont override val excludeNonMusic: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_exclude_non_music), true) + override val withHidden: Boolean + get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), false) + override val shouldBeObserving: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) @@ -116,7 +121,9 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont listener.onMusicLocationsChanged() } getString(R.string.set_key_separators), - getString(R.string.set_key_auto_sort_names) -> { + getString(R.string.set_key_auto_sort_names), + getString(R.string.set_key_with_hidden), + getString(R.string.set_key_exclude_non_music) -> { L.d("Dispatching indexing setting change for $key") listener.onIndexingSettingChanged() } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt index 8727aba3d..752dead1a 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/categories/MusicPreferenceFragment.kt @@ -67,5 +67,14 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music) true } } + if (preference.key == getString(R.string.set_key_with_hidden)) { + L.d("Configuring ignore hidden files setting") + preference.onPreferenceChangeListener = + Preference.OnPreferenceChangeListener { _, _ -> + L.d("Ignore hidden files setting changed, reloading music") + musicModel.refresh() + true + } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt index b55801fbd..217206d98 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/accent/Accent.kt @@ -65,22 +65,22 @@ private val accentThemes = private val accentBlackThemes = intArrayOf( - R.style.Theme_Auxio_Black_Red, - R.style.Theme_Auxio_Black_Pink, - R.style.Theme_Auxio_Black_Purple, - R.style.Theme_Auxio_Black_DeepPurple, - R.style.Theme_Auxio_Black_Indigo, - R.style.Theme_Auxio_Black_Blue, - R.style.Theme_Auxio_Black_DeepBlue, - R.style.Theme_Auxio_Black_Cyan, - R.style.Theme_Auxio_Black_Teal, - R.style.Theme_Auxio_Black_Green, - R.style.Theme_Auxio_Black_DeepGreen, - R.style.Theme_Auxio_Black_Lime, - R.style.Theme_Auxio_Black_Yellow, - R.style.Theme_Auxio_Black_Orange, - R.style.Theme_Auxio_Black_Brown, - R.style.Theme_Auxio_Black_Grey, + R.style.Theme_Auxio_Red_Black, + R.style.Theme_Auxio_Pink_Black, + R.style.Theme_Auxio_Purple_Black, + R.style.Theme_Auxio_DeepPurple_Black, + R.style.Theme_Auxio_Indigo_Black, + R.style.Theme_Auxio_Blue_Black, + R.style.Theme_Auxio_DeepBlue_Black, + R.style.Theme_Auxio_Cyan_Black, + R.style.Theme_Auxio_Teal_Black, + R.style.Theme_Auxio_Green_Black, + R.style.Theme_Auxio_DeepGreen_Black, + R.style.Theme_Auxio_Lime_Black, + R.style.Theme_Auxio_Yellow_Black, + R.style.Theme_Auxio_Orange_Black, + R.style.Theme_Auxio_Brown_Black, + R.style.Theme_Auxio_Grey_Black, R.style.Theme_Auxio_Black // Dynamic colors are on the base theme ) diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index ace598087..80439fe96 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -266,4 +266,4 @@ الفئات الخاصة بك ستضهر هنا. اعادة تحميل مكتبة الموسيقى عند حصول تغيير(يتطلب تنبيه ثابت) تحذير: استخدام هذا الاعداد قد ينتج عنه ان يتم تفسير بعض العلامات بشكل خاطئ مثل ان تحتوي على قيم متعددة. يمكن ان يتم حل هذا بتقديم الفواصل الغير مرغوبةبالشارحة الخلفية(\\). - \ No newline at end of file + diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index e6117970d..af1438fb0 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -230,7 +230,7 @@ Koma (,) Semikoolon (;) Pluss (+) - Ampersand (&) + Ampersand (and-märk) Seadista tähemärke, mis eraldavad siltides mitut väärtust Kaldkriips (/) Hoiatus: selle seadistuse kasutamisel ei pruugi mitu väärtust siltides olla alati korralikult tuvastatud; seda olukorda saad proovida lahendada täiendava prefiksi lisamisega kurakaldkriipsu näol (\\). @@ -324,4 +324,4 @@ Sinu žanrid saavad olema nähtavad siin. Sinu albumid saavad olema nähtavad siin. Sinu esitusloendid saavad olema nähtavad siin. - \ No newline at end of file + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a152d00e3..7d2fe3d74 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -152,9 +152,9 @@ Regolazione senza tag Casuale Tutto in casuale - Regolazione mediante tag + Regolazione in base ai tag Il pre-amp è applicato alla regolazione esistente durante la riproduzione - Riproduci dall\'elemento mostrato + Riproduci dall\'elemento corrente Gestisci le cartelle da dove caricare la musica Cartelle musicali Matroska audio @@ -182,7 +182,7 @@ Ricarica la tua libreria musicale se subisce cambiamenti (richiede notifica persistente) Caricamento musica Monitoraggio libreria musicale - Data aggiunta + Data di aggiunta Ricaricamento automatico EP EP @@ -219,7 +219,7 @@ Separatori multi-valore Configura i caratteri che identificano tag con valori multipli Barra (/) - Attenzione: potrebbero verificarsi degli errori nell\'interpretazione di alcuni tag con valori multipli. Puoi risolvere aggiungendo come prefisso la barra rovesciata () ai separatori indesiderati. + Attenzione: potrebbero verificarsi degli errori nell\'interpretazione di alcuni tag con valori multipli. Puoi risolvere questo problema aggiungendo come prefisso una barra rovesciata (\\) ai separatori indesiderati. E commerciale (&) Compilation live Compilation remix @@ -334,4 +334,4 @@ MPEG-4 contenente %s Apple Lossless Audio Codec (ALAC) Sconosciuto - \ No newline at end of file + diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 954c3d4a9..1c02fbacb 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -19,4 +19,91 @@ პირდაპირი EP სინგლები სინგლი + Live ალბომები + Remix ალბომები + არტისტი + არტისტები + ჟანრი + ჟანრები + დასაკრავი სია + დასაკრავი სიები + ახალი დასაკრავი სია + ცარიელი დასაკრავი სია + იმპორტი + ექსპორტი + სახელის გადარქმევა + დასაკრავი სია იმპორტირებულია + დასაკრავი სიის იმპორტი + დასაკრავი სიის ექსპორტი + წაშლა + დასაკრავი სიის წაშლა ? + ჩასწორება + ძებნა + ფილტრი + ყველა + სახელი + დასაკრავი სიის სახელის შეცვლა + თარიღი + ხანგრძლივობა + დამატების თარიღი + დაალაგე + სიმღერების რაოდენობა + დაალაგე + ზრდადობით + კლებადობით + მიხედვით + დაკვრა + ეხლა იკვრება + შემდეგი + დამკვრელი სიაში დამატება + არტისტის ნახვა + ალბომის ნახვა + რიგი + შემთხვევითი მუსიკა + გაზიარება + ფორმატი + ნახვა + სიმღერის პარამეტრები + პარამეტრების ნახვა + ზომა + დამატება + მეტი + გაუქმება + შენახვა + ვერსია + აპლიკაციის შესახებ + დაკოპირებულია + ავტორი + ინფორმაცია + უკუკავშირი + ელექტრონული შეტყობინების გაგზავნა + დონაცია + სიმღერების ჩატვირთვა… + დაამატე რიგში + დასაკრავი სია იმპორტირებულია + დასაკრავი სიის სახელი შეცვლილია + ძებნა თქვენს ბიბლიოთეკაში… + ალბომები აქ გამოჩნდება. + არტისტები აქ გამოჩნდება. + თემა + ღია + მუქი + შავი თემა + შემდეგზე გადასვლა + ყველა სიმღერის დაკვრა + დასაკრავი სია წაშლილია + დასაკრავი სია ექსპორტირებულია + დასაკრავი სია შექმნილია + დამატებულია დასაკრავი სიაში + ავტომატური + სიმღერები აქ გამოჩნდება. + პარამეტრები + ფერები + ავტომატური ჩატვირთვა + სურათები + მუსიკის საქაღალდე + საქაღალდეები + ახალი საქაღალდე + სიმღერები ვერ მოიძებნა + ბოლო სიმღერაზე გადასვლა diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index dfd3baee3..fab6bfb0d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -335,4 +335,4 @@ Tutaj pojawią się dodani artyści. Tutaj pojawią się dodane playlisty. Tutaj pojawią się dodane gatunki. - \ No newline at end of file + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 4ba7c30a5..e714bc71f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -2,7 +2,7 @@ Tentar novamente - Confirmar + Conceder Gêneros Artistas Álbuns @@ -91,7 +91,7 @@ Recarregar música Retroceder a música antes de voltar para a anterior O Auxio precisa de permissão para ler sua biblioteca de músicas - Um reprodutor de música simples e racional para android. + Um reprodutor de música simples e racional para Android. Carregando a sua biblioteca de músicas… Ano Duração @@ -167,17 +167,17 @@ Álbum ao vivo Trilhas sonoras Trilha sonora - Álbum remix + Álbum de remix EP ao vivo - Álbum de Remix + EP de remix Single ao vivo Monitorando alterações na sua biblioteca de músicas… Abas da biblioteca Gênero Reproduzir do artista Ajuste em faixas com metadados - Carregando música - Carregando música + Carregamento de músicas + Carregando músicas Monitorando a biblioteca de músicas Cantos arredondados Pular para o próximo @@ -219,9 +219,9 @@ Ignora arquivos de áudio que não sejam música, como podcasts Aviso: Usar essa configuração pode resultar em alguns metadados serem interpretadas incorretamente como tendo múltiplos valores. Você pode resolver isso pré-definindo caracteres de separador indesejados com uma barra invertida (\\). Ignorar arquivos que não sejam músicas - Capas de álbuns - Desligado - Rápido + Qualidade das capas de álbuns + Desativado + Equilibrado Alta qualidade Mixagens de DJ Mixagem de DJ @@ -332,6 +332,8 @@ Os seus artistas aparecerão aqui. As suas playlists aparecerão aqui. Os seus gêneros aparecerão aqui. - Salvar espaço + Economizar espaço Nova pasta - \ No newline at end of file + Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache) + Ignorar arquivos ocultos + diff --git a/app/src/main/res/values/colors_ui_black.xml b/app/src/main/res/values/colors_ui_black.xml new file mode 100644 index 000000000..4d0b01b47 --- /dev/null +++ b/app/src/main/res/values/colors_ui_black.xml @@ -0,0 +1,145 @@ + + + #000000 + #000000 + #211b1a + #000000 + #110c0c + #130e0e + #181413 + #1e1918 + + #000000 + #000000 + #201b1b + #000000 + #110c0d + #130e0f + #181414 + #1e1819 + + #000000 + #000000 + #1f1b1e + #000000 + #0f0c0f + #110e11 + #171416 + #1c191c + + #000000 + #000000 + #1d1c1f + #000000 + #0e0d10 + #100f12 + #161417 + #1b1a1d + + #000000 + #000000 + #1c1c1f + #000000 + #0d0d10 + #0f0f12 + #141417 + #1a1a1d + + #000000 + #000000 + #1b1c1e + #000000 + #0c0d0f + #0e1012 + #131517 + #191a1c + + #000000 + #000000 + #1a1d1e + #000000 + #0b0e0f + #0d1011 + #121517 + #181a1c + + #000000 + #000000 + #1a1d1e + #000000 + #0b0e0e + #0d1010 + #121515 + #181b1b + + #000000 + #000000 + #1a1d1c + #000000 + #0b0e0d + #0d100f + #121514 + #181b1a + + #000000 + #000000 + #1b1d1a + #000000 + #0c0e0b + #0e100d + #131512 + #191b18 + + #000000 + #000000 + #1b1d19 + #000000 + #0d0e0a + #0f100d + #141512 + #191b16 + + #000000 + #000000 + #1c1d18 + #000000 + #0d0e09 + #10100c + #141511 + #1a1a16 + + #000000 + #000000 + #1f1c17 + #000000 + #100d09 + #120f0b + #171410 + #1d1a15 + + #000000 + #000000 + #201b18 + #000000 + #110d0a + #130f0c + #181411 + #1d1916 + #000000 + #000000 + #1e1c1b + #000000 + #0f0d0d + #110f0f + #161414 + #1b1a19 + + #000000 + #000000 + #1d1c1c + #000000 + #0e0d0d + #100f0f + #151515 + #1a1a1a + \ No newline at end of file diff --git a/app/src/main/res/values/settings.xml b/app/src/main/res/values/settings.xml index 901803a0c..eec560b35 100644 --- a/app/src/main/res/values/settings.xml +++ b/app/src/main/res/values/settings.xml @@ -18,6 +18,7 @@ auxio_square_covers auxio_include_dirs auxio_exclude_non_music + auxio_with_hidden auxio_music_locations2 auxio_separators auxio_auto_sort_names @@ -78,6 +79,7 @@ @string/set_cover_mode_save_space @string/set_cover_mode_balanced @string/set_cover_mode_high_quality + @string/set_cover_mode_as_is @@ -85,6 +87,7 @@ @integer/cover_mode_save_space @integer/cover_mode_balanced @integer/cover_mode_high_quality + @integer/cover_mode_as_is @@ -181,4 +184,5 @@ 0xA125 0xA11D 0xA11E + 0xA126 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 37e68cc51..c055b0dd0 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -267,6 +267,8 @@ Reload the music library whenever it changes (requires persistent notification) Exclude non-music Ignore audio files that are not music, such as podcasts + Include hidden files + Include files and folders that are hidden (ex. .cache) Multi-value separators Configure characters that denote multiple tag values Comma (,) @@ -285,6 +287,7 @@ Save space Balanced High quality + As is Force square album covers Crop all album covers to a 1:1 aspect ratio diff --git a/app/src/main/res/values/themes_black.xml b/app/src/main/res/values/themes_black.xml index ed68ba1f9..75b8f6d24 100644 --- a/app/src/main/res/values/themes_black.xml +++ b/app/src/main/res/values/themes_black.xml @@ -1,71 +1,173 @@ - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/preferences_music.xml b/app/src/main/res/xml/preferences_music.xml index 607f2c7f2..b3b5106f5 100644 --- a/app/src/main/res/xml/preferences_music.xml +++ b/app/src/main/res/xml/preferences_music.xml @@ -14,6 +14,12 @@ app:key="@string/set_key_exclude_non_music" app:summary="@string/set_exclude_non_music_desc" app:title="@string/set_exclude_non_music" /> + + - \ No newline at end of file + diff --git a/fastlane/metadata/android/cs/full_description.txt b/fastlane/metadata/android/cs/full_description.txt index e1e6af29c..04831f786 100644 --- a/fastlane/metadata/android/cs/full_description.txt +++ b/fastlane/metadata/android/cs/full_description.txt @@ -6,8 +6,7 @@ Auxio je lokální hudební přehrávač s rychlým a spolehlivým UI/UX bez spo - Responzivní UI podle nejnovějších pokynů Material Design - Příjemné UX, které upřednostňuje snadné používání před okrajovými případy - Přizpůsobitelné chování -- Podpora čísel disků, více interpretů, typů vydání, -přesná/původní data, štítky pro řazení a další +- Podpora čísel disků, více interpretů, typů vydání, přesných/původních dat, štítků pro řazení a dalších - Pokročilý systém umělců spojující interprety a interprety alb - Správa složek podporující SD karty - Spolehlivá funkce seznamů skladeb @@ -22,4 +21,4 @@ přesná/původní data, štítky pro řazení a další - Automatické přehrávání při připojení sluchátek - Stylové widgety, které se automaticky adaptují své velikosti - Plně soukromý a offline -- Žádné zaoblené obaly alb (ve výchozím nastavení) +- Žádné zaoblené obaly alb (pokud je nechcete) diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index 62f87e1ad..97df9a418 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -22,4 +22,4 @@ Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, ab - Autoplay bei Kopfhörern - Stylische Widgets, die ihre Größe anpassen - vollständig privat und offline -- keine abgerundeten Album-Cover (standardmäßig) +- keine abgerundeten Album-Cover (wenn du willst) diff --git a/fastlane/metadata/android/en-US/changelogs/61.txt b/fastlane/metadata/android/en-US/changelogs/61.txt new file mode 100644 index 000000000..41e1fa8aa --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/61.txt @@ -0,0 +1,4 @@ +Auxio 4.0.0 completely overhauls the user experience, with a refreshed design based on the latest Material Design specs +and a brand new music loader with signifigant improvements to device and tag support. +This issue fixes several regressions from v3.6.3 functionality. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v4.0.2. diff --git a/fastlane/metadata/android/en-US/images/featureGraphic.png b/fastlane/metadata/android/en-US/images/featureGraphic.png index 52a9ff852..55e7b0342 100644 Binary files a/fastlane/metadata/android/en-US/images/featureGraphic.png and b/fastlane/metadata/android/en-US/images/featureGraphic.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png index 6657a9e38..fad7362d1 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot0.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png index fc7667973..6dd7269be 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot1.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png index 8b71fa259..fbc3bf201 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot2.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png index ac44384b7..5bdbb9a3b 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot3.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png index 2a5d413a3..8e62d14b4 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot4.png differ diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png index 0dc12f9d7..a5a09ae13 100644 Binary files a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png and b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png differ diff --git a/fastlane/metadata/android/et/full_description.txt b/fastlane/metadata/android/et/full_description.txt index 14417fef1..4fe18fc3c 100644 --- a/fastlane/metadata/android/et/full_description.txt +++ b/fastlane/metadata/android/et/full_description.txt @@ -6,15 +6,14 @@ Auxio on kohalikus nutiseadmes töötav kiire ja usaldusväärse kasutajaliidese - viimastest Material Designi põhimõtetest lähtuv kiire kasutajaliides - arendajate omal hinnangul põhinev kasutajaliides, mis eelistab kasutuse lihtsust harvaesinevate olukordade lahendamisele - kohandatav käitumine -- plaadinumbrite, mitme esitaja, väljaande tüübi, -täpsete avaldamise kuupäevade sortimiseks mõeldud siltide ja palju muu sarnase tugi +- plaadinumbrite, mitme esitaja, väljaande tüübi, täpsete avaldamise kuupäevade sortimiseks mõeldud siltide ja palju muu sarnase tugi - tavalisest tõhusam esitajate haldus, mis normaliseerib esitajad ning albumi esitajad - kaustade haldus, mis saab hakkama SD-kaartidega - usaldusväärse esitusloendi loomine - taasesituse oleku meeldejätmine - tugi Android Auto liidestusele - automaatne taasesitus lugudevahelise vaikusteta -- taasesituse valjuse tundlikkuse tugi (MP3, FLAC, OGG, OPUS ja MP4 failide pihul) +- taasesituse valjuse tundlikkuse tugi (MP3, FLAC, OGG, OPUS ja MP4 failide puhul) - välise ekvalaiseri tugi (nt. Wavelet) - äärest-ääreni visuaal - lõimitud albumikaante tugi @@ -22,4 +21,4 @@ täpsete avaldamise kuupäevade sortimiseks mõeldud siltide ja palju muu sarnas - automaatne taasesitus kõrvaklappidest - stiilsed vidinad, mis automaatselt kohandavad oma suurust - täiesti privaatne ja võrguühendust mittevajav -- plaadikaante ümarad nurgad puuduvad (vaikimisi) +- plaadikaante ümarad nurgad puuduvad (kui seda eelistad) diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index 0d40db862..a5c141a11 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -1,4 +1,4 @@ -Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na osnovi modernih biblioteka za reprodukciju, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu. +Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na osnovi modernih biblioteka za reprodukciju, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu.. Značajke @@ -6,14 +6,12 @@ Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih - Snappy UI izvedeno iz najnovijih smjernica za materijalni dizajn - Iskustveni korisnički doživljaj koji daje prednost jednostavnosti upotrebe u odnosu na rubne slučajeve - Prilagodljivo ponašanje -- Podrška za brojeve diskova, više izvođača, vrste izdanja, -precizni/izvorni datumi, sortiranje oznaka i više +- Podrška za brojeve diskova, više izvođača, vrste izdanja, precizni/izvorni datumi, sortiranje oznaka i više - Napredni sustav izvođača koji ujedinjuje izvođače i izvođače albuma - Upravljanje mapama koje podržava SD karticu - Pouzdana funkcija popisa pjesama -- Automaska podrška za Android +- Automaska podrška za Android Auto - Automatska reprodukcija bez prekida -- Postojanost stanja reprodukcije - Puna podrška za ReplayGain (na MP3, FLAC, OGG, OPUS i MP4 datotekama) - Podrška za vanjski ekvilizator (npr. Wavelet) - Od ruba do ruba @@ -22,4 +20,4 @@ precizni/izvorni datumi, sortiranje oznaka i više - Automatska reprodukcija slušalica - Elegantni widgeti koji se automatski prilagođavaju njihovoj veličini - Potpuno privatno i izvan mreže -- Bez zaobljenih naslovnica albuma (zadano) +- Bez zaobljenih naslovnica albuma (ako ih želite) diff --git a/fastlane/metadata/android/it/short_description.txt b/fastlane/metadata/android/it/short_description.txt index ccbd35600..39fc5a513 100644 --- a/fastlane/metadata/android/it/short_description.txt +++ b/fastlane/metadata/android/it/short_description.txt @@ -1 +1 @@ -Un semplice, razionale lettore musicale +Un lettore musicale semplice e razionale diff --git a/fastlane/metadata/android/pt-BR/full_description.txt b/fastlane/metadata/android/pt-BR/full_description.txt index 3c263cbcf..162bca762 100644 --- a/fastlane/metadata/android/pt-BR/full_description.txt +++ b/fastlane/metadata/android/pt-BR/full_description.txt @@ -21,4 +21,4 @@ Auxio é um reprodutor de música local com uma interface/experiência de usuár - Reprodução automática em fones de ouvido. - Widgets elegantes que se adaptam automaticamente ao tamanho. - Completamente privado e off-line. -- Sem capas de álbuns arredondadas (por padrão). +- Sem capas de álbuns arredondadas (caso queira). diff --git a/fastlane/metadata/android/uk/full_description.txt b/fastlane/metadata/android/uk/full_description.txt index 4fbd4baca..742c702c6 100644 --- a/fastlane/metadata/android/uk/full_description.txt +++ b/fastlane/metadata/android/uk/full_description.txt @@ -6,8 +6,7 @@ Auxio — це локальний музичний плеєр із швидки - Snappy UI, створений на основі останніх рекомендацій Material Design - Переконливий UX, який надає перевагу простоті використання над крайніми випадками - Настроювана поведінка -- Підтримка номерів дисків, кількох виконавців, типів випусків, -точні/оригінальні дати, теги сортування тощо +- Підтримка номерів дисків, кількох виконавців, типів випусків, точних/оригінальних дат, тегів сортування тощо — Розширена система виконавців, яка об’єднує виконавців і виконавців альбомів - Керування папками з підтримкою SD-карти — Надійна функція списків відтворення @@ -22,4 +21,4 @@ Auxio — це локальний музичний плеєр із швидки - Автовідтворення гарнітури - Стильні віджети, які автоматично адаптуються до їх розміру - Повністю приватний і офлайн -- Немає округлених обкладинок альбомів (за замовчуванням) +- Немає закруглених обкладинок альбомів (якщо ви їх хочете) diff --git a/fastlane/metadata/android/zh-CN/full_description.txt b/fastlane/metadata/android/zh-CN/full_description.txt index cc2ef74c1..96ed1d7b8 100644 --- a/fastlane/metadata/android/zh-CN/full_description.txt +++ b/fastlane/metadata/android/zh-CN/full_description.txt @@ -6,8 +6,7 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没 - 源于最新 Material You 设计规范的灵动界面 - 优先考虑易用性的独到用户体验 - 可定制的播放器行为 -- 支持唱片编号、多名艺术家、发布类型、精确/原始日期、 -标签排序及其他更多功能 +- 支持唱片编号、多名艺术家、发行类型、精确/原始日期、排序标签以及更多 - 统一“艺术家”和“专辑艺术家”的高级“艺术家”系统 - 文件夹管理功能可以感知到 SD 卡 - 可靠的播放列表功能 @@ -22,4 +21,4 @@ Auxio 是一款本地音乐播放器,它拥有快速、可靠的 UI/UX,没 - 耳机连接时自动播放 - 按桌面尺寸自适应的风格化微件 - 完全离线且私密 -- 没有圆角的专辑封面(默认设置) +- 没有圆角的专辑封面(即使你想要) diff --git a/musikr/src/main/cpp/taglib_jni.cpp b/musikr/src/main/cpp/taglib_jni.cpp index abb94e9f7..65904aa5c 100644 --- a/musikr/src/main/cpp/taglib_jni.cpp +++ b/musikr/src/main/cpp/taglib_jni.cpp @@ -30,7 +30,7 @@ #include "taglib/vorbisfile.h" #include "taglib/wavfile.h" -bool parseMpeg(const char *name, TagLib::File *file, +bool parseMpeg(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *mpegFile = dynamic_cast(file); if (mpegFile == nullptr) { @@ -41,7 +41,7 @@ bool parseMpeg(const char *name, TagLib::File *file, try { jBuilder.setId3v1(*id3v1Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v1 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v1 tag in %s: %s", name.c_str(), e.what()); } } auto id3v2Tag = mpegFile->ID3v2Tag(); @@ -49,13 +49,13 @@ bool parseMpeg(const char *name, TagLib::File *file, try { jBuilder.setId3v2(*id3v2Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what()); } } return true; } -bool parseMp4(const char *name, TagLib::File *file, +bool parseMp4(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *mp4File = dynamic_cast(file); if (mp4File == nullptr) { @@ -66,13 +66,13 @@ bool parseMp4(const char *name, TagLib::File *file, try { jBuilder.setMp4(*tag); } catch (std::exception &e) { - LOGE("Unable to parse MP4 tag in %s: %s", name, e.what()); + LOGE("Unable to parse MP4 tag in %s: %s", name.c_str(), e.what()); } } return true; } -bool parseFlac(const char *name, TagLib::File *file, +bool parseFlac(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *flacFile = dynamic_cast(file); if (flacFile == nullptr) { @@ -83,7 +83,7 @@ bool parseFlac(const char *name, TagLib::File *file, try { jBuilder.setId3v1(*id3v1Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v1 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v1 tag in %s: %s", name.c_str(), e.what()); } } auto id3v2Tag = flacFile->ID3v2Tag(); @@ -91,7 +91,7 @@ bool parseFlac(const char *name, TagLib::File *file, try { jBuilder.setId3v2(*id3v2Tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what()); } } auto xiphComment = flacFile->xiphComment(); @@ -99,7 +99,8 @@ bool parseFlac(const char *name, TagLib::File *file, try { jBuilder.setXiph(*xiphComment); } catch (std::exception &e) { - LOGE("Unable to parse Xiph comment in %s: %s", name, e.what()); + LOGE("Unable to parse Xiph comment in %s: %s", name.c_str(), + e.what()); } } auto pics = flacFile->pictureList(); @@ -107,7 +108,7 @@ bool parseFlac(const char *name, TagLib::File *file, return true; } -bool parseOpus(const char *name, TagLib::File *file, +bool parseOpus(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *opusFile = dynamic_cast(file); if (opusFile == nullptr) { @@ -118,13 +119,14 @@ bool parseOpus(const char *name, TagLib::File *file, try { jBuilder.setXiph(*tag); } catch (std::exception &e) { - LOGE("Unable to parse Xiph comment in %s: %s", name, e.what()); + LOGE("Unable to parse Xiph comment in %s: %s", name.c_str(), + e.what()); } } return true; } -bool parseVorbis(const char *name, TagLib::File *file, +bool parseVorbis(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *vorbisFile = dynamic_cast(file); if (vorbisFile == nullptr) { @@ -135,13 +137,13 @@ bool parseVorbis(const char *name, TagLib::File *file, try { jBuilder.setXiph(*tag); } catch (std::exception &e) { - LOGE("Unable to parse Xiph comment %s: %s", name, e.what()); + LOGE("Unable to parse Xiph comment %s: %s", name.c_str(), e.what()); } } return true; } -bool parseWav(const char *name, TagLib::File *file, +bool parseWav(const std::string &name, TagLib::File *file, JMetadataBuilder &jBuilder) { auto *wavFile = dynamic_cast(file); if (wavFile == nullptr) { @@ -152,7 +154,7 @@ bool parseWav(const char *name, TagLib::File *file, try { jBuilder.setId3v2(*tag); } catch (std::exception &e) { - LOGE("Unable to parse ID3v2 tag in %s: %s", name, e.what()); + LOGE("Unable to parse ID3v2 tag in %s: %s", name.c_str(), e.what()); } } return true; @@ -162,7 +164,7 @@ extern "C" JNIEXPORT jobject JNICALL Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env, jobject /* this */, jobject inputStream) { - const char *name = nullptr; + std::string name = "unknown file"; try { JInputStream jStream {env, inputStream}; name = jStream.name(); @@ -189,12 +191,12 @@ Java_org_oxycblt_musikr_metadata_TagLibJNI_openNative(JNIEnv *env, } else if (parseWav(name, file, jBuilder)) { jBuilder.setMimeType("audio/wav"); } else { - LOGE("File format in %s is not supported", name); + LOGE("File format in %s is not supported", name.c_str()); return nullptr; } return jBuilder.build(); } catch (std::exception &e) { - LOGE("Unable to parse metadata in %s: %s", name != nullptr ? name : "unknown file", e.what()); + LOGE("Unable to parse metadata in %s: %s", name.c_str(), e.what()); return nullptr; } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/Config.kt b/musikr/src/main/java/org/oxycblt/musikr/Config.kt index a793680db..6cded8171 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Config.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Config.kt @@ -19,6 +19,7 @@ package org.oxycblt.musikr import org.oxycblt.musikr.cache.Cache +import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.MutableCovers import org.oxycblt.musikr.playlist.db.StoredPlaylists import org.oxycblt.musikr.tag.interpret.Naming @@ -37,7 +38,7 @@ data class Storage( * with the cache for best performance. This will be used during music loading and when * retrieving cover information from the library. */ - val storedCovers: MutableCovers, + val storedCovers: MutableCovers, /** * A repository of user-created playlists that should also be loaded into the library. This will @@ -53,5 +54,8 @@ data class Interpretation( val naming: Naming, /** What separators delimit multi-value audio tags. */ - val separators: Separators + val separators: Separators, + + /** Whether to ignore hidden files and directories (those starting with a dot). */ + val ignoreHidden: Boolean ) diff --git a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt index c18a01684..934880963 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/Musikr.kt @@ -71,7 +71,7 @@ interface Musikr { fun new(context: Context, storage: Storage, interpretation: Interpretation): Musikr = MusikrImpl( storage, - ExploreStep.from(context, storage), + ExploreStep.from(context, storage, interpretation), ExtractStep.from(context, storage), EvaluateStep.new(storage, interpretation)) } diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt index 277495d3a..670ee2f31 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/Cache.kt @@ -18,12 +18,13 @@ package org.oxycblt.musikr.cache +import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.pipeline.RawSong abstract class Cache { - internal abstract suspend fun read(file: DeviceFile, covers: Covers): CacheResult + internal abstract suspend fun read(file: DeviceFile, covers: Covers): CacheResult internal abstract suspend fun write(song: RawSong) diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt index d1700777c..7fc755f1d 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/CacheDatabase.kt @@ -31,9 +31,10 @@ import androidx.room.RoomDatabase import androidx.room.Transaction import androidx.room.TypeConverter import androidx.room.TypeConverters +import org.oxycblt.musikr.cover.Cover +import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.cover.ObtainResult -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.Properties import org.oxycblt.musikr.pipeline.RawSong import org.oxycblt.musikr.tag.Date @@ -41,7 +42,7 @@ import org.oxycblt.musikr.tag.parse.ParsedTags import org.oxycblt.musikr.util.correctWhitespace import org.oxycblt.musikr.util.splitEscaped -@Database(entities = [CachedSong::class], version = 60, exportSchema = false) +@Database(entities = [CachedSong::class], version = 61, exportSchema = false) internal abstract class CacheDatabase : RoomDatabase() { abstract fun visibleDao(): VisibleCacheDao @@ -118,13 +119,13 @@ internal data class CachedSong( val replayGainAlbumAdjustment: Float?, val coverId: String?, ) { - suspend fun intoRawSong(file: DeviceFile, covers: Covers): RawSong? { + suspend fun intoRawSong(file: DeviceFile, covers: Covers): RawSong? { val cover = when (val result = coverId?.let { covers.obtain(it) }) { // We found the cover. - is ObtainResult.Hit -> result.cover + is CoverResult.Hit -> result.cover // We actually didn't find the cover, can't safely convert. - is ObtainResult.Miss -> return null + is CoverResult.Miss -> return null // No cover in the first place, can ignore. null -> null } diff --git a/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt b/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt index 4707ffe3f..c4107c3a5 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cache/StoredCache.kt @@ -19,8 +19,9 @@ package org.oxycblt.musikr.cache import android.content.Context +import org.oxycblt.musikr.cover.Cover import org.oxycblt.musikr.cover.Covers -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.pipeline.RawSong interface StoredCache { @@ -53,7 +54,7 @@ private abstract class BaseStoredCache(protected val writeDao: CacheWriteDao) : private class VisibleStoredCache(private val visibleDao: VisibleCacheDao, writeDao: CacheWriteDao) : BaseStoredCache(writeDao) { - override suspend fun read(file: DeviceFile, covers: Covers): CacheResult { + override suspend fun read(file: DeviceFile, covers: Covers): CacheResult { val song = visibleDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file, null) if (song.modifiedMs != file.modifiedMs) { // We *found* this file earlier, but it's out of date. @@ -77,7 +78,7 @@ private class InvisibleStoredCache( private val invisibleCacheDao: InvisibleCacheDao, writeDao: CacheWriteDao ) : BaseStoredCache(writeDao) { - override suspend fun read(file: DeviceFile, covers: Covers) = + override suspend fun read(file: DeviceFile, covers: Covers) = CacheResult.Miss(file, invisibleCacheDao.selectAddedMs(file.uri.toString())) class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() { diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt index 52f9e15e3..16af47506 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/CoverFormat.kt @@ -29,11 +29,13 @@ abstract class CoverFormat { companion object { fun jpeg(params: CoverParams): CoverFormat = - CoverFormatImpl("jpg", params, Bitmap.CompressFormat.JPEG) + CompressingCoverFormat("jpg", params, Bitmap.CompressFormat.JPEG) + + fun asIs(): CoverFormat = AsIsCoverFormat() } } -private class CoverFormatImpl( +private class CompressingCoverFormat( override val extension: String, private val params: CoverParams, private val format: Bitmap.CompressFormat, @@ -63,3 +65,16 @@ private class CoverFormatImpl( return inSampleSize } } + +private class AsIsCoverFormat : CoverFormat() { + override val extension: String = "bin" + + override fun transcodeInto(data: ByteArray, output: OutputStream): Boolean { + return try { + output.write(data) + true + } catch (e: Exception) { + false + } + } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt index e3f41b386..902bce5c5 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt @@ -19,27 +19,79 @@ package org.oxycblt.musikr.cover import java.io.InputStream +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Metadata -interface Covers { - suspend fun obtain(id: String): ObtainResult +interface Covers { + suspend fun obtain(id: String): CoverResult + + companion object { + fun chain(vararg many: Covers): Covers = + object : Covers { + override suspend fun obtain(id: String): CoverResult { + for (cover in many) { + val result = cover.obtain(id) + if (result is CoverResult.Hit) { + return CoverResult.Hit(result.cover) + } + } + return CoverResult.Miss() + } + } + } } -interface MutableCovers : Covers { - suspend fun write(data: ByteArray): Cover +interface MutableCovers : Covers { + suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult suspend fun cleanup(excluding: Collection) + + companion object { + fun chain(vararg many: MutableCovers): MutableCovers = + object : MutableCovers { + override suspend fun obtain(id: String): CoverResult { + for (cover in many) { + val result = cover.obtain(id) + if (result is CoverResult.Hit) { + return CoverResult.Hit(result.cover) + } + } + return CoverResult.Miss() + } + + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + for (cover in many) { + val result = cover.create(file, metadata) + if (result is CoverResult.Hit) { + return CoverResult.Hit(result.cover) + } + } + return CoverResult.Miss() + } + + override suspend fun cleanup(excluding: Collection) { + for (cover in many) { + cover.cleanup(excluding) + } + } + } + } } -sealed interface ObtainResult { - data class Hit(val cover: T) : ObtainResult +sealed interface CoverResult { + data class Hit(val cover: T) : CoverResult - class Miss : ObtainResult + class Miss : CoverResult } interface Cover { val id: String suspend fun open(): InputStream? + + override fun equals(other: Any?): Boolean + + override fun hashCode(): Int } class CoverCollection private constructor(val covers: List) { diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt index 4c8390869..aadafad1d 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt @@ -21,15 +21,17 @@ package org.oxycblt.musikr.cover import android.os.ParcelFileDescriptor import org.oxycblt.musikr.fs.app.AppFile import org.oxycblt.musikr.fs.app.AppFiles +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Metadata open class FileCovers(private val appFiles: AppFiles, private val coverFormat: CoverFormat) : - Covers { - override suspend fun obtain(id: String): ObtainResult { + Covers { + override suspend fun obtain(id: String): CoverResult { val file = appFiles.find(getFileName(id)) return if (file != null) { - ObtainResult.Hit(FileCoverImpl(id, file)) + CoverResult.Hit(FileCoverImpl(id, file)) } else { - ObtainResult.Miss() + CoverResult.Miss() } } @@ -40,11 +42,12 @@ class MutableFileCovers( private val appFiles: AppFiles, private val coverFormat: CoverFormat, private val coverIdentifier: CoverIdentifier -) : FileCovers(appFiles, coverFormat), MutableCovers { - override suspend fun write(data: ByteArray): FileCover { +) : FileCovers(appFiles, coverFormat), MutableCovers { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + val data = metadata.cover ?: return CoverResult.Miss() val id = coverIdentifier.identify(data) - val file = appFiles.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } - return FileCoverImpl(id, file) + val coverFile = appFiles.write(getFileName(id)) { coverFormat.transcodeInto(data, it) } + return CoverResult.Hit(FileCoverImpl(id, coverFile)) } override suspend fun cleanup(excluding: Collection) { diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt new file mode 100644 index 000000000..46db555e6 --- /dev/null +++ b/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2025 Auxio Project + * FolderCovers.kt is part of Auxio. + * + * 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.musikr.cover + +import android.content.Context +import android.net.Uri +import android.os.ParcelFileDescriptor +import java.io.InputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.withContext +import org.oxycblt.musikr.fs.device.DeviceDirectory +import org.oxycblt.musikr.fs.device.DeviceFile +import org.oxycblt.musikr.metadata.Metadata + +open class FolderCovers(private val context: Context) : Covers { + override suspend fun obtain(id: String): CoverResult { + // Parse the ID to get the directory URI + if (!id.startsWith("folder:")) { + return CoverResult.Miss() + } + + // TODO: Check if the dir actually exists still to avoid stale uris + val directoryUri = id.substring("folder:".length) + val uri = Uri.parse(directoryUri) + + // Check if the URI is still valid + val exists = + withContext(Dispatchers.IO) { + try { + context.contentResolver.openFileDescriptor(uri, "r")?.close() + true + } catch (e: Exception) { + false + } + } + + return if (exists) { + CoverResult.Hit(FolderCoverImpl(context, uri)) + } else { + CoverResult.Miss() + } + } +} + +class MutableFolderCovers(private val context: Context) : + FolderCovers(context), MutableCovers { + override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult { + val parent = file.parent.await() + val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss() + return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri)) + } + + override suspend fun cleanup(excluding: Collection) { + // No cleanup needed for folder covers as they are external files + // that should not be managed by the app + } + + private suspend fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? { + return directory.children + .mapNotNull { node -> if (node is DeviceFile && isCoverArtFile(node)) node else null } + .firstOrNull() + } + + private fun isCoverArtFile(file: DeviceFile): Boolean { + val filename = requireNotNull(file.path.name).lowercase() + val mimeType = file.mimeType.lowercase() + + // Check if the file is an image + if (!mimeType.startsWith("image/")) { + return false + } + + // Common cover art filenames + val coverNames = + listOf( + "cover", + "folder", + "album", + "albumart", + "front", + "artwork", + "art", + "folder", + "coverart") + + // Check if the filename matches any common cover art names + // Also check for case variations (e.g., Cover.jpg, COVER.JPG) + val filenameWithoutExt = filename.substringBeforeLast(".") + val extension = filename.substringAfterLast(".", "") + + return coverNames.any { coverName -> + filenameWithoutExt.equals(coverName, ignoreCase = true) && + (extension.equals("jpg", ignoreCase = true) || + extension.equals("jpeg", ignoreCase = true) || + extension.equals("png", ignoreCase = true)) + } + } +} + +interface FolderCover : FileCover + +private data class FolderCoverImpl( + private val context: Context, + private val uri: Uri, +) : FolderCover { + override val id = "folder:$uri" + + override suspend fun fd(): ParcelFileDescriptor? = + withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") } + + override suspend fun open(): InputStream? = + withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) } +} diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/DeviceFile.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt similarity index 60% rename from musikr/src/main/java/org/oxycblt/musikr/fs/DeviceFile.kt rename to musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt index 6baac772f..cee785cb0 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/DeviceFile.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFile.kt @@ -16,14 +16,29 @@ * along with this program. If not, see . */ -package org.oxycblt.musikr.fs +package org.oxycblt.musikr.fs.device import android.net.Uri +import kotlinx.coroutines.Deferred +import org.oxycblt.musikr.fs.Path -internal data class DeviceFile( - val uri: Uri, +sealed interface DeviceNode { + val uri: Uri + val path: Path +} + +data class DeviceDirectory( + override val uri: Uri, + override val path: Path, + val parent: Deferred?, + val children: List +) : DeviceNode + +data class DeviceFile( + override val uri: Uri, + override val path: Path, + val modifiedMs: Long, val mimeType: String, - val path: Path, val size: Long, - val modifiedMs: Long -) + val parent: Deferred +) : DeviceNode diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt index 2f737995c..5ee0d3417 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt @@ -22,6 +22,8 @@ import android.content.ContentResolver import android.content.Context import android.net.Uri import android.provider.DocumentsContract +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Deferred import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow @@ -29,7 +31,6 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flow -import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.MusicLocation import org.oxycblt.musikr.fs.Path @@ -37,66 +38,82 @@ internal interface DeviceFiles { fun explore(locations: Flow): Flow companion object { - fun from(context: Context): DeviceFiles = DeviceFilesImpl(context.contentResolverSafe) + fun from(context: Context, ignoreHidden: Boolean): DeviceFiles = + DeviceFilesImpl(context.contentResolverSafe, ignoreHidden) } } @OptIn(ExperimentalCoroutinesApi::class) -private class DeviceFilesImpl(private val contentResolver: ContentResolver) : DeviceFiles { +private class DeviceFilesImpl( + private val contentResolver: ContentResolver, + private val ignoreHidden: Boolean +) : DeviceFiles { override fun explore(locations: Flow): Flow = locations.flatMapMerge { location -> - exploreImpl( - contentResolver, + // Set up the children flow for the root directory + exploreDirectoryImpl( location.uri, DocumentsContract.getTreeDocumentId(location.uri), - location.path) + location.path, + null) } - private fun exploreImpl( - contentResolver: ContentResolver, + private fun exploreDirectoryImpl( rootUri: Uri, treeDocumentId: String, - relativePath: Path + relativePath: Path, + parent: Deferred? ): Flow = flow { - contentResolver.useQuery( - DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId), - PROJECTION) { cursor -> - val childUriIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) - val displayNameIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) - val mimeTypeIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) - val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) - val lastModifiedIndex = - cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) - val recursive = mutableListOf>() - while (cursor.moveToNext()) { - val childId = cursor.getString(childUriIndex) - val displayName = cursor.getString(displayNameIndex) - val newPath = relativePath.file(displayName) - val mimeType = cursor.getString(mimeTypeIndex) - if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { - // This does NOT block the current coroutine. Instead, we will - // evaluate this flow in parallel later to maximize throughput. - recursive.add(exploreImpl(contentResolver, rootUri, childId, newPath)) - } else if (mimeType.startsWith("audio/") && mimeType != "audio/x-mpegurl") { - // Immediately emit all files given that it's just an O(1) op. - // This also just makes sure the outer flow has a reason to exist - // rather than just being a glorified async. - val lastModified = cursor.getLong(lastModifiedIndex) - val size = cursor.getLong(sizeIndex) - emit( - DeviceFile( - DocumentsContract.buildDocumentUriUsingTree(rootUri, childId), - mimeType, - newPath, - size, - lastModified)) - } + // Make a kotlin future + val uri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId) + val directoryDeferred = CompletableDeferred() + val recursive = mutableListOf>() + val children = mutableListOf() + contentResolver.useQuery(uri, PROJECTION) { cursor -> + val childUriIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID) + val displayNameIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME) + val mimeTypeIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE) + val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE) + val lastModifiedIndex = + cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED) + + while (cursor.moveToNext()) { + val childId = cursor.getString(childUriIndex) + val displayName = cursor.getString(displayNameIndex) + + // Skip hidden files/directories if ignoreHidden is true + if (ignoreHidden && displayName.startsWith(".")) { + continue + } + + val newPath = relativePath.file(displayName) + val mimeType = cursor.getString(mimeTypeIndex) + val lastModified = cursor.getLong(lastModifiedIndex) + + if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) { + recursive.add( + exploreDirectoryImpl(rootUri, childId, newPath, directoryDeferred)) + } else { + val size = cursor.getLong(sizeIndex) + val childUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, childId) + val file = + DeviceFile( + uri = childUri, + mimeType = mimeType, + path = newPath, + size = size, + modifiedMs = lastModified, + parent = directoryDeferred) + children.add(file) + emit(file) } - emitAll(recursive.asFlow().flattenMerge()) } + } + directoryDeferred.complete(DeviceDirectory(uri, relativePath, parent, children)) + emitAll(recursive.asFlow().flattenMerge()) } private companion object { diff --git a/musikr/src/main/java/org/oxycblt/musikr/graph/MusicGraph.kt b/musikr/src/main/java/org/oxycblt/musikr/graph/MusicGraph.kt index fc9420618..2b634ae5e 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/graph/MusicGraph.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/graph/MusicGraph.kt @@ -144,6 +144,8 @@ private class MusicGraphBuilderImpl : MusicGraph.Builder { it.pointerMap[v363Pointer]?.forEach { index -> it.songVertices[index] = vertex } val v400Pointer = SongPointer.UID(entry.value.preSong.v400Uid) it.pointerMap[v400Pointer]?.forEach { index -> it.songVertices[index] = vertex } + val v401Pointer = SongPointer.UID(entry.value.preSong.v401Uid) + it.pointerMap[v401Pointer]?.forEach { index -> it.songVertices[index] = vertex } } } diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt index 758e4483a..ba16830b5 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/Metadata.kt @@ -18,7 +18,7 @@ package org.oxycblt.musikr.metadata -internal data class Metadata( +data class Metadata( val id3v2: Map>, val xiph: Map>, val mp4: Map>, @@ -53,7 +53,7 @@ internal data class Metadata( } } -internal data class Properties( +data class Properties( val mimeType: String, val durationMs: Long, val bitrateKbps: Int, diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt index 0b0cfc48d..7ce64d949 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/MetadataExtractor.kt @@ -22,7 +22,7 @@ import android.os.ParcelFileDescriptor import java.io.FileInputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile internal interface MetadataExtractor { suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata? diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt index c7486e220..213e68ea7 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/NativeInputStream.kt @@ -21,7 +21,7 @@ package org.oxycblt.musikr.metadata import android.util.Log import java.io.FileInputStream import java.nio.ByteBuffer -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile internal class NativeInputStream(private val deviceFile: DeviceFile, fis: FileInputStream) { private val channel = fis.channel diff --git a/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt b/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt index d5105f3a8..e0b48ee00 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/metadata/TagLibJNI.kt @@ -19,7 +19,7 @@ package org.oxycblt.musikr.metadata import java.io.FileInputStream -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile internal object TagLibJNI { init { diff --git a/musikr/src/main/java/org/oxycblt/musikr/model/LibraryImpl.kt b/musikr/src/main/java/org/oxycblt/musikr/model/LibraryImpl.kt index e60d4f662..381be7290 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/model/LibraryImpl.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/model/LibraryImpl.kt @@ -38,6 +38,7 @@ internal data class LibraryImpl( ) : MutableLibrary { private val songUidMap = songs.associateBy { it.uid } private val v400SongUidMap = songs.associateBy { it.v400Uid } + private val v401SongUidMap = songs.associateBy { it.v401Uid } private val albumUidMap = albums.associateBy { it.uid } private val artistUidMap = artists.associateBy { it.uid } private val genreUidMap = genres.associateBy { it.uid } @@ -46,7 +47,8 @@ internal data class LibraryImpl( override fun empty() = songs.isEmpty() // Compat hack. See TagInterpreter for why this needs to be done - override fun findSong(uid: Music.UID) = songUidMap[uid] ?: v400SongUidMap[uid] + override fun findSong(uid: Music.UID) = + songUidMap[uid] ?: v400SongUidMap[uid] ?: v401SongUidMap[uid] override fun findSongByPath(path: Path) = songs.find { it.path == path } diff --git a/musikr/src/main/java/org/oxycblt/musikr/model/SongImpl.kt b/musikr/src/main/java/org/oxycblt/musikr/model/SongImpl.kt index 383abf11c..c06e5d0e1 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/model/SongImpl.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/model/SongImpl.kt @@ -46,6 +46,8 @@ internal class SongImpl(private val handle: SongCore) : Song { val v400Uid = preSong.v400Uid + val v401Uid = preSong.v401Uid + override val name = preSong.name override val track = preSong.track override val disc = preSong.disc diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt index 1e77659e1..761b0d047 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt @@ -24,14 +24,15 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.emitAll +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.merge +import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Storage -import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.MusicLocation +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.fs.device.DeviceFiles import org.oxycblt.musikr.playlist.PlaylistFile import org.oxycblt.musikr.playlist.db.StoredPlaylists @@ -41,8 +42,9 @@ internal interface ExploreStep { fun explore(locations: List): Flow companion object { - fun from(context: Context, storage: Storage): ExploreStep = - ExploreStepImpl(DeviceFiles.from(context), storage.storedPlaylists) + fun from(context: Context, storage: Storage, interpretation: Interpretation): ExploreStep = + ExploreStepImpl( + DeviceFiles.from(context, interpretation.ignoreHidden), storage.storedPlaylists) } } @@ -54,13 +56,8 @@ private class ExploreStepImpl( val audios = deviceFiles .explore(locations.asFlow()) - .mapNotNull { - when { - it.mimeType == M3U.MIME_TYPE -> null - it.mimeType.startsWith("audio/") -> ExploreNode.Audio(it) - else -> null - } - } + .filter { it.mimeType.startsWith("audio/") && it.mimeType != M3U.MIME_TYPE } + .map { ExploreNode.Audio(it) } .flowOn(Dispatchers.IO) .buffer() val playlists = diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt index 1d204fe5a..576980b3a 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExtractStep.kt @@ -35,8 +35,9 @@ import org.oxycblt.musikr.Storage import org.oxycblt.musikr.cache.Cache import org.oxycblt.musikr.cache.CacheResult import org.oxycblt.musikr.cover.Cover +import org.oxycblt.musikr.cover.CoverResult import org.oxycblt.musikr.cover.MutableCovers -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.metadata.MetadataExtractor import org.oxycblt.musikr.metadata.Properties import org.oxycblt.musikr.playlist.PlaylistFile @@ -62,7 +63,7 @@ private class ExtractStepImpl( private val metadataExtractor: MetadataExtractor, private val tagParser: TagParser, private val cacheFactory: Cache.Factory, - private val storedCovers: MutableCovers + private val covers: MutableCovers ) : ExtractStep { @OptIn(ExperimentalCoroutinesApi::class) override fun extract(nodes: Flow): Flow { @@ -78,17 +79,20 @@ private class ExtractStepImpl( val audioNodes = filterFlow.right val playlistNodes = filterFlow.left.map { ExtractedMusic.Valid.Playlist(it) } + // First distribute audio nodes for parallel cache reading val readDistributedFlow = audioNodes.distribute(8) val cacheResults = readDistributedFlow.flows .map { flow -> flow - .map { wrap(it) { file -> cache.read(file, storedCovers) } } + .map { wrap(it) { file -> cache.read(file, covers) } } .flowOn(Dispatchers.IO) .buffer(Channel.UNLIMITED) } .flattenMerge() .buffer(Channel.UNLIMITED) + + // Divert cache hits and misses val cacheFlow = cacheResults.divert { when (it) { @@ -96,89 +100,84 @@ private class ExtractStepImpl( is CacheResult.Miss -> Divert.Right(it.file) } } + + // Cache hits can be directly converted to valid songs val cachedSongs = cacheFlow.left.map { ExtractedMusic.Valid.Song(it) } - val uncachedSongs = cacheFlow.right - val fds = - uncachedSongs - .mapNotNull { - wrap(it) { file -> - withContext(Dispatchers.IO) { - context.contentResolver.openFileDescriptor(file.uri, "r")?.let { fd -> - FileWith(file, fd) - } - } - } - } - .flowOn(Dispatchers.IO) - .buffer(Channel.UNLIMITED) + // Process uncached files in parallel + val uncachedFiles = cacheFlow.right + val processingDistributedFlow = uncachedFiles.distribute(8) - val metadata = - fds.mapNotNull { fileWith -> - wrap(fileWith.file) { _ -> - metadataExtractor - .extract(fileWith.file, fileWith.with) - .let { FileWith(fileWith.file, it) } - .also { withContext(Dispatchers.IO) { fileWith.with.close() } } - } - } - .flowOn(Dispatchers.IO) - // Covers are pretty big, so cap the amount of parsed metadata in-memory to at most - // 8 to minimize GCs. - .buffer(8) - - val extractedSongs = - metadata - .map { fileWith -> - if (fileWith.with != null) { - val tags = tagParser.parse(fileWith.with) - val cover = fileWith.with.cover?.let { storedCovers.write(it) } - RawSong(fileWith.file, fileWith.with.properties, tags, cover, addingMs) - } else { - null - } - } - .flowOn(Dispatchers.IO) - .buffer(Channel.UNLIMITED) - - val extractedFilter = - extractedSongs.divert { - if (it != null) Divert.Left(it) else Divert.Right(ExtractedMusic.Invalid) - } - - val write = extractedFilter.left - val invalid = extractedFilter.right - - val writeDistributedFlow = write.distribute(8) - val writtenSongs = - writeDistributedFlow.flows + // Process each uncached file in parallel flows + val processedSongs = + processingDistributedFlow.flows .map { flow -> flow - .map { - wrap(it, cache::write) - ExtractedMusic.Valid.Song(it) + .mapNotNull { file -> + wrap(file) { f -> + withContext(Dispatchers.IO) { + context.contentResolver.openFileDescriptor(f.uri, "r") + } + ?.use { + val extractedMetadata = metadataExtractor.extract(file, it) + + if (extractedMetadata != null) { + val tags = tagParser.parse(extractedMetadata) + val cover = + when (val result = + covers.create(f, extractedMetadata)) { + is CoverResult.Hit -> result.cover + else -> null + } + val rawSong = + RawSong( + f, + extractedMetadata.properties, + tags, + cover, + addingMs) + cache.write(rawSong) + + ExtractedMusic.Valid.Song(rawSong) + } else { + ExtractedMusic.Invalid + } + } + } } .flowOn(Dispatchers.IO) .buffer(Channel.UNLIMITED) } .flattenMerge() + .buffer(Channel.UNLIMITED) + + // Separate valid processed songs from invalid ones + val processedFlow = + processedSongs.divert { + when (it) { + is ExtractedMusic.Valid.Song -> Divert.Left(it) + is ExtractedMusic.Invalid -> Divert.Right(it) + else -> Divert.Right(ExtractedMusic.Invalid) + } + } + + val processedValidSongs = processedFlow.left + val invalidSongs = processedFlow.right val merged = merge( filterFlow.manager, readDistributedFlow.manager, cacheFlow.manager, + processingDistributedFlow.manager, + processedFlow.manager, cachedSongs, - extractedFilter.manager, - writeDistributedFlow.manager, - writtenSongs, - invalid, + processedValidSongs, + invalidSongs, playlistNodes) return merged.onCompletion { cache.finalize() } } - - private data class FileWith(val file: DeviceFile, val with: T) } internal data class RawSong( diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt index 1f6efc892..65d7525f5 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/PipelineException.kt @@ -18,7 +18,7 @@ package org.oxycblt.musikr.pipeline -import org.oxycblt.musikr.fs.DeviceFile +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.playlist.PlaylistFile import org.oxycblt.musikr.playlist.interpret.PrePlaylist import org.oxycblt.musikr.tag.interpret.PreSong diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt index d5722b0a6..3c744d51e 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/PreMusic.kt @@ -33,6 +33,7 @@ import org.oxycblt.musikr.tag.ReplayGainAdjustment internal data class PreSong( val v363Uid: Music.UID, val v400Uid: Music.UID, + val v401Uid: Music.UID, val musicBrainzId: UUID?, val name: Name.Known, val rawName: String, diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt index de6f07a49..eb2787e7a 100644 --- a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt +++ b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt @@ -20,8 +20,8 @@ package org.oxycblt.musikr.tag.interpret import org.oxycblt.musikr.Interpretation import org.oxycblt.musikr.Music -import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.Format +import org.oxycblt.musikr.fs.device.DeviceFile import org.oxycblt.musikr.pipeline.RawSong import org.oxycblt.musikr.tag.Disc import org.oxycblt.musikr.tag.Name @@ -67,13 +67,15 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T val songNameOrFile = song.tags.name ?: requireNotNull(song.file.path.name) val songNameOrFileWithoutExt = song.tags.name ?: requireNotNull(song.file.path.name).split('.').first() + val songNameOrFileWithoutExtCorrect = + song.tags.name ?: requireNotNull(song.file.path.name).substringBeforeLast(".") val albumNameOrDir = song.tags.albumName ?: song.file.path.directory.name val musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull() val v363uid = musicBrainzId?.let { Music.UID.musicBrainz(Music.UID.Item.SONG, it) } ?: Music.UID.auxio(Music.UID.Item.SONG) { - update(songNameOrFileWithoutExt) + update(songNameOrFileWithoutExtCorrect) update(albumNameOrDir) update(song.tags.date) @@ -103,9 +105,24 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T update(albumArtistNames.ifEmpty { artistNames }.ifEmpty { listOf(null) }) } + val v401uid = + musicBrainzId?.let { Music.UID.musicBrainz(Music.UID.Item.SONG, it) } + ?: Music.UID.auxio(Music.UID.Item.SONG) { + update(songNameOrFileWithoutExt) + update(albumNameOrDir) + update(song.tags.date) + + update(song.tags.track) + update(song.tags.disc) + + update(song.tags.artistNames) + update(song.tags.albumArtistNames) + } + return PreSong( v363Uid = v363uid, v400Uid = v400uid, + v401Uid = v401uid, uri = uri, path = song.file.path, size = song.file.size, @@ -113,8 +130,8 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T modifiedMs = song.file.modifiedMs, addedMs = song.addedMs, musicBrainzId = musicBrainzId, - name = interpretation.naming.name(songNameOrFileWithoutExt, song.tags.sortName), - rawName = songNameOrFileWithoutExt, + name = interpretation.naming.name(songNameOrFileWithoutExtCorrect, song.tags.sortName), + rawName = songNameOrFileWithoutExtCorrect, track = song.tags.track, disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) }, date = song.tags.date,