Merge branch 'dev' into weblate-auxio-strings
This commit is contained in:
commit
09b7a1f775
59 changed files with 1180 additions and 1063 deletions
18
CHANGELOG.md
18
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
|
||||
|
|
11
README.md
11
README.md
|
@ -2,8 +2,8 @@
|
|||
<h1 align="center"><b>Auxio</b></h1>
|
||||
<h4 align="center">A simple, rational music player for android.</h4>
|
||||
<p align="center">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.1">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.1&color=64B5F6&style=flat">
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/tag/v4.0.2">
|
||||
<img alt="Latest Version" src="https://img.shields.io/static/v1?label=tag&message=v4.0.2&color=64B5F6&style=flat">
|
||||
</a>
|
||||
<a href="https://github.com/oxygencobalt/Auxio/releases/">
|
||||
<img alt="Releases" src="https://img.shields.io/github/downloads/OxygenCobalt/Auxio/total.svg?color=4B95DE&style=flat">
|
||||
|
@ -15,7 +15,12 @@
|
|||
</p>
|
||||
<h4 align="center"><a href="/CHANGELOG.md">Changelog</a> | <a href="https://github.com/OxygenCobalt/Auxio/wiki">Wiki</a> | <a href="https://github.com/OxygenCobalt/Auxio#Donate">Donate</a></h4>
|
||||
<p align="center">
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="170"></a>
|
||||
<a href="https://f-droid.org/app/org.oxycblt.auxio"><img src="https://fdroid.gitlab.io/artwork/badge/get-it-on.png" width="250"></a>
|
||||
<a href="https://accrescent.app/app/org.oxycblt.auxio">
|
||||
<img alt="Get it on Accrescent" src="https://accrescent.app/badges/get-it-on.png" width="250">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://hosted.weblate.org/engage/auxio/"><img height=64 src="https://hosted.weblate.org/widgets/auxio/-/strings/287x66-grey.png" alt="Translation status" /></a>
|
||||
</p>
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -28,7 +28,7 @@ import android.os.ParcelFileDescriptor
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||
import org.oxycblt.musikr.cover.CoverResult
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
|
||||
class CoverProvider() : ContentProvider() {
|
||||
override fun onCreate(): Boolean = true
|
||||
|
|
|
@ -64,7 +64,7 @@ import org.oxycblt.musikr.Artist
|
|||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
/**
|
||||
* Auxio's extension of [ImageView] that enables cover art loading and playing indicator and
|
||||
|
|
|
@ -46,7 +46,7 @@ import kotlinx.coroutines.withContext
|
|||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
class CoverCollectionFetcher
|
||||
private constructor(
|
||||
|
|
|
@ -40,7 +40,7 @@ import javax.inject.Inject
|
|||
import okio.FileSystem
|
||||
import okio.buffer
|
||||
import okio.source
|
||||
import org.oxycblt.musikr.cover.Cover
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
|
||||
class CoverFetcher private constructor(private val context: Context, private val cover: Cover) :
|
||||
Fetcher {
|
||||
|
|
|
@ -21,8 +21,8 @@ package org.oxycblt.auxio.image.coil
|
|||
import coil3.key.Keyer
|
||||
import coil3.request.Options
|
||||
import javax.inject.Inject
|
||||
import org.oxycblt.musikr.cover.Cover
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
|
||||
class CoverKeyer @Inject constructor() : Keyer<Cover> {
|
||||
override fun key(data: Cover, options: Options) = "${data.id}&${options.size}"
|
||||
|
|
|
@ -23,7 +23,7 @@ import dagger.Module
|
|||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.oxycblt.musikr.cover.CoverIdentifier
|
||||
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
|
|
|
@ -19,21 +19,26 @@
|
|||
package org.oxycblt.auxio.image.covers
|
||||
|
||||
import java.util.UUID
|
||||
import org.oxycblt.musikr.cover.CoverParams
|
||||
import org.oxycblt.musikr.covers.embedded.CoverParams
|
||||
|
||||
data class CoverSilo(val revision: UUID, val params: CoverParams?) {
|
||||
override fun toString() =
|
||||
"${revision}.${params?.let { "${params.resolution}${params.quality}" }}"
|
||||
"${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
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -19,9 +19,9 @@
|
|||
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.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Metadata
|
||||
|
||||
|
|
|
@ -23,21 +23,21 @@ 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
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.covers.fs.FSCovers
|
||||
import org.oxycblt.musikr.covers.fs.MutableFSCovers
|
||||
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
|
||||
import org.oxycblt.musikr.covers.embedded.CoverParams
|
||||
import org.oxycblt.musikr.covers.embedded.FileCover
|
||||
|
||||
interface SettingCovers {
|
||||
suspend fun mutate(context: Context, revision: UUID): MutableCovers<out Cover>
|
||||
|
||||
companion object {
|
||||
fun immutable(context: Context): Covers<FileCover> =
|
||||
Covers.chain(BaseSiloedCovers(context), FolderCovers(context))
|
||||
Covers.chain(BaseSiloedCovers(context), FSCovers(context))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -57,5 +57,5 @@ constructor(private val imageSettings: ImageSettings, private val identifier: Co
|
|||
private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams?) =
|
||||
MutableCovers.chain(
|
||||
MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier),
|
||||
MutableFolderCovers(context))
|
||||
MutableFSCovers(context))
|
||||
}
|
||||
|
|
|
@ -22,16 +22,16 @@ import android.content.Context
|
|||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
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.fs.app.AppFiles
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.covers.embedded.CoverFormat
|
||||
import org.oxycblt.musikr.covers.embedded.CoverIdentifier
|
||||
import org.oxycblt.musikr.covers.embedded.FileCover
|
||||
import org.oxycblt.musikr.covers.embedded.EmbeddedCovers
|
||||
import org.oxycblt.musikr.covers.embedded.MutableEmbeddedCovers
|
||||
import org.oxycblt.musikr.fs.app.AppFS
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Metadata
|
||||
|
||||
|
@ -39,20 +39,20 @@ class BaseSiloedCovers(private val context: Context) : Covers<FileCover> {
|
|||
override suspend fun obtain(id: String): CoverResult<FileCover> {
|
||||
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)) {
|
||||
val embeddedCovers = EmbeddedCovers(core.files, core.format)
|
||||
return when (val result = embeddedCovers.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) :
|
||||
open class SiloedCovers(private val silo: CoverSilo, private val embeddedCovers: EmbeddedCovers) :
|
||||
Covers<FileCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FileCover> {
|
||||
val coverId = SiloedCoverId.parse(id) ?: return CoverResult.Miss()
|
||||
if (silo != coverId.silo) return CoverResult.Miss()
|
||||
return when (val result = fileCovers.obtain(coverId.id)) {
|
||||
return when (val result = embeddedCovers.obtain(coverId.id)) {
|
||||
is CoverResult.Hit -> CoverResult.Hit(SiloedCover(silo, result.cover))
|
||||
is CoverResult.Miss -> CoverResult.Miss()
|
||||
}
|
||||
|
@ -61,7 +61,7 @@ open class SiloedCovers(private val silo: CoverSilo, private val fileCovers: Fil
|
|||
companion object {
|
||||
suspend fun from(context: Context, silo: CoverSilo): SiloedCovers {
|
||||
val core = SiloCore.from(context, silo)
|
||||
return SiloedCovers(silo, FileCovers(core.files, core.format))
|
||||
return SiloedCovers(silo, EmbeddedCovers(core.files, core.format))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -70,7 +70,7 @@ class MutableSiloedCovers
|
|||
private constructor(
|
||||
private val rootDir: File,
|
||||
private val silo: CoverSilo,
|
||||
private val fileCovers: MutableFileCovers
|
||||
private val fileCovers: MutableEmbeddedCovers
|
||||
) : SiloedCovers(silo, fileCovers), MutableCovers<FileCover> {
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> =
|
||||
when (val result = fileCovers.create(file, metadata)) {
|
||||
|
@ -96,7 +96,7 @@ private constructor(
|
|||
): MutableSiloedCovers {
|
||||
val core = SiloCore.from(context, silo)
|
||||
return MutableSiloedCovers(
|
||||
core.rootDir, silo, MutableFileCovers(core.files, core.format, coverIdentifier))
|
||||
core.rootDir, silo, MutableEmbeddedCovers(core.files, core.format, coverIdentifier))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -120,7 +120,7 @@ data class SiloedCoverId(val silo: CoverSilo, val id: String) {
|
|||
}
|
||||
}
|
||||
|
||||
private data class SiloCore(val rootDir: File, val files: AppFiles, val format: CoverFormat) {
|
||||
private data class SiloCore(val rootDir: File, val files: AppFS, val format: CoverFormat) {
|
||||
companion object {
|
||||
suspend fun from(context: Context, silo: CoverSilo): SiloCore {
|
||||
val rootDir: File
|
||||
|
@ -129,7 +129,7 @@ private data class SiloCore(val rootDir: File, val files: AppFiles, val format:
|
|||
rootDir = context.coversDir()
|
||||
revisionDir = rootDir.resolve(silo.toString()).apply { mkdirs() }
|
||||
}
|
||||
val files = AppFiles.at(revisionDir)
|
||||
val files = AppFS.at(revisionDir)
|
||||
val format = silo.params?.let(CoverFormat::jpeg) ?: CoverFormat.asIs()
|
||||
return SiloCore(rootDir, files, format)
|
||||
}
|
||||
|
|
|
@ -29,6 +29,7 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.coroutines.yield
|
||||
import org.oxycblt.auxio.image.covers.SettingCovers
|
||||
import org.oxycblt.auxio.music.MusicRepository.IndexingWorker
|
||||
import org.oxycblt.auxio.music.shim.WriteOnlyMutableCache
|
||||
import org.oxycblt.musikr.IndexingProgress
|
||||
import org.oxycblt.musikr.Interpretation
|
||||
import org.oxycblt.musikr.Library
|
||||
|
@ -38,7 +39,7 @@ import org.oxycblt.musikr.MutableLibrary
|
|||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.Storage
|
||||
import org.oxycblt.musikr.cache.StoredCache
|
||||
import org.oxycblt.musikr.cache.db.MutableDBCache
|
||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||
import org.oxycblt.musikr.tag.interpret.Naming
|
||||
import org.oxycblt.musikr.tag.interpret.Separators
|
||||
|
@ -236,7 +237,7 @@ class MusicRepositoryImpl
|
|||
@Inject
|
||||
constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
private val storedCache: StoredCache,
|
||||
private val dbCache: MutableDBCache,
|
||||
private val storedPlaylists: StoredPlaylists,
|
||||
private val settingCovers: SettingCovers,
|
||||
private val musicSettings: MusicSettings
|
||||
|
@ -384,15 +385,14 @@ constructor(
|
|||
Naming.simple()
|
||||
}
|
||||
val locations = musicSettings.musicLocations
|
||||
val ignoreHidden = musicSettings.ignoreHidden
|
||||
val withHidden = musicSettings.withHidden
|
||||
|
||||
val currentRevision = musicSettings.revision
|
||||
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
|
||||
val cache = if (withCache) storedCache.visible() else storedCache.invisible()
|
||||
val cache = if (withCache) dbCache else WriteOnlyMutableCache(dbCache)
|
||||
val covers = settingCovers.mutate(context, newRevision)
|
||||
val storage = Storage(cache, covers, storedPlaylists)
|
||||
val interpretation = Interpretation(nameFactory, separators, ignoreHidden)
|
||||
|
||||
val interpretation = Interpretation(nameFactory, separators, withHidden)
|
||||
val result =
|
||||
Musikr.new(context, storage, interpretation).run(locations, ::emitIndexingProgress)
|
||||
// Music loading completed, update the revision right now so we re-use this work
|
||||
|
|
|
@ -41,7 +41,7 @@ interface MusicSettings : Settings<MusicSettings.Listener> {
|
|||
/** 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 ignoreHidden: Boolean
|
||||
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. */
|
||||
|
@ -92,8 +92,8 @@ 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 ignoreHidden: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_ignore_hidden), true)
|
||||
override val withHidden: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_with_hidden), true)
|
||||
|
||||
override val shouldBeObserving: Boolean
|
||||
get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false)
|
||||
|
@ -122,7 +122,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext private val cont
|
|||
}
|
||||
getString(R.string.set_key_separators),
|
||||
getString(R.string.set_key_auto_sort_names),
|
||||
getString(R.string.set_key_ignore_hidden),
|
||||
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()
|
||||
|
|
|
@ -25,7 +25,7 @@ import dagger.hilt.InstallIn
|
|||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
import org.oxycblt.musikr.cache.StoredCache
|
||||
import org.oxycblt.musikr.cache.db.MutableDBCache
|
||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||
|
||||
@Module
|
||||
|
@ -33,7 +33,7 @@ import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
|||
class MusikrShimModule {
|
||||
@Singleton
|
||||
@Provides
|
||||
fun storedCache(@ApplicationContext context: Context) = StoredCache.from(context)
|
||||
fun cache(@ApplicationContext context: Context) = MutableDBCache.from(context)
|
||||
|
||||
@Singleton
|
||||
@Provides
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* WriteOnlyMutableCache.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.auxio.music.shim
|
||||
|
||||
import org.oxycblt.musikr.cache.CacheResult
|
||||
import org.oxycblt.musikr.cache.CachedSong
|
||||
import org.oxycblt.musikr.cache.MutableCache
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
|
||||
class WriteOnlyMutableCache(private val inner: MutableCache) : MutableCache {
|
||||
override suspend fun read(file: DeviceFile): CacheResult {
|
||||
return when (val result = inner.read(file)) {
|
||||
is CacheResult.Hit -> CacheResult.Stale(file, result.song.addedMs)
|
||||
else -> result
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun write(cachedSong: CachedSong) {
|
||||
inner.write(cachedSong)
|
||||
}
|
||||
|
||||
override suspend fun cleanup(excluding: List<CachedSong>) {
|
||||
inner.cleanup(excluding)
|
||||
}
|
||||
}
|
|
@ -67,7 +67,7 @@ class MusicPreferenceFragment : BasePreferenceFragment(R.xml.preferences_music)
|
|||
true
|
||||
}
|
||||
}
|
||||
if (preference.key == getString(R.string.set_key_ignore_hidden)) {
|
||||
if (preference.key == getString(R.string.set_key_with_hidden)) {
|
||||
L.d("Configuring ignore hidden files setting")
|
||||
preference.onPreferenceChangeListener =
|
||||
Preference.OnPreferenceChangeListener { _, _ ->
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
@ -334,7 +334,7 @@
|
|||
<string name="lng_empty_genres">Os seus gêneros aparecerão aqui.</string>
|
||||
<string name="set_cover_mode_save_space">Economizar espaço</string>
|
||||
<string name="set_locations_new">Nova pasta</string>
|
||||
<string name="set_ignore_hidden_desc">Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache)</string>
|
||||
<string name="set_ignore_hidden">Ignorar arquivos ocultos</string>
|
||||
<string name="set_cover_mode_as_is">Qualidade original</string>
|
||||
<string name="set_with_hidden_desc">Ignorar arquivos e pastas que estão ocultos (por exemplo, .cache)</string>
|
||||
<string name="set_with_hidden">Ignorar arquivos ocultos</string>
|
||||
</resources>
|
||||
|
|
145
app/src/main/res/values/colors_ui_black.xml
Normal file
145
app/src/main/res/values/colors_ui_black.xml
Normal file
|
@ -0,0 +1,145 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="red_surface_black">#000000</color>
|
||||
<color name="red_surfaceDim_black">#000000</color>
|
||||
<color name="red_surfaceBright_black">#211b1a</color>
|
||||
<color name="red_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="red_surfaceContainerLow_black">#110c0c</color>
|
||||
<color name="red_surfaceContainer_black">#130e0e</color>
|
||||
<color name="red_surfaceContainerHigh_black">#181413</color>
|
||||
<color name="red_surfaceContainerHighest_black">#1e1918</color>
|
||||
|
||||
<color name="pink_surface_black">#000000</color>
|
||||
<color name="pink_surfaceDim_black">#000000</color>
|
||||
<color name="pink_surfaceBright_black">#201b1b</color>
|
||||
<color name="pink_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="pink_surfaceContainerLow_black">#110c0d</color>
|
||||
<color name="pink_surfaceContainer_black">#130e0f</color>
|
||||
<color name="pink_surfaceContainerHigh_black">#181414</color>
|
||||
<color name="pink_surfaceContainerHighest_black">#1e1819</color>
|
||||
|
||||
<color name="purple_surface_black">#000000</color>
|
||||
<color name="purple_surfaceDim_black">#000000</color>
|
||||
<color name="purple_surfaceBright_black">#1f1b1e</color>
|
||||
<color name="purple_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="purple_surfaceContainerLow_black">#0f0c0f</color>
|
||||
<color name="purple_surfaceContainer_black">#110e11</color>
|
||||
<color name="purple_surfaceContainerHigh_black">#171416</color>
|
||||
<color name="purple_surfaceContainerHighest_black">#1c191c</color>
|
||||
|
||||
<color name="deep_purple_surface_black">#000000</color>
|
||||
<color name="deep_purple_surfaceDim_black">#000000</color>
|
||||
<color name="deep_purple_surfaceBright_black">#1d1c1f</color>
|
||||
<color name="deep_purple_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="deep_purple_surfaceContainerLow_black">#0e0d10</color>
|
||||
<color name="deep_purple_surfaceContainer_black">#100f12</color>
|
||||
<color name="deep_purple_surfaceContainerHigh_black">#161417</color>
|
||||
<color name="deep_purple_surfaceContainerHighest_black">#1b1a1d</color>
|
||||
|
||||
<color name="indigo_surface_black">#000000</color>
|
||||
<color name="indigo_surfaceDim_black">#000000</color>
|
||||
<color name="indigo_surfaceBright_black">#1c1c1f</color>
|
||||
<color name="indigo_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="indigo_surfaceContainerLow_black">#0d0d10</color>
|
||||
<color name="indigo_surfaceContainer_black">#0f0f12</color>
|
||||
<color name="indigo_surfaceContainerHigh_black">#141417</color>
|
||||
<color name="indigo_surfaceContainerHighest_black">#1a1a1d</color>
|
||||
|
||||
<color name="blue_surface_black">#000000</color>
|
||||
<color name="blue_surfaceDim_black">#000000</color>
|
||||
<color name="blue_surfaceBright_black">#1b1c1e</color>
|
||||
<color name="blue_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="blue_surfaceContainerLow_black">#0c0d0f</color>
|
||||
<color name="blue_surfaceContainer_black">#0e1012</color>
|
||||
<color name="blue_surfaceContainerHigh_black">#131517</color>
|
||||
<color name="blue_surfaceContainerHighest_black">#191a1c</color>
|
||||
|
||||
<color name="deep_blue_surface_black">#000000</color>
|
||||
<color name="deep_blue_surfaceDim_black">#000000</color>
|
||||
<color name="deep_blue_surfaceBright_black">#1a1d1e</color>
|
||||
<color name="deep_blue_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="deep_blue_surfaceContainerLow_black">#0b0e0f</color>
|
||||
<color name="deep_blue_surfaceContainer_black">#0d1011</color>
|
||||
<color name="deep_blue_surfaceContainerHigh_black">#121517</color>
|
||||
<color name="deep_blue_surfaceContainerHighest_black">#181a1c</color>
|
||||
|
||||
<color name="cyan_surface_black">#000000</color>
|
||||
<color name="cyan_surfaceDim_black">#000000</color>
|
||||
<color name="cyan_surfaceBright_black">#1a1d1e</color>
|
||||
<color name="cyan_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="cyan_surfaceContainerLow_black">#0b0e0e</color>
|
||||
<color name="cyan_surfaceContainer_black">#0d1010</color>
|
||||
<color name="cyan_surfaceContainerHigh_black">#121515</color>
|
||||
<color name="cyan_surfaceContainerHighest_black">#181b1b</color>
|
||||
|
||||
<color name="teal_surface_black">#000000</color>
|
||||
<color name="teal_surfaceDim_black">#000000</color>
|
||||
<color name="teal_surfaceBright_black">#1a1d1c</color>
|
||||
<color name="teal_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="teal_surfaceContainerLow_black">#0b0e0d</color>
|
||||
<color name="teal_surfaceContainer_black">#0d100f</color>
|
||||
<color name="teal_surfaceContainerHigh_black">#121514</color>
|
||||
<color name="teal_surfaceContainerHighest_black">#181b1a</color>
|
||||
|
||||
<color name="green_surface_black">#000000</color>
|
||||
<color name="green_surfaceDim_black">#000000</color>
|
||||
<color name="green_surfaceBright_black">#1b1d1a</color>
|
||||
<color name="green_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="green_surfaceContainerLow_black">#0c0e0b</color>
|
||||
<color name="green_surfaceContainer_black">#0e100d</color>
|
||||
<color name="green_surfaceContainerHigh_black">#131512</color>
|
||||
<color name="green_surfaceContainerHighest_black">#191b18</color>
|
||||
|
||||
<color name="deep_green_surface_black">#000000</color>
|
||||
<color name="deep_green_surfaceDim_black">#000000</color>
|
||||
<color name="deep_green_surfaceBright_black">#1b1d19</color>
|
||||
<color name="deep_green_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="deep_green_surfaceContainerLow_black">#0d0e0a</color>
|
||||
<color name="deep_green_surfaceContainer_black">#0f100d</color>
|
||||
<color name="deep_green_surfaceContainerHigh_black">#141512</color>
|
||||
<color name="deep_green_surfaceContainerHighest_black">#191b16</color>
|
||||
|
||||
<color name="lime_surface_black">#000000</color>
|
||||
<color name="lime_surfaceDim_black">#000000</color>
|
||||
<color name="lime_surfaceBright_black">#1c1d18</color>
|
||||
<color name="lime_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="lime_surfaceContainerLow_black">#0d0e09</color>
|
||||
<color name="lime_surfaceContainer_black">#10100c</color>
|
||||
<color name="lime_surfaceContainerHigh_black">#141511</color>
|
||||
<color name="lime_surfaceContainerHighest_black">#1a1a16</color>
|
||||
|
||||
<color name="yellow_surface_black">#000000</color>
|
||||
<color name="yellow_surfaceDim_black">#000000</color>
|
||||
<color name="yellow_surfaceBright_black">#1f1c17</color>
|
||||
<color name="yellow_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="yellow_surfaceContainerLow_black">#100d09</color>
|
||||
<color name="yellow_surfaceContainer_black">#120f0b</color>
|
||||
<color name="yellow_surfaceContainerHigh_black">#171410</color>
|
||||
<color name="yellow_surfaceContainerHighest_black">#1d1a15</color>
|
||||
|
||||
<color name="orange_surface_black">#000000</color>
|
||||
<color name="orange_surfaceDim_black">#000000</color>
|
||||
<color name="orange_surfaceBright_black">#201b18</color>
|
||||
<color name="orange_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="orange_surfaceContainerLow_black">#110d0a</color>
|
||||
<color name="orange_surfaceContainer_black">#130f0c</color>
|
||||
<color name="orange_surfaceContainerHigh_black">#181411</color>
|
||||
<color name="orange_surfaceContainerHighest_black">#1d1916</color>
|
||||
<color name="brown_surface_black">#000000</color>
|
||||
<color name="brown_surfaceDim_black">#000000</color>
|
||||
<color name="brown_surfaceBright_black">#1e1c1b</color>
|
||||
<color name="brown_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="brown_surfaceContainerLow_black">#0f0d0d</color>
|
||||
<color name="brown_surfaceContainer_black">#110f0f</color>
|
||||
<color name="brown_surfaceContainerHigh_black">#161414</color>
|
||||
<color name="brown_surfaceContainerHighest_black">#1b1a19</color>
|
||||
|
||||
<color name="grey_surface_black">#000000</color>
|
||||
<color name="grey_surfaceDim_black">#000000</color>
|
||||
<color name="grey_surfaceBright_black">#1d1c1c</color>
|
||||
<color name="grey_surfaceContainerLowest_black">#000000</color>
|
||||
<color name="grey_surfaceContainerLow_black">#0e0d0d</color>
|
||||
<color name="grey_surfaceContainer_black">#100f0f</color>
|
||||
<color name="grey_surfaceContainerHigh_black">#151515</color>
|
||||
<color name="grey_surfaceContainerHighest_black">#1a1a1a</color>
|
||||
</resources>
|
|
@ -18,7 +18,7 @@
|
|||
<string name="set_key_square_covers" translatable="false">auxio_square_covers</string>
|
||||
<string name="set_key_music_dirs_include" translatable="false">auxio_include_dirs</string>
|
||||
<string name="set_key_exclude_non_music" translatable="false">auxio_exclude_non_music</string>
|
||||
<string name="set_key_ignore_hidden" translatable="false">auxio_ignore_hidden</string>
|
||||
<string name="set_key_with_hidden" translatable="false">auxio_with_hidden</string>
|
||||
<string name="set_key_music_locations" translatable="false">auxio_music_locations2</string>
|
||||
<string name="set_key_separators" translatable="false">auxio_separators</string>
|
||||
<string name="set_key_auto_sort_names" translatable="false">auxio_auto_sort_names</string>
|
||||
|
|
|
@ -267,8 +267,8 @@
|
|||
<string name="set_observing_desc">Reload the music library whenever it changes (requires persistent notification)</string>
|
||||
<string name="set_exclude_non_music">Exclude non-music</string>
|
||||
<string name="set_exclude_non_music_desc">Ignore audio files that are not music, such as podcasts</string>
|
||||
<string name="set_ignore_hidden">Ignore hidden files</string>
|
||||
<string name="set_ignore_hidden_desc">Skip files and folders that are hidden (ex. .cache)</string>
|
||||
<string name="set_with_hidden">Include hidden files</string>
|
||||
<string name="set_with_hidden_desc">Include audio files that are hidden (ex. .cache)</string>
|
||||
<string name="set_separators">Multi-value separators</string>
|
||||
<string name="set_separators_desc">Configure characters that denote multiple tag values</string>
|
||||
<string name="set_separators_comma">Comma (,)</string>
|
||||
|
|
|
@ -1,71 +1,173 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<resources>
|
||||
|
||||
<style name="Theme.Auxio.Black" parent="Theme.Auxio.Base">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<item name="colorSurfaceDim">@color/m3_ref_palette_dynamic_neutral_variant4</item>
|
||||
<item name="colorSurfaceBright">@color/m3_ref_palette_dynamic_neutral_variant12</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/m3_ref_palette_dynamic_neutral_variant0</item>
|
||||
<item name="colorSurfaceContainerLow">@color/m3_ref_palette_dynamic_neutral_variant4</item>
|
||||
<item name="colorSurfaceContainer">@color/m3_ref_palette_dynamic_neutral_variant6</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/m3_ref_palette_dynamic_neutral_variant10</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/m3_ref_palette_dynamic_neutral_variant12</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Red" parent="Theme.Auxio.Red">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Red.Black" parent="Theme.Auxio.Red">
|
||||
<item name="colorSurface">@color/red_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/red_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/red_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/red_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/red_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/red_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/red_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/red_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Pink" parent="Theme.Auxio.Pink">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Pink.Black" parent="Theme.Auxio.Pink">
|
||||
<item name="colorSurface">@color/pink_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/pink_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/pink_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/pink_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/pink_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/pink_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/pink_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/pink_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Purple" parent="Theme.Auxio.Purple">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Purple.Black" parent="Theme.Auxio.Purple">
|
||||
<item name="colorSurface">@color/purple_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/purple_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/purple_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/purple_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/purple_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/purple_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/purple_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/purple_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.DeepPurple" parent="Theme.Auxio.DeepPurple">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.DeepPurple.Black" parent="Theme.Auxio.DeepPurple">
|
||||
<item name="colorSurface">@color/deep_purple_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/deep_purple_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/deep_purple_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/deep_purple_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/deep_purple_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/deep_purple_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/deep_purple_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/deep_purple_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Indigo" parent="Theme.Auxio.Indigo">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Indigo.Black" parent="Theme.Auxio.Indigo">
|
||||
<item name="colorSurface">@color/indigo_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/indigo_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/indigo_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/indigo_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/indigo_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/indigo_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/indigo_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/indigo_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Blue" parent="Theme.Auxio.Blue">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Blue.Black" parent="Theme.Auxio.Blue">
|
||||
<item name="colorSurface">@color/blue_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/blue_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/blue_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/blue_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/blue_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/blue_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/blue_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/blue_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.DeepBlue" parent="Theme.Auxio.DeepBlue">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.DeepBlue.Black" parent="Theme.Auxio.DeepBlue">
|
||||
<item name="colorSurface">@color/deep_blue_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/deep_blue_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/deep_blue_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/deep_blue_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/deep_blue_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/deep_blue_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/deep_blue_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/deep_blue_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Cyan" parent="Theme.Auxio.Cyan">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Cyan.Black" parent="Theme.Auxio.Cyan">
|
||||
<item name="colorSurface">@color/cyan_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/cyan_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/cyan_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/cyan_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/cyan_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/cyan_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/cyan_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/cyan_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Teal" parent="Theme.Auxio.Teal">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Teal.Black" parent="Theme.Auxio.Teal">
|
||||
<item name="colorSurface">@color/teal_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/teal_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/teal_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/teal_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/teal_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/teal_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/teal_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/teal_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Green" parent="Theme.Auxio.Green">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Green.Black" parent="Theme.Auxio.Green">
|
||||
<item name="colorSurface">@color/green_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/green_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/green_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/green_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/green_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/green_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/green_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/green_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.DeepGreen" parent="Theme.Auxio.DeepGreen">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.DeepGreen.Black" parent="Theme.Auxio.DeepGreen">
|
||||
<item name="colorSurface">@color/deep_green_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/deep_green_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/deep_green_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/deep_green_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/deep_green_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/deep_green_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/deep_green_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/deep_green_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Lime" parent="Theme.Auxio.Lime">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Lime.Black" parent="Theme.Auxio.Lime">
|
||||
<item name="colorSurface">@color/lime_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/lime_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/lime_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/lime_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/lime_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/lime_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/lime_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/lime_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Yellow" parent="Theme.Auxio.Yellow">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Yellow.Black" parent="Theme.Auxio.Yellow">
|
||||
<item name="colorSurface">@color/yellow_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/yellow_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/yellow_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/yellow_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/yellow_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/yellow_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/yellow_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/yellow_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Orange" parent="Theme.Auxio.Orange">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Orange.Black" parent="Theme.Auxio.Orange">
|
||||
<item name="colorSurface">@color/orange_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/orange_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/orange_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/orange_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/orange_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/orange_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/orange_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/orange_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Brown" parent="Theme.Auxio.Brown">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Brown.Black" parent="Theme.Auxio.Brown">
|
||||
<item name="colorSurface">@color/brown_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/brown_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/brown_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/brown_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/brown_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/brown_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/brown_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/brown_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
|
||||
<style name="Theme.Auxio.Black.Grey" parent="Theme.Auxio.Grey">
|
||||
<item name="colorSurface">@android:color/black</item>
|
||||
<style name="Theme.Auxio.Grey.Black" parent="Theme.Auxio.Grey">
|
||||
<item name="colorSurface">@color/grey_surface_black</item>
|
||||
<item name="colorSurfaceDim">@color/grey_surfaceDim_black</item>
|
||||
<item name="colorSurfaceBright">@color/grey_surfaceBright_black</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/grey_surfaceContainerLowest_black</item>
|
||||
<item name="colorSurfaceContainerLow">@color/grey_surfaceContainerLow_black</item>
|
||||
<item name="colorSurfaceContainer">@color/grey_surfaceContainer_black</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/grey_surfaceContainerHigh_black</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/grey_surfaceContainerHighest_black</item>
|
||||
</style>
|
||||
</resources>
|
|
@ -17,9 +17,9 @@
|
|||
|
||||
<SwitchPreferenceCompat
|
||||
app:defaultValue="true"
|
||||
app:key="@string/set_key_ignore_hidden"
|
||||
app:summary="@string/set_ignore_hidden_desc"
|
||||
app:title="@string/set_ignore_hidden" />
|
||||
app:key="@string/set_key_with_hidden"
|
||||
app:summary="@string/set_with_hidden_desc"
|
||||
app:title="@string/set_with_hidden" />
|
||||
|
||||
<org.oxycblt.auxio.settings.ui.WrappedDialogPreference
|
||||
app:key="@string/set_key_separators"
|
||||
|
|
4
fastlane/metadata/android/en-US/changelogs/61.txt
Normal file
4
fastlane/metadata/android/en-US/changelogs/61.txt
Normal file
|
@ -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.
|
|
@ -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<TagLib::MPEG::File*>(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<TagLib::MP4::File*>(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<TagLib::FLAC::File*>(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<TagLib::Ogg::Opus::File*>(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<TagLib::Ogg::Vorbis::File*>(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<TagLib::RIFF::WAV::File*>(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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,9 @@
|
|||
|
||||
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.cache.MutableCache
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||
import org.oxycblt.musikr.tag.interpret.Naming
|
||||
import org.oxycblt.musikr.tag.interpret.Separators
|
||||
|
@ -28,17 +28,17 @@ import org.oxycblt.musikr.tag.interpret.Separators
|
|||
/** Side-effect laden [Storage] for use during music loading and [MutableLibrary] operation. */
|
||||
data class Storage(
|
||||
/**
|
||||
* A factory producing a repository of cached metadata to read and write from over the course of
|
||||
* music loading. This will only be used during music loading.
|
||||
* A repository of cached metadata to read and write from over the course of music loading. This
|
||||
* will only be used during music loading.
|
||||
*/
|
||||
val cache: Cache.Factory,
|
||||
val cache: MutableCache,
|
||||
|
||||
/**
|
||||
* A repository of cover images to for re-use during music loading. Should be kept in lock-step
|
||||
* with the cache for best performance. This will be used during music loading and when
|
||||
* retrieving cover information from the library.
|
||||
*/
|
||||
val storedCovers: MutableCovers<out Cover>,
|
||||
val covers: MutableCovers<out Cover>,
|
||||
|
||||
/**
|
||||
* A repository of user-created playlists that should also be loaded into the library. This will
|
||||
|
@ -56,6 +56,6 @@ data class Interpretation(
|
|||
/** What separators delimit multi-value audio tags. */
|
||||
val separators: Separators,
|
||||
|
||||
/** Whether to ignore hidden files and directories (those starting with a dot). */
|
||||
val ignoreHidden: Boolean = true
|
||||
/** Whether to include hidden files and directories (those starting with a dot). */
|
||||
val withHidden: Boolean
|
||||
)
|
||||
|
|
|
@ -25,8 +25,8 @@ import java.security.MessageDigest
|
|||
import java.util.UUID
|
||||
import kotlinx.parcelize.IgnoredOnParcel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.oxycblt.musikr.cover.Cover
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.musikr.fs.Format
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
@ -143,6 +143,6 @@ private class LibraryResultImpl(
|
|||
override val library: MutableLibrary
|
||||
) : LibraryResult {
|
||||
override suspend fun cleanup() {
|
||||
storage.storedCovers.cleanup(library.songs.mapNotNull { it.cover })
|
||||
storage.covers.cleanup(library.songs.mapNotNull { it.cover })
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,25 +18,32 @@
|
|||
|
||||
package org.oxycblt.musikr.cache
|
||||
|
||||
import org.oxycblt.musikr.cover.Cover
|
||||
import org.oxycblt.musikr.cover.Covers
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.pipeline.RawSong
|
||||
import org.oxycblt.musikr.metadata.Properties
|
||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||
|
||||
abstract class Cache {
|
||||
internal abstract suspend fun read(file: DeviceFile, covers: Covers<out Cover>): CacheResult
|
||||
|
||||
internal abstract suspend fun write(song: RawSong)
|
||||
|
||||
internal abstract suspend fun finalize()
|
||||
|
||||
abstract class Factory {
|
||||
internal abstract fun open(): Cache
|
||||
}
|
||||
interface Cache {
|
||||
suspend fun read(file: DeviceFile): CacheResult
|
||||
}
|
||||
|
||||
internal sealed interface CacheResult {
|
||||
data class Hit(val song: RawSong) : CacheResult
|
||||
interface MutableCache : Cache {
|
||||
suspend fun write(cachedSong: CachedSong)
|
||||
|
||||
data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult
|
||||
suspend fun cleanup(excluding: List<CachedSong>)
|
||||
}
|
||||
|
||||
data class CachedSong(
|
||||
val file: DeviceFile,
|
||||
val properties: Properties,
|
||||
val tags: ParsedTags,
|
||||
val coverId: String?,
|
||||
val addedMs: Long
|
||||
)
|
||||
|
||||
sealed interface CacheResult {
|
||||
data class Hit(val song: CachedSong) : CacheResult
|
||||
|
||||
data class Miss(val file: DeviceFile) : CacheResult
|
||||
|
||||
data class Stale(val file: DeviceFile, val addedMs: Long) : CacheResult
|
||||
}
|
||||
|
|
|
@ -1,209 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CacheDatabase.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cache
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
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.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Properties
|
||||
import org.oxycblt.musikr.pipeline.RawSong
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
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)
|
||||
internal abstract class CacheDatabase : RoomDatabase() {
|
||||
abstract fun visibleDao(): VisibleCacheDao
|
||||
|
||||
abstract fun invisibleDao(): InvisibleCacheDao
|
||||
|
||||
abstract fun writeDao(): CacheWriteDao
|
||||
|
||||
companion object {
|
||||
fun from(context: Context) =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
internal interface VisibleCacheDao {
|
||||
@Query("SELECT * FROM CachedSong WHERE uri = :uri")
|
||||
suspend fun selectSong(uri: String): CachedSong?
|
||||
|
||||
@Query("SELECT addedMs FROM CachedSong WHERE uri = :uri")
|
||||
suspend fun selectAddedMs(uri: String): Long?
|
||||
|
||||
@Transaction suspend fun touch(uri: String) = updateTouchedNs(uri, System.nanoTime())
|
||||
|
||||
@Query("UPDATE CachedSong SET touchedNs = :nowNs WHERE uri = :uri")
|
||||
suspend fun updateTouchedNs(uri: String, nowNs: Long)
|
||||
}
|
||||
|
||||
@Dao
|
||||
internal interface InvisibleCacheDao {
|
||||
@Query("SELECT addedMs FROM CachedSong WHERE uri = :uri")
|
||||
suspend fun selectAddedMs(uri: String): Long?
|
||||
}
|
||||
|
||||
@Dao
|
||||
internal interface CacheWriteDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong)
|
||||
|
||||
@Query("DELETE FROM CachedSong WHERE touchedNs < :now") suspend fun pruneOlderThan(now: Long)
|
||||
}
|
||||
|
||||
@Entity
|
||||
@TypeConverters(CachedSong.Converters::class)
|
||||
internal data class CachedSong(
|
||||
@PrimaryKey val uri: String,
|
||||
val modifiedMs: Long,
|
||||
val addedMs: Long,
|
||||
val touchedNs: Long,
|
||||
val mimeType: String,
|
||||
val durationMs: Long,
|
||||
val bitrateHz: Int,
|
||||
val sampleRateHz: Int,
|
||||
val musicBrainzId: String?,
|
||||
val name: String?,
|
||||
val sortName: String?,
|
||||
val track: Int?,
|
||||
val disc: Int?,
|
||||
val subtitle: String?,
|
||||
val date: Date?,
|
||||
val albumMusicBrainzId: String?,
|
||||
val albumName: String?,
|
||||
val albumSortName: String?,
|
||||
val releaseTypes: List<String>,
|
||||
val artistMusicBrainzIds: List<String>,
|
||||
val artistNames: List<String>,
|
||||
val artistSortNames: List<String>,
|
||||
val albumArtistMusicBrainzIds: List<String>,
|
||||
val albumArtistNames: List<String>,
|
||||
val albumArtistSortNames: List<String>,
|
||||
val genreNames: List<String>,
|
||||
val replayGainTrackAdjustment: Float?,
|
||||
val replayGainAlbumAdjustment: Float?,
|
||||
val coverId: String?,
|
||||
) {
|
||||
suspend fun intoRawSong(file: DeviceFile, covers: Covers<out Cover>): RawSong? {
|
||||
val cover =
|
||||
when (val result = coverId?.let { covers.obtain(it) }) {
|
||||
// We found the cover.
|
||||
is CoverResult.Hit<out Cover> -> result.cover
|
||||
// We actually didn't find the cover, can't safely convert.
|
||||
is CoverResult.Miss<out Cover> -> return null
|
||||
// No cover in the first place, can ignore.
|
||||
null -> null
|
||||
}
|
||||
return RawSong(
|
||||
file,
|
||||
Properties(mimeType, durationMs, bitrateHz, sampleRateHz),
|
||||
ParsedTags(
|
||||
musicBrainzId = musicBrainzId,
|
||||
name = name,
|
||||
sortName = sortName,
|
||||
durationMs = durationMs,
|
||||
track = track,
|
||||
disc = disc,
|
||||
subtitle = subtitle,
|
||||
date = date,
|
||||
albumMusicBrainzId = albumMusicBrainzId,
|
||||
albumName = albumName,
|
||||
albumSortName = albumSortName,
|
||||
releaseTypes = releaseTypes,
|
||||
artistMusicBrainzIds = artistMusicBrainzIds,
|
||||
artistNames = artistNames,
|
||||
artistSortNames = artistSortNames,
|
||||
albumArtistMusicBrainzIds = albumArtistMusicBrainzIds,
|
||||
albumArtistNames = albumArtistNames,
|
||||
albumArtistSortNames = albumArtistSortNames,
|
||||
genreNames = genreNames,
|
||||
replayGainTrackAdjustment = replayGainTrackAdjustment,
|
||||
replayGainAlbumAdjustment = replayGainAlbumAdjustment),
|
||||
cover = cover,
|
||||
addedMs = addedMs)
|
||||
}
|
||||
|
||||
object Converters {
|
||||
@TypeConverter
|
||||
fun fromMultiValue(values: List<String>) =
|
||||
values.joinToString(";") { it.replace(";", "\\;") }
|
||||
|
||||
@TypeConverter
|
||||
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
|
||||
|
||||
@TypeConverter fun fromDate(date: Date?) = date?.toString()
|
||||
|
||||
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromRawSong(rawSong: RawSong) =
|
||||
CachedSong(
|
||||
uri = rawSong.file.uri.toString(),
|
||||
modifiedMs = rawSong.file.modifiedMs,
|
||||
addedMs = rawSong.addedMs,
|
||||
// Should be strictly monotonic so we don't prune this
|
||||
// by accident later.
|
||||
touchedNs = System.nanoTime(),
|
||||
musicBrainzId = rawSong.tags.musicBrainzId,
|
||||
name = rawSong.tags.name,
|
||||
sortName = rawSong.tags.sortName,
|
||||
durationMs = rawSong.tags.durationMs,
|
||||
track = rawSong.tags.track,
|
||||
disc = rawSong.tags.disc,
|
||||
subtitle = rawSong.tags.subtitle,
|
||||
date = rawSong.tags.date,
|
||||
albumMusicBrainzId = rawSong.tags.albumMusicBrainzId,
|
||||
albumName = rawSong.tags.albumName,
|
||||
albumSortName = rawSong.tags.albumSortName,
|
||||
releaseTypes = rawSong.tags.releaseTypes,
|
||||
artistMusicBrainzIds = rawSong.tags.artistMusicBrainzIds,
|
||||
artistNames = rawSong.tags.artistNames,
|
||||
artistSortNames = rawSong.tags.artistSortNames,
|
||||
albumArtistMusicBrainzIds = rawSong.tags.albumArtistMusicBrainzIds,
|
||||
albumArtistNames = rawSong.tags.albumArtistNames,
|
||||
albumArtistSortNames = rawSong.tags.albumArtistSortNames,
|
||||
genreNames = rawSong.tags.genreNames,
|
||||
replayGainTrackAdjustment = rawSong.tags.replayGainTrackAdjustment,
|
||||
replayGainAlbumAdjustment = rawSong.tags.replayGainAlbumAdjustment,
|
||||
coverId = rawSong.cover?.id,
|
||||
mimeType = rawSong.properties.mimeType,
|
||||
bitrateHz = rawSong.properties.bitrateKbps,
|
||||
sampleRateHz = rawSong.properties.sampleRateHz)
|
||||
}
|
||||
}
|
|
@ -1,88 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* StoredCache.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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.device.DeviceFile
|
||||
import org.oxycblt.musikr.pipeline.RawSong
|
||||
|
||||
interface StoredCache {
|
||||
fun visible(): Cache.Factory
|
||||
|
||||
fun invisible(): Cache.Factory
|
||||
|
||||
companion object {
|
||||
fun from(context: Context): StoredCache = StoredCacheImpl(CacheDatabase.from(context))
|
||||
}
|
||||
}
|
||||
|
||||
private class StoredCacheImpl(private val cacheDatabase: CacheDatabase) : StoredCache {
|
||||
override fun visible(): Cache.Factory = VisibleStoredCache.Factory(cacheDatabase)
|
||||
|
||||
override fun invisible(): Cache.Factory = InvisibleStoredCache.Factory(cacheDatabase)
|
||||
}
|
||||
|
||||
private abstract class BaseStoredCache(protected val writeDao: CacheWriteDao) : Cache() {
|
||||
private val created = System.nanoTime()
|
||||
|
||||
override suspend fun write(song: RawSong) = writeDao.updateSong(CachedSong.fromRawSong(song))
|
||||
|
||||
override suspend fun finalize() {
|
||||
// Anything not create during this cache's use implies that it has not been
|
||||
// access during this run and should be pruned.
|
||||
writeDao.pruneOlderThan(created)
|
||||
}
|
||||
}
|
||||
|
||||
private class VisibleStoredCache(private val visibleDao: VisibleCacheDao, writeDao: CacheWriteDao) :
|
||||
BaseStoredCache(writeDao) {
|
||||
override suspend fun read(file: DeviceFile, covers: Covers<out Cover>): 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.
|
||||
// Send back it with the timestamp so it will be re-used.
|
||||
// The touch timestamp will be updated on write.
|
||||
return CacheResult.Miss(file, song.addedMs)
|
||||
}
|
||||
// Valid file, update the touch time.
|
||||
visibleDao.touch(file.uri.toString())
|
||||
val rawSong = song.intoRawSong(file, covers) ?: return CacheResult.Miss(file, song.addedMs)
|
||||
return CacheResult.Hit(rawSong)
|
||||
}
|
||||
|
||||
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
|
||||
override fun open() =
|
||||
VisibleStoredCache(cacheDatabase.visibleDao(), cacheDatabase.writeDao())
|
||||
}
|
||||
}
|
||||
|
||||
private class InvisibleStoredCache(
|
||||
private val invisibleCacheDao: InvisibleCacheDao,
|
||||
writeDao: CacheWriteDao
|
||||
) : BaseStoredCache(writeDao) {
|
||||
override suspend fun read(file: DeviceFile, covers: Covers<out Cover>) =
|
||||
CacheResult.Miss(file, invisibleCacheDao.selectAddedMs(file.uri.toString()))
|
||||
|
||||
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
|
||||
override fun open() =
|
||||
InvisibleStoredCache(cacheDatabase.invisibleDao(), cacheDatabase.writeDao())
|
||||
}
|
||||
}
|
127
musikr/src/main/java/org/oxycblt/musikr/cache/db/CacheDatabase.kt
vendored
Normal file
127
musikr/src/main/java/org/oxycblt/musikr/cache/db/CacheDatabase.kt
vendored
Normal file
|
@ -0,0 +1,127 @@
|
|||
/*
|
||||
* Copyright (c) 2023 Auxio Project
|
||||
* CacheDatabase.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cache.db
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Database
|
||||
import androidx.room.Entity
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.PrimaryKey
|
||||
import androidx.room.Query
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import androidx.room.Transaction
|
||||
import androidx.room.TypeConverter
|
||||
import androidx.room.TypeConverters
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
import org.oxycblt.musikr.util.correctWhitespace
|
||||
import org.oxycblt.musikr.util.splitEscaped
|
||||
|
||||
@Database(entities = [CachedSongData::class], version = 61, exportSchema = false)
|
||||
internal abstract class CacheDatabase : RoomDatabase() {
|
||||
abstract fun readDao(): CacheReadDao
|
||||
|
||||
abstract fun writeDao(): CacheWriteDao
|
||||
|
||||
companion object {
|
||||
fun from(context: Context) =
|
||||
Room.databaseBuilder(
|
||||
context.applicationContext, CacheDatabase::class.java, "music_cache.db")
|
||||
.fallbackToDestructiveMigration()
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
@Dao
|
||||
internal interface CacheReadDao {
|
||||
@Query("SELECT * FROM CachedSongData WHERE uri = :uri")
|
||||
suspend fun selectSong(uri: String): CachedSongData?
|
||||
}
|
||||
|
||||
@Dao
|
||||
internal interface CacheWriteDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun updateSong(CachedSongData: CachedSongData)
|
||||
|
||||
@Transaction
|
||||
suspend fun deleteExcludingUris(uris: Set<String>) {
|
||||
val delete = selectAllUris().toSet() - uris
|
||||
for (chunk in delete.chunked(999)) {
|
||||
deleteExcludingUriChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
@Query("SELECT uri FROM CachedSongData") suspend fun selectAllUris(): List<String>
|
||||
|
||||
@Query("DELETE FROM CachedSongData WHERE uri IN (:uris)")
|
||||
suspend fun deleteExcludingUriChunk(uris: List<String>)
|
||||
}
|
||||
|
||||
@Entity
|
||||
@TypeConverters(CachedSongData.Converters::class)
|
||||
internal data class CachedSongData(
|
||||
@PrimaryKey val uri: Uri,
|
||||
val modifiedMs: Long,
|
||||
val addedMs: Long,
|
||||
val mimeType: String,
|
||||
val durationMs: Long,
|
||||
val bitrateKbps: Int,
|
||||
val sampleRateHz: Int,
|
||||
val musicBrainzId: String?,
|
||||
val name: String?,
|
||||
val sortName: String?,
|
||||
val track: Int?,
|
||||
val disc: Int?,
|
||||
val subtitle: String?,
|
||||
val date: Date?,
|
||||
val albumMusicBrainzId: String?,
|
||||
val albumName: String?,
|
||||
val albumSortName: String?,
|
||||
val releaseTypes: List<String>,
|
||||
val artistMusicBrainzIds: List<String>,
|
||||
val artistNames: List<String>,
|
||||
val artistSortNames: List<String>,
|
||||
val albumArtistMusicBrainzIds: List<String>,
|
||||
val albumArtistNames: List<String>,
|
||||
val albumArtistSortNames: List<String>,
|
||||
val genreNames: List<String>,
|
||||
val replayGainTrackAdjustment: Float?,
|
||||
val replayGainAlbumAdjustment: Float?,
|
||||
val coverId: String?,
|
||||
) {
|
||||
object Converters {
|
||||
@TypeConverter
|
||||
fun fromMultiValue(values: List<String>) =
|
||||
values.joinToString(";") { it.replace(";", "\\;") }
|
||||
|
||||
@TypeConverter
|
||||
fun toMultiValue(string: String) = string.splitEscaped { it == ';' }.correctWhitespace()
|
||||
|
||||
@TypeConverter fun fromDate(date: Date?) = date?.toString()
|
||||
|
||||
@TypeConverter fun toDate(string: String?) = string?.let(Date::from)
|
||||
|
||||
@TypeConverter fun toUri(string: String) = Uri.parse(string)
|
||||
|
||||
@TypeConverter fun fromUri(uri: Uri) = uri.toString()
|
||||
}
|
||||
}
|
120
musikr/src/main/java/org/oxycblt/musikr/cache/db/DBCache.kt
vendored
Normal file
120
musikr/src/main/java/org/oxycblt/musikr/cache/db/DBCache.kt
vendored
Normal file
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* DBCache.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cache.db
|
||||
|
||||
import android.content.Context
|
||||
import org.oxycblt.musikr.cache.Cache
|
||||
import org.oxycblt.musikr.cache.CacheResult
|
||||
import org.oxycblt.musikr.cache.CachedSong
|
||||
import org.oxycblt.musikr.cache.MutableCache
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Properties
|
||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||
|
||||
open class DBCache internal constructor(private val readDao: CacheReadDao) : Cache {
|
||||
override suspend fun read(file: DeviceFile): CacheResult {
|
||||
val dbSong = readDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file)
|
||||
if (dbSong.modifiedMs != file.modifiedMs) {
|
||||
return CacheResult.Stale(file, dbSong.addedMs)
|
||||
}
|
||||
val song =
|
||||
CachedSong(
|
||||
file,
|
||||
Properties(
|
||||
dbSong.mimeType, dbSong.durationMs, dbSong.bitrateKbps, dbSong.sampleRateHz),
|
||||
ParsedTags(
|
||||
musicBrainzId = dbSong.musicBrainzId,
|
||||
name = dbSong.name,
|
||||
sortName = dbSong.sortName,
|
||||
durationMs = dbSong.durationMs,
|
||||
track = dbSong.track,
|
||||
disc = dbSong.disc,
|
||||
subtitle = dbSong.subtitle,
|
||||
date = dbSong.date,
|
||||
albumMusicBrainzId = dbSong.albumMusicBrainzId,
|
||||
albumName = dbSong.albumName,
|
||||
albumSortName = dbSong.albumSortName,
|
||||
releaseTypes = dbSong.releaseTypes,
|
||||
artistMusicBrainzIds = dbSong.artistMusicBrainzIds,
|
||||
artistNames = dbSong.artistNames,
|
||||
artistSortNames = dbSong.artistSortNames,
|
||||
albumArtistMusicBrainzIds = dbSong.albumArtistMusicBrainzIds,
|
||||
albumArtistNames = dbSong.albumArtistNames,
|
||||
albumArtistSortNames = dbSong.albumArtistSortNames,
|
||||
genreNames = dbSong.genreNames,
|
||||
replayGainTrackAdjustment = dbSong.replayGainTrackAdjustment,
|
||||
replayGainAlbumAdjustment = dbSong.replayGainAlbumAdjustment),
|
||||
coverId = dbSong.coverId,
|
||||
addedMs = dbSong.addedMs)
|
||||
return CacheResult.Hit(song)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(context: Context) = DBCache(CacheDatabase.from(context).readDao())
|
||||
}
|
||||
}
|
||||
|
||||
class MutableDBCache
|
||||
private constructor(readDao: CacheReadDao, private val writeDao: CacheWriteDao) :
|
||||
MutableCache, DBCache(readDao) {
|
||||
override suspend fun write(cachedSong: CachedSong) {
|
||||
val dbSong =
|
||||
CachedSongData(
|
||||
uri = cachedSong.file.uri,
|
||||
modifiedMs = cachedSong.file.modifiedMs,
|
||||
addedMs = cachedSong.addedMs,
|
||||
mimeType = cachedSong.properties.mimeType,
|
||||
durationMs = cachedSong.properties.durationMs,
|
||||
bitrateKbps = cachedSong.properties.bitrateKbps,
|
||||
sampleRateHz = cachedSong.properties.sampleRateHz,
|
||||
musicBrainzId = cachedSong.tags.musicBrainzId,
|
||||
name = cachedSong.tags.name,
|
||||
sortName = cachedSong.tags.sortName,
|
||||
track = cachedSong.tags.track,
|
||||
disc = cachedSong.tags.disc,
|
||||
subtitle = cachedSong.tags.subtitle,
|
||||
date = cachedSong.tags.date,
|
||||
albumMusicBrainzId = cachedSong.tags.albumMusicBrainzId,
|
||||
albumName = cachedSong.tags.albumName,
|
||||
albumSortName = cachedSong.tags.albumSortName,
|
||||
releaseTypes = cachedSong.tags.releaseTypes,
|
||||
artistMusicBrainzIds = cachedSong.tags.artistMusicBrainzIds,
|
||||
artistNames = cachedSong.tags.artistNames,
|
||||
artistSortNames = cachedSong.tags.artistSortNames,
|
||||
albumArtistMusicBrainzIds = cachedSong.tags.albumArtistMusicBrainzIds,
|
||||
albumArtistNames = cachedSong.tags.albumArtistNames,
|
||||
albumArtistSortNames = cachedSong.tags.albumArtistSortNames,
|
||||
genreNames = cachedSong.tags.genreNames,
|
||||
replayGainTrackAdjustment = cachedSong.tags.replayGainTrackAdjustment,
|
||||
replayGainAlbumAdjustment = cachedSong.tags.replayGainAlbumAdjustment,
|
||||
coverId = cachedSong.coverId)
|
||||
writeDao.updateSong(dbSong)
|
||||
}
|
||||
|
||||
override suspend fun cleanup(excluding: List<CachedSong>) {
|
||||
writeDao.deleteExcludingUris(excluding.mapTo(mutableSetOf()) { it.file.uri.toString() })
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun from(context: Context): MutableDBCache {
|
||||
val db = CacheDatabase.from(context)
|
||||
return MutableDBCache(db.readDao(), db.writeDao())
|
||||
}
|
||||
}
|
||||
}
|
|
@ -16,8 +16,9 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cover
|
||||
package org.oxycblt.musikr.covers
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import java.io.InputStream
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Metadata
|
||||
|
@ -94,6 +95,10 @@ interface Cover {
|
|||
override fun hashCode(): Int
|
||||
}
|
||||
|
||||
interface FDCover : Cover {
|
||||
suspend fun fd(): ParcelFileDescriptor?
|
||||
}
|
||||
|
||||
class CoverCollection private constructor(val covers: List<Cover>) {
|
||||
override fun hashCode() = covers.hashCode()
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* CoverFormat.kt is part of Auxio.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cover
|
||||
package org.oxycblt.musikr.covers.embedded
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cover
|
||||
package org.oxycblt.musikr.covers.embedded
|
||||
|
||||
import java.security.MessageDigest
|
||||
|
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cover
|
||||
package org.oxycblt.musikr.covers.embedded
|
||||
|
||||
class CoverParams private constructor(val resolution: Int, val quality: Int) {
|
||||
override fun hashCode() = 31 * resolution + quality
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* FileCovers.kt is part of Auxio.
|
||||
* InternalCovers.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
|
||||
|
@ -16,20 +16,24 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cover
|
||||
package org.oxycblt.musikr.covers.embedded
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.covers.FDCover
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.fs.app.AppFS
|
||||
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<FileCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FileCover> {
|
||||
val file = appFiles.find(getFileName(id))
|
||||
open class EmbeddedCovers(private val appFS: AppFS, private val coverFormat: CoverFormat) :
|
||||
Covers<FDCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FDCover> {
|
||||
val file = appFS.find(getFileName(id))
|
||||
return if (file != null) {
|
||||
CoverResult.Hit(FileCoverImpl(id, file))
|
||||
CoverResult.Hit(InternalCoverImpl(id, file))
|
||||
} else {
|
||||
CoverResult.Miss()
|
||||
}
|
||||
|
@ -38,30 +42,26 @@ open class FileCovers(private val appFiles: AppFiles, private val coverFormat: C
|
|||
protected fun getFileName(id: String) = "$id.${coverFormat.extension}"
|
||||
}
|
||||
|
||||
class MutableFileCovers(
|
||||
private val appFiles: AppFiles,
|
||||
class MutableEmbeddedCovers(
|
||||
private val appFS: AppFS,
|
||||
private val coverFormat: CoverFormat,
|
||||
private val coverIdentifier: CoverIdentifier
|
||||
) : FileCovers(appFiles, coverFormat), MutableCovers<FileCover> {
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FileCover> {
|
||||
) : EmbeddedCovers(appFS, coverFormat), MutableCovers<FDCover> {
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
|
||||
val data = metadata.cover ?: return CoverResult.Miss()
|
||||
val id = coverIdentifier.identify(data)
|
||||
val coverFile = appFiles.write(getFileName(id)) { coverFormat.transcodeInto(data, it) }
|
||||
return CoverResult.Hit(FileCoverImpl(id, coverFile))
|
||||
val coverFile = appFS.write(getFileName(id)) { coverFormat.transcodeInto(data, it) }
|
||||
return CoverResult.Hit(InternalCoverImpl(id, coverFile))
|
||||
}
|
||||
|
||||
override suspend fun cleanup(excluding: Collection<Cover>) {
|
||||
val used = excluding.mapTo(mutableSetOf()) { getFileName(it.id) }
|
||||
appFiles.deleteWhere { it !in used }
|
||||
appFS.deleteWhere { it !in used }
|
||||
}
|
||||
}
|
||||
|
||||
interface FileCover : Cover {
|
||||
suspend fun fd(): ParcelFileDescriptor?
|
||||
}
|
||||
|
||||
private data class FileCoverImpl(override val id: String, private val appFile: AppFile) :
|
||||
FileCover {
|
||||
private data class InternalCoverImpl(override val id: String, private val appFile: AppFile) :
|
||||
FDCover {
|
||||
override suspend fun fd() = appFile.fd()
|
||||
|
||||
override suspend fun open() = appFile.open()
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* FolderCovers.kt is part of Auxio.
|
||||
* FSCovers.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
|
||||
|
@ -16,7 +16,7 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.cover
|
||||
package org.oxycblt.musikr.covers.fs
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
|
@ -26,22 +26,24 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.covers.FDCover
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
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<FolderCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FolderCover> {
|
||||
open class FSCovers(private val context: Context) : Covers<FDCover> {
|
||||
override suspend fun obtain(id: String): CoverResult<FDCover> {
|
||||
// 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 {
|
||||
|
@ -60,10 +62,9 @@ open class FolderCovers(private val context: Context) : Covers<FolderCover> {
|
|||
}
|
||||
}
|
||||
|
||||
class MutableFolderCovers(private val context: Context) :
|
||||
FolderCovers(context), MutableCovers<FolderCover> {
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FolderCover> {
|
||||
val parent = file.parent
|
||||
class MutableFSCovers(private val context: Context) : FSCovers(context), MutableCovers<FDCover> {
|
||||
override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult<FDCover> {
|
||||
val parent = file.parent.await()
|
||||
val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss()
|
||||
return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri))
|
||||
}
|
||||
|
@ -73,22 +74,23 @@ class MutableFolderCovers(private val context: Context) :
|
|||
// 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 findCoverInDirectory(directory: DeviceDirectory): DeviceFile? {
|
||||
return directory.children.firstNotNullOfOrNull { node ->
|
||||
if (node is DeviceFile && isCoverArtFile(
|
||||
node
|
||||
)
|
||||
) node else null
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
@ -99,10 +101,8 @@ class MutableFolderCovers(private val context: Context) :
|
|||
"artwork",
|
||||
"art",
|
||||
"folder",
|
||||
"cover")
|
||||
"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(".", "")
|
||||
|
||||
|
@ -115,12 +115,10 @@ class MutableFolderCovers(private val context: Context) :
|
|||
}
|
||||
}
|
||||
|
||||
interface FolderCover : FileCover
|
||||
|
||||
private data class FolderCoverImpl(
|
||||
private val context: Context,
|
||||
private val uri: Uri,
|
||||
) : FolderCover {
|
||||
) : FDCover {
|
||||
override val id = "folder:$uri"
|
||||
|
||||
override suspend fun fd(): ParcelFileDescriptor? =
|
|
@ -1,6 +1,6 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* AppFiles.kt is part of Auxio.
|
||||
* AppFS.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
|
||||
|
@ -28,7 +28,7 @@ import kotlinx.coroutines.sync.Mutex
|
|||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
interface AppFiles {
|
||||
interface AppFS {
|
||||
suspend fun find(name: String): AppFile?
|
||||
|
||||
suspend fun write(name: String, block: suspend (OutputStream) -> Unit): AppFile
|
||||
|
@ -36,9 +36,9 @@ interface AppFiles {
|
|||
suspend fun deleteWhere(block: (String) -> Boolean)
|
||||
|
||||
companion object {
|
||||
suspend fun at(dir: File): AppFiles {
|
||||
suspend fun at(dir: File): AppFS {
|
||||
withContext(Dispatchers.IO) { check(dir.exists() && dir.isDirectory) }
|
||||
return AppFilesImpl(dir)
|
||||
return AppFSImpl(dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -49,7 +49,7 @@ interface AppFile {
|
|||
suspend fun open(): InputStream?
|
||||
}
|
||||
|
||||
private class AppFilesImpl(private val dir: File) : AppFiles {
|
||||
private class AppFSImpl(private val dir: File) : AppFS {
|
||||
private val fileMutexes = mutableMapOf<String, Mutex>()
|
||||
private val mapMutex = Mutex()
|
||||
|
152
musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt
Normal file
152
musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFS.kt
Normal file
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DeviceFS.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.fs.device
|
||||
|
||||
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
|
||||
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.MusicLocation
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
|
||||
internal interface DeviceFS {
|
||||
fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile>
|
||||
|
||||
companion object {
|
||||
fun from(context: Context, withHidden: Boolean): DeviceFS =
|
||||
DeviceFSImpl(context.contentResolverSafe, withHidden)
|
||||
}
|
||||
}
|
||||
|
||||
sealed interface DeviceNode {
|
||||
val uri: Uri
|
||||
val path: Path
|
||||
}
|
||||
|
||||
data class DeviceDirectory(
|
||||
override val uri: Uri,
|
||||
override val path: Path,
|
||||
val parent: Deferred<DeviceDirectory>?,
|
||||
var children: List<DeviceNode>
|
||||
) : DeviceNode
|
||||
|
||||
data class DeviceFile(
|
||||
override val uri: Uri,
|
||||
override val path: Path,
|
||||
val modifiedMs: Long,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val parent: Deferred<DeviceDirectory>
|
||||
) : DeviceNode
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private class DeviceFSImpl(
|
||||
private val contentResolver: ContentResolver,
|
||||
private val withHidden: Boolean
|
||||
) : DeviceFS {
|
||||
override fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> =
|
||||
locations.flatMapMerge { location ->
|
||||
exploreDirectoryImpl(
|
||||
location.uri,
|
||||
DocumentsContract.getTreeDocumentId(location.uri),
|
||||
location.path,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private fun exploreDirectoryImpl(
|
||||
rootUri: Uri,
|
||||
treeDocumentId: String,
|
||||
relativePath: Path,
|
||||
parent: Deferred<DeviceDirectory>?
|
||||
): Flow<DeviceFile> = flow {
|
||||
// Make a kotlin future
|
||||
val uri = DocumentsContract.buildChildDocumentsUriUsingTree(rootUri, treeDocumentId)
|
||||
val directoryDeferred = CompletableDeferred<DeviceDirectory>()
|
||||
val recursive = mutableListOf<Flow<DeviceFile>>()
|
||||
val children = mutableListOf<DeviceNode>()
|
||||
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 (!withHidden && 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)
|
||||
}
|
||||
}
|
||||
directoryDeferred.complete(DeviceDirectory(uri, relativePath, parent, children))
|
||||
emitAll(recursive.asFlow().flattenMerge())
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PROJECTION =
|
||||
arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED
|
||||
)
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DeviceFile.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.fs.device
|
||||
|
||||
import android.net.Uri
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
|
||||
sealed interface DeviceNode {
|
||||
val uri: Uri
|
||||
val path: Path
|
||||
}
|
||||
|
||||
data class DeviceDirectory(
|
||||
override val uri: Uri,
|
||||
override val path: Path,
|
||||
val parent: DeviceDirectory?,
|
||||
var children: Flow<DeviceNode>
|
||||
) : DeviceNode
|
||||
|
||||
data class DeviceFile(
|
||||
override val uri: Uri,
|
||||
override val path: Path,
|
||||
val modifiedMs: Long,
|
||||
val mimeType: String,
|
||||
val size: Long,
|
||||
val parent: DeviceDirectory
|
||||
) : DeviceNode
|
|
@ -1,139 +0,0 @@
|
|||
/*
|
||||
* Copyright (c) 2024 Auxio Project
|
||||
* DeviceFiles.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.fs.device
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.DocumentsContract
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.emptyFlow
|
||||
import kotlinx.coroutines.flow.flatMapMerge
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.oxycblt.musikr.fs.MusicLocation
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
|
||||
internal interface DeviceFiles {
|
||||
fun explore(locations: Flow<MusicLocation>, ignoreHidden: Boolean = true): Flow<DeviceNode>
|
||||
|
||||
companion object {
|
||||
fun from(context: Context): DeviceFiles = DeviceFilesImpl(context.contentResolverSafe)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private class DeviceFilesImpl(private val contentResolver: ContentResolver) : DeviceFiles {
|
||||
override fun explore(locations: Flow<MusicLocation>, ignoreHidden: Boolean): Flow<DeviceNode> =
|
||||
locations.flatMapMerge { location ->
|
||||
// Create a root directory for each location
|
||||
val rootDirectory =
|
||||
DeviceDirectory(
|
||||
uri = location.uri, path = location.path, parent = null, children = emptyFlow())
|
||||
|
||||
// Set up the children flow for the root directory
|
||||
rootDirectory.children =
|
||||
exploreDirectoryImpl(
|
||||
contentResolver,
|
||||
location.uri,
|
||||
DocumentsContract.getTreeDocumentId(location.uri),
|
||||
location.path,
|
||||
rootDirectory,
|
||||
ignoreHidden)
|
||||
|
||||
// Return a flow that emits the root directory
|
||||
flow { emit(rootDirectory) }
|
||||
}
|
||||
|
||||
private fun exploreDirectoryImpl(
|
||||
contentResolver: ContentResolver,
|
||||
rootUri: Uri,
|
||||
treeDocumentId: String,
|
||||
relativePath: Path,
|
||||
parent: DeviceDirectory,
|
||||
ignoreHidden: Boolean
|
||||
): Flow<DeviceNode> = 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)
|
||||
|
||||
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)
|
||||
val childUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, childId)
|
||||
|
||||
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
|
||||
// Create a directory node with empty children flow initially
|
||||
val directory =
|
||||
DeviceDirectory(
|
||||
uri = childUri,
|
||||
path = newPath,
|
||||
parent = parent,
|
||||
children = emptyFlow())
|
||||
|
||||
// Set up the children flow for this directory
|
||||
directory.children =
|
||||
exploreDirectoryImpl(
|
||||
contentResolver, rootUri, childId, newPath, directory, ignoreHidden)
|
||||
|
||||
// Emit the directory node
|
||||
emit(directory)
|
||||
} else {
|
||||
val size = cursor.getLong(sizeIndex)
|
||||
emit(
|
||||
DeviceFile(
|
||||
uri = childUri,
|
||||
mimeType = mimeType,
|
||||
path = newPath,
|
||||
size = size,
|
||||
modifiedMs = lastModified,
|
||||
parent = parent))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val PROJECTION =
|
||||
arrayOf(
|
||||
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
|
||||
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
|
||||
DocumentsContract.Document.COLUMN_MIME_TYPE,
|
||||
DocumentsContract.Document.COLUMN_SIZE,
|
||||
DocumentsContract.Document.COLUMN_LAST_MODIFIED)
|
||||
}
|
||||
}
|
|
@ -18,24 +18,29 @@
|
|||
|
||||
package org.oxycblt.musikr.metadata
|
||||
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import java.io.FileInputStream
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
|
||||
internal interface MetadataExtractor {
|
||||
suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor): Metadata?
|
||||
suspend fun extract(deviceFile: DeviceFile): Metadata?
|
||||
|
||||
companion object {
|
||||
fun new(): MetadataExtractor = MetadataExtractorImpl
|
||||
fun from(context: Context): MetadataExtractor =
|
||||
MetadataExtractorImpl(context.contentResolver)
|
||||
}
|
||||
}
|
||||
|
||||
private object MetadataExtractorImpl : MetadataExtractor {
|
||||
override suspend fun extract(deviceFile: DeviceFile, fd: ParcelFileDescriptor) =
|
||||
private class MetadataExtractorImpl(private val contentResolver: ContentResolver) :
|
||||
MetadataExtractor {
|
||||
override suspend fun extract(deviceFile: DeviceFile): Metadata? =
|
||||
withContext(Dispatchers.IO) {
|
||||
contentResolver.openFileDescriptor(deviceFile.uri, "r")?.use { fd ->
|
||||
val fis = FileInputStream(fd.fileDescriptor)
|
||||
TagLibJNI.open(deviceFile, fis).also { fis.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -22,7 +22,7 @@ import org.oxycblt.musikr.Album
|
|||
import org.oxycblt.musikr.Artist
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
import org.oxycblt.musikr.tag.interpret.PreAlbum
|
||||
import org.oxycblt.musikr.util.update
|
||||
|
|
|
@ -23,7 +23,7 @@ import org.oxycblt.musikr.Artist
|
|||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.musikr.tag.interpret.PreArtist
|
||||
import org.oxycblt.musikr.util.update
|
||||
|
||||
|
|
|
@ -22,7 +22,7 @@ import org.oxycblt.musikr.Artist
|
|||
import org.oxycblt.musikr.Genre
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.musikr.tag.interpret.PreGenre
|
||||
import org.oxycblt.musikr.util.update
|
||||
|
||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.musikr.model
|
|||
|
||||
import org.oxycblt.musikr.Playlist
|
||||
import org.oxycblt.musikr.Song
|
||||
import org.oxycblt.musikr.cover.CoverCollection
|
||||
import org.oxycblt.musikr.covers.CoverCollection
|
||||
import org.oxycblt.musikr.playlist.interpret.PrePlaylistInfo
|
||||
import org.oxycblt.musikr.tag.Name
|
||||
|
||||
|
|
|
@ -18,16 +18,9 @@
|
|||
|
||||
package org.oxycblt.musikr.pipeline
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.fold
|
||||
import org.oxycblt.musikr.Interpretation
|
||||
import org.oxycblt.musikr.MutableLibrary
|
||||
import org.oxycblt.musikr.Storage
|
||||
|
@ -38,7 +31,7 @@ import org.oxycblt.musikr.playlist.interpret.PlaylistInterpreter
|
|||
import org.oxycblt.musikr.tag.interpret.TagInterpreter
|
||||
|
||||
internal interface EvaluateStep {
|
||||
suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary
|
||||
suspend fun evaluate(extractedMusic: Flow<Extracted>): MutableLibrary
|
||||
|
||||
companion object {
|
||||
fun new(storage: Storage, interpretation: Interpretation): EvaluateStep =
|
||||
|
@ -56,33 +49,16 @@ private class EvaluateStepImpl(
|
|||
private val storedPlaylists: StoredPlaylists,
|
||||
private val libraryFactory: LibraryFactory
|
||||
) : EvaluateStep {
|
||||
override suspend fun evaluate(extractedMusic: Flow<ExtractedMusic>): MutableLibrary {
|
||||
val filterFlow =
|
||||
extractedMusic.filterIsInstance<ExtractedMusic.Valid>().divert {
|
||||
when (it) {
|
||||
is ExtractedMusic.Valid.Song -> Divert.Right(it.song)
|
||||
is ExtractedMusic.Valid.Playlist -> Divert.Left(it.file)
|
||||
override suspend fun evaluate(extractedMusic: Flow<Extracted>): MutableLibrary =
|
||||
extractedMusic
|
||||
.filterIsInstance<Extracted.Valid>()
|
||||
.tryFold(MusicGraph.builder()) { graphBuilder, extracted ->
|
||||
when (extracted) {
|
||||
is RawSong -> graphBuilder.add(tagInterpreter.interpret(extracted))
|
||||
is RawPlaylist ->
|
||||
graphBuilder.add(playlistInterpreter.interpret(extracted.file))
|
||||
}
|
||||
graphBuilder
|
||||
}
|
||||
val rawSongs = filterFlow.right
|
||||
val preSongs =
|
||||
rawSongs
|
||||
.map { wrap(it, tagInterpreter::interpret) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.buffer(Channel.UNLIMITED)
|
||||
val prePlaylists =
|
||||
filterFlow.left
|
||||
.map { wrap(it, playlistInterpreter::interpret) }
|
||||
.flowOn(Dispatchers.Default)
|
||||
.buffer(Channel.UNLIMITED)
|
||||
val graphBuilder = MusicGraph.builder()
|
||||
val graphBuild =
|
||||
merge(
|
||||
filterFlow.manager,
|
||||
preSongs.onEach { wrap(it, graphBuilder::add) },
|
||||
prePlaylists.onEach { wrap(it, graphBuilder::add) })
|
||||
graphBuild.collect()
|
||||
val graph = graphBuilder.build()
|
||||
return libraryFactory.create(graph, storedPlaylists, playlistInterpreter)
|
||||
}
|
||||
.let { libraryFactory.create(it.build(), storedPlaylists, playlistInterpreter) }
|
||||
}
|
||||
|
|
|
@ -25,66 +25,82 @@ 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.flattenMerge
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import org.oxycblt.musikr.Interpretation
|
||||
import org.oxycblt.musikr.Storage
|
||||
import org.oxycblt.musikr.cache.Cache
|
||||
import org.oxycblt.musikr.cache.CacheResult
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.Covers
|
||||
import org.oxycblt.musikr.fs.MusicLocation
|
||||
import org.oxycblt.musikr.fs.device.DeviceDirectory
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.fs.device.DeviceFiles
|
||||
import org.oxycblt.musikr.fs.device.DeviceNode
|
||||
import org.oxycblt.musikr.playlist.PlaylistFile
|
||||
import org.oxycblt.musikr.fs.device.DeviceFS
|
||||
import org.oxycblt.musikr.playlist.db.StoredPlaylists
|
||||
import org.oxycblt.musikr.playlist.m3u.M3U
|
||||
|
||||
internal interface ExploreStep {
|
||||
fun explore(locations: List<MusicLocation>): Flow<ExploreNode>
|
||||
fun explore(locations: List<MusicLocation>): Flow<Explored>
|
||||
|
||||
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(
|
||||
DeviceFS.from(context, interpretation.withHidden),
|
||||
storage.cache,
|
||||
storage.covers,
|
||||
storage.storedPlaylists)
|
||||
}
|
||||
}
|
||||
|
||||
private class ExploreStepImpl(
|
||||
private val deviceFiles: DeviceFiles,
|
||||
private val deviceFS: DeviceFS,
|
||||
private val cache: Cache,
|
||||
private val covers: Covers<out Cover>,
|
||||
private val storedPlaylists: StoredPlaylists
|
||||
) : ExploreStep {
|
||||
override fun explore(locations: List<MusicLocation>): Flow<ExploreNode> {
|
||||
val audios =
|
||||
deviceFiles
|
||||
.explore(locations.asFlow())
|
||||
.flattenFilter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.buffer()
|
||||
val playlists =
|
||||
flow { emitAll(storedPlaylists.read().asFlow()) }
|
||||
.map { ExploreNode.Playlist(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.buffer()
|
||||
return merge(audios, playlists)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private fun Flow<DeviceNode>.flattenFilter(block: (DeviceFile) -> Boolean): Flow<ExploreNode> =
|
||||
flow {
|
||||
collect {
|
||||
val recurse = mutableListOf<Flow<ExploreNode>>()
|
||||
when {
|
||||
it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it))
|
||||
it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block))
|
||||
else -> {}
|
||||
override fun explore(locations: List<MusicLocation>): Flow<Explored> {
|
||||
val addingMs = System.currentTimeMillis()
|
||||
return merge(
|
||||
deviceFS
|
||||
.explore(locations.asFlow(),)
|
||||
.filter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
|
||||
.distribute(8)
|
||||
.distributedMap { file ->
|
||||
val cachedSong =
|
||||
when (val cacheResult = cache.read(file)) {
|
||||
is CacheResult.Hit -> cacheResult.song
|
||||
is CacheResult.Stale ->
|
||||
return@distributedMap NewSong(cacheResult.file, cacheResult.addedMs)
|
||||
is CacheResult.Miss ->
|
||||
return@distributedMap NewSong(cacheResult.file, addingMs)
|
||||
}
|
||||
emitAll(recurse.asFlow().flattenMerge())
|
||||
val cover =
|
||||
cachedSong.coverId?.let { coverId ->
|
||||
when (val coverResult = covers.obtain(coverId)) {
|
||||
is CoverResult.Hit -> coverResult.cover
|
||||
else ->
|
||||
return@distributedMap NewSong(
|
||||
cachedSong.file, cachedSong.addedMs)
|
||||
}
|
||||
}
|
||||
RawSong(
|
||||
cachedSong.file,
|
||||
cachedSong.properties,
|
||||
cachedSong.tags,
|
||||
cover,
|
||||
cachedSong.addedMs)
|
||||
}
|
||||
.flattenMerge()
|
||||
.flowOn(Dispatchers.IO)
|
||||
.buffer(),
|
||||
flow { emitAll(storedPlaylists.read().asFlow()) }
|
||||
.map { RawPlaylist(it) }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.buffer())
|
||||
}
|
||||
|
||||
internal sealed interface ExploreNode {
|
||||
data class Audio(val file: DeviceFile) : ExploreNode
|
||||
|
||||
data class Playlist(val file: PlaylistFile) : ExploreNode
|
||||
}
|
||||
|
|
|
@ -19,181 +19,63 @@
|
|||
package org.oxycblt.musikr.pipeline
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.flattenMerge
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.withContext
|
||||
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.device.DeviceFile
|
||||
import org.oxycblt.musikr.cache.CachedSong
|
||||
import org.oxycblt.musikr.cache.MutableCache
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.covers.CoverResult
|
||||
import org.oxycblt.musikr.covers.MutableCovers
|
||||
import org.oxycblt.musikr.metadata.MetadataExtractor
|
||||
import org.oxycblt.musikr.metadata.Properties
|
||||
import org.oxycblt.musikr.playlist.PlaylistFile
|
||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||
import org.oxycblt.musikr.tag.parse.TagParser
|
||||
|
||||
internal interface ExtractStep {
|
||||
fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic>
|
||||
fun extract(nodes: Flow<Explored>): Flow<Extracted>
|
||||
|
||||
companion object {
|
||||
fun from(context: Context, storage: Storage): ExtractStep =
|
||||
ExtractStepImpl(
|
||||
context,
|
||||
MetadataExtractor.new(),
|
||||
TagParser.new(),
|
||||
storage.cache,
|
||||
storage.storedCovers)
|
||||
MetadataExtractor.from(context), TagParser.new(), storage.cache, storage.covers)
|
||||
}
|
||||
}
|
||||
|
||||
private class ExtractStepImpl(
|
||||
private val context: Context,
|
||||
private val metadataExtractor: MetadataExtractor,
|
||||
private val tagParser: TagParser,
|
||||
private val cacheFactory: Cache.Factory,
|
||||
private val cache: MutableCache,
|
||||
private val covers: MutableCovers<out Cover>
|
||||
) : ExtractStep {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
|
||||
val cache = cacheFactory.open()
|
||||
val addingMs = System.currentTimeMillis()
|
||||
val filterFlow =
|
||||
nodes.divert {
|
||||
override fun extract(nodes: Flow<Explored>): Flow<Extracted> {
|
||||
val exclude = mutableListOf<CachedSong>()
|
||||
return nodes
|
||||
.distribute(8)
|
||||
.distributedMap {
|
||||
when (it) {
|
||||
is ExploreNode.Audio -> Divert.Right(it.file)
|
||||
is ExploreNode.Playlist -> Divert.Left(it.file)
|
||||
}
|
||||
}
|
||||
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, covers) } }
|
||||
.flowOn(Dispatchers.IO)
|
||||
.buffer(Channel.UNLIMITED)
|
||||
}
|
||||
.flattenMerge()
|
||||
.buffer(Channel.UNLIMITED)
|
||||
|
||||
// Divert cache hits and misses
|
||||
val cacheFlow =
|
||||
cacheResults.divert {
|
||||
when (it) {
|
||||
is CacheResult.Hit -> Divert.Left(it.song)
|
||||
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) }
|
||||
|
||||
// Process uncached files in parallel
|
||||
val uncachedFiles = cacheFlow.right
|
||||
val processingDistributedFlow = uncachedFiles.distribute(8)
|
||||
|
||||
// Process each uncached file in parallel flows
|
||||
val processedSongs =
|
||||
processingDistributedFlow.flows
|
||||
.map { flow ->
|
||||
flow
|
||||
.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)
|
||||
is RawSong -> it
|
||||
is RawPlaylist -> it
|
||||
is NewSong -> {
|
||||
val metadata =
|
||||
metadataExtractor.extract(it.file) ?: return@distributedMap InvalidSong
|
||||
val tags = tagParser.parse(metadata)
|
||||
val cover =
|
||||
when (val result =
|
||||
covers.create(f, extractedMetadata)) {
|
||||
when (val result = covers.create(it.file, metadata)) {
|
||||
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
|
||||
val cachedSong =
|
||||
CachedSong(it.file, metadata.properties, tags, cover?.id, it.addedMs)
|
||||
cache.write(cachedSong)
|
||||
exclude.add(cachedSong)
|
||||
val rawSong = RawSong(it.file, metadata.properties, tags, cover, it.addedMs)
|
||||
rawSong
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.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)
|
||||
.onCompletion { cache.cleanup(exclude) }
|
||||
}
|
||||
}
|
||||
|
||||
val processedValidSongs = processedFlow.left
|
||||
val invalidSongs = processedFlow.right
|
||||
|
||||
val merged =
|
||||
merge(
|
||||
filterFlow.manager,
|
||||
readDistributedFlow.manager,
|
||||
cacheFlow.manager,
|
||||
processingDistributedFlow.manager,
|
||||
processedFlow.manager,
|
||||
cachedSongs,
|
||||
processedValidSongs,
|
||||
invalidSongs,
|
||||
playlistNodes)
|
||||
|
||||
return merged.onCompletion { cache.finalize() }
|
||||
}
|
||||
}
|
||||
|
||||
internal data class RawSong(
|
||||
val file: DeviceFile,
|
||||
val properties: Properties,
|
||||
val tags: ParsedTags,
|
||||
val cover: Cover?,
|
||||
val addedMs: Long
|
||||
)
|
||||
|
||||
internal sealed interface ExtractedMusic {
|
||||
sealed interface Valid : ExtractedMusic {
|
||||
data class Song(val song: RawSong) : Valid
|
||||
|
||||
data class Playlist(val file: PlaylistFile) : Valid
|
||||
}
|
||||
|
||||
data object Invalid : ExtractedMusic
|
||||
}
|
||||
|
|
|
@ -26,46 +26,13 @@ import kotlinx.coroutines.flow.map
|
|||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.flow.withIndex
|
||||
|
||||
internal sealed interface Divert<L, R> {
|
||||
data class Left<L, R>(val value: L) : Divert<L, R>
|
||||
|
||||
data class Right<L, R>(val value: R) : Divert<L, R>
|
||||
}
|
||||
|
||||
internal class DivertedFlow<L, R>(
|
||||
val manager: Flow<Nothing>,
|
||||
val left: Flow<L>,
|
||||
val right: Flow<R>
|
||||
)
|
||||
|
||||
internal inline fun <T, L, R> Flow<T>.divert(
|
||||
crossinline predicate: (T) -> Divert<L, R>
|
||||
): DivertedFlow<L, R> {
|
||||
val leftChannel = Channel<L>(Channel.UNLIMITED)
|
||||
val rightChannel = Channel<R>(Channel.UNLIMITED)
|
||||
val managedFlow =
|
||||
flow<Nothing> {
|
||||
collect {
|
||||
when (val result = predicate(it)) {
|
||||
is Divert.Left -> leftChannel.send(result.value)
|
||||
is Divert.Right -> rightChannel.send(result.value)
|
||||
}
|
||||
}
|
||||
leftChannel.close()
|
||||
rightChannel.close()
|
||||
}
|
||||
return DivertedFlow(managedFlow, leftChannel.receiveAsFlow(), rightChannel.receiveAsFlow())
|
||||
}
|
||||
|
||||
internal class DistributedFlow<T>(val manager: Flow<Nothing>, val flows: Flow<Flow<T>>)
|
||||
|
||||
/**
|
||||
* Equally "distributes" the values of some flow across n new flows.
|
||||
*
|
||||
* Note that this function requires the "manager" flow to be consumed alongside the split flows in
|
||||
* order to function. Without this, all of the newly split flows will simply block.
|
||||
*/
|
||||
internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
|
||||
internal fun <T> Flow<T>.distribute(n: Int): Flow<Flow<T>> {
|
||||
val posChannels = List(n) { Channel<T>(Channel.UNLIMITED) }
|
||||
val managerFlow =
|
||||
flow<Nothing> {
|
||||
|
@ -77,6 +44,32 @@ internal fun <T> Flow<T>.distribute(n: Int): DistributedFlow<T> {
|
|||
channel.close()
|
||||
}
|
||||
}
|
||||
val hotFlows = posChannels.asFlow().map { it.receiveAsFlow() }
|
||||
return DistributedFlow(managerFlow, hotFlows)
|
||||
return (posChannels.map { it.receiveAsFlow() } + managerFlow).asFlow()
|
||||
}
|
||||
|
||||
internal fun <T, R> Flow<Flow<T>>.distributedMap(transform: suspend (T) -> R): Flow<Flow<R>> =
|
||||
flow {
|
||||
collect { innerFlow -> emit(innerFlow.tryMap(transform)) }
|
||||
}
|
||||
|
||||
internal fun <T, R> Flow<T>.tryMap(transform: suspend (T) -> R): Flow<R> = flow {
|
||||
collect { value ->
|
||||
try {
|
||||
emit(transform(value))
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(value, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun <T, A> Flow<T>.tryFold(initial: A, operation: suspend (A, T) -> A): A {
|
||||
var accumulator = initial
|
||||
collect { value ->
|
||||
try {
|
||||
accumulator = operation(accumulator, value)
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(value, e)
|
||||
}
|
||||
}
|
||||
return accumulator
|
||||
}
|
|
@ -18,71 +18,9 @@
|
|||
|
||||
package org.oxycblt.musikr.pipeline
|
||||
|
||||
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
|
||||
|
||||
class PipelineException(val processing: WhileProcessing, val error: Exception) : Exception() {
|
||||
class PipelineException(whileProcessing: Any?, val error: Exception) : Exception() {
|
||||
override val cause = error
|
||||
|
||||
override val message = "Error while processing ${processing}: ${error.stackTraceToString()}"
|
||||
}
|
||||
|
||||
sealed interface WhileProcessing {
|
||||
class AFile internal constructor(private val file: DeviceFile) : WhileProcessing {
|
||||
override fun toString() = "File @ ${file.path}"
|
||||
}
|
||||
|
||||
class ARawSong internal constructor(private val rawSong: RawSong) : WhileProcessing {
|
||||
override fun toString() = "Raw Song @ ${rawSong.file.path}"
|
||||
}
|
||||
|
||||
class APlaylistFile internal constructor(private val playlist: PlaylistFile) : WhileProcessing {
|
||||
override fun toString() = "Playlist File @ ${playlist.name}"
|
||||
}
|
||||
|
||||
class APreSong internal constructor(private val preSong: PreSong) : WhileProcessing {
|
||||
override fun toString() = "Pre Song @ ${preSong.path}"
|
||||
}
|
||||
|
||||
class APrePlaylist internal constructor(private val prePlaylist: PrePlaylist) :
|
||||
WhileProcessing {
|
||||
override fun toString() = "Pre Playlist @ ${prePlaylist.name}"
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun <R> wrap(file: DeviceFile, block: suspend (DeviceFile) -> R): R =
|
||||
try {
|
||||
block(file)
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(WhileProcessing.AFile(file), e)
|
||||
}
|
||||
|
||||
internal suspend fun <R> wrap(song: RawSong, block: suspend (RawSong) -> R): R =
|
||||
try {
|
||||
block(song)
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(WhileProcessing.ARawSong(song), e)
|
||||
}
|
||||
|
||||
internal suspend fun <R> wrap(file: PlaylistFile, block: suspend (PlaylistFile) -> R): R =
|
||||
try {
|
||||
block(file)
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(WhileProcessing.APlaylistFile(file), e)
|
||||
}
|
||||
|
||||
internal suspend fun <R> wrap(song: PreSong, block: suspend (PreSong) -> R): R =
|
||||
try {
|
||||
block(song)
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(WhileProcessing.APreSong(song), e)
|
||||
}
|
||||
|
||||
internal suspend fun <R> wrap(playlist: PrePlaylist, block: suspend (PrePlaylist) -> R): R =
|
||||
try {
|
||||
block(playlist)
|
||||
} catch (e: Exception) {
|
||||
throw PipelineException(WhileProcessing.APrePlaylist(playlist), e)
|
||||
override val message =
|
||||
"Error while processing ${whileProcessing}: ${error.stackTraceToString()}"
|
||||
}
|
||||
|
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright (c) 2025 Auxio Project
|
||||
* PipelineItem.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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.oxycblt.musikr.pipeline
|
||||
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.fs.device.DeviceFile
|
||||
import org.oxycblt.musikr.metadata.Properties
|
||||
import org.oxycblt.musikr.playlist.PlaylistFile
|
||||
import org.oxycblt.musikr.tag.parse.ParsedTags
|
||||
|
||||
internal sealed interface PipelineItem
|
||||
|
||||
internal sealed interface Incomplete : PipelineItem
|
||||
|
||||
internal sealed interface Complete : PipelineItem
|
||||
|
||||
internal sealed interface Explored : PipelineItem {
|
||||
sealed interface New : Explored, Incomplete
|
||||
|
||||
sealed interface Known : Explored, Complete
|
||||
}
|
||||
|
||||
internal data class NewSong(val file: DeviceFile, val addedMs: Long) : Explored.New
|
||||
|
||||
internal sealed interface Extracted : PipelineItem {
|
||||
sealed interface Valid : Complete, Extracted
|
||||
|
||||
sealed interface Invalid : Extracted
|
||||
}
|
||||
|
||||
internal data object InvalidSong : Extracted.Invalid
|
||||
|
||||
internal data class RawPlaylist(val file: PlaylistFile) : Explored.Known, Extracted.Valid
|
||||
|
||||
internal data class RawSong(
|
||||
val file: DeviceFile,
|
||||
val properties: Properties,
|
||||
val tags: ParsedTags,
|
||||
val cover: Cover?,
|
||||
val addedMs: Long
|
||||
) : Explored.Known, Extracted.Valid
|
|
@ -21,7 +21,7 @@ package org.oxycblt.musikr.tag.interpret
|
|||
import android.net.Uri
|
||||
import java.util.UUID
|
||||
import org.oxycblt.musikr.Music
|
||||
import org.oxycblt.musikr.cover.Cover
|
||||
import org.oxycblt.musikr.covers.Cover
|
||||
import org.oxycblt.musikr.fs.Format
|
||||
import org.oxycblt.musikr.fs.Path
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
|
|
|
@ -68,8 +68,7 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T
|
|||
val songNameOrFileWithoutExt =
|
||||
song.tags.name ?: requireNotNull(song.file.path.name).split('.').first()
|
||||
val songNameOrFileWithoutExtCorrect =
|
||||
song.tags.name
|
||||
?: requireNotNull(song.file.path.name).split('.').dropLast(1).joinToString(".")
|
||||
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()
|
||||
|
@ -131,7 +130,7 @@ 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),
|
||||
name = interpretation.naming.name(songNameOrFileWithoutExtCorrect, song.tags.sortName),
|
||||
rawName = songNameOrFileWithoutExtCorrect,
|
||||
track = song.tags.track,
|
||||
disc = song.tags.disc?.let { Disc(it, song.tags.subtitle) },
|
||||
|
|
|
@ -20,7 +20,7 @@ package org.oxycblt.musikr.tag.parse
|
|||
|
||||
import org.oxycblt.musikr.tag.Date
|
||||
|
||||
internal data class ParsedTags(
|
||||
data class ParsedTags(
|
||||
val durationMs: Long,
|
||||
val replayGainTrackAdjustment: Float? = null,
|
||||
val replayGainAlbumAdjustment: Float? = null,
|
||||
|
|
Loading…
Reference in a new issue