diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt
deleted file mode 100644
index 251e8ce7d..000000000
--- a/app/src/main/java/org/oxycblt/auxio/image/covers/CompatCovers.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright (c) 2025 Auxio Project
- * CompatCovers.kt is part of Auxio.
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-
-package org.oxycblt.auxio.image.covers
-
-import android.content.Context
-import android.net.Uri
-import android.os.Build
-import android.os.ParcelFileDescriptor
-import android.provider.MediaStore
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import org.oxycblt.musikr.cover.Cover
-import org.oxycblt.musikr.cover.CoverResult
-import org.oxycblt.musikr.cover.Covers
-import org.oxycblt.musikr.cover.FileCover
-import org.oxycblt.musikr.cover.MutableCovers
-import org.oxycblt.musikr.fs.device.DeviceFile
-import org.oxycblt.musikr.metadata.Metadata
-
-open class CompatCovers(private val context: Context, private val inner: Covers) :
- Covers {
- override suspend fun obtain(id: String): CoverResult {
- when (val innerResult = inner.obtain(id)) {
- is CoverResult.Hit -> return CoverResult.Hit(innerResult.cover)
- is CoverResult.Miss -> {
- if (!id.startsWith("compat:")) return CoverResult.Miss()
- val uri = Uri.parse(id.substringAfter("compat:"))
- return CoverResult.Hit(CompatCover(context, uri))
- }
- }
- }
-}
-
-class MutableCompatCovers(
- private val context: Context,
- private val inner: MutableCovers
-) : CompatCovers(context, inner), MutableCovers {
- override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult {
- when (val innerResult = inner.create(file, metadata)) {
- is CoverResult.Hit -> return CoverResult.Hit(innerResult.cover)
- is CoverResult.Miss -> {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
- return CoverResult.Miss()
- }
- val mediaStoreUri =
- MediaStore.getMediaUri(context, file.uri) ?: return CoverResult.Miss()
- val proj = arrayOf(MediaStore.MediaColumns._ID)
- val cursor = context.contentResolver.query(mediaStoreUri, proj, null, null, null)
- val uri =
- cursor.use {
- if (it == null || !it.moveToFirst()) {
- return CoverResult.Miss()
- }
- val id = it.getLong(it.getColumnIndexOrThrow(MediaStore.MediaColumns._ID))
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
- appendPath(id.toString())
- appendPath("albumart")
- build()
- }
- }
- return CoverResult.Hit(CompatCover(context, uri))
- }
- }
- }
-
- override suspend fun cleanup(excluding: Collection) {}
-}
-
-class CompatCover(private val context: Context, private val uri: Uri) : FileCover {
- override val id = "compat:$uri"
-
- override suspend fun fd(): ParcelFileDescriptor? {
- return context.contentResolver.openFileDescriptor(uri, "r")
- }
-
- override suspend fun open() =
- withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
-}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt
index 2ec58ab66..ac0a4f7fa 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SettingCovers.kt
@@ -28,14 +28,16 @@ import org.oxycblt.musikr.cover.CoverIdentifier
import org.oxycblt.musikr.cover.CoverParams
import org.oxycblt.musikr.cover.Covers
import org.oxycblt.musikr.cover.FileCover
+import org.oxycblt.musikr.cover.FolderCovers
import org.oxycblt.musikr.cover.MutableCovers
+import org.oxycblt.musikr.cover.MutableFolderCovers
interface SettingCovers {
suspend fun mutate(context: Context, revision: UUID): MutableCovers
companion object {
- fun immutable(context: Context): Covers =
- CompatCovers(context, BaseSiloedCovers(context))
+ fun immutable(context: Context): Covers =
+ Covers.chain(BaseSiloedCovers(context), FolderCovers(context))
}
}
@@ -52,6 +54,7 @@ constructor(private val imageSettings: ImageSettings, private val identifier: Co
}
private suspend fun siloedCovers(context: Context, revision: UUID, with: CoverParams) =
- MutableCompatCovers(
- context, MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier))
+ MutableCovers.chain(
+ MutableSiloedCovers.from(context, CoverSilo(revision, with), identifier),
+ MutableFolderCovers(context))
}
diff --git a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt
index d40112c20..ab7e71d9c 100644
--- a/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt
+++ b/app/src/main/java/org/oxycblt/auxio/image/covers/SiloedCovers.kt
@@ -31,8 +31,8 @@ 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.device.DeviceFile
import org.oxycblt.musikr.fs.app.AppFiles
+import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.metadata.Metadata
class BaseSiloedCovers(private val context: Context) : Covers {
diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt
index 8d7b19eec..902bce5c5 100644
--- a/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt
+++ b/musikr/src/main/java/org/oxycblt/musikr/cover/Covers.kt
@@ -24,12 +24,58 @@ import org.oxycblt.musikr.metadata.Metadata
interface Covers {
suspend fun obtain(id: String): CoverResult
+
+ companion object {
+ fun chain(vararg many: Covers): Covers =
+ object : Covers {
+ override suspend fun obtain(id: String): CoverResult {
+ for (cover in many) {
+ val result = cover.obtain(id)
+ if (result is CoverResult.Hit) {
+ return CoverResult.Hit(result.cover)
+ }
+ }
+ return CoverResult.Miss()
+ }
+ }
+ }
}
interface MutableCovers : Covers {
suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult
suspend fun cleanup(excluding: Collection)
+
+ companion object {
+ fun chain(vararg many: MutableCovers): MutableCovers =
+ object : MutableCovers {
+ override suspend fun obtain(id: String): CoverResult {
+ for (cover in many) {
+ val result = cover.obtain(id)
+ if (result is CoverResult.Hit) {
+ return CoverResult.Hit(result.cover)
+ }
+ }
+ return CoverResult.Miss()
+ }
+
+ override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult {
+ for (cover in many) {
+ val result = cover.create(file, metadata)
+ if (result is CoverResult.Hit) {
+ return CoverResult.Hit(result.cover)
+ }
+ }
+ return CoverResult.Miss()
+ }
+
+ override suspend fun cleanup(excluding: Collection) {
+ for (cover in many) {
+ cover.cleanup(excluding)
+ }
+ }
+ }
+ }
}
sealed interface CoverResult {
@@ -42,6 +88,10 @@ interface Cover {
val id: String
suspend fun open(): InputStream?
+
+ override fun equals(other: Any?): Boolean
+
+ override fun hashCode(): Int
}
class CoverCollection private constructor(val covers: List) {
diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt
index 3edcfd6d8..aadafad1d 100644
--- a/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt
+++ b/musikr/src/main/java/org/oxycblt/musikr/cover/FileCovers.kt
@@ -19,9 +19,9 @@
package org.oxycblt.musikr.cover
import android.os.ParcelFileDescriptor
-import org.oxycblt.musikr.fs.device.DeviceFile
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) :
diff --git a/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt b/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt
new file mode 100644
index 000000000..3e8963911
--- /dev/null
+++ b/musikr/src/main/java/org/oxycblt/musikr/cover/FolderCovers.kt
@@ -0,0 +1,114 @@
+/*
+ * Copyright (c) 2025 Auxio Project
+ * FolderCovers.kt is part of Auxio.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.oxycblt.musikr.cover
+
+import android.content.Context
+import android.net.Uri
+import android.os.ParcelFileDescriptor
+import java.io.InputStream
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.firstOrNull
+import kotlinx.coroutines.flow.mapNotNull
+import kotlinx.coroutines.withContext
+import org.oxycblt.musikr.fs.device.DeviceDirectory
+import org.oxycblt.musikr.fs.device.DeviceFile
+import org.oxycblt.musikr.metadata.Metadata
+
+open class FolderCovers(private val context: Context) : Covers {
+ override suspend fun obtain(id: String): CoverResult {
+ // Parse the ID to get the directory URI
+ if (!id.startsWith("folder:")) {
+ return CoverResult.Miss()
+ }
+
+ val directoryUri = id.substring("folder:".length)
+ val uri = Uri.parse(directoryUri)
+ return CoverResult.Hit(FolderCoverImpl(context, uri))
+ }
+}
+
+class MutableFolderCovers(private val context: Context) :
+ FolderCovers(context), MutableCovers {
+ override suspend fun create(file: DeviceFile, metadata: Metadata): CoverResult {
+ val parent = file.parent
+ val coverFile = findCoverInDirectory(parent) ?: return CoverResult.Miss()
+ return CoverResult.Hit(FolderCoverImpl(context, coverFile.uri))
+ }
+
+ override suspend fun cleanup(excluding: Collection) {
+ // No cleanup needed for folder covers as they are external files
+ // that should not be managed by the app
+ }
+
+ private suspend fun findCoverInDirectory(directory: DeviceDirectory): DeviceFile? {
+ return directory.children
+ .mapNotNull { node -> if (node is DeviceFile && isCoverArtFile(node)) node else null }
+ .firstOrNull()
+ }
+
+ private fun isCoverArtFile(file: DeviceFile): Boolean {
+ val filename = requireNotNull(file.path.name).lowercase()
+ val mimeType = file.mimeType.lowercase()
+
+ // Check if the file is an image
+ if (!mimeType.startsWith("image/")) {
+ return false
+ }
+
+ // Common cover art filenames
+ val coverNames =
+ listOf(
+ "cover",
+ "folder",
+ "album",
+ "albumart",
+ "front",
+ "artwork",
+ "art",
+ "folder",
+ "cover")
+
+ // Check if the filename matches any common cover art names
+ // Also check for case variations (e.g., Cover.jpg, COVER.JPG)
+ val filenameWithoutExt = filename.substringBeforeLast(".")
+ val extension = filename.substringAfterLast(".", "")
+
+ return coverNames.any { coverName ->
+ filenameWithoutExt.equals(coverName, ignoreCase = true) &&
+ (extension.equals("jpg", ignoreCase = true) ||
+ extension.equals("jpeg", ignoreCase = true) ||
+ extension.equals("png", ignoreCase = true))
+ }
+ }
+}
+
+interface FolderCover : FileCover
+
+private data class FolderCoverImpl(
+ private val context: Context,
+ private val uri: Uri,
+) : FolderCover {
+ override val id = "folder:$uri"
+
+ override suspend fun fd(): ParcelFileDescriptor? =
+ withContext(Dispatchers.IO) { context.contentResolver.openFileDescriptor(uri, "r") }
+
+ override suspend fun open(): InputStream? =
+ withContext(Dispatchers.IO) { context.contentResolver.openInputStream(uri) }
+}
diff --git a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt
index 0fbee972f..2ba57558d 100644
--- a/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt
+++ b/musikr/src/main/java/org/oxycblt/musikr/fs/device/DeviceFiles.kt
@@ -25,8 +25,8 @@ import android.provider.DocumentsContract
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
-import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flatMapMerge
+import kotlinx.coroutines.flow.flow
import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.Path
@@ -43,23 +43,20 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
override fun explore(locations: Flow, ignoreHidden: Boolean): Flow =
locations.flatMapMerge { location ->
// Create a root directory for each location
- val rootDirectory = DeviceDirectory(
- uri = location.uri,
- path = location.path,
- parent = null,
- children = emptyFlow()
- )
-
+ 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
- )
-
+ 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) }
}
@@ -84,7 +81,7 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
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)
@@ -98,26 +95,21 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
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()
- )
-
+ 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
- )
-
+ directory.children =
+ exploreDirectoryImpl(
+ contentResolver, rootUri, childId, newPath, directory, ignoreHidden)
+
// Emit the directory node
emit(directory)
} else {
@@ -129,9 +121,7 @@ private class DeviceFilesImpl(private val contentResolver: ContentResolver) : De
path = newPath,
size = size,
modifiedMs = lastModified,
- parent = parent
- )
- )
+ parent = parent))
}
}
}
diff --git a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt
index 94da7e590..65f742688 100644
--- a/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt
+++ b/musikr/src/main/java/org/oxycblt/musikr/pipeline/ExploreStep.kt
@@ -31,11 +31,11 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.oxycblt.musikr.Storage
+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.DeviceNode
-import org.oxycblt.musikr.fs.MusicLocation
import org.oxycblt.musikr.fs.device.DeviceFiles
+import org.oxycblt.musikr.fs.device.DeviceNode
import org.oxycblt.musikr.playlist.PlaylistFile
import org.oxycblt.musikr.playlist.db.StoredPlaylists
import org.oxycblt.musikr.playlist.m3u.M3U
@@ -57,9 +57,7 @@ private class ExploreStepImpl(
val audios =
deviceFiles
.explore(locations.asFlow())
- .flattenFilter {
- it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE
- }
+ .flattenFilter { it.mimeType.startsWith("audio/") || it.mimeType == M3U.MIME_TYPE }
.flowOn(Dispatchers.IO)
.buffer()
val playlists =
@@ -71,17 +69,18 @@ private class ExploreStepImpl(
}
@OptIn(ExperimentalCoroutinesApi::class)
- private fun Flow.flattenFilter(block: (DeviceFile) -> Boolean): Flow = flow {
- collect {
- val recurse = mutableListOf>()
- when {
- it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it))
- it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block))
- else -> {}
+ private fun Flow.flattenFilter(block: (DeviceFile) -> Boolean): Flow =
+ flow {
+ collect {
+ val recurse = mutableListOf>()
+ when {
+ it is DeviceFile && block(it) -> emit(ExploreNode.Audio(it))
+ it is DeviceDirectory -> recurse.add(it.children.flattenFilter(block))
+ else -> {}
+ }
+ emitAll(recurse.asFlow().flattenMerge())
}
- emitAll(recurse.asFlow().flattenMerge())
}
- }
}
internal sealed interface ExploreNode {
diff --git a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt
index de1447c8b..0a81ba6f2 100644
--- a/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt
+++ b/musikr/src/main/java/org/oxycblt/musikr/tag/interpret/TagInterpreter.kt
@@ -20,8 +20,8 @@ package org.oxycblt.musikr.tag.interpret
import org.oxycblt.musikr.Interpretation
import org.oxycblt.musikr.Music
-import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.fs.Format
+import org.oxycblt.musikr.fs.device.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
import org.oxycblt.musikr.tag.Disc
import org.oxycblt.musikr.tag.Name